/**
 * MapView Component
 *
 * OpenLayers map with ol-ext LayerSwitcher for base map selection.
 *
 * Usage:
 *   import { MapView } from './components/MapView.js';
 *
 *   const map = new MapView('map', {
 *     center: [-1.5, 7.5],  // Ghana
 *     zoom: 7,
 *     basemap: 'osm'
 *   });
 *
 *   map.onClick((lon, lat) => console.log('Clicked:', lon, lat));
 *   map.addMarker(lon, lat, { name: 'Point A' });
 */

import Map from 'ol/Map';
import View from 'ol/View';
import Overlay from 'ol/Overlay';
import TileLayer from 'ol/layer/Tile';
import ImageLayer from 'ol/layer/Image';
import LayerGroup from 'ol/layer/Group';
import VectorLayer from 'ol/layer/Vector';
import VectorImageLayer from 'ol/layer/VectorImage';
import VectorSource from 'ol/source/Vector';
import ImageWMS from 'ol/source/ImageWMS';
import TileWMS from 'ol/source/TileWMS';
import OSM from 'ol/source/OSM';
import XYZ from 'ol/source/XYZ';
import { fromLonLat, toLonLat } from 'ol/proj';
import { Point, LineString, Polygon as PolygonGeom } from 'ol/geom';
import Feature from 'ol/Feature';
import { Style, Circle, Fill, Stroke, Text } from 'ol/style';
import GeoJSON from 'ol/format/GeoJSON';
import { getArea, getLength } from 'ol/sphere';
import { fromCircle } from 'ol/geom/Polygon';
import ScaleLine from 'ol/control/ScaleLine';
import { formatLength, formatLengthFull, formatArea, formatAreaFull } from '../units.js';

// ol-ext LayerSwitcher
import LayerSwitcher from 'ol-ext/control/LayerSwitcher';

// ol-ext SearchNominatim
import SearchNominatim from 'ol-ext/control/SearchNominatim';

// ol-ext EditBar for drawing/editing features
import EditBar from 'ol-ext/control/EditBar';
import Bar from 'ol-ext/control/Bar';
import Button from 'ol-ext/control/Button';

// ol-ext TouchCursor for touch-enabled devices
import TouchCursor from 'ol-ext/interaction/TouchCursor';

// ol-ext ModifyFeature for cross-layer modification
import ModifyFeature from 'ol-ext/interaction/ModifyFeature';

// ol-ext UndoRedo interaction
import UndoRedo from 'ol-ext/interaction/UndoRedo';

// ol-ext SnapGuides — snaps drawing vertices to alignment guides
import SnapGuides from 'ol-ext/interaction/SnapGuides';

// ol Select interaction (for custom multi-layer Select)
import Select from 'ol/interaction/Select';
import { click as clickCondition } from 'ol/events/condition';

// ol-ext Split interaction (for line splitting) and Toggle control
import Split from 'ol-ext/interaction/Split';
import Toggle from 'ol-ext/control/Toggle';
import TextButton from 'ol-ext/control/TextButton';

// Custom polygon split interaction
import { PolygonSplitInteraction } from '../interactions/PolygonSplitInteraction.js';

// Custom polygon merge interaction
import { PolygonMergeInteraction } from '../interactions/PolygonMergeInteraction.js';

// Custom polygon divide interaction
import { PolygonDivideInteraction } from '../interactions/PolygonDivideInteraction.js';

// Toast notifications
import { showToast } from '../toast.js';

// CSS imports
import 'ol/ol.css';
import 'ol-ext/dist/ol-ext.css';
import '../styles/layerswitcher.css';

export class MapView {
  constructor(targetId, options = {}) {
    this.options = options;
    this.markerSource = new VectorSource();
    this.clickCallbacks = [];

    // Category emoji and label mapping
    // Add new categories here - they will automatically appear in the dropdown
    this.categoryEmojis = {
      'default': { emoji: '📍', label: 'Default' },
      'water': { emoji: '💧', label: 'Water Point' },
      'school': { emoji: '🏫', label: 'School' },
      'health': { emoji: '🏥', label: 'Health Facility' },
      'market': { emoji: '🏪', label: 'Market' },
      'other': { emoji: '📌', label: 'Other' }
    };

    // Helper to get emoji for a category
    this.getEmoji = (category) => {
      const cat = this.categoryEmojis[category];
      return cat ? cat.emoji : '📍';
    };

    // Helper to generate category options HTML for select dropdowns
    this.getCategoryOptionsHtml = () => {
      return Object.entries(this.categoryEmojis)
        .map(([key, { emoji, label }]) =>
          `<option value="${key}">${emoji} ${label}</option>`
        )
        .join('\n            ');
    };

    // Create emoji style helper
    this.createEmojiStyle = (emoji, fontSize = 24) => {
      return new Style({
        text: new Text({
          text: emoji,
          font: `${fontSize}px sans-serif`,
          textBaseline: 'bottom',
          textAlign: 'center',
          offsetY: -5,
      }),
    });
    };

    // Default marker style (pin emoji)
    this.defaultStyle = this.createEmojiStyle('📍', 32);

    // Selected marker style (larger)
    this.selectedStyle = this.createEmojiStyle('📍', 42);

    // Initialize category styles with emojis
    this.categoryStyles = {};
    for (const [category, { emoji }] of Object.entries(this.categoryEmojis)) {
      this.categoryStyles[category] = this.createEmojiStyle(emoji, 32);
    }

    // Create base layers group
    const baseLayers = this.createBaseLayers(options.basemap || 'topo');

    // Markers layer — hidden at startup; the user enables it from the
    // LayerSwitcher when they want to see location markers / category pins.
    this.markersLayer = new VectorLayer({
      title: 'Markers',
      source: this.markerSource,
      style: (feature) => this.getFeatureStyle(feature),
      visible: false,
    });

    // Overlay layers group (for remote data like boundaries)
    this.overlayGroup = new LayerGroup({
      title: 'Overlays',
    });

    // Create map
    // Layer order (bottom → top): Base Maps, Markers, Overlays
    // MapTools will insert Measurements and Drawings between Markers and Overlays.
    // initEditBar() will insert its Drawings group above those.
    // Final LayerSwitcher order (top → bottom):
    //   Overlays, Drawings, Measurements, Markers, Base Maps
    this.map = new Map({
      target: targetId,
      layers: [
        baseLayers,
        this.markersLayer,
        this.overlayGroup,
      ],
      view: new View({
        center: fromLonLat(options.center || [0, 0]),
        zoom: options.zoom || 2,
        minZoom: options.minZoom || 2,
        maxZoom: options.maxZoom || 19,
      })
    });

    // Add LayerSwitcher control
    const layerSwitcher = new LayerSwitcher({
      collapsed: true,
      mouseover: true,
      extent: true,
      trash: false,
      oninfo: null,
    });
    this.map.addControl(layerSwitcher);

    // Apply the LUSPA branded icon to the LayerSwitcher's collapse button.
    // Done in JS so the URL respects Vite's BASE_URL — survives deployment
    // under any sub-path.
    // NOTE: folder name is `app-icons`, NOT `icons` — Apache aliases `/icons/`
    //       by default to its own directory-listing thumbnails, which would
    //       intercept this request server-side.
    queueMicrotask(() => {
      const btn = layerSwitcher.element?.querySelector(':scope > button');
      if (btn) {
        const baseUrl = (import.meta.env?.BASE_URL || '/').replace(/\/?$/, '/');
        btn.style.backgroundImage = `url('${baseUrl}app-icons/luspa-72x72.png')`;
      }
    });

    // ------------------------------------------------------------------
    // Decorate each layer's <li> as it's rendered:
    //   • inject a type-tag chip (WMS / XYZ / VEC / …) next to the label
    //   • add a green "+" button to the "External Source" group header
    // After each draw cycle, refresh the panel chrome (active count badge
    // + footer reset button). Schedule once per cycle via a microtask.
    // ------------------------------------------------------------------
    let _lsChromeScheduled = false;
    layerSwitcher.on('drawlist', (evt) => {
      this._decorateLayerListItem(evt.layer, evt.li);

      if (!_lsChromeScheduled) {
        _lsChromeScheduled = true;
        queueMicrotask(() => {
          _lsChromeScheduled = false;
          this._refreshLayerSwitcherChrome(layerSwitcher);
        });
      }
    });

    // Re-render the chrome whenever any layer's visibility changes (so the
    // active-count badge updates even when the user toggles via the panel).
    this.map.getLayers().on('change', () => {
      this._refreshLayerSwitcherChrome(layerSwitcher);
    });
    // Hook visibility events on every layer (recursive into groups).
    this._wireLayerSwitcherVisibilityHooks(layerSwitcher);

    // Create the add-layer dialog (hidden by default)
    this._createAddLayerDialog();

    // Create the legend panel (shows legends for visible layers that have one)
    this._createLegendPanel();

    // Add ScaleBar control
    this.scaleBar = new ScaleLine({
      bar: true,
      steps: 4,
      text: true,
      minWidth: 140,
    });
    this.map.addControl(this.scaleBar);

    // GPS rendering layers (current position + recorded trail) and the
    // expandable "My Location" control (Locate Me + Record Trail sub-buttons).
    this._initGpsRendering();
    this._createLocationControl();

    // Dedicated base-map picker — sits above the My Location button
    this._createBaseMapPicker();

    // Add SearchNominatim control
    const searchNominatim = new SearchNominatim({
      placeholder: 'Search location...',
      typing: 300,        // Delay before search (ms)
      minLength: 3,       // Minimum characters to start search
      maxItems: 10,       // Maximum results to show
      collapsed: true,    // Start collapsed
      // Limit search to improve relevance (can be adjusted)
      // countrycodes: 'gh',  // Uncomment to limit to Ghana
    });
    this.map.addControl(searchNominatim);

    // Handle search result selection
    searchNominatim.on('select', (event) => {
      const searchResult = event.search;
      if (searchResult) {
        // SearchNominatim returns a plain object with lon/lat properties (as strings)
        const lon = parseFloat(searchResult.lon);
        const lat = parseFloat(searchResult.lat);
        const lonLat = [lon, lat];
        const coordinate = fromLonLat(lonLat);

        // Navigate to the selected location
        this.navigateTo(lon, lat, 14);

        // Trigger search select callbacks
        const result = {
          coordinate: coordinate,
          lonLat: lonLat,
          name: searchResult.display_name || searchResult.name || 'Unknown',
          searchResult: searchResult,
        };
        this.searchSelectCallbacks.forEach(cb => cb(result));
      }
    });

    // Store reference for external access
    this.searchNominatim = searchNominatim;
    this.searchSelectCallbacks = [];

    // Track selected feature
    this.selectedFeature = null;

    // Create popup overlay for hover
    this.createPopup();

    // Create info popup for double-click feature details
    this.createInfoPopup();

    // Create Add Location popup form
    this.createAddLocationPopup();

    // Create editable parcel form popup
    this.createParcelEditPopup();

    // Create drawn polygon attribute popup
    this.createDrawnPolygonPopup();

    // Create merge identifier (UPN) chooser popup
    this.createMergePopup();

    // Create divide polygon popup (number input)
    this.createDividePopup();

    // Double-click callbacks
    this.dblClickCallbacks = [];

    // EditBar is set up lazily via initEditBar() once the Drawings
    // layer/group is available (called from main.js after loadLayers).
    this.editBar = null;
    this.drawingsSource = null;
    this.drawingsLayer = null;
    this.touchCursor = null;
    this._editBarActive = false;
  }

  // ============================================================================
  // EditBar + Drawings Layer + TouchCursor
  // ============================================================================

  /**
   * Initialise the EditBar with a dedicated "Drawings" LayerGroup.
   *
   * A "Drawings" LayerGroup is created at the top of the overlay stack
   * containing a "sketches" VectorLayer for storing drawn features.
   * The EditBar, Select and Modify interactions are only active while
   * edit mode is on; in all other cases normal click / double-click
   * behaviour is preserved.
   *
   * Call this once from main.js after the layer groups have been created.
   */
  initEditBar() {
    // 1. Create a "Drawings" LayerGroup with a "sketches" VectorLayer inside
    this.drawingsSource = new VectorSource();
    this.drawingsLayer = new VectorLayer({
      title: 'sketches',
      source: this.drawingsSource,
      style: new Style({
        stroke: new Stroke({ color: '#f59e0b', width: 2.5 }),
        fill: new Fill({ color: 'rgba(245,158,11,0.15)' }),
        image: new Circle({
          radius: 6,
          fill: new Fill({ color: '#f59e0b' }),
          stroke: new Stroke({ color: '#fff', width: 1.5 }),
        }),
      }),
    });

    this._drawingsGroup = new LayerGroup({
      title: 'Drawings',
      layers: [this.drawingsLayer],
    });
    // Insert as a top-level map layer just before the Overlays group so the
    // LayerSwitcher order is: Overlays > Drawings > Measurements > Markers > Base Maps.
    // Find Overlays by reference rather than assuming it is the last layer —
    // other layers (e.g. the GPS trail/position layers added by
    // _initGpsRendering) may sit on top of it.
    const mapLayers = this.map.getLayers();
    const overlayIdx = mapLayers.getArray().indexOf(this.overlayGroup);
    mapLayers.insertAt(overlayIdx >= 0 ? overlayIdx : mapLayers.getLength(), this._drawingsGroup);

    // 2. Create a Select interaction that works on ALL vector layers.
    //    It starts INACTIVE so it doesn't steal clicks from normal handlers.
    this._selectInteraction = new Select({
      condition: clickCondition,
      filter: (feature, layer) => !!layer,
      layers: (layer) => layer instanceof VectorLayer,
    });
    this._selectInteraction.setActive(false);
    this.map.addInteraction(this._selectInteraction);

    // 3. Create a ModifyFeature interaction bound to the selection.
    //    Also starts inactive.
    this._modifyInteraction = new ModifyFeature({
      features: this._selectInteraction.getFeatures(),
    });
    this._modifyInteraction.setActive(false);

    // 3b. Fire onFeatureModified callbacks when a modification completes.
    //     Consumers attach via onFeatureModified() and decide for themselves
    //     whether to react (e.g. the staged-import code persists geometry
    //     changes back to external_import_features).
    this._modifyInteraction.on('modifyend', (evt) => {
      if (!this._featureModifiedCallbacks?.length) return;
      const features = evt.features?.getArray?.() || [];
      for (const f of features) {
        for (const cb of this._featureModifiedCallbacks) {
          try { cb(f); } catch (err) { console.warn('[MapView] onFeatureModified callback failed:', err); }
        }
      }
    });

    // 4. UndoRedo interaction — watches the drawings source
    this._undoRedo = new UndoRedo();
    this.map.addInteraction(this._undoRedo);

    // 5. Build the EditBar — all interactions enabled.
    this.editBar = new EditBar({
      source: this.drawingsSource,
      interactions: {
        Select: this._selectInteraction,
        ModifySelect: this._modifyInteraction,
        DrawPoint: true,
        DrawLine: true,
        DrawPolygon: true,
        DrawRegular: true,
        DrawHole: true,
        Delete: true,
        Info: true,
        Transform: true,
        Split: false,
      },
    });
    this.map.addControl(this.editBar);

    // 5b. Persistent vertex overlay — when edit mode is active and the user
    //     selects a polygon (or line) for modification, render a small dot
    //     at every vertex so the user can see all editable nodes at a glance.
    //     ol-ext's ModifyFeature only renders the closest vertex on hover; this
    //     overlay complements that without subclassing the interaction.
    this._setupVertexOverlay();

    // 6. Add extra buttons (Undo, Redo, Save) as a sub-bar
    //    inside the EditBar so they appear inline.
    const extraBar = new Bar({
      group: true,
      // Stable class so CSS can move this group (undo/redo/save/snap) to a
      // second row on small screens — see `.ol-editbar-actions` media query.
      className: 'ol-editbar-actions',
      controls: [
        new Button({
          html: '<i class="bi bi-arrow-counterclockwise"></i>',
          className: 'ol-undo',
          title: 'Undo',
          handleClick: () => {
            if (this._undoRedo.hasUndo()) this._undoRedo.undo();
          },
        }),
        new Button({
          html: '<i class="bi bi-arrow-clockwise"></i>',
          className: 'ol-redo',
          title: 'Redo',
          handleClick: () => {
            if (this._undoRedo.hasRedo()) this._undoRedo.redo();
          },
        }),
        new Button({
          html: '<i class="bi bi-floppy"></i>',
          className: 'ol-save',
          title: 'Save drawings',
          handleClick: () => {
            this.dispatchEditEvent('save');
          },
        }),
      ],
    });
    this.editBar.addControl(extraBar);

    // 6a-split. Custom Split tool with Lines / Polygons sub-categories.
    //   The default ol-ext Split only handles LineString.  We add a parent
    //   Toggle with a sub-bar containing two sub-toggles: "Lines" (ol-ext
    //   Split) and "Polygons" (our PolygonSplitInteraction).
    // No explicit sources → both interactions search ALL visible vector layers,
    // so they work on drawn features, parcels, zones, and any other polygon layer.
    this._lineSplitInteraction = new Split();
    this._polygonSplitInteraction = new PolygonSplitInteraction();
    this.map.addInteraction(this._lineSplitInteraction);
    this.map.addInteraction(this._polygonSplitInteraction);
    this._lineSplitInteraction.setActive(false);
    this._polygonSplitInteraction.setActive(false);

    // When a parcel is split, the user picks which piece keeps the UPN.
    this._polygonSplitInteraction.on('splitpick', (evt) => {
      const idFields = ['UPN', 'upn', 'id', 'parcelid', 'parcel_id', 'PARCELID', 'PARCEL_ID', 'ID'];
      for (const feat of evt.features) {
        if (feat === evt.picked) continue;
        for (const field of idFields) {
          if (feat.get(field) !== undefined) {
            feat.set(field, '');
          }
        }
      }
    });

    // Polygon Divide interaction (parameter-driven equal-area division)
    this._polygonDivideInteraction = new PolygonDivideInteraction();
    this.map.addInteraction(this._polygonDivideInteraction);
    this._polygonDivideInteraction.setActive(false);

    const splitLineToggle = new Toggle({
      html: '<i class="bi bi-slash-lg"></i>',
      className: 'ol-split-line',
      title: 'Split Lines',
      name: 'SplitLine',
      interaction: this._lineSplitInteraction,
      autoActivate: true,
    });
    const splitPolyToggle = new Toggle({
      html: '<i class="bi bi-scissors"></i>',
      className: 'ol-split-polygon',
      title: 'Split Polygons',
      name: 'SplitPolygon',
      interaction: this._polygonSplitInteraction,
    });
    const splitDivideToggle = new Toggle({
      html: '<i class="bi bi-grid-3x3-gap"></i>',
      className: 'ol-split-divide',
      title: 'Divide Polygon',
      name: 'DividePolygon',
      interaction: this._polygonDivideInteraction,
    });

    const splitSubBar = new Bar({
      toggleOne: true,
      autoDeactivate: true,
      controls: [splitLineToggle, splitPolyToggle, splitDivideToggle],
    });

    const splitParentToggle = new Toggle({
      className: 'ol-split',
      title: 'Split',
      name: 'Split',
      bar: splitSubBar,
      onToggle: (active) => {
        if (!active) {
          this._lineSplitInteraction.setActive(false);
          this._polygonSplitInteraction.setActive(false);
          this._polygonDivideInteraction.setActive(false);
        }
      },
    });
    this.editBar.addControl(splitParentToggle);

    // Listen for divide form request → show divide popup
    this._polygonDivideInteraction.on('divideform', (evt) => {
      this.showDividePopup(evt.feature, evt.source, evt.coordinate);
    });
    this._polygonDivideInteraction.on('dividecancel', () => {
      this.hideDividePopup();
    });

    // When a parcel is divided, the user picks which piece keeps the UPN.
    // The picked piece gets the original properties; all others get UPN cleared.
    this._polygonDivideInteraction.on('dividepick', (evt) => {
      const idFields = ['UPN', 'upn', 'id', 'parcelid', 'parcel_id', 'PARCELID', 'PARCEL_ID', 'ID'];
      for (const feat of evt.features) {
        if (feat === evt.picked) continue;
        // Clear identifier fields on the non-picked pieces
        for (const field of idFields) {
          if (feat.get(field) !== undefined) {
            feat.set(field, '');
          }
        }
      }
    });

    // 6a-merge. Polygon Merge tool — select two adjacent polygons, click shared
    //   edges, and merge them into one.  For parcels, a UPN chooser popup appears.
    this._polygonMergeInteraction = new PolygonMergeInteraction();
    this.map.addInteraction(this._polygonMergeInteraction);
    this._polygonMergeInteraction.setActive(false);

    const mergeToggle = new Toggle({
      html: '<i class="bi bi-union"></i>',
      className: 'ol-merge',
      title: 'Merge Polygons',
      name: 'Merge',
      interaction: this._polygonMergeInteraction,
    });
    this.editBar.addControl(mergeToggle);

    // Listen for merged-parcel event → show UPN chooser
    this._polygonMergeInteraction.on('mergedparcel', (evt) => {
      this.showMergeIdentifierPopup(evt.merged, evt.propsA, evt.propsB, evt.coordinate);
    });

    // Small-screen layout: insert a zero-height, full-width flex line-break
    // immediately BEFORE the action group. On phones (see the .ol-editbar
    // media query) this forces the wrap to happen here, so the action group
    // (undo/redo/save/snap) together with the Split and Merge toggles all land
    // on a single second row instead of Split/Merge spilling onto a third row.
    // The break is display:none on wider screens, so desktop layout is unchanged.
    const editbarEl = this.editBar.element;
    if (editbarEl && extraBar.element && extraBar.element.parentNode === editbarEl) {
      const breakEl = document.createElement('div');
      breakEl.className = 'ol-editbar-break';
      editbarEl.insertBefore(breakEl, extraBar.element);
    }

    // 6b. SnapGuides — shows alignment guides while drawing.
    //     Uses VectorImageLayer for GPU-friendly canvas rendering instead of
    //     re-creating individual SVG elements on every guide update.
    this._snapGuidesEnabled = localStorage.getItem('snap-guides-enabled') === '1';
    this._snapGuides = new SnapGuides({
      pixelTolerance: 10,
      vectorClass: VectorImageLayer,
    });
    this.map.addInteraction(this._snapGuides);

    // Connect SnapGuides to whichever draw interaction becomes active.
    // setDrawInteraction() only tracks one at a time, so we re-bind
    // whenever a draw tool is activated.
    const drawToolNames = ['DrawPoint', 'DrawLine', 'DrawPolygon', 'DrawHole', 'DrawRegular'];
    for (const name of drawToolNames) {
      const interaction = this.editBar.getInteraction(name);
      if (interaction) {
        interaction.on('change:active', () => {
          if (interaction.getActive()) {
            this._snapGuides.setDrawInteraction(interaction);
          }
        });
      }
    }

    // Also connect SnapGuides to the Modify interaction for vertex editing
    if (this._modifyInteraction) {
      this._snapGuides.setModifyInteraction(this._modifyInteraction);
    }

    // 6c. Snap-guides toggle button (magnet icon) — persisted in localStorage
    const snapToggleBtn = new Button({
      html: '<i class="bi bi-magnet"></i>',
      className: 'ol-snap-toggle' + (this._snapGuidesEnabled ? ' ol-active' : ''),
      title: 'Toggle Snap Guides',
      handleClick: () => {
        this._snapGuidesEnabled = !this._snapGuidesEnabled;
        localStorage.setItem('snap-guides-enabled', this._snapGuidesEnabled ? '1' : '0');
        // Update visual state
        snapToggleBtn.element.classList.toggle('ol-active', this._snapGuidesEnabled);
        // Activate or deactivate the interaction
        if (this._snapGuides) {
          this._snapGuides.setActive(this._snapGuidesEnabled && this._editBarActive);
        }
        console.log('[MapView] Snap guides:', this._snapGuidesEnabled ? 'ON' : 'OFF');
      },
    });
    this._snapToggleBtn = snapToggleBtn;
    extraBar.addControl(snapToggleBtn);

    // Start hidden — use the full setEditMode(false) so the Select +
    // Modify interactions are deactivated (the EditBar constructor may
    // have re-activated them).
    this.setEditMode(false);

    // 7. Link EditBar visibility to the Drawings group's visibility.
    this._drawingsGroup.on('change:visible', () => {
      const visible = this._drawingsGroup.getVisible();
      this.setEditMode(visible);
    });

    // 8. Touch-device detection & TouchCursor setup
    //
    // The TouchCursor is only practical on *touch-only* devices (phones,
    // tablets) where the finger is the sole pointing device. On hybrid
    // laptops — a touchscreen plus a touchpad/mouse — the changed cursor
    // gets in the way, because the user is most likely driving the map
    // with the precise pointer, not the screen.
    //
    // The classic `'ontouchstart' in window` / `maxTouchPoints` test can't
    // tell the two apart: it is true for both a tablet and a touchscreen
    // laptop. We additionally consult CSS Media Queries Level 4 pointer
    // features, which DO distinguish them:
    //
    //   any-pointer: fine  → at least one fine pointer exists
    //                        (mouse / touchpad / stylus). True on a
    //                        touch laptop, false on a phone/tablet.
    //   any-hover: hover   → at least one device can hover. Same split.
    //
    // So we enable the TouchCursor only when the device is touch-capable
    // AND exposes no fine/hovering pointer — i.e. a genuine touch-only
    // device. `_isTouchOnlyDevice()` is also reactive: if the user later
    // plugs in a mouse, `_refreshTouchCursor()` tears the cursor down.
    if (this._isTouchOnlyDevice()) {
      this.touchCursor = new TouchCursor({
        className: 'ol-editbar-cursor',
      });
      this.map.addInteraction(this.touchCursor);
      this.touchCursor.setActive(false);
      console.log('[MapView] Touch-only device detected — TouchCursor added');
    } else {
      console.log('[MapView] Fine pointer available — TouchCursor skipped');
    }

    // React to pointer-capability changes (e.g. a Bluetooth mouse paired
    // with a tablet, or a tablet docked to a trackpad keyboard). When a
    // fine pointer appears we drop the TouchCursor; when the last one is
    // removed on a touch device we add it back.
    this._installPointerCapabilityWatcher();

    // 9. Listen for polygon features drawn via EditBar's DrawPolygon tool.
    //    When a Polygon is added to the drawings source, show the attribute popup.
    this.drawingsSource.on('addfeature', (evt) => {
      const feature = evt.feature;
      const geom = feature.getGeometry();
      if (!geom || geom.getType() !== 'Polygon') return;

      const coordinate = geom.getInteriorPoint().getCoordinates();
      this.showDrawnPolygonPopup(feature, coordinate);
    });

    console.log('[MapView] EditBar initialised with Drawings group, UndoRedo and SnapGuides (default:', this._snapGuidesEnabled ? 'ON' : 'OFF', ')');
  }

  /**
   * Dispatch a custom edit event (e.g. 'save').
   * External code can listen via mapView.onEditEvent('save', callback).
   * @param {string} type
   */
  dispatchEditEvent(type) {
    if (!this._editEventListeners) return;
    const listeners = this._editEventListeners[type];
    if (listeners) {
      listeners.forEach((fn) => fn());
    }
  }

  /**
   * Listen for custom edit events (e.g. 'save').
   * @param {string} type - Event name
   * @param {Function} callback
   */
  onEditEvent(type, callback) {
    if (!this._editEventListeners) this._editEventListeners = {};
    if (!this._editEventListeners[type]) this._editEventListeners[type] = [];
    this._editEventListeners[type].push(callback);
  }

  /**
   * Toggle edit mode on or off.
   *
   * When ON:  EditBar is visible, Select + Modify interactions are active.
   * When OFF: EditBar is hidden, Select + Modify are deactivated, any
   *           current selection is cleared so normal click / double-click
   *           events work without interference.
   *
   * @param {boolean} active
   */
  setEditMode(active) {
    this._editBarActive = !!active;

    if (this.editBar) {
      this.editBar.setVisible(this._editBarActive);

      if (!this._editBarActive) {
        // Deactivate all EditBar controls (DrawPoint, DrawLine, etc.)
        // so no draw interaction stays active in the background.
        this.editBar.deactivateControls();
      }
    }

    // Activate / deactivate Select + Modify
    if (this._selectInteraction) {
      if (!this._editBarActive) {
        // Clear any current selection first
        this._selectInteraction.getFeatures().clear();
      }
      this._selectInteraction.setActive(this._editBarActive);
    }
    if (this._modifyInteraction) {
      this._modifyInteraction.setActive(this._editBarActive);
    }

    // Toggle SnapGuides — only active when both edit mode AND the user toggle are on
    if (this._snapGuides) {
      this._snapGuides.setActive(this._snapGuidesEnabled && this._editBarActive);
    }

    // Toggle TouchCursor
    if (this.touchCursor) {
      this.touchCursor.setActive(this._editBarActive);
    }

    // Clear persistent vertex highlights when leaving edit mode
    if (!this._editBarActive && this._vertexOverlaySource) {
      this._vertexOverlaySource.clear();
    }

    console.log('[MapView] Edit mode:', this._editBarActive ? 'ON' : 'OFF');
  }

  /**
   * Check whether edit mode (select / modify) is currently active.
   * @returns {boolean}
   */
  isEditMode() {
    return this._editBarActive;
  }

  // ============================================================================
  // Pointer-capability detection (touch-only vs. hybrid laptop)
  // ============================================================================

  /**
   * Decide whether the ol-ext TouchCursor should be used.
   *
   * Returns true ONLY for genuine touch-only devices (phones, tablets) —
   * i.e. devices that are touch-capable but expose no fine pointer and no
   * hover capability. Hybrid laptops (touchscreen + touchpad/mouse) return
   * false, so they keep the normal cursor.
   *
   * @returns {boolean}
   */
  _isTouchOnlyDevice() {
    const hasTouch = ('ontouchstart' in window) ||
      (navigator.maxTouchPoints > 0) ||
      (navigator.msMaxTouchPoints > 0);
    if (!hasTouch) return false;

    // Without matchMedia we can't refine the signal — fall back to the
    // historical behaviour (treat any touch device as touch).
    if (typeof window.matchMedia !== 'function') return true;

    // `any-pointer: fine` is true when ANY attached pointing device is
    // fine (mouse / touchpad / stylus); `any-hover: hover` when ANY device
    // can hover. A phone/tablet satisfies neither; a touch laptop both.
    const hasFinePointer = window.matchMedia('(any-pointer: fine)').matches;
    const canHover       = window.matchMedia('(any-hover: hover)').matches;

    return !hasFinePointer && !canHover;
  }

  /**
   * Add or remove the TouchCursor to match the current pointer capability,
   * preserving its active state relative to edit mode. Called both at init
   * and whenever the pointer-capability media queries change.
   */
  _refreshTouchCursor() {
    const wantCursor = this._isTouchOnlyDevice();

    if (wantCursor && !this.touchCursor) {
      this.touchCursor = new TouchCursor({ className: 'ol-editbar-cursor' });
      this.map.addInteraction(this.touchCursor);
      this.touchCursor.setActive(this._editBarActive);
      console.log('[MapView] Pointer change → TouchCursor added');
    } else if (!wantCursor && this.touchCursor) {
      this.map.removeInteraction(this.touchCursor);
      this.touchCursor = null;
      console.log('[MapView] Pointer change → TouchCursor removed (fine pointer present)');
    }
  }

  /**
   * Watch the pointer-capability media queries and re-evaluate the
   * TouchCursor when they change — e.g. a Bluetooth mouse paired with a
   * tablet, or a 2-in-1 docked to / undocked from a keyboard-trackpad.
   */
  _installPointerCapabilityWatcher() {
    if (typeof window.matchMedia !== 'function') return;
    if (this._pointerMediaQueries) return;   // already installed

    const queries = ['(any-pointer: fine)', '(any-hover: hover)']
      .map((q) => window.matchMedia(q));
    const onChange = () => this._refreshTouchCursor();

    for (const mq of queries) {
      // addEventListener is the modern API; addListener is the legacy
      // fallback for older Safari.
      if (typeof mq.addEventListener === 'function') {
        mq.addEventListener('change', onChange);
      } else if (typeof mq.addListener === 'function') {
        mq.addListener(onChange);
      }
    }
    this._pointerMediaQueries = queries;
    this._pointerMediaListener = onChange;
  }

  // ============================================================================
  // Persistent Vertex Highlight Overlay
  // ============================================================================

  /**
   * Create a vector layer that renders a small dot at every vertex of any
   * currently-selected feature (polygon, multipolygon, line, multiline).
   * Only active while edit mode is on.
   *
   * Hooks:
   *  - `select` event from the Select interaction → rebuild dots for the new selection
   *  - `change` event on the selected feature → reposition dots when a vertex is dragged
   */
  _setupVertexOverlay() {
    this._vertexOverlaySource = new VectorSource();
    this._vertexOverlayLayer = new VectorLayer({
      title: '__vertex_highlight__',
      source: this._vertexOverlaySource,
      // Render above all other overlays but below ModifyFeature's hover indicator
      zIndex: 990,
      style: new Style({
        image: new Circle({
          radius: 4,
          fill: new Fill({ color: 'rgba(14,165,233,0.85)' }), // brand blue
          stroke: new Stroke({ color: '#fff', width: 1.2 }),
        }),
      }),
    });
    // Hide from LayerSwitcher — purely visual, not user-toggleable
    this._vertexOverlayLayer.set('displayInLayerSwitcher', false);
    this.map.addLayer(this._vertexOverlayLayer);

    // Bound handler so we can attach/detach by reference
    this._onSelectedFeatureGeomChange = () => this._refreshVertexOverlay();

    // Track which feature(s) we're listening on, so we can unhook cleanly
    this._vertexTrackedFeatures = new Set();

    // When the selection changes, swap which features we listen to and rebuild dots
    this._selectInteraction.on('select', () => this._refreshVertexOverlay());
  }

  /**
   * Rebuild the vertex overlay from the current Select interaction's features.
   * No-ops when not in edit mode.
   */
  _refreshVertexOverlay() {
    if (!this._vertexOverlaySource) return;
    this._vertexOverlaySource.clear();

    // Detach change listeners from previously-tracked features
    if (this._vertexTrackedFeatures) {
      for (const f of this._vertexTrackedFeatures) {
        f.un('change', this._onSelectedFeatureGeomChange);
      }
      this._vertexTrackedFeatures.clear();
    }

    if (!this._editBarActive || !this._selectInteraction) return;

    const selected = this._selectInteraction.getFeatures().getArray();
    for (const feat of selected) {
      const geom = feat.getGeometry();
      if (!geom) continue;
      const type = geom.getType();
      if (!['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'].includes(type)) {
        continue;
      }
      const coords = this._collectAllVertices(geom);
      for (const c of coords) {
        this._vertexOverlaySource.addFeature(new Feature(new Point(c)));
      }
      // Listen for vertex moves on this feature
      feat.on('change', this._onSelectedFeatureGeomChange);
      this._vertexTrackedFeatures.add(feat);
    }
  }

  /**
   * Walk a (Multi)Polygon or (Multi)LineString geometry and return the flat
   * list of vertex coordinates. Polygon rings have a duplicate closing vertex
   * (last == first) which is dropped here so we don't render two dots on top
   * of each other.
   *
   * @param {Geometry} geom
   * @returns {Array<Array<number>>}
   */
  _collectAllVertices(geom) {
    const out = [];
    const isCoord = (v) => Array.isArray(v) && typeof v[0] === 'number';

    const visitRing = (ring, isPolygonRing) => {
      const len = isPolygonRing && ring.length > 1 ? ring.length - 1 : ring.length;
      for (let i = 0; i < len; i++) out.push(ring[i]);
    };

    const type = geom.getType();
    const coords = geom.getCoordinates();

    switch (type) {
      case 'Polygon':
        // coords = [outerRing, hole1, hole2, …]
        for (const ring of coords) visitRing(ring, true);
        break;
      case 'MultiPolygon':
        // coords = [poly1, poly2, …]; each poly = [outerRing, hole1, …]
        for (const poly of coords) for (const ring of poly) visitRing(ring, true);
        break;
      case 'LineString':
        visitRing(coords, false);
        break;
      case 'MultiLineString':
        for (const line of coords) visitRing(line, false);
        break;
      default:
        // Fallback: deep walk to find arrays of [x, y]
        const walk = (v) => {
          if (isCoord(v)) out.push(v);
          else if (Array.isArray(v)) for (const sub of v) walk(sub);
        };
        walk(coords);
    }
    return out;
  }

  /**
   * Get the Drawings layer for external access.
   * @returns {VectorLayer}
   */
  getDrawingsLayer() {
    return this.drawingsLayer;
  }

  /**
   * Get the Drawings source for external access.
   * @returns {VectorSource}
   */
  getDrawingsSource() {
    return this.drawingsSource;
  }

  /**
   * Get the EditBar control for external access.
   * @returns {EditBar}
   */
  getEditBar() {
    return this.editBar;
  }

  /**
   * Update the ScaleBar units ('metric' or 'imperial').
   * @param {'metric'|'imperial'} system
   */
  setScaleBarUnits(system) {
    if (this.scaleBar) {
      this.scaleBar.setUnits(system === 'imperial' ? 'imperial' : 'metric');
    }
  }

  /**
   * Create the popup overlay element and add to map
   */
  createPopup() {
    // Create popup container element
    this.popupElement = document.createElement('div');
    this.popupElement.className = 'map-popup';
    this.popupElement.style.cssText = `
      position: absolute;
      background: var(--card, #fff);
      color: var(--card-foreground, #1e1a4b);
      border-radius: 8px;
      padding: 10px 14px;
      box-shadow: 0 2px 8px rgba(0,0,0,0.25);
      font-family: var(--font-body, 'Exo', sans-serif);
      font-size: 13px;
      min-width: 150px;
      max-width: 280px;
      pointer-events: none;
      z-index: 1000;
      border: 1px solid var(--border, #1e1a4b1f);
    `;

    // Create the overlay
    this.popup = new Overlay({
      element: this.popupElement,
      positioning: 'bottom-center',
      offset: [0, -15],
      stopEvent: false,
    });

    this.map.addOverlay(this.popup);

    // Set up hover handler
    this.setupHoverPopup();
  }

  /**
   * Set up the hover popup behavior
   */
  setupHoverPopup() {
    let currentFeature = null;

    this.map.on('pointermove', (evt) => {
      if (evt.dragging) {
        this.hidePopup();
        return;
      }

      // Only find features that are location markers (have 'name' property)
      const feature = this.map.forEachFeatureAtPixel(evt.pixel, (f) => {
        // Only return features that have a 'name' property (location markers)
        if (f.get('name')) {
          return f;
        }
        return null;
      });

      if (feature && feature !== currentFeature) {
        currentFeature = feature;
        this.showPopup(feature, evt.coordinate);
      } else if (!feature && currentFeature) {
        currentFeature = null;
        this.hidePopup();
      }

      // Update cursor - only show pointer for location markers
      this.map.getTargetElement().style.cursor = feature ? 'pointer' : '';
    });

    // Hide popup when mouse leaves the map
    this.map.getTargetElement().addEventListener('mouseleave', () => {
      this.hidePopup();
      currentFeature = null;
    });
  }

  /**
   * Show popup with feature attributes
   */
  showPopup(feature, coordinate) {
    const name = feature.get('name') || 'Unnamed';
    const category = feature.get('category') || 'default';
    const description = feature.get('description');
    const lon = feature.get('lon');
    const lat = feature.get('lat');
    const emoji = this.getEmoji(category);

    // Build popup content
    let html = `
      <div style="font-weight: 600; font-size: 14px; margin-bottom: 6px;">
        ${emoji} ${this.escapeHtml(name)}
      </div>
    `;

    // Category badge
    const categoryColors = {
      'water': '#3b82f6',
      'school': '#f59e0b',
      'health': '#ef4444',
      'market': '#8b5cf6',
      'default': '#2d5016',
      'other': '#6b7280'
    };
    const catColor = categoryColors[category] || '#6b7280';
    html += `
      <div style="margin-bottom: 6px;">
        <span style="
          background: ${catColor}20;
          color: ${catColor};
          padding: 2px 8px;
          border-radius: 4px;
          font-size: 11px;
          font-weight: 500;
        ">${category}</span>
      </div>
    `;

    // Description if available
    if (description) {
      html += `
        <div style="color: var(--muted-foreground, #7a7a7a); font-size: 12px; margin-bottom: 6px; line-height: 1.4;">
          ${this.escapeHtml(description)}
        </div>
      `;
    }

    // Coordinates
    if (lon !== undefined && lat !== undefined) {
      html += `
        <div style="color: var(--muted-foreground, #7a7a7a); font-size: 11px; font-family: monospace;">
          ${Number(lon).toFixed(5)}, ${Number(lat).toFixed(5)}
        </div>
      `;
    }

    this.popupElement.innerHTML = html;
    this.popup.setPosition(coordinate);
  }

  /**
   * Hide the popup
   */
  hidePopup() {
    this.popup.setPosition(undefined);
  }

  /**
   * Create the info popup overlay for double-click feature details
   */
  createInfoPopup() {
    this.infoPopupElement = document.createElement('div');
    this.infoPopupElement.className = 'map-info-popup';
    this.infoPopupElement.style.cssText = `
      position: absolute;
      background: var(--card, #fff);
      color: var(--card-foreground, #1e1a4b);
      border-radius: 10px;
      padding: 0;
      box-shadow: 0 4px 16px rgba(0,0,0,0.3);
      font-family: var(--font-body, 'Exo', sans-serif);
      font-size: 13px;
      min-width: 220px;
      max-width: 320px;
      max-height: 70vh;
      display: flex;
      flex-direction: column;
      z-index: 1001;
      border: 1px solid var(--border, #1e1a4b1f);
      overflow: hidden;
    `;

    this.infoPopup = new Overlay({
      element: this.infoPopupElement,
      positioning: 'bottom-center',
      offset: [0, -10],
      stopEvent: true,
      autoPan: true,
      autoPanAnimation: { duration: 250 },
    });

    this.map.addOverlay(this.infoPopup);
  }

  /**
   * Show the info popup with feature attributes and area
   * @param {Feature} feature - OpenLayers feature
   * @param {Array} coordinate - Map coordinate [x, y]
   * @param {Object} [options] - Display options
   * @param {string} [options.title='Feature Info'] - Popup header title
   * @param {string} [options.color='#e11d48'] - Header background colour
   */
  showInfoPopup(feature, coordinate, options = {}) {
    const { title = 'Feature Info', color = '#e11d48' } = options;
    const properties = feature.getProperties();
    const geometry = feature.getGeometry();
    const geomType = geometry.getType();

    // Build attributes table rows (skip geometry and internal keys)
    const skipKeys = ['geometry', '_layerType'];
    let rows = '';
    for (const [key, value] of Object.entries(properties)) {
      if (skipKeys.includes(key) || value === undefined || value === null) continue;
      rows += `
        <tr>
          <td style="padding:4px 8px;font-weight:600;color:var(--muted-foreground, #7a7a7a);white-space:nowrap;">${this.escapeHtml(key)}</td>
          <td style="padding:4px 8px;color:var(--foreground, #1e1a4b);">${this.escapeHtml(String(value))}</td>
        </tr>
      `;
    }

    // Add measurement row based on geometry type
    if (geomType === 'Polygon' || geomType === 'MultiPolygon') {
      // Area for polygons
      const areaSqm = getArea(geometry, { projection: 'EPSG:3857' });
      const areaFormatted = formatAreaFull(areaSqm);
      rows += `
        <tr style="border-top:1px solid var(--border, #1e1a4b1f);">
          <td style="padding:4px 8px;font-weight:600;color:var(--muted-foreground, #7a7a7a);white-space:nowrap;">area</td>
          <td style="padding:4px 8px;color:var(--foreground, #1e1a4b);">${areaFormatted}</td>
        </tr>
      `;
    } else if (geomType === 'LineString' || geomType === 'MultiLineString') {
      // Length for lines
      const lengthM = getLength(geometry, { projection: 'EPSG:3857' });
      const lengthFormatted = formatLengthFull(lengthM);
      rows += `
        <tr style="border-top:1px solid var(--border, #1e1a4b1f);">
          <td style="padding:4px 8px;font-weight:600;color:var(--muted-foreground, #7a7a7a);white-space:nowrap;">length</td>
          <td style="padding:4px 8px;color:var(--foreground, #1e1a4b);">${lengthFormatted}</td>
        </tr>
      `;
    } else if (geomType === 'Point') {
      // Coordinates for points
      const coords = toLonLat(geometry.getCoordinates());
      const lon = coords[0].toFixed(6);
      const lat = coords[1].toFixed(6);
      rows += `
        <tr style="border-top:1px solid var(--border, #1e1a4b1f);">
          <td style="padding:4px 8px;font-weight:600;color:var(--muted-foreground, #7a7a7a);white-space:nowrap;">longitude</td>
          <td style="padding:4px 8px;color:var(--foreground, #1e1a4b);">${lon}</td>
        </tr>
        <tr>
          <td style="padding:4px 8px;font-weight:600;color:var(--muted-foreground, #7a7a7a);white-space:nowrap;">latitude</td>
          <td style="padding:4px 8px;color:var(--foreground, #1e1a4b);">${lat}</td>
        </tr>
      `;
    }

    const html = `
      <div style="background:${color};color:#fff;padding:8px 12px;font-weight:600;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;border-radius:10px 10px 0 0;">
        <span>${this.escapeHtml(title)}</span>
        <button id="info-popup-close" style="background:none;border:none;color:#fff;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;">&times;</button>
      </div>
      <div style="padding:8px 4px;overflow-y:auto;flex:1 1 auto;min-height:0;">
        <table style="width:100%;border-collapse:collapse;font-size:13px;">
          ${rows}
        </table>
      </div>
    `;

    this.infoPopupElement.innerHTML = html;
    this.infoPopup.setPosition(coordinate);

    // Close button handler
    this.infoPopupElement.querySelector('#info-popup-close').addEventListener('click', () => {
      this.hideInfoPopup();
    });
  }

  /**
   * Hide the info popup
   */
  hideInfoPopup() {
    this.infoPopup.setPosition(undefined);
  }

  // ============================================================================
  // Circle Intersection Analysis
  // ============================================================================

  /**
   * Analyse which features from overlay layers intersect a measurement circle
   * and show the results in the info popup.
   *
   * @param {Feature} circleFeature - The measurement circle feature (Circle geometry)
   * @param {Array} coordinate - Map coordinate for popup placement [x, y]
   */
  /**
   * Collect intersection results (parcels, zones, other) into a
   * structured { label, value } array for both HTML and PDF rendering.
   */
  _collectIntersectionRows(parcelFeatures, zoneFeatures, otherByLayer) {
    const dataRows = [];

    if (parcelFeatures.length > 0) {
      dataRows.push({ label: 'Parcels', value: String(parcelFeatures.length), color: '#0ea5e9' });
    }

    if (zoneFeatures.length > 0) {
      const names = zoneFeatures.map(f =>
        f.get('colzonename') || f.get('zone_name') || f.get('name') || 'unnamed'
      );
      dataRows.push({ label: 'Zones', value: String(zoneFeatures.length), color: '#7c3aed' });
      dataRows.push({ label: 'Zone Names', value: names.map(n => this.escapeHtml(n)).join(', '), color: '#7c3aed' });
    }

    for (const [title, features] of Object.entries(otherByLayer)) {
      dataRows.push({ label: this.escapeHtml(title), value: `${features.length} feature(s)` });
    }

    if (dataRows.length === 0) {
      dataRows.push({ label: '', value: 'No intersecting features found', empty: true });
    }

    return dataRows;
  }

  /**
   * Build the full popup HTML for an analysis popup (circle or area).
   *
   * @param {string} emoji - Header emoji
   * @param {string} title - e.g. "Circle Analysis"
   * @param {Array<{label:string, value:string, color?:string, empty?:boolean}>} dataRows
   * @returns {string} HTML
   */
  _buildAnalysisPopupHtml(emoji, title, dataRows) {
    let tableRows = '';
    for (const row of dataRows) {
      if (row.empty) {
        tableRows += `
          <tr style="border-top:1px solid var(--border, #1e1a4b1f);">
            <td colspan="2" style="padding:8px;color:#999;text-align:center;font-style:italic;">${row.value}</td>
          </tr>`;
        continue;
      }
      const labelColor = row.color || 'var(--muted-foreground, #7a7a7a)';
      const border = row._first ? '' : 'border-top:1px solid var(--border, #1e1a4b1f);';
      tableRows += `
        <tr style="${border}">
          <td style="padding:4px 8px;font-weight:600;color:${labelColor};white-space:nowrap;">${row.label}</td>
          <td style="padding:4px 8px;color:var(--foreground, #1e1a4b);">${row.value}</td>
        </tr>`;
    }

    return `
      <div style="background:var(--brand-navy, #1e1a4b);color:#fff;padding:8px 12px;font-weight:600;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;border-radius:10px 10px 0 0;">
        <span>${emoji} ${title}</span>
        <button id="info-popup-close" style="background:none;border:none;color:#fff;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;">&times;</button>
      </div>
      <div style="padding:8px 4px;overflow-y:auto;flex:1 1 auto;min-height:0;">
        <table style="width:100%;border-collapse:collapse;font-size:13px;">
          ${tableRows}
        </table>
      </div>
      <div style="padding:2px 8px 8px;display:flex;justify-content:flex-end;gap:6px;flex-shrink:0;border-top:1px solid var(--border, #1e1a4b1f);">
        <button id="info-popup-export-gis"
          style="background:var(--brand-navy,#1e1a4b);color:#fff;border:none;border-radius:6px;padding:5px 12px;font-size:12px;cursor:pointer;font-family:inherit;">
          🗺️ Export GIS
        </button>
        <button id="info-popup-export-pdf"
          style="background:var(--brand-navy,#1e1a4b);color:#fff;border:none;border-radius:6px;padding:5px 12px;font-size:12px;cursor:pointer;font-family:inherit;">
          📄 Export PDF
        </button>
      </div>`;
  }

  /**
   * Show the analysis popup, attach close + PDF / GIS export handlers.
   *
   * @param {string}   emoji
   * @param {string}   title          - e.g. "Area Analysis" / "Circle Analysis"
   * @param {Array}    dataRows       - summary rows (PDF uses these)
   * @param {number[]} coordinate     - popup anchor
   * @param {Object}   [exportContext] - features + clip geometry for GIS export.
   *                                    When omitted, the Export GIS button is
   *                                    disabled (no features to export).
   *   { kind: 'area' | 'circle',
   *     clipGeometry,
   *     parcelFeatures, zoneFeatures, otherByLayer }
   */
  _showAnalysisPopup(emoji, title, dataRows, coordinate, exportContext = null) {
    this.infoPopupElement.innerHTML = this._buildAnalysisPopupHtml(emoji, title, dataRows);
    this.infoPopup.setPosition(coordinate);

    this.infoPopupElement.querySelector('#info-popup-close').addEventListener('click', () => {
      this.hideInfoPopup();
    });

    // PDF export — dynamic import so jspdf is only loaded on demand
    this.infoPopupElement.querySelector('#info-popup-export-pdf')?.addEventListener('click', () => {
      // Strip HTML from values and remove the color/empty keys for the PDF
      const pdfRows = dataRows
        .filter(r => !r.empty)
        .map(r => ({ label: r.label, value: r.value.replace(/<[^>]*>/g, '') }));

      import('../pdf-export.js').then(({ exportAnalysisPDF }) => {
        exportAnalysisPDF({ title, rows: pdfRows });
      }).catch(err => {
        console.error('[MapView] PDF export failed:', err);
      });
    });

    // GIS export — dispatches a window-level CustomEvent so main.js can open
    // the format/field-rename modal without MapView pulling in the writers.
    const gisBtn = this.infoPopupElement.querySelector('#info-popup-export-gis');
    if (gisBtn) {
      const total = exportContext
        ? exportContext.parcelFeatures.length
          + exportContext.zoneFeatures.length
          + Object.values(exportContext.otherByLayer).reduce((s, arr) => s + arr.length, 0)
        : 0;
      if (!exportContext || total === 0) {
        gisBtn.disabled = true;
        gisBtn.style.opacity = '0.5';
        gisBtn.style.cursor = 'not-allowed';
        gisBtn.title = 'No intersecting features to export';
      } else {
        gisBtn.addEventListener('click', () => {
          window.dispatchEvent(new CustomEvent('lupmis:export-gis', {
            detail: { title, ...exportContext },
          }));
        });
      }
    }
  }

  showCircleIntersectionPopup(circleFeature, coordinate) {
    const circleGeom = circleFeature.getGeometry();
    if (!circleGeom || typeof circleGeom.getCenter !== 'function') return;

    // Convert the OL Circle to a polygon (64 sides) for intersection testing
    const circlePoly = fromCircle(circleGeom, 64);
    const circleExtent = circlePoly.getExtent();

    const radius = circleFeature.get('_radius') || circleGeom.getRadius();

    // Collect intersecting features grouped by layer type
    const parcelFeatures = [];
    const zoneFeatures = [];
    const otherByLayer = {};

    const intersectsCircle = (feature) => {
      const geom = feature.getGeometry();
      if (!geom) return false;
      const fExtent = geom.getExtent();
      if (
        fExtent[2] < circleExtent[0] ||
        fExtent[0] > circleExtent[2] ||
        fExtent[3] < circleExtent[1] ||
        fExtent[1] > circleExtent[3]
      ) {
        return false;
      }
      return circlePoly.intersectsExtent(fExtent) && this._geometriesIntersect(circlePoly, geom);
    };

    const scanGroup = (group, groupTitle) => {
      group.getLayers().forEach((layer) => {
        if (layer instanceof LayerGroup) {
          scanGroup(layer, layer.get('title') || groupTitle);
        } else if (layer instanceof VectorLayer && layer.getVisible()) {
          const layerTitle = layer.get('title') || groupTitle || 'Unknown';
          const source = layer.getSource();
          if (!source) return;

          const candidates = source.getFeaturesInExtent(circleExtent);
          for (const f of candidates) {
            const fType = f.get('_layerType');
            if (fType === 'measure_circle' || fType === 'measure_circle_radius') continue;

            if (!intersectsCircle(f)) continue;

            if (fType === 'parcel') {
              parcelFeatures.push(f);
            } else if (fType === 'collector_zone') {
              zoneFeatures.push(f);
            } else {
              if (!otherByLayer[layerTitle]) otherByLayer[layerTitle] = [];
              otherByLayer[layerTitle].push(f);
            }
          }
        }
      });
    };

    scanGroup(this.overlayGroup, 'Overlays');

    // Build structured data rows
    const radiusFormatted = formatLength(radius);
    const areaSqm = Math.PI * radius * radius;
    const areaFormatted = formatArea(areaSqm);

    const dataRows = [
      { label: 'Radius', value: radiusFormatted, _first: true },
      { label: 'Area', value: areaFormatted },
      ...this._collectIntersectionRows(parcelFeatures, zoneFeatures, otherByLayer),
    ];

    this._showAnalysisPopup('⭕', 'Circle Analysis', dataRows, coordinate, {
      kind: 'circle',
      clipGeometry: circlePoly,
      parcelFeatures,
      zoneFeatures,
      otherByLayer,
    });
  }

  /**
   * Show an intersection-analysis popup for a measured area polygon.
   * Same logic as showCircleIntersectionPopup but works with an
   * arbitrary Polygon geometry instead of a circle.
   *
   * @param {Feature} polygonFeature - The measure_area feature
   * @param {number[]} coordinate - Map coordinate for the popup anchor
   */
  showAreaIntersectionPopup(polygonFeature, coordinate) {
    const polyGeom = polygonFeature.getGeometry();
    if (!polyGeom) return;

    const polyExtent = polyGeom.getExtent();

    // Compute area via ol/sphere for geodesic accuracy
    const areaSqm = getArea(polyGeom, { projection: 'EPSG:3857' });
    const areaFormatted = formatArea(areaSqm);

    // Compute perimeter
    const perimeterM = getLength(polyGeom, { projection: 'EPSG:3857' });
    const perimeterFormatted = formatLength(perimeterM);

    // Collect intersecting features grouped by layer type
    const parcelFeatures = [];
    const zoneFeatures = [];
    const otherByLayer = {};

    const intersectsPoly = (feature) => {
      const geom = feature.getGeometry();
      if (!geom) return false;
      const fExtent = geom.getExtent();
      if (
        fExtent[2] < polyExtent[0] ||
        fExtent[0] > polyExtent[2] ||
        fExtent[3] < polyExtent[1] ||
        fExtent[1] > polyExtent[3]
      ) {
        return false;
      }
      return polyGeom.intersectsExtent(fExtent) && this._geometriesIntersect(polyGeom, geom);
    };

    const scanGroup = (group, groupTitle) => {
      group.getLayers().forEach((layer) => {
        if (layer instanceof LayerGroup) {
          scanGroup(layer, layer.get('title') || groupTitle);
        } else if (layer instanceof VectorLayer && layer.getVisible()) {
          const layerTitle = layer.get('title') || groupTitle || 'Unknown';
          const source = layer.getSource();
          if (!source) return;

          const candidates = source.getFeaturesInExtent(polyExtent);
          for (const f of candidates) {
            const fType = f.get('_layerType');
            if (fType === 'measure_area' || fType === 'measure_circle' || fType === 'measure_circle_radius') continue;

            if (!intersectsPoly(f)) continue;

            if (fType === 'parcel') {
              parcelFeatures.push(f);
            } else if (fType === 'collector_zone') {
              zoneFeatures.push(f);
            } else {
              if (!otherByLayer[layerTitle]) otherByLayer[layerTitle] = [];
              otherByLayer[layerTitle].push(f);
            }
          }
        }
      });
    };

    scanGroup(this.overlayGroup, 'Overlays');

    // Build structured data rows
    const dataRows = [
      { label: 'Area', value: areaFormatted, _first: true },
      { label: 'Perimeter', value: perimeterFormatted },
      ...this._collectIntersectionRows(parcelFeatures, zoneFeatures, otherByLayer),
    ];

    this._showAnalysisPopup('📐', 'Area Analysis', dataRows, coordinate, {
      kind: 'area',
      clipGeometry: polyGeom,
      parcelFeatures,
      zoneFeatures,
      otherByLayer,
    });
  }

  /**
   * Test whether two geometries truly intersect (beyond just extent overlap).
   * Works for Polygon/MultiPolygon against any geometry type.
   *
   * @param {Geometry} geomA - First geometry (usually the circle polygon)
   * @param {Geometry} geomB - Second geometry
   * @returns {boolean}
   * @private
   */
  _geometriesIntersect(geomA, geomB) {
    const typeB = geomB.getType();

    // For polygons / multi-polygons: check if any coordinate of B is inside A,
    // or if any coordinate of A is inside B (covers overlap & containment).
    if (typeB === 'Polygon' || typeB === 'MultiPolygon') {
      // Check if any vertex of B lies inside A (use flatCoordinates for efficiency)
      const flatB = geomB.getFlatCoordinates();
      const stride = geomB.getStride();
      for (let i = 0; i < flatB.length; i += stride) {
        if (geomA.intersectsCoordinate([flatB[i], flatB[i + 1]])) return true;
      }
      // Check if any vertex of A lies inside B
      const flatA = geomA.getFlatCoordinates();
      const strideA = geomA.getStride();
      for (let i = 0; i < flatA.length; i += strideA) {
        if (geomB.intersectsCoordinate([flatA[i], flatA[i + 1]])) return true;
      }
      return false;
    }

    if (typeB === 'Point') {
      return geomA.intersectsCoordinate(geomB.getCoordinates());
    }

    if (typeB === 'LineString' || typeB === 'MultiLineString') {
      const flatB = geomB.getFlatCoordinates();
      const stride = geomB.getStride();
      for (let i = 0; i < flatB.length; i += stride) {
        if (geomA.intersectsCoordinate([flatB[i], flatB[i + 1]])) return true;
      }
      return false;
    }

    // Fallback: extent overlap is good enough
    return true;
  }

  // ============================================================================
  // Parcel Edit Popup (single-click editable form)
  // ============================================================================

  /**
   * Create the parcel edit popup overlay with a dynamic form.
   */
  createParcelEditPopup() {
    this.parcelEditElement = document.createElement('div');
    this.parcelEditElement.className = 'map-parcel-edit-popup';
    this.parcelEditElement.style.cssText = `
      position: absolute;
      background: var(--card, #fff);
      color: var(--card-foreground, #1e1a4b);
      border-radius: 10px;
      box-shadow: 0 4px 20px rgba(0,0,0,0.3);
      font-family: var(--font-body, 'Exo', sans-serif);
      font-size: 13px;
      min-width: 280px;
      max-width: 360px;
      max-height: 420px;
      z-index: 1002;
      border: 2px solid var(--primary, #005eb8);
      overflow: hidden;
      display: flex;
      flex-direction: column;
    `;

    this.parcelEditPopup = new Overlay({
      element: this.parcelEditElement,
      positioning: 'bottom-center',
      offset: [0, -10],
      stopEvent: true,
      autoPan: true,
      autoPanAnimation: { duration: 250 },
    });

    this.map.addOverlay(this.parcelEditPopup);

    // Callbacks for save events
    this._parcelEditCallbacks = [];
    // Track the current feature being edited
    this._parcelEditFeature = null;
  }

  /**
   * Show the parcel edit popup with an editable form for all feature attributes.
   * Internal keys (_layerType, geometry) are excluded from the form.
   *
   * @param {Feature} feature - The OL feature to edit
   * @param {Array} coordinate - Map coordinate [x, y]
   */
  showParcelEditPopup(feature, coordinate) {
    this._parcelEditFeature = feature;
    const properties = feature.getProperties();

    // Keys to skip in the form
    const skipKeys = ['geometry', '_layerType'];

    // Build form fields from feature properties
    let fieldsHtml = '';
    for (const [key, value] of Object.entries(properties)) {
      if (skipKeys.includes(key)) continue;
      const displayVal = (value === null || value === undefined) ? '' : String(value);
      const escapedKey = this.escapeHtml(key);
      const escapedVal = this.escapeHtml(displayVal);
      fieldsHtml += `
        <div style="margin-bottom:8px;">
          <label style="display:block;font-size:11px;font-weight:600;color:var(--muted-foreground, #7a7a7a);margin-bottom:2px;">${escapedKey}</label>
          <input type="text" name="${escapedKey}" value="${escapedVal}"
                 style="width:100%;padding:6px 8px;border:1px solid var(--border, #1e1a4b1f);border-radius:4px;font-size:13px;color:var(--foreground, #1e1a4b);background:var(--muted, #f2f4f7);min-height:34px;"
          />
        </div>
      `;
    }

    const html = `
      <div style="background:var(--primary, #005eb8);color:#fff;padding:8px 12px;font-weight:600;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;">
        <span>✏️ Edit Parcel</span>
        <button class="parcel-edit-close" style="background:none;border:none;color:#fff;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;">&times;</button>
      </div>
      <form class="parcel-edit-form" style="padding:10px 12px;overflow-y:auto;flex:1;">
        ${fieldsHtml}
        <div style="display:flex;gap:8px;margin-top:10px;">
          <button type="submit" style="flex:1;padding:8px 12px;background:var(--primary, #005eb8);color:#fff;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;min-height:38px;">
            💾 Save
          </button>
          <button type="button" class="parcel-edit-cancel" style="flex:1;padding:8px 12px;background:var(--muted-foreground, #7a7a7a);color:#fff;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;min-height:38px;">
            Cancel
          </button>
        </div>
      </form>
    `;

    this.parcelEditElement.innerHTML = html;
    this.parcelEditPopup.setPosition(coordinate);

    // Close / Cancel handlers
    this.parcelEditElement.querySelector('.parcel-edit-close').addEventListener('click', () => {
      this.hideParcelEditPopup();
    });
    this.parcelEditElement.querySelector('.parcel-edit-cancel').addEventListener('click', () => {
      this.hideParcelEditPopup();
    });

    // Form submit handler
    const form = this.parcelEditElement.querySelector('.parcel-edit-form');
    form.addEventListener('submit', (e) => {
      e.preventDefault();

      // Collect all edited values
      const formData = new FormData(form);
      const updatedProps = {};
      for (const [key, value] of formData.entries()) {
        updatedProps[key] = value;
      }

      // Restore internal properties that were excluded from the form
      updatedProps._layerType = 'parcel';

      // Update the feature's properties in-place
      for (const [key, value] of Object.entries(updatedProps)) {
        this._parcelEditFeature.set(key, value);
      }

      // Notify external listeners
      for (const cb of this._parcelEditCallbacks) {
        cb(this._parcelEditFeature, updatedProps);
      }

      this.hideParcelEditPopup();
    });
  }

  /**
   * Hide the parcel edit popup.
   */
  hideParcelEditPopup() {
    this.parcelEditPopup.setPosition(undefined);
    this._parcelEditFeature = null;
  }

  /**
   * Register a callback for when a parcel edit is saved.
   * Callback receives (feature, updatedProperties).
   *
   * @param {Function} callback
   */
  onParcelEdit(callback) {
    this._parcelEditCallbacks.push(callback);
  }

  // ============================================================================
  // Merge Identifier (UPN) Chooser Popup
  // ============================================================================

  /**
   * Create the merge identifier popup overlay.
   * Shown after two parcels are merged so the user can choose which UPN to keep.
   */
  createMergePopup() {
    this.mergePopupElement = document.createElement('div');
    this.mergePopupElement.className = 'map-merge-popup';
    this.mergePopupElement.style.cssText = `
      position: absolute;
      background: var(--card, #fff);
      color: var(--card-foreground, #1e1a4b);
      border-radius: 10px;
      box-shadow: 0 4px 20px rgba(0,0,0,0.3);
      font-family: var(--font-body, 'Exo', sans-serif);
      font-size: 13px;
      min-width: 280px;
      max-width: 360px;
      z-index: 1002;
      border: 2px solid #10b981;
      overflow: hidden;
      display: flex;
      flex-direction: column;
    `;

    this.mergePopup = new Overlay({
      element: this.mergePopupElement,
      positioning: 'bottom-center',
      offset: [0, -10],
      stopEvent: true,
      autoPan: true,
      autoPanAnimation: { duration: 250 },
    });

    this.map.addOverlay(this.mergePopup);
  }

  /**
   * Show the merge identifier popup so the user can pick which parcel's
   * attributes (including UPN) the merged polygon should inherit.
   *
   * @param {Feature} mergedFeature  The newly created merged feature
   * @param {Object} propsA  Properties from original parcel A
   * @param {Object} propsB  Properties from original parcel B
   * @param {Array} coordinate  Map coordinate [x, y] for popup placement
   */
  showMergeIdentifierPopup(mergedFeature, propsA, propsB, coordinate) {
    // Extract identifiers — try common parcel ID field names
    const idFields = ['UPN', 'upn', 'id', 'parcelid', 'parcel_id', 'PARCELID', 'PARCEL_ID', 'ID'];
    const getLabel = (props) => {
      for (const field of idFields) {
        if (props[field] !== undefined && props[field] !== null && String(props[field]).trim()) {
          return { field, value: String(props[field]) };
        }
      }
      return { field: 'id', value: 'Unknown' };
    };

    const labelA = getLabel(propsA);
    const labelB = getLabel(propsB);

    const html = `
      <div style="background:#10b981;color:#fff;padding:8px 12px;font-weight:600;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;">
        <span>🔗 Merged Parcel — Choose Identifier</span>
        <button class="merge-popup-close" style="background:none;border:none;color:#fff;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;">&times;</button>
      </div>
      <div style="padding:12px;">
        <p style="margin:0 0 10px;color:var(--muted-foreground, #7a7a7a);font-size:12px;">
          Select which parcel's attributes the merged polygon should keep:
        </p>
        <label style="display:flex;align-items:center;padding:10px;border:2px solid var(--border, #1e1a4b1f);border-radius:8px;cursor:pointer;margin-bottom:8px;transition:border-color 0.15s;">
          <input type="radio" name="merge-choice" value="A" checked
                 style="margin-right:10px;accent-color:#0ea5e9;width:16px;height:16px;" />
          <div>
            <div style="font-weight:600;color:#0ea5e9;">Parcel A</div>
            <div style="font-size:12px;color:var(--muted-foreground, #7a7a7a);">${this.escapeHtml(labelA.field)}: ${this.escapeHtml(labelA.value)}</div>
          </div>
        </label>
        <label style="display:flex;align-items:center;padding:10px;border:2px solid var(--border, #1e1a4b1f);border-radius:8px;cursor:pointer;margin-bottom:12px;transition:border-color 0.15s;">
          <input type="radio" name="merge-choice" value="B"
                 style="margin-right:10px;accent-color:#f59e0b;width:16px;height:16px;" />
          <div>
            <div style="font-weight:600;color:#f59e0b;">Parcel B</div>
            <div style="font-size:12px;color:var(--muted-foreground, #7a7a7a);">${this.escapeHtml(labelB.field)}: ${this.escapeHtml(labelB.value)}</div>
          </div>
        </label>
        <div style="display:flex;gap:8px;">
          <button class="merge-popup-confirm" style="flex:1;padding:8px 12px;background:#10b981;color:#fff;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;min-height:38px;">
            ✅ Confirm
          </button>
          <button class="merge-popup-cancel" style="flex:1;padding:8px 12px;background:var(--muted-foreground, #7a7a7a);color:#fff;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;min-height:38px;">
            Cancel
          </button>
        </div>
      </div>
    `;

    this.mergePopupElement.innerHTML = html;
    this.mergePopup.setPosition(coordinate);

    // Close / Cancel — keep parcel A properties (the default from clone)
    const close = () => {
      this.mergePopup.setPosition(undefined);
    };
    this.mergePopupElement.querySelector('.merge-popup-close').addEventListener('click', close);
    this.mergePopupElement.querySelector('.merge-popup-cancel').addEventListener('click', close);

    // Confirm — apply chosen parcel's properties
    this.mergePopupElement.querySelector('.merge-popup-confirm').addEventListener('click', () => {
      const choice = this.mergePopupElement.querySelector('input[name="merge-choice"]:checked').value;
      const chosenProps = choice === 'A' ? propsA : propsB;

      // Copy all properties (except geometry) onto the merged feature
      const skipKeys = ['geometry'];
      for (const [key, value] of Object.entries(chosenProps)) {
        if (skipKeys.includes(key)) continue;
        mergedFeature.set(key, value);
      }
      // Ensure _layerType is preserved
      mergedFeature.set('_layerType', 'parcel');

      // Notify parcel edit callbacks
      for (const cb of this._parcelEditCallbacks) {
        cb(mergedFeature, chosenProps);
      }

      close();
    });

    // Highlight radio labels on selection
    const labels = this.mergePopupElement.querySelectorAll('label');
    const radios = this.mergePopupElement.querySelectorAll('input[name="merge-choice"]');
    const updateHighlight = () => {
      labels.forEach((lbl) => {
        const radio = lbl.querySelector('input');
        lbl.style.borderColor = radio.checked ? (radio.value === 'A' ? '#0ea5e9' : '#f59e0b') : 'var(--border, #1e1a4b1f)';
      });
    };
    radios.forEach((r) => r.addEventListener('change', updateHighlight));
    updateHighlight();
  }

  // ============================================================================
  // Divide Polygon Popup (number input)
  // ============================================================================

  /**
   * Create the divide polygon popup overlay.
   * Shown after the user selects a polygon with the Divide tool, so they
   * can enter the number of equal pieces.
   */
  createDividePopup() {
    this.dividePopupElement = document.createElement('div');
    this.dividePopupElement.className = 'map-divide-popup';
    this.dividePopupElement.style.cssText = `
      position: absolute;
      background: var(--card, #fff);
      color: var(--card-foreground, #1e1a4b);
      border-radius: 10px;
      box-shadow: 0 4px 20px rgba(0,0,0,0.3);
      font-family: var(--font-body, 'Exo', sans-serif);
      font-size: 13px;
      min-width: 260px;
      max-width: 320px;
      z-index: 1002;
      border: 2px solid #8b5cf6;
      overflow: hidden;
      display: flex;
      flex-direction: column;
    `;

    this.dividePopup = new Overlay({
      element: this.dividePopupElement,
      positioning: 'bottom-center',
      offset: [0, -10],
      stopEvent: true,
      autoPan: true,
      autoPanAnimation: { duration: 250 },
    });

    this.map.addOverlay(this.dividePopup);
  }

  /**
   * Show the divide popup so the user can enter the number of divisions.
   *
   * @param {Feature} feature     The selected polygon feature
   * @param {VectorSource} source The source containing the feature
   * @param {Array} coordinate    Map coordinate [x, y] for popup placement
   */
  showDividePopup(feature, source, coordinate) {
    const html = `
      <div style="background:#8b5cf6;color:#fff;padding:8px 12px;font-weight:600;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;">
        <span>Divide Polygon</span>
        <button class="divide-popup-close" style="background:none;border:none;color:#fff;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;">&times;</button>
      </div>
      <div style="padding:12px;">
        <p style="margin:0 0 10px;color:var(--muted-foreground, #7a7a7a);font-size:12px;">
          Enter the number of equal pieces:
        </p>
        <input type="number" class="divide-input" min="2" max="50" value="2"
               style="width:100%;padding:8px 10px;border:2px solid var(--border, #1e1a4b1f);border-radius:6px;font-size:16px;font-weight:600;text-align:center;color:var(--foreground, #1e1a4b);background:var(--muted, #f2f4f7);min-height:40px;" />
        <div style="display:flex;gap:8px;margin-top:12px;">
          <button class="divide-popup-confirm" style="flex:1;padding:8px 12px;background:#8b5cf6;color:#fff;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;min-height:38px;">
            Divide
          </button>
          <button class="divide-popup-cancel" style="flex:1;padding:8px 12px;background:var(--muted-foreground, #7a7a7a);color:#fff;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;min-height:38px;">
            Cancel
          </button>
        </div>
      </div>
    `;

    this.dividePopupElement.innerHTML = html;
    this.dividePopup.setPosition(coordinate);

    const input = this.dividePopupElement.querySelector('.divide-input');
    input.focus();
    input.select();

    // Close / Cancel
    const cancel = () => {
      this.hideDividePopup();
      this._polygonDivideInteraction.cancelDivide();
    };
    this.dividePopupElement.querySelector('.divide-popup-close').addEventListener('click', cancel);
    this.dividePopupElement.querySelector('.divide-popup-cancel').addEventListener('click', cancel);

    // Confirm
    this.dividePopupElement.querySelector('.divide-popup-confirm').addEventListener('click', () => {
      const n = parseInt(input.value, 10);
      if (!n || n < 2) {
        input.style.borderColor = '#ef4444';
        return;
      }
      this.hideDividePopup();
      this._polygonDivideInteraction.performDivide(n);
    });

    // Allow Enter key to confirm
    input.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') {
        e.preventDefault();
        this.dividePopupElement.querySelector('.divide-popup-confirm').click();
      }
    });
  }

  /**
   * Hide the divide popup.
   */
  hideDividePopup() {
    this.dividePopup.setPosition(undefined);
  }

  // ============================================================================
  // Drawn Polygon Attribute Popup
  // ============================================================================

  /**
   * Create the drawn polygon attribute popup overlay.
   * Shown after the area measurement polygon is completed so the user can
   * attach parcel-like attributes to the drawn polygon.
   */
  createDrawnPolygonPopup() {
    this.drawnPolygonElement = document.createElement('div');
    this.drawnPolygonElement.className = 'map-drawn-polygon-popup';
    this.drawnPolygonElement.style.cssText = `
      position: absolute;
      background: var(--card, #fff);
      border-radius: var(--radius-xl, 0.75rem);
      box-shadow: 0 4px 20px rgba(0,0,0,0.2);
      font-family: var(--font-body, 'Exo', sans-serif);
      font-size: 13px;
      min-width: 280px;
      max-width: 360px;
      max-height: 420px;
      z-index: 1002;
      border: 2px solid var(--success, #006b3f);
      overflow: hidden;
      display: flex;
      flex-direction: column;
    `;

    this.drawnPolygonPopup = new Overlay({
      element: this.drawnPolygonElement,
      positioning: 'bottom-center',
      offset: [0, -10],
      stopEvent: true,
      autoPan: true,
      autoPanAnimation: { duration: 250 },
    });

    this.map.addOverlay(this.drawnPolygonPopup);
    this._drawnPolygonCallbacks = [];
    this._drawnPolygonFeature = null;
  }

  /**
   * Get attribute keys from existing parcel features on the map.
   * Scans the overlay group for the first feature with _layerType='parcel'
   * and returns its property key names (excluding internal keys).
   *
   * @returns {string[]} Array of attribute key names
   */
  getParcelAttributeKeys() {
    const skipKeys = ['geometry', '_layerType'];
    const keys = [];

    const scanGroup = (group) => {
      if (keys.length > 0) return;
      group.getLayers().forEach((layer) => {
        if (keys.length > 0) return;
        if (layer instanceof LayerGroup) {
          scanGroup(layer);
        } else if (layer instanceof VectorLayer) {
          const source = layer.getSource();
          if (!source) return;
          for (const f of source.getFeatures()) {
            if (f.get('_layerType') !== 'parcel') continue;
            const props = f.getProperties();
            for (const key of Object.keys(props)) {
              if (!skipKeys.includes(key)) keys.push(key);
            }
            return; // one parcel is enough for the schema
          }
        }
      });
    };

    scanGroup(this.overlayGroup);
    return keys;
  }

  /**
   * Show the drawn polygon attribute popup.
   * Discovers attribute keys from existing parcel features and creates
   * a blank form with those fields.
   *
   * @param {Feature} feature - The drawn polygon feature
   * @param {Array} coordinate - Map coordinate [x, y] for popup placement
   */
  showDrawnPolygonPopup(feature, coordinate) {
    this._drawnPolygonFeature = feature;

    // Discover attribute keys from existing parcels
    const attributeKeys = this.getParcelAttributeKeys();

    if (attributeKeys.length === 0) {
      console.warn('[MapView] No parcel attributes found — cannot build form');
      return;
    }

    // Build form fields (all blank)
    let fieldsHtml = '';
    for (const key of attributeKeys) {
      const escapedKey = this.escapeHtml(key);
      fieldsHtml += `
        <div style="margin-bottom:8px;">
          <label style="display:block;font-size:11px;font-weight:600;color:var(--muted-foreground, #7a7a7a);margin-bottom:2px;">${escapedKey}</label>
          <input type="text" name="${escapedKey}" value=""
                 style="width:100%;padding:6px 8px;border:1px solid var(--border, #1e1a4b1f);border-radius:4px;font-size:13px;color:var(--foreground, #1e1a4b);background:var(--muted, #f2f4f7);min-height:34px;"
          />
        </div>
      `;
    }

    // Area display
    const geom = feature.getGeometry();
    const areaSqm = getArea(geom, { projection: 'EPSG:3857' });
    const areaFormatted = formatArea(areaSqm);

    const html = `
      <div style="background:var(--success, #006b3f);color:var(--success-foreground, #fff);padding:8px 12px;font-weight:600;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;">
        <span>📐 Polygon Attributes</span>
        <button class="drawn-polygon-close" style="background:none;border:none;color:var(--success-foreground, #fff);font-size:18px;cursor:pointer;padding:0 4px;line-height:1;">&times;</button>
      </div>
      <div style="padding:8px 12px;background:var(--muted, #f2f4f7);border-bottom:1px solid var(--border, #1e1a4b1f);font-size:12px;color:var(--muted-foreground, #7a7a7a);flex-shrink:0;">
        Area: <strong>${areaFormatted}</strong>
      </div>
      <form class="drawn-polygon-form" style="padding:10px 12px;overflow-y:auto;flex:1;">
        ${fieldsHtml}
        <div style="display:flex;gap:8px;margin-top:10px;">
          <button type="submit" style="flex:1;padding:8px 12px;background:var(--success, #006b3f);color:var(--success-foreground, #fff);border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;min-height:38px;">
            💾 Save
          </button>
          <button type="button" class="drawn-polygon-cancel" style="flex:1;padding:8px 12px;background:var(--muted-foreground, #7a7a7a);color:#fff;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;min-height:38px;">
            Cancel
          </button>
        </div>
      </form>
    `;

    this.drawnPolygonElement.innerHTML = html;
    this.drawnPolygonPopup.setPosition(coordinate);

    // Close / Cancel handlers
    this.drawnPolygonElement.querySelector('.drawn-polygon-close').addEventListener('click', () => {
      this.hideDrawnPolygonPopup();
    });
    this.drawnPolygonElement.querySelector('.drawn-polygon-cancel').addEventListener('click', () => {
      this.hideDrawnPolygonPopup();
    });

    // Form submit handler
    const form = this.drawnPolygonElement.querySelector('.drawn-polygon-form');
    form.addEventListener('submit', (e) => {
      e.preventDefault();

      const formData = new FormData(form);
      const props = {};
      for (const [key, value] of formData.entries()) {
        props[key] = value;
      }

      // Set properties on the feature
      for (const [key, value] of Object.entries(props)) {
        this._drawnPolygonFeature.set(key, value);
      }

      // Tag as parcel so it integrates with existing parcel tools
      this._drawnPolygonFeature.set('_layerType', 'parcel');

      // Notify listeners
      for (const cb of this._drawnPolygonCallbacks) {
        cb(this._drawnPolygonFeature, props);
      }

      this.hideDrawnPolygonPopup();
    });
  }

  /**
   * Hide the drawn polygon attribute popup.
   */
  hideDrawnPolygonPopup() {
    this.drawnPolygonPopup.setPosition(undefined);
    this._drawnPolygonFeature = null;
  }

  /**
   * Register a callback for when drawn polygon attributes are saved.
   * Callback receives (feature, properties).
   *
   * @param {Function} callback
   */
  onDrawnPolygonSave(callback) {
    this._drawnPolygonCallbacks.push(callback);
  }

  /**
   * Register a callback fired after the user finishes modifying a feature
   * with the EditBar Modify interaction. Callback receives the OL feature
   * whose geometry just changed. Consumers (e.g. the import-staging code in
   * main.js) inspect the feature's tags (_externalImportId / _clientUuid)
   * to decide whether to react. Multiple callbacks are supported.
   *
   * @param {Function} callback - (feature) => void | Promise<void>
   */
  onFeatureModified(callback) {
    if (!this._featureModifiedCallbacks) this._featureModifiedCallbacks = [];
    this._featureModifiedCallbacks.push(callback);
  }

  /**
   * Register a double-click callback.
   * Callback receives (lon, lat, feature, event).
   * Feature is the first feature found at the click pixel across all overlay layers,
   * or null if no feature was hit.
   * When a feature is hit, the default double-click-zoom is suppressed.
   */
  onDblClick(callback) {
    this.dblClickCallbacks.push(callback);

    // Set up the listener once
    if (this.dblClickCallbacks.length === 1) {
      this.map.on('dblclick', (evt) => {
        const [lon, lat] = toLonLat(evt.coordinate);

        // Find any feature at the clicked pixel (overlay layers, not just markers)
        let clickedFeature = null;
        this.map.forEachFeatureAtPixel(evt.pixel, (feature) => {
          clickedFeature = feature;
          return true; // stop at first hit
        });

        // If a feature was hit, prevent the default double-click zoom
        if (clickedFeature) {
          evt.preventDefault();
          evt.stopPropagation();
        }

        // Call all registered callbacks
        for (const cb of this.dblClickCallbacks) {
          cb(lon, lat, clickedFeature, evt);
        }

        // Return false to suppress DoubleClickZoom interaction when on a feature
        if (clickedFeature) return false;
      });
    }

    return () => {
      const idx = this.dblClickCallbacks.indexOf(callback);
      if (idx > -1) this.dblClickCallbacks.splice(idx, 1);
    };
  }

  /**
   * Escape HTML to prevent XSS
   */
  escapeHtml(text) {
    if (!text) return '';
    const div = document.createElement('div');
    div.textContent = text;
    return div.innerHTML;
  }

  /**
   * Create the Add Location popup form overlay
   */
  createAddLocationPopup() {
    // Create popup container element
    this.addLocationPopupElement = document.createElement('div');
    this.addLocationPopupElement.className = 'map-add-location-popup';
    this.addLocationPopupElement.innerHTML = `
      <div class="add-location-popup-header">
        <span>➕ Add Location</span>
        <button type="button" class="add-location-popup-close" aria-label="Close">&times;</button>
      </div>
      <form id="map-add-location-form">
        <div class="add-location-popup-field">
          <label for="map-location-name">Name <span class="text-danger">*</span></label>
          <input type="text" id="map-location-name" name="name" required placeholder="e.g., Water Point A">
        </div>
        <div class="add-location-popup-field">
          <label for="map-location-category">Category</label>
          <select id="map-location-category" name="category">
            ${this.getCategoryOptionsHtml()}
          </select>
        </div>
        <div class="add-location-popup-field">
          <label for="map-location-description">Description</label>
          <textarea id="map-location-description" name="description" rows="2" placeholder="Optional notes..."></textarea>
        </div>
        <div class="add-location-popup-coords">
          <small>📍 <span id="map-location-coords"></span></small>
        </div>
        <button type="submit" class="add-location-popup-submit">➕ Add Location</button>
      </form>
    `;

    // Create the overlay
    this.addLocationPopup = new Overlay({
      element: this.addLocationPopupElement,
      positioning: 'bottom-center',
      offset: [0, -10],
      stopEvent: true,  // Prevent click from propagating
      autoPan: true,
      autoPanAnimation: {
        duration: 250,
      },
    });

    this.map.addOverlay(this.addLocationPopup);

    // Store clicked coordinates
    this.addLocationCoords = null;

    // Set up close button handler
    const closeBtn = this.addLocationPopupElement.querySelector('.add-location-popup-close');
    closeBtn.addEventListener('click', () => {
      this.hideAddLocationPopup();
    });

    // Store form submit callbacks
    this.addLocationCallbacks = [];
  }

  /**
   * Show the Add Location popup at the specified coordinate
   */
  showAddLocationPopup(coordinate) {
    const [lon, lat] = toLonLat(coordinate);
    this.addLocationCoords = { lon, lat };

    // Update coordinates display
    const coordsEl = this.addLocationPopupElement.querySelector('#map-location-coords');
    coordsEl.textContent = `${lon.toFixed(6)}, ${lat.toFixed(6)}`;

    // Reset form
    const form = this.addLocationPopupElement.querySelector('#map-add-location-form');
    form.reset();

    // Position and show popup
    this.addLocationPopup.setPosition(coordinate);
  }

  /**
   * Hide the Add Location popup
   */
  hideAddLocationPopup() {
    this.addLocationPopup.setPosition(undefined);
    this.addLocationCoords = null;
  }

  /**
   * Register a callback for when a location is submitted via the map popup
   * Callback receives: { name, category, description, lon, lat }
   */
  onAddLocation(callback) {
    this.addLocationCallbacks.push(callback);

    // Set up form submit handler (only once)
    if (this.addLocationCallbacks.length === 1) {
      const form = this.addLocationPopupElement.querySelector('#map-add-location-form');
      form.addEventListener('submit', (e) => {
        e.preventDefault();

        if (!this.addLocationCoords) return;

        const formData = new FormData(form);
        const data = {
          name: formData.get('name'),
          category: formData.get('category'),
          description: formData.get('description'),
          lon: this.addLocationCoords.lon,
          lat: this.addLocationCoords.lat,
        };

        // Call all registered callbacks
        this.addLocationCallbacks.forEach(cb => cb(data));

        // Hide popup after submission
        this.hideAddLocationPopup();
      });
    }
  }

  /**
   * Create base layers group for LayerSwitcher
   */
  createBaseLayers(defaultBasemap) {


    const topoLayer = new TileLayer({
      title: 'Topographic',
      type: 'base',
      zIndex: -100,
      visible: defaultBasemap === 'topo',
      source: new XYZ({
        url: 'https://{a-c}.tile.opentopomap.org/{z}/{x}/{y}.png',
        attributions: 'Map data: © OpenTopoMap',
        maxZoom: 17,
        crossOrigin: 'anonymous',
      }),
    });
    topoLayer.set('basemapKey', 'topo');

    const cartoLightLayer = new TileLayer({
      title: 'Carto Light',
      type: 'base',
      zIndex: -100,
      visible: defaultBasemap === 'carto-light',
      source: new XYZ({
        url: 'https://{a-c}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png',
        attributions: '© CARTO',
        maxZoom: 19,
        crossOrigin: 'anonymous',
      }),
    });
    cartoLightLayer.set('basemapKey', 'carto-light');

    const cartoDarkLayer = new TileLayer({
      title: 'Carto Dark',
      type: 'base',
      zIndex: -100,
      visible: defaultBasemap === 'carto-dark',
      source: new XYZ({
        url: 'https://{a-c}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
        attributions: '© CARTO',
        maxZoom: 19,
        crossOrigin: 'anonymous',
      }),
    });
    cartoDarkLayer.set('basemapKey', 'carto-dark');

    const osmCycleLayer = new TileLayer({
      title: 'OSM Cycle map',
      type: 'base',
      zIndex: -100,
      visible: false, //defaultBasemap === 'osm',
      source: new OSM({
      					"url" : "https://tile.thunderforest.com/cycle/{z}/{x}/{y}.png?apikey=ae1339c46dd3446b9c491e7336d38760"
						}),
    });

    osmCycleLayer.set('basemapKey', 'cycle');

    const satelliteLayer = new TileLayer({
      title: 'Satellite',
      type: 'base',
      zIndex: -100,
      visible: defaultBasemap === 'satellite',
      source: new XYZ({
        url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
        attributions: 'Tiles © Esri',
        maxZoom: 19,
        crossOrigin: 'anonymous',
      }),
    });
    satelliteLayer.set('basemapKey', 'satellite');
    const googleLayer = new TileLayer({
      title: 'Google Sat',
      type: 'base',
      zIndex: -100,
      visible: defaultBasemap === 'googlesat',
      source: new XYZ({
//         url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
        url: 'http://mt0.google.com/vt/lyrs=y&hl=en&x={x}&y={y}&z={z}&s=Ga',
        attributions: 'Tiles © Google',
        maxZoom: 19,
        crossOrigin: 'anonymous',
      }),
    });
    googleLayer.set('basemapKey', 'googlesat');

    const osmLayer = new TileLayer({
      title: 'OpenStreetMap',
      type: 'base',
      zIndex: -100,
      visible: defaultBasemap === 'osm',
      source: new OSM(),
    });
    osmLayer.set('basemapKey', 'osm');

    // Remember the base-map layers so setBaseMap() can toggle visibility later
    this._baseMapLayers = [
      cartoLightLayer, cartoDarkLayer, osmCycleLayer,
      satelliteLayer, googleLayer, osmLayer, topoLayer,
    ];


    // Return LayerGroup. Hidden from the main LayerSwitcher — base maps are
    // managed by the dedicated base-map picker (see _createBaseMapPicker)
    // accessed via the layers-stack icon above the My Location button.
    const baseGroup = new LayerGroup({
      title: 'Base Maps',
      layers: [
        cartoLightLayer,
        cartoDarkLayer,
        satelliteLayer,
        osmCycleLayer,
        googleLayer,
        osmLayer,
        topoLayer,
      ],
    });
    baseGroup.set('displayInLayerSwitcher', false);
    return baseGroup;
  }

  /**
   * Switch the active base map by key.
   * Sets exactly one base layer visible; hides all others.
   *
   * @param {string} key  Basemap key: 'none' | 'topo' | 'osm' | 'satellite' | 'googlesat' | 'carto-light' | 'carto-dark' | 'cycle'
   * @returns {boolean}   true if the key matched a known base layer (or 'none')
   */
  setBaseMap(key) {
    if (!this._baseMapLayers) return false;
    // 'none' switches the base map off entirely — hide every base layer so the
    // map renders on a blank background (useful over imagery overlays / when a
    // full-coverage overlay should stand alone).
    if (key === 'none') {
      for (const layer of this._baseMapLayers) layer.setVisible(false);
      console.log('[MapView] Base map switched off (none)');
      this.map.dispatchEvent({ type: 'basemapchange', key: 'none' });
      return true;
    }
    let matched = false;
    for (const layer of this._baseMapLayers) {
      const on = layer.get('basemapKey') === key;
      layer.setVisible(on);
      if (on) matched = true;
    }
    if (matched) {
      console.log('[MapView] Base map switched to:', key);
      // Notify external UIs (Settings dropdown, base-map picker, …) so they
      // can keep their visible state in sync.
      this.map.dispatchEvent({ type: 'basemapchange', key });
    }
    return matched;
  }

  /**
   * Build the floating "Base Map" picker — a small icon button stacked
   * directly above the My Location control, plus a slide-out card with
   * thumbnail chips for every selectable base map.
   *
   * Hidden in tandem with the main LayerSwitcher: clicking outside the
   * picker (or making a selection) closes it.
   *
   * Two-way sync with the existing Settings dropdown is via the
   * `basemapchange` event fired from setBaseMap().
   */
  _createBaseMapPicker() {
    // Configuration — must match the basemapKey set in createBaseLayers.
    // The colour gradients hint at each base map's character so the chip is
    // recognisable without rendering an actual tile preview.
    const OPTIONS = [
      { key: 'topo',        label: 'Topographic',  grad: 'linear-gradient(135deg,#e8d5b7,#a67c52)' },
      { key: 'osm',         label: 'OpenStreetMap',grad: 'linear-gradient(135deg,#d4e6f1,#85c1e9)' },
      { key: 'satellite',   label: 'Satellite',    grad: 'linear-gradient(135deg,#1b4332,#40916c)' },
      { key: 'googlesat',   label: 'Google Sat',   grad: 'linear-gradient(135deg,#2a5d3d,#4a8c5a)' },
      { key: 'carto-light', label: 'Carto Light',  grad: 'linear-gradient(135deg,#f5f5f5,#d4d4d4)' },
      { key: 'carto-dark',  label: 'Carto Dark',   grad: 'linear-gradient(135deg,#1a1a2e,#0f3460)' },
      // "None" turns the base map off — checkerboard hints at a blank/transparent background.
      { key: 'none',        label: 'None',         grad: 'repeating-conic-gradient(#e5e7eb 0 25%, #fff 0 50%) 50% / 12px 12px' },
    ];

    const target = this.map.getTargetElement();
    if (!target) return;

    // ---------- Toggle button ----------
    const btn = document.createElement('button');
    btn.type = 'button';
    btn.className = 'ls-basemap-toggle';
    btn.title = 'Switch base map';
    btn.setAttribute('aria-label', 'Switch base map');
    btn.innerHTML =
      '<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">' +
        '<path d="M9 2L16 5.8L9 9.6L2 5.8L9 2Z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>' +
        '<path d="M2 9.2L9 13L16 9.2" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/>' +
        '<path d="M2 12.4L9 16.2L16 12.4" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" stroke-opacity=".4"/>' +
      '</svg>';
    target.appendChild(btn);

    // ---------- Picker panel ----------
    const panel = document.createElement('div');
    panel.className = 'ls-basemap-panel';
    panel.innerHTML =
      '<div class="ls-basemap-header">Base Map</div>' +
      '<div class="ls-basemap-grid">' +
        OPTIONS.map((opt) => `
          <label class="ls-bm-chip">
            <input type="radio" name="lupmis-basemap" value="${opt.key}">
            <div class="ls-bm-label">
              <div class="ls-bm-thumb" style="background:${opt.grad};"></div>
              <div class="ls-bm-name">${opt.label}</div>
            </div>
          </label>
        `).join('') +
      '</div>';
    target.appendChild(panel);

    this._basemapPanel  = panel;
    this._basemapToggle = btn;

    /** Mark the radio matching the currently-visible base layer. */
    const syncSelection = (key) => {
      const k = key || this._baseMapLayers?.find((l) => l.getVisible())?.get('basemapKey');
      panel.querySelectorAll('input[name="lupmis-basemap"]').forEach((r) => {
        r.checked = (r.value === k);
      });
    };
    syncSelection();

    // ---------- Events ----------

    // Toggle button → open / close the panel
    btn.addEventListener('click', (e) => {
      e.stopPropagation();
      const open = !panel.classList.contains('open');
      panel.classList.toggle('open', open);
      btn.classList.toggle('active', open);
      if (open) syncSelection();
    });

    // Click outside → close
    document.addEventListener('click', (e) => {
      if (!panel.classList.contains('open')) return;
      if (panel.contains(e.target) || btn.contains(e.target)) return;
      panel.classList.remove('open');
      btn.classList.remove('active');
    });

    // Selection → apply, persist, close
    panel.addEventListener('change', (e) => {
      const radio = e.target.closest('input[type=radio][name="lupmis-basemap"]');
      if (!radio) return;
      const key = radio.value;
      this.setBaseMap(key);
      try { localStorage.setItem('default-basemap', key); } catch {}
      panel.classList.remove('open');
      btn.classList.remove('active');
    });

    // Keep the radio state synced when other UIs (Settings dropdown) change it
    this.map.on('basemapchange', (evt) => syncSelection(evt.key));
  }

  // ============================================================================
  // GPS: current-position + trail rendering, and the expandable Location control
  //
  // NOTE: MapView deliberately knows nothing about the GeoTracker engine,
  // SQLocal, or sync. It only (a) renders what it's told and (b) emits UI
  // intents via callbacks. main.js wires those intents to the GeoTracker so the
  // map stays reusable/decoupled.
  // ============================================================================

  /** Create the vector layers used to draw the live position and the trail. */
  _initGpsRendering() {
    this._gpsPositionSource = new VectorSource();
    this._gpsTrailSource = new VectorSource();
    this._gpsTrailCoords = []; // [ [x,y], ... ] in map projection

    // Trail line (drawn under the position marker)
    this._gpsTrailLayer = new VectorLayer({
      source: this._gpsTrailSource,
      zIndex: 940,
      style: new Style({
        stroke: new Stroke({ color: '#ff6d00', width: 4, lineCap: 'round', lineJoin: 'round' }),
      }),
      properties: { title: 'GPS Trail', displayInLayerSwitcher: false },
    });

    // Current position: accuracy halo + solid dot
    this._gpsPositionLayer = new VectorLayer({
      source: this._gpsPositionSource,
      zIndex: 950,
      style: (feature) => {
        if (feature.get('_kind') === 'accuracy') {
          return new Style({
            fill: new Fill({ color: 'rgba(0,94,184,0.12)' }),
            stroke: new Stroke({ color: 'rgba(0,94,184,0.35)', width: 1 }),
          });
        }
        return new Style({
          image: new Circle({
            radius: 7,
            fill: new Fill({ color: '#005eb8' }),
            stroke: new Stroke({ color: '#ffffff', width: 2.5 }),
          }),
        });
      },
      properties: { title: 'GPS Position', displayInLayerSwitcher: false },
    });

    this.map.addLayer(this._gpsTrailLayer);
    this.map.addLayer(this._gpsPositionLayer);

    this._gpsCallbacks = { locate: [], record: [] };
    this._gpsRecording = false;
  }

  /** Register a callback fired when the user taps "Locate Me". */
  onLocateMe(cb) { this._gpsCallbacks.locate.push(cb); }
  /** Register a callback fired when the user toggles trail recording. Receives the desired state (true=start). */
  onToggleRecording(cb) { this._gpsCallbacks.record.push(cb); }

  /**
   * Draw / move the current-position marker and accuracy halo.
   * @param {number} lon
   * @param {number} lat
   * @param {number|null} [accuracy] horizontal accuracy in metres
   */
  showCurrentPosition(lon, lat, accuracy = null) {
    if (lon == null || lat == null) return;
    const center = fromLonLat([lon, lat]);
    this._gpsPositionSource.clear();

    if (accuracy && accuracy > 0) {
      // Approximate the accuracy circle in projected units. Good enough for a
      // visual halo at typical zoom levels.
      const resAtLat = accuracy / Math.cos((lat * Math.PI) / 180);
      const halo = new Feature({ geometry: new PolygonGeom([this._circleRing(center, resAtLat)]) });
      halo.set('_kind', 'accuracy');
      this._gpsPositionSource.addFeature(halo);
    }
    const dot = new Feature({ geometry: new Point(center) });
    dot.set('_kind', 'dot');
    this._gpsPositionSource.addFeature(dot);
  }

  /** @private build a ring of coordinates approximating a circle (metres → projected). */
  _circleRing(center, radiusMeters, segments = 48) {
    // Convert metres to projected units (Web Mercator) roughly via the
    // resolution at the centre latitude.
    const ring = [];
    const metersPerUnit = 1; // EPSG:3857 units are metres (approx near equator/locally)
    const r = radiusMeters / metersPerUnit;
    for (let i = 0; i <= segments; i++) {
      const a = (i / segments) * 2 * Math.PI;
      ring.push([center[0] + r * Math.cos(a), center[1] + r * Math.sin(a)]);
    }
    return ring;
  }

  /** Smoothly center the view on a coordinate. */
  centerOn(lon, lat, zoom = 16) {
    const view = this.map.getView();
    view.animate({ center: fromLonLat([lon, lat]), zoom, duration: 500 });
  }

  /** Reset the trail line (call when a new recording starts). */
  startTrailRender() {
    this._gpsTrailCoords = [];
    this._gpsTrailSource.clear();
  }

  /** Append a coordinate to the growing trail line. */
  appendTrailPoint(lon, lat) {
    if (lon == null || lat == null) return;
    this._gpsTrailCoords.push(fromLonLat([lon, lat]));
    this._gpsTrailSource.clear();
    if (this._gpsTrailCoords.length >= 2) {
      this._gpsTrailSource.addFeature(new Feature({ geometry: new LineString(this._gpsTrailCoords) }));
    }
  }

  /** Remove the rendered trail (does not affect stored data). */
  clearTrailRender() {
    this._gpsTrailCoords = [];
    this._gpsTrailSource.clear();
  }

  /** Reflect recording state on the control button. */
  setRecordingState(active) {
    this._gpsRecording = !!active;
    if (this._recordBtn) {
      this._recordBtn.classList.toggle('recording', this._gpsRecording);
      this._recordBtn.title = this._gpsRecording ? 'Stop trail recording' : 'Record GPS trail';
      this._recordBtn.innerHTML = this._gpsRecording
        ? '<i class="bi bi-stop-fill"></i>'
        : '<i class="bi bi-record-circle"></i>';
    }
    if (this._locateToggle) this._locateToggle.classList.toggle('recording', this._gpsRecording);
  }

  /**
   * Build the expandable "My Location" control: a main button that reveals two
   * sub-buttons (Locate Me, Record Trail). Anchored at the same spot the old
   * ol-ext GeolocationButton occupied (bottom-right), so the base-map picker
   * still lines up above it.
   */
  _createLocationControl() {
    const target = this.map.getTargetElement();
    if (!target) return;

    // Main toggle
    const toggle = document.createElement('button');
    toggle.type = 'button';
    toggle.className = 'ls-locate-toggle';
    toggle.title = 'My Location';
    toggle.setAttribute('aria-label', 'My Location');
    toggle.innerHTML = '<i class="bi bi-geo-alt-fill"></i>';
    target.appendChild(toggle);

    // Sub-button cluster (hidden until the main button is tapped)
    const actions = document.createElement('div');
    actions.className = 'ls-locate-actions';
    actions.innerHTML =
      '<button type="button" class="ls-locate-btn ls-locate-me" title="Locate me">' +
        '<i class="bi bi-crosshair"></i></button>' +
      '<button type="button" class="ls-locate-btn ls-locate-record" title="Record GPS trail">' +
        '<i class="bi bi-record-circle"></i></button>';
    target.appendChild(actions);

    this._locateToggle = toggle;
    this._locateActions = actions;
    this._locateMeBtn = actions.querySelector('.ls-locate-me');
    this._recordBtn = actions.querySelector('.ls-locate-record');

    const close = () => { actions.classList.remove('open'); toggle.classList.remove('active'); };
    const open = () => { actions.classList.add('open'); toggle.classList.add('active'); };

    toggle.addEventListener('click', (e) => {
      e.stopPropagation();
      actions.classList.contains('open') ? close() : open();
    });

    // Tap outside closes the cluster (but never while recording, so the stop
    // button stays reachable).
    document.addEventListener('click', (e) => {
      if (!actions.classList.contains('open')) return;
      if (actions.contains(e.target) || toggle.contains(e.target)) return;
      if (this._gpsRecording) return;
      close();
    });

    this._locateMeBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      for (const cb of this._gpsCallbacks.locate) { try { cb(); } catch (err) { console.error(err); } }
      if (!this._gpsRecording) close();
    });

    this._recordBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      const next = !this._gpsRecording;
      for (const cb of this._gpsCallbacks.record) { try { cb(next); } catch (err) { console.error(err); } }
    });
  }

  /**
   * Get style for a feature (handles selection state)
   */
  getFeatureStyle(feature) {
    const category = feature.get('category') || 'default';
    const emoji = this.getEmoji(category);

    if (feature === this.selectedFeature) {
      // Return selected style with the correct emoji and highlight
      return [
        // Background highlight circle
        new Style({
          image: new Circle({
            radius: 22,
            fill: new Fill({ color: 'rgba(220, 38, 38, 0.25)' }),
            stroke: new Stroke({ color: '#dc2626', width: 3 }),
          }),
        }),
        // Emoji on top, larger
        new Style({
          text: new Text({
            text: emoji,
            font: '40px sans-serif',
            textBaseline: 'bottom',
            textAlign: 'center',
            offsetY: -5,
          }),
        }),
      ];
    }

    // Check for custom style
    const customStyle = feature.get('style');
    if (customStyle) {
      return customStyle;
    }

    // Return category-based emoji style
    if (this.categoryStyles[category]) {
      return this.categoryStyles[category];
    }

    return this.defaultStyle;
  }

  /**
   * Set category-based styles with emojis
   * @param {Object} styles - Map of category to config { emoji, label, fontSize }
   */
  setCategoryStyles(styles) {
    for (const [category, config] of Object.entries(styles)) {
      // Update category mapping if provided
      if (config.emoji) {
        if (!this.categoryEmojis[category]) {
          this.categoryEmojis[category] = { emoji: config.emoji, label: config.label || category };
        } else {
          this.categoryEmojis[category].emoji = config.emoji;
          if (config.label) {
            this.categoryEmojis[category].label = config.label;
          }
        }
    }

      // Create/update style
      const emoji = this.getEmoji(category);
      const fontSize = config.fontSize || 28;

      this.categoryStyles[category] = this.createEmojiStyle(emoji, fontSize);
    }

    // Refresh markers
    this.markerSource.changed();
  }

  /**
   * Add a single marker
   */
  addMarker(lon, lat, properties = {}) {
    console.log('[MapView] Adding marker at', lon, lat, 'with properties:', properties);

    const feature = new Feature({
      geometry: new Point(fromLonLat([lon, lat])),
      ...properties,
    });

    // Store original coordinates for easy access
    feature.set('lon', lon);
    feature.set('lat', lat);

    this.markerSource.addFeature(feature);
    console.log('[MapView] Marker added, total features:', this.markerSource.getFeatures().length);
    return feature;
  }

  /**
   * Add multiple markers from an array of location objects
   */
  addMarkers(locations) {
    console.log('[MapView] Adding', locations.length, 'markers');

    const features = locations.map((loc) => {
      const feature = new Feature({
        geometry: new Point(fromLonLat([loc.longitude, loc.latitude])),
        id: loc.id,
        name: loc.name,
        description: loc.description,
        category: loc.category,
        lon: loc.longitude,
        lat: loc.latitude,
      });
      return feature;
    });

    this.markerSource.addFeatures(features);
    console.log('[MapView] Markers added, total features:', this.markerSource.getFeatures().length);
    return features;
  }

  /**
   * Clear all markers
   */
  clearMarkers() {
    this.markerSource.clear();
    this.selectedFeature = null;
  }

  /**
   * Remove a specific marker by feature or ID
   */
  removeMarker(featureOrId) {
    if (typeof featureOrId === 'object') {
      this.markerSource.removeFeature(featureOrId);
    } else {
      const feature = this.markerSource.getFeatures().find(
        f => f.get('id') === featureOrId
      );
      if (feature) {
        this.markerSource.removeFeature(feature);
      }
    }
  }

  /**
   * Get all markers
   */
  getMarkers() {
    return this.markerSource.getFeatures();
  }

  /**
   * Find marker by ID
   */
  findMarker(id) {
    return this.markerSource.getFeatures().find(f => f.get('id') === id);
  }

  /**
   * Select a marker (highlights it)
   */
  selectMarker(featureOrId) {
    if (typeof featureOrId === 'object') {
      this.selectedFeature = featureOrId;
    } else {
      this.selectedFeature = this.findMarker(featureOrId);
    }
    this.markerSource.changed();
    return this.selectedFeature;
  }

  /**
   * Clear selection
   */
  clearSelection() {
    this.selectedFeature = null;
    this.markerSource.changed();
  }

  /**
   * Zoom to a specific location
   */
  zoomTo(lon, lat, zoom = 15) {
    this.map.getView().animate({
      center: fromLonLat([lon, lat]),
      zoom: zoom,
      duration: 500,
    });
  }

  /**
   * Fit view to show all markers
   */
  fitToMarkers(padding = 50) {
    const extent = this.markerSource.getExtent();
    if (extent && extent[0] !== Infinity) {
      this.map.getView().fit(extent, {
        padding: [padding, padding, padding, padding],
        duration: 500,
        maxZoom: 16,
      });
    }
  }

  /**
   * Get current map center in lon/lat
   */
  getCenter() {
    const center = this.map.getView().getCenter();
    return toLonLat(center);
  }

  /**
   * Get current zoom level
   */
  getZoom() {
    return this.map.getView().getZoom();
  }

  /**
   * Set map center
   */
  setCenter(lon, lat) {
    this.map.getView().setCenter(fromLonLat([lon, lat]));
  }

  /**
   * Set zoom level
   */
  setZoom(zoom) {
    this.map.getView().setZoom(zoom);
  }

  /**
   * Register click callback
   * Callback receives (lon, lat, feature, event)
   *
   * Single-click is delayed by 300 ms so that a double-click can cancel it.
   * If the click lands on an overlay feature (e.g. district boundary) the
   * single-click is suppressed entirely — only double-click will fire.
   */
  onClick(callback) {
    this.clickCallbacks.push(callback);

    // Set up click handler if this is the first callback
    if (this.clickCallbacks.length === 1) {
      this._clickTimer = null;

      // Double-click cancels any pending single-click
      this.map.on('dblclick', () => {
        if (this._clickTimer) {
          clearTimeout(this._clickTimer);
          this._clickTimer = null;
        }
      });

      this.map.on('click', (evt) => {
        // Cancel any previous pending click
        if (this._clickTimer) {
          clearTimeout(this._clickTimer);
          this._clickTimer = null;
        }

        // When NOT in edit / draw mode, immediately clear any feature
        // the Select interaction may have grabbed on this click so the
        // user never sees a selection flash.
        if (!this._editBarActive && this._selectInteraction) {
          this._selectInteraction.getFeatures().clear();
        }

        // Check what features sit under the click pixel
        let hasOverlayFeature = false;
        let hasParcelFeature = false;
        let markerFeature = null;
        this.map.forEachFeatureAtPixel(evt.pixel, (feature) => {
          if (feature.get('_layerType') === 'parcel') {
            hasParcelFeature = true;
          }
          if (feature.get('name')) {
            markerFeature = feature;
          }
          hasOverlayFeature = true;
        });

        // If an overlay feature was hit, suppress single-click
        // UNLESS it's a parcel or a location marker
        if (hasOverlayFeature && !hasParcelFeature && !markerFeature) {
          return;
        }

        // Delay the single-click to allow double-click to cancel it
        const [lon, lat] = toLonLat(evt.coordinate);
        this._clickTimer = setTimeout(() => {
          this._clickTimer = null;

          // Find location marker at pixel
          let clickedFeature = null;
          this.map.forEachFeatureAtPixel(evt.pixel, (feature) => {
            if (feature.get('name')) {
              clickedFeature = feature;
              return true;
            }
          });

          for (const cb of this.clickCallbacks) {
            cb(lon, lat, clickedFeature, evt);
          }
        }, 300);
      });
    }

    // Return unsubscribe function
    return () => {
      const index = this.clickCallbacks.indexOf(callback);
      if (index > -1) {
        this.clickCallbacks.splice(index, 1);
      }
    };
  }

  /**
   * Register pointer move callback (for hover effects)
   */
  onPointerMove(callback) {
    this.map.on('pointermove', (evt) => {
      if (evt.dragging) return;

      const [lon, lat] = toLonLat(evt.coordinate);

      // Only find location markers (features with 'name' property)
      let hoveredFeature = null;
      this.map.forEachFeatureAtPixel(evt.pixel, (feature) => {
        if (feature.get('name')) {
        hoveredFeature = feature;
        return true;
        }
      });

      // Change cursor
      this.map.getTargetElement().style.cursor = hoveredFeature ? 'pointer' : '';

      callback(lon, lat, hoveredFeature, evt);
    });
  }

  /**
   * Enable cursor change on marker hover
   * Note: This is now handled automatically by the popup system
   */
  enableHoverCursor() {
    // Cursor changes are now handled by setupHoverPopup()
    // This method is kept for backwards compatibility
  }

  /**
   * Add a GeoJSON layer (visible in LayerSwitcher).
   * By default the layer is added to the root overlay group.
   * Pass a targetGroup (LayerGroup) to nest it inside a specific group.
   *
   * @param {Object} geojson - GeoJSON FeatureCollection or Feature
   * @param {string} title - Layer title for the LayerSwitcher
   * @param {Object} [styleOptions] - Optional style configuration
   * @param {string} [styleOptions.strokeColor='#3b82f6'] - Stroke color
   * @param {number} [styleOptions.strokeWidth=2] - Stroke width
   * @param {string} [styleOptions.fillColor='rgba(59,130,246,0.1)'] - Fill color
   * @param {number[]} [styleOptions.strokeDash] - Dash pattern for the stroke
   *   (passed straight to ol/style/Stroke#lineDash, e.g. [4,4]). Useful for
   *   contextual overlays (grids, draft outlines) so they read differently
   *   from solid property boundaries.
   * @param {LayerGroup} [targetGroup] - Optional group to add the layer to
   * @returns {VectorLayer} The created layer
   */
  addGeoJSONLayer(geojson, title, styleOptions = {}, targetGroup = null) {
    const {
      strokeColor = '#3b82f6',
      strokeWidth = 2,
      strokeDash  = null,
      fillColor   = 'rgba(59,130,246,0.1)',
      // Optional line "casing": a thicker darker stroke drawn UNDERNEATH the
      // main stroke. Used for road-like layers to make light-colored lines
      // visible on any base map. Set lineCasingColor to enable; the casing
      // width defaults to strokeWidth + 2.
      lineCasingColor = null,
      lineCasingWidth = null,
      pointRadius = 5,
      pointFillColor   = null,  // defaults to strokeColor
      pointStrokeColor = '#ffffff',
      pointStrokeWidth = 1.5,
    } = styleOptions;

    const source = new VectorSource({
      features: new GeoJSON().readFeatures(geojson, {
        featureProjection: 'EPSG:3857',
      }),
    });

    // Build per-geometry styles. OpenLayers picks `image` for Point /
    // MultiPoint, `stroke`+`fill` for Polygon / MultiPolygon, and `stroke`
    // alone for LineString / MultiLineString. Putting all three on a single
    // Style is enough — but a Style with only stroke+fill leaves Points
    // invisible, which is what was happening on shapefile import.
    const fillStyle  = new Fill({ color: fillColor });
    const pointStyle = new Circle({
      radius: pointRadius,
      fill:   new Fill({ color: pointFillColor || strokeColor }),
      stroke: new Stroke({ color: pointStrokeColor, width: pointStrokeWidth }),
    });

    // If a line casing is requested, return an array of two Styles per
    // feature: the casing renders first (underneath), then the inner stroke.
    // For polygons the casing also outlines them; for points the casing has
    // no effect (Point geometries only render `image`).
    const mainStroke = new Stroke({
      color: strokeColor,
      width: strokeWidth,
      ...(strokeDash ? { lineDash: strokeDash } : {}),
    });

    let layerStyle;
    if (lineCasingColor) {
      const casingW = lineCasingWidth != null ? lineCasingWidth : strokeWidth + 2;
      layerStyle = [
        new Style({
          stroke: new Stroke({ color: lineCasingColor, width: casingW }),
        }),
        new Style({
          stroke: mainStroke,
          fill:   fillStyle,
          image:  pointStyle,
        }),
      ];
    } else {
      layerStyle = new Style({
        stroke: mainStroke,
        fill:   fillStyle,
        image:  pointStyle,
      });
    }

    const layer = new VectorLayer({
      title: title,
      source: source,
      style: layerStyle,
    });
    layer.set('typeTag', styleOptions.typeTag || 'VEC');

    // Derive a friendly "Vector / Polygon" / "Vector / Line" / "Vector / Point"
    // subtitle from the first feature's geometry type, unless the caller
    // already supplied one in styleOptions.
    //
    // Layers created EMPTY (parcels, OSM_roads, …, populated later from the
    // API) leave the subtitle absent until the first feature arrives —
    // see the `addfeature` listener below.
    const describeFromGeom = (geomType) => {
      if (!geomType) return null;
      if (geomType.includes('Polygon'))    return 'Vector / Polygon';
      if (geomType.includes('LineString')) return 'Vector / Line';
      if (geomType.includes('Point'))      return 'Vector / Point';
      return 'Vector';
    };

    if (styleOptions.typeDescription) {
      layer.set('typeDescription', styleOptions.typeDescription);
    } else {
      const feats = source.getFeatures();
      const initial = describeFromGeom(feats[0]?.getGeometry?.()?.getType?.());
      if (initial) {
        layer.set('typeDescription', initial);
      } else {
        // Source is empty — wait for the first feature and set then.
        const once = (ev) => {
          const desc = describeFromGeom(ev.feature.getGeometry?.()?.getType?.());
          if (desc) layer.set('typeDescription', desc);
          source.un('addfeature', once);
        };
        source.on('addfeature', once);
      }
    }

    const group = targetGroup || this.overlayGroup;
    group.getLayers().push(layer);

    console.log('[MapView] GeoJSON layer added:', title, '→', source.getFeatures().length, 'features',
      targetGroup ? `(in group "${targetGroup.get('title')}")` : '');
    return layer;
  }

  /**
   * Add a LayerGroup to the overlay group.
   * Used to create layer categories from the remote catalogue;
   * individual vector layers will be added into these groups later.
   *
   * @param {number|string} id - Unique layer group id (from the API)
   * @param {string} title - Group title for the LayerSwitcher
   * @param {string} [description=''] - Group description (stored as property)
   * @returns {LayerGroup} The created (empty) layer group
   */
  addLayerGroup(id, title, description = '') {
    const group = new LayerGroup({
      title: title.trim(),
    });

    // Store metadata for later use
    group.set('layerId', id);
    group.set('description', description);

    this.overlayGroup.getLayers().push(group);

    console.log('[MapView] Layer group added:', title.trim(), '(id:', id + ')');
    return group;
  }

  /**
   * Add a WMS layer to a layer group.
   *
   * @param {string} groupTitle     Title of the target LayerGroup (e.g. 'Biophysical Environment')
   * @param {string} title          Display title for the layer
   * @param {string} url            WMS server URL
   * @param {string} layers         WMS LAYERS parameter
   * @param {Object} [options]      Extra options
   * @param {string} [options.serverType='geoserver']  Server type hint ('geoserver'|'mapserver'|'qgis'|null)
   * @param {string} [options.style]                   WMS STYLES parameter (e.g. 'colours' for DEAfrica DEM)
   * @param {boolean} [options.visible=true]           Initial visibility
   * @param {string}  [options.attributions]           Attribution HTML
   * @param {number}  [options.opacity=1]              Layer opacity (0–1). Use ~0.5 for background-style layers.
   * @param {number}  [options.zIndex]                 Render z-index. Use negative values (e.g. -10) to force the
   *                                                   layer behind all default-z-index layers regardless of group order.
   * @param {string}  [options.legendUrl]              URL of a legend image to display while the layer is visible.
   * @param {boolean} [options.onlineOnly=false]       If true, show a toast when the user toggles the layer on
   *                                                   while offline, explaining that the layer requires connectivity.
   * @returns {TileLayer|null}      The created layer, or null if group not found
   */
  addWMSLayer(groupTitle, title, url, layers, options = {}) {
    const group = this.getLayerGroupByTitle(groupTitle);
    if (!group) {
      console.warn(`[MapView] Layer group "${groupTitle}" not found — cannot add WMS layer "${title}"`);
      return null;
    }

    const params = { LAYERS: layers, TILED: true, WIDTH: 256, HEIGHT: 256 };
    if (options.style !== undefined) params.STYLES = options.style;

    const wmsSource = new TileWMS({
      url,
      params,
      serverType: options.serverType !== undefined ? options.serverType : 'geoserver',
      crossOrigin: 'anonymous',
      hidpi: false,
      attributions: options.attributions,
    });

    const wmsLayer = new TileLayer({
      title,
      visible: options.visible !== undefined ? options.visible : true,
      source: wmsSource,
      opacity: options.opacity !== undefined ? options.opacity : 1,
      zIndex: options.zIndex,
    });
    wmsLayer.set('typeTag', 'WMS');
    wmsLayer.set('typeDescription', 'WMS / Raster');

    // Show toast on tile load errors (e.g. server rejects request)
    wmsSource.on('tileloaderror', () => {
      showToast(`WMS layer "${title}" — tile load error. Check the URL and layer name.`, 'warning', 5000);
    });

    group.getLayers().push(wmsLayer);

    // Register legend AFTER push so that a failure here doesn't block the LayerSwitcher
    if (options.legendUrl) {
      try {
        this._registerLegend(wmsLayer, title, options.legendUrl);
      } catch (err) {
        console.warn(`[MapView] Could not register legend for "${title}":`, err);
      }
    }

    // Online-only warning: when the user toggles the layer on while offline,
    // surface a toast explaining why nothing will render.
    if (options.onlineOnly) {
      this._attachOnlineOnlyHandler(wmsLayer, title);
    }

    console.log(`[MapView] WMS layer added: "${title}" → group "${groupTitle}"`);
    return wmsLayer;
  }

  /**
   * Add an XYZ tile layer to a layer group.
   *
   * @param {string} groupTitle     Title of the target LayerGroup
   * @param {string} title          Display title for the layer
   * @param {string} url            XYZ tile URL template (with {z}/{x}/{y} placeholders)
   * @param {Object} [options]      Extra options
   * @param {boolean} [options.visible=true]  Initial visibility
   * @param {string}  [options.attributions]  Attribution HTML
   * @param {number}  [options.maxZoom=19]    Maximum zoom level
   * @param {number}  [options.opacity=1]     Layer opacity (0–1). Use ~0.5 for background-style layers.
   * @param {number}  [options.zIndex]        Render z-index. Use negative values to force behind other layers.
   * @param {string}  [options.legendUrl]     URL of a legend image to display while the layer is visible.
   * @param {boolean} [options.onlineOnly=false]  If true, show a toast when the user toggles the layer on
   *                                              while offline, explaining that the layer requires connectivity.
   * @returns {TileLayer|null}      The created layer, or null if group not found
   */
  addXYZLayer(groupTitle, title, url, options = {}) {
    const group = this.getLayerGroupByTitle(groupTitle);
    if (!group) {
      console.warn(`[MapView] Layer group "${groupTitle}" not found — cannot add XYZ layer "${title}"`);
      return null;
    }

    const xyzSource = new XYZ({
      url,
      crossOrigin: 'anonymous',
      maxZoom: options.maxZoom !== undefined ? options.maxZoom : 19,
      attributions: options.attributions,
    });

    const xyzLayer = new TileLayer({
      title,
      visible: options.visible !== undefined ? options.visible : true,
      source: xyzSource,
      opacity: options.opacity !== undefined ? options.opacity : 1,
      zIndex: options.zIndex,
    });
    xyzLayer.set('typeTag', 'XYZ');
    xyzLayer.set('typeDescription', 'XYZ / Tile');

    // Show toast on tile load errors
    xyzSource.on('tileloaderror', () => {
      showToast(`XYZ layer "${title}" — tile load error. Check the URL.`, 'warning', 5000);
    });

    group.getLayers().push(xyzLayer);

    // Register legend AFTER push so that a failure here doesn't block the LayerSwitcher
    if (options.legendUrl) {
      try {
        this._registerLegend(xyzLayer, title, options.legendUrl);
      } catch (err) {
        console.warn(`[MapView] Could not register legend for "${title}":`, err);
      }
    }

    // Online-only warning: when the user toggles the layer on while offline,
    // surface a toast explaining why nothing will render.
    if (options.onlineOnly) {
      this._attachOnlineOnlyHandler(xyzLayer, title);
    }

    console.log(`[MapView] XYZ layer added: "${title}" → group "${groupTitle}"`);
    return xyzLayer;
  }

  // ============================================================================
  // Add External Layer Dialog
  // ============================================================================

  /**
   * Create the add-layer dialog overlay (hidden by default).
   * Appended to the map target element so it stays within the map viewport.
   */
  _createAddLayerDialog() {
    this._addLayerDialog = document.createElement('div');
    this._addLayerDialog.className = 'map-add-layer-dialog';
    this._addLayerDialog.style.cssText = `
      display:none;position:absolute;top:0;left:0;right:0;bottom:0;
      z-index:1100;background:rgba(0,0,0,0.4);
      align-items:center;justify-content:center;
    `;

    const card = document.createElement('div');
    card.style.cssText = `
      background:var(--card, #fff);color:var(--card-foreground, #1e1a4b);
      border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,0.35);
      font-family:var(--font-body, 'Exo', sans-serif);font-size:13px;
      width:340px;max-width:90vw;border:2px solid #10b981;overflow:hidden;
    `;

    card.innerHTML = `
      <div style="background:#10b981;color:#fff;padding:10px 14px;font-weight:600;display:flex;justify-content:space-between;align-items:center;">
        <span>Add External Layer</span>
        <button class="add-layer-close" style="background:none;border:none;color:#fff;font-size:18px;cursor:pointer;padding:0 4px;line-height:1;">&times;</button>
      </div>
      <div style="padding:14px;display:flex;flex-direction:column;gap:10px;">
        <div>
          <label style="font-weight:600;font-size:12px;display:block;margin-bottom:4px;">Layer Type</label>
          <div class="add-layer-types" style="display:flex;gap:6px;">
            <label style="flex:1;display:flex;align-items:center;gap:4px;cursor:pointer;padding:6px 8px;border:2px solid var(--border, #1e1a4b1f);border-radius:6px;font-size:12px;font-weight:600;">
              <input type="radio" name="add-layer-type" value="wms" checked style="accent-color:#10b981;"> WMS
            </label>
            <label style="flex:1;display:flex;align-items:center;gap:4px;cursor:pointer;padding:6px 8px;border:2px solid var(--border, #1e1a4b1f);border-radius:6px;font-size:12px;font-weight:600;">
              <input type="radio" name="add-layer-type" value="wfs" style="accent-color:#10b981;"> WFS
            </label>
            <label style="flex:1;display:flex;align-items:center;gap:4px;cursor:pointer;padding:6px 8px;border:2px solid var(--border, #1e1a4b1f);border-radius:6px;font-size:12px;font-weight:600;">
              <input type="radio" name="add-layer-type" value="xyz" style="accent-color:#10b981;"> XYZ
            </label>
          </div>
        </div>
        <div>
          <label style="font-weight:600;font-size:12px;display:block;margin-bottom:4px;">Server URL</label>
          <input type="text" class="add-layer-url" placeholder="https://example.com/wms"
                 style="width:100%;padding:8px 10px;border:2px solid var(--border, #1e1a4b1f);border-radius:6px;font-size:13px;background:var(--muted, #f2f4f7);color:var(--foreground, #1e1a4b);box-sizing:border-box;" />
        </div>
        <div class="add-layer-name-row">
          <label style="font-weight:600;font-size:12px;display:block;margin-bottom:4px;">Layer Name</label>
          <input type="text" class="add-layer-name" placeholder="workspace:layer_name"
                 style="width:100%;padding:8px 10px;border:2px solid var(--border, #1e1a4b1f);border-radius:6px;font-size:13px;background:var(--muted, #f2f4f7);color:var(--foreground, #1e1a4b);box-sizing:border-box;" />
          <div style="font-size:11px;color:var(--muted-foreground, #7a7a7a);margin-top:2px;" class="add-layer-name-hint">
            WMS LAYERS parameter (e.g. workspace:layer)
          </div>
        </div>
        <div>
          <label style="font-weight:600;font-size:12px;display:block;margin-bottom:4px;">Display Title</label>
          <input type="text" class="add-layer-title" placeholder="My Layer"
                 style="width:100%;padding:8px 10px;border:2px solid var(--border, #1e1a4b1f);border-radius:6px;font-size:13px;background:var(--muted, #f2f4f7);color:var(--foreground, #1e1a4b);box-sizing:border-box;" />
        </div>
        <div style="display:flex;gap:8px;margin-top:4px;">
          <button class="add-layer-confirm" style="flex:1;padding:8px 12px;background:#10b981;color:#fff;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;min-height:38px;">
            Add Layer
          </button>
          <button class="add-layer-cancel" style="flex:1;padding:8px 12px;background:var(--muted-foreground, #7a7a7a);color:#fff;border:none;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;min-height:38px;">
            Cancel
          </button>
        </div>
      </div>
    `;

    this._addLayerDialog.appendChild(card);
    this.map.getTargetElement().appendChild(this._addLayerDialog);

    // Type radio change — toggle layer name row visibility
    const nameRow = card.querySelector('.add-layer-name-row');
    const nameHint = card.querySelector('.add-layer-name-hint');
    const urlInput = card.querySelector('.add-layer-url');
    card.querySelectorAll('input[name="add-layer-type"]').forEach((radio) => {
      radio.addEventListener('change', () => {
        const type = radio.value;
        if (type === 'xyz') {
          nameRow.style.display = 'none';
          urlInput.placeholder = 'https://example.com/tiles/{z}/{x}/{y}.png';
        } else {
          nameRow.style.display = '';
          urlInput.placeholder = type === 'wms'
            ? 'https://example.com/wms'
            : 'https://example.com/wfs';
          nameHint.textContent = type === 'wms'
            ? 'WMS LAYERS parameter (e.g. workspace:layer)'
            : 'WFS typename (e.g. workspace:layer)';
        }
      });
    });

    // Close / Cancel
    const close = () => this._hideAddLayerDialog();
    card.querySelector('.add-layer-close').addEventListener('click', close);
    card.querySelector('.add-layer-cancel').addEventListener('click', close);
    this._addLayerDialog.addEventListener('click', (e) => {
      if (e.target === this._addLayerDialog) close();
    });

    // Confirm
    card.querySelector('.add-layer-confirm').addEventListener('click', () => {
      const type = card.querySelector('input[name="add-layer-type"]:checked').value;
      const url = card.querySelector('.add-layer-url').value.trim();
      const layerName = card.querySelector('.add-layer-name').value.trim();
      const title = card.querySelector('.add-layer-title').value.trim();

      if (!url) {
        card.querySelector('.add-layer-url').style.borderColor = '#ef4444';
        return;
      }
      if ((type === 'wms' || type === 'wfs') && !layerName) {
        card.querySelector('.add-layer-name').style.borderColor = '#ef4444';
        return;
      }
      if (!title) {
        card.querySelector('.add-layer-title').style.borderColor = '#ef4444';
        return;
      }

      this._addExternalLayer(type, url, layerName, title);
      this._hideAddLayerDialog();
    });

    // Enter key to confirm
    card.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') {
        e.preventDefault();
        card.querySelector('.add-layer-confirm').click();
      }
      if (e.key === 'Escape') {
        e.preventDefault();
        close();
      }
    });
  }

  /**
   * Show the add-layer dialog.
   */
  showAddLayerDialog() {
    const dlg = this._addLayerDialog;
    // Reset form
    dlg.querySelector('.add-layer-url').value = '';
    dlg.querySelector('.add-layer-name').value = '';
    dlg.querySelector('.add-layer-title').value = '';
    dlg.querySelectorAll('input[name="add-layer-type"]')[0].checked = true;
    dlg.querySelector('.add-layer-name-row').style.display = '';
    dlg.querySelector('.add-layer-url').placeholder = 'https://example.com/wms';
    dlg.querySelector('.add-layer-name-hint').textContent = 'WMS LAYERS parameter (e.g. workspace:layer)';

    // Reset border colours
    dlg.querySelectorAll('input[type="text"]').forEach((inp) => {
      inp.style.borderColor = 'var(--border, #1e1a4b1f)';
    });

    dlg.style.display = 'flex';
    dlg.querySelector('.add-layer-url').focus();
  }

  /**
   * Hide the add-layer dialog.
   */
  _hideAddLayerDialog() {
    this._addLayerDialog.style.display = 'none';
  }

  /**
   * Add an external layer to the "External Source" group.
   *
   * @param {string} type       'wms' | 'wfs' | 'xyz'
   * @param {string} url        Server URL
   * @param {string} layerName  WMS LAYERS / WFS typename (ignored for XYZ)
   * @param {string} title      Display title in layer switcher
   */
  _addExternalLayer(type, url, layerName, title) {
    const group = this._externalSourceGroup;
    if (!group) {
      showToast('Layer group "External Source" not found.', 'error', 4000);
      return;
    }

    let layer;

    switch (type) {
      case 'wms': {
        const wmsSrc = new TileWMS({
          url,
          params: { LAYERS: layerName, TILED: true, WIDTH: 256, HEIGHT: 256 },
          serverType: 'geoserver',
          crossOrigin: 'anonymous',
          hidpi: false,
        });
        layer = new TileLayer({
          title,
          visible: true,
          source: wmsSrc,
        });
        wmsSrc.on('tileloaderror', () => {
          showToast(`WMS "${title}" — tile load error. Check URL and layer name.`, 'warning', 5000);
        });
        break;
      }

      case 'wfs': {
        const wfsUrl = `${url}${url.includes('?') ? '&' : '?'}` +
          `service=WFS&version=1.1.0&request=GetFeature` +
          `&typename=${encodeURIComponent(layerName)}` +
          `&outputFormat=application/json&srsname=EPSG:3857`;

        const wfsSource = new VectorSource({
          url: wfsUrl,
          format: new GeoJSON(),
        });
        wfsSource.on('featuresloaderror', () => {
          showToast(`WFS "${title}" — load error. Check URL and layer name.`, 'warning', 5000);
        });

        layer = new VectorLayer({
          title,
          visible: true,
          source: wfsSource,
          style: new Style({
            stroke: new Stroke({ color: '#e11d48', width: 2 }),
            fill: new Fill({ color: 'rgba(225,29,72,0.15)' }),
          }),
        });
        break;
      }

      case 'xyz':
        layer = new TileLayer({
          title,
          visible: true,
          source: new XYZ({
            url,
            crossOrigin: 'anonymous',
          }),
        });
        layer.getSource().on('tileloaderror', () => {
          showToast(`XYZ "${title}" — tile load error. Check the URL template.`, 'warning', 5000);
        });
        break;

      default:
        showToast(`Unknown layer type: ${type}`, 'error', 4000);
        return;
    }

    // Tag for the LayerSwitcher chip
    layer.set('typeTag', type.toUpperCase()); // 'WMS' | 'WFS' | 'XYZ'
    layer.set('typeDescription', {
      wms: 'WMS / Raster',
      wfs: 'WFS / Vector',
      xyz: 'XYZ / Tile',
    }[type] || type.toUpperCase());

    // User-added external layers ARE removable — they're not part of the
    // app's built-in data model.
    layer.set('removable', true);

    group.getLayers().push(layer);
    showToast(`Layer "${title}" added to External Source.`, 'success', 3000);
    console.log(`[MapView] External ${type.toUpperCase()} layer added: "${title}"`);
  }

  // ============================================================================
  // LayerSwitcher decoration (Option A — visual refresh)
  // ============================================================================

  /**
   * Decorate a layer's <li> after ol-ext renders it:
   *   • inject a type-tag chip next to the layer label
   *   • inject the green "+" button on the External Source group header
   *
   * Idempotent — safe to call repeatedly; each injected element checks
   * whether it already exists in the row.
   */
  _decorateLayerListItem(layer, li) {
    // 1. Type-tag chip (e.g. WMS / XYZ / VEC) next to the layer name.
    //    Inserted INSIDE the label's <span> so it doesn't collide with the
    //    label's left padding (where ol-ext draws the checkbox via ::before).
    const tag = layer.get('typeTag'); // 'WMS' | 'WFS' | 'XYZ' | 'VEC' | 'GEO' | 'BASE'
    if (tag) {
      const labelSpan = li.querySelector(':scope > .li-content > label > span');
      if (labelSpan && !labelSpan.querySelector(':scope > .ls-type-tag')) {
        const chip = document.createElement('span');
        chip.className = `ls-type-tag ls-type-tag-${String(tag).toLowerCase()}`;
        chip.textContent = String(tag);
        chip.title = `${tag} layer`;
        labelSpan.appendChild(chip);
      }
    }

    // 2. Replace ol-ext's bar-drawn +/- chevron with the GeoView chevron SVG.
    //    The SVG uses stroke="currentColor", so CSS `color` (set in
    //    layerswitcher.css) tints it. Rotation handles the open/closed state.
    const btnBar = li.querySelector(':scope > .ol-layerswitcher-buttons');
    if (btnBar) {
      const chevronEl = btnBar.querySelector(':scope > .expend-layers, :scope > .collapse-layers');
      if (chevronEl && !chevronEl.querySelector(':scope > svg.ls-chevron-svg')) {
        chevronEl.innerHTML =
          '<svg class="ls-chevron-svg" width="11" height="11" viewBox="0 0 11 11" fill="none" aria-hidden="true">' +
          '<path d="M3 2L7 5.5L3 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path>' +
          '</svg>';
      }
    }

    // 3. Layer-type subtitle ("Vector / Polygon", "WMS / Raster", …) below
    //    the layer name. Only rendered when layer.get('typeDescription') is
    //    set. For layers that start empty and gain features later (the API
    //    loaders), we listen for `change:typeDescription` and update or
    //    insert the subtitle then.
    const content = li.querySelector(':scope > .li-content');
    const ensureSubtitle = () => {
      if (!content) return;
      const text = layer.get('typeDescription');
      let sub = content.querySelector(':scope > .ls-layer-subtitle');
      if (!text) {
        if (sub) sub.remove();
        return;
      }
      if (!sub) {
        sub = document.createElement('div');
        sub.className = 'ls-layer-subtitle';
        const label = content.querySelector(':scope > label');
        if (label && label.nextSibling) {
          content.insertBefore(sub, label.nextSibling);
        } else {
          content.appendChild(sub);
        }
      }
      sub.textContent = text;
    };
    ensureSubtitle();
    if (!layer._lsSubtitleHooked) {
      layer._lsSubtitleHooked = true;
      layer.on('change:typeDescription', () => {
        // The <li> may not exist any more (panel re-rendered between events)
        // — guard with a fresh lookup via the LayerSwitcher next time it draws.
        // For now we just call ensureSubtitle bound to the original li.
        ensureSubtitle();
      });
    }

    // 4. Per-layer Remove button — only for layers explicitly marked
    //    `removable: true` (external sources, imported files, …). Built-in
    //    layers (Parcels, OSM_roads, district boundary, …) are NOT removable
    //    so the user can't accidentally delete them.
    if (layer.get('removable') === true && btnBar && !btnBar.querySelector(':scope > .ls-remove-btn')) {
      const removeBtn = document.createElement('button');
      removeBtn.type = 'button';
      removeBtn.className = 'ls-remove-btn';
      removeBtn.title = 'Remove this layer';
      removeBtn.setAttribute('aria-label', 'Remove layer');
      removeBtn.innerHTML =
        '<svg width="11" height="11" viewBox="0 0 11 11" fill="none" aria-hidden="true">' +
        '<path d="M2 2l7 7M9 2L2 9" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"></path>' +
        '</svg>';
      removeBtn.addEventListener('click', (e) => {
        e.stopPropagation();
        this._removeLayer(layer);
      });
      btnBar.appendChild(removeBtn);
    }

    // 5b. Import-state chip on layers staged via the external-dataset import
    //    flow (LUPMIS2_Import_Upload_Design.docx §3.2). The chip's text and
    //    behaviour depend on `_externalImportStatus`:
    //       'mapped'    → "Upload N"          (click → upload)
    //       'uploading' → spinner             (disabled)
    //       'submitted' → "✓ submitted"       (in upload_tmp, awaiting review)
    //       'migrated'  → "✓ live"            (supervisor promoted to lu_parcels)
    //       'failed'    → "N errors — fix?"
    //       'other'/null→ no chip
    //    Clicks dispatch a window-level CustomEvent so main.js can react
    //    without MapView knowing anything about staging.
    const importId = layer.get('_externalImportId');
    if (importId != null) {
      const labelSpan = li.querySelector(':scope > .li-content > label > span');
      let chip = labelSpan ? labelSpan.querySelector(':scope > .ls-import-chip') : null;
      const status = layer.get('_externalImportStatus') || 'mapped';
      const featureCount = layer.getSource()?.getFeatures().length ?? 0;
      const errorCount = layer.get('_externalImportErrorCount') ?? 0;

      // Decide chip appearance.
      const chipSpec = (() => {
        switch (status) {
          case 'mapped':
            return { text: `Upload ${featureCount}`, cls: 'ls-import-chip-mapped',
                     title: 'Upload this dataset to the database', clickable: true };
          case 'uploading':
            return { text: '…', cls: 'ls-import-chip-uploading',
                     title: 'Uploading…', clickable: false };
          case 'submitted':
            return { text: '✓ submitted', cls: 'ls-import-chip-submitted',
                     title: 'Uploaded — awaiting supervisor review', clickable: false };
          case 'migrated':
            return { text: '✓ live', cls: 'ls-import-chip-migrated',
                     title: 'Approved by supervisor and live on the server', clickable: false };
          case 'failed':
            return { text: `${errorCount} errors — fix?`, cls: 'ls-import-chip-failed',
                     title: 'Some rows failed; click to review', clickable: true };
          case 'other':
          case null:
          case undefined:
          default:
            return null;
        }
      })();

      if (!chipSpec) {
        if (chip) chip.remove();
      } else if (labelSpan) {
        if (!chip) {
          chip = document.createElement('span');
          chip.className = 'ls-import-chip';
          labelSpan.appendChild(chip);
        }
        chip.className = `ls-import-chip ${chipSpec.cls}`;
        chip.textContent = chipSpec.text;
        chip.title = chipSpec.title;
        chip.style.cursor = chipSpec.clickable ? 'pointer' : 'default';
        chip.style.opacity = chipSpec.clickable ? '1' : '0.85';

        // Replace any prior listener by cloning the node.
        const fresh = chip.cloneNode(true);
        chip.replaceWith(fresh);
        chip = fresh;

        if (chipSpec.clickable) {
          chip.addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();
            window.dispatchEvent(new CustomEvent('lupmis:import-chip-click', {
              detail: { importId, status, layer },
            }));
          });
        }
      }
    }

    // 5. "+" button on the External Source group
    const groupTitle = (layer.get('title') || '').toLowerCase();
    if (groupTitle.includes('external')) {
      this._externalSourceGroup = layer;
      // btnBar already resolved above (same .ol-layerswitcher-buttons element)
      if (btnBar && !btnBar.querySelector('.ol-add-layer')) {
        const addBtn = document.createElement('span');
        addBtn.className = 'ol-add-layer';
        addBtn.title = 'Add external layer';
        addBtn.textContent = '+';
        addBtn.style.cssText = `
          display:inline-flex !important;align-items:center;justify-content:center;
          width:22px !important;height:22px !important;border-radius:50%;
          background:#41b6a6 !important;color:#fff !important;
          font-size:15px !important;font-weight:700;
          cursor:pointer;line-height:1 !important;
          margin:0 4px 0 0;vertical-align:middle;
          transition:background 0.2s;box-sizing:border-box;border:none;
        `;
        addBtn.addEventListener('mouseenter', () => { addBtn.style.background = '#329686'; });
        addBtn.addEventListener('mouseleave', () => { addBtn.style.background = '#41b6a6'; });
        addBtn.addEventListener('click', (e) => {
          e.stopPropagation();
          this.showAddLayerDialog();
        });
        btnBar.prepend(addBtn);
      }
    }
  }

  /**
   * Remove a layer from its parent group, after confirmation. Only called
   * from the per-layer × button injected by `_decorateLayerListItem` — and
   * that button is only injected for layers marked `removable: true`, so
   * built-in layers (Parcels, OSM_roads, …) can never reach this path.
   *
   * @param {Layer} layer
   */
  _removeLayer(layer) {
    const title = layer.get('title') || 'this layer';
    if (!confirm(`Remove "${title}" from the map?\n\nThis only affects the current session — built-in layers cannot be removed.`)) {
      return;
    }

    // Find the parent group that owns this layer and call .remove() on its
    // collection. Walk recursively from the overlay group.
    const visit = (group) => {
      const layers = group.getLayers();
      if (layers.getArray().includes(layer)) {
        layers.remove(layer);
        return true;
      }
      let removed = false;
      layers.forEach((child) => {
        if (!removed && child.getLayers) {
          removed = visit(child);
        }
      });
      return removed;
    };

    const ok = visit(this.overlayGroup);
    if (ok) {
      console.log(`[MapView] Removed layer "${title}"`);
      showToast(`Removed "${title}" from the map.`, 'info', 3000);
    } else {
      console.warn(`[MapView] Could not find layer "${title}" in any group`);
    }
  }

  /**
   * Inject (or refresh) the panel chrome — an "active count" badge at the top
   * and a footer row with "Reset overlays" button at the bottom.
   *
   * The chrome lives in `.panel-container` (the wrapping <div>), not inside
   * the `<ul class="panel">` — that way the badge and footer are siblings of
   * the layer list rather than malformed children of a `<ul>`.
   *
   * Called from drawlist via queueMicrotask, so it runs once per redraw cycle
   * regardless of how many layers are in the panel.
   */
  _refreshLayerSwitcherChrome(layerSwitcher) {
    const panelContainer = layerSwitcher.element?.querySelector('.panel-container');
    const ul = layerSwitcher.element?.querySelector('ul.panel');
    if (!panelContainer || !ul) return;

    // --- Active-count badge (top of panel-container, before the <ul>) ---
    let badge = panelContainer.querySelector(':scope > .ls-active-badge');
    if (!badge) {
      badge = document.createElement('div');
      badge.className = 'ls-active-badge';
      badge.innerHTML = `
        <span class="ls-active-badge-title">Layers</span>
        <span class="ls-active-badge-count">0 active</span>
      `;
      panelContainer.insertBefore(badge, ul);
    }

    // --- Footer row (bottom of panel-container, after the <ul>) ---
    let footer = panelContainer.querySelector(':scope > .ls-footer-row');
    if (!footer) {
      footer = document.createElement('div');
      footer.className = 'ls-footer-row';
      footer.innerHTML = `
        <span class="ls-footer-note">— layers total</span>
        <button type="button" class="ls-footer-btn"
                title="Hide every overlay (base map stays on)">
          Reset
        </button>
      `;
      panelContainer.appendChild(footer);

      footer.querySelector('.ls-footer-btn').addEventListener('click', (e) => {
        e.stopPropagation();
        this._resetAllOverlays();
      });
    }

    // --- Update counters ---
    const counts = this._countLayers();
    badge.querySelector('.ls-active-badge-count').textContent =
      `${counts.activeOverlays} active`;
    footer.querySelector('.ls-footer-note').textContent =
      `${counts.totalOverlays} overlay${counts.totalOverlays === 1 ? '' : 's'}`;
  }

  /**
   * Walk the overlay group recursively, returning the number of *leaf* layers
   * (i.e. not groups) and how many of them are currently visible.
   * Excludes base maps and internal layers (Markers, Drawings, vertex overlay).
   */
  _countLayers() {
    let totalOverlays = 0;
    let activeOverlays = 0;

    const HIDDEN_INTERNAL = new Set(['__vertex_highlight__']);

    const visit = (group) => {
      group.getLayers().forEach((layer) => {
        if (layer.get('displayInLayerSwitcher') === false) return;
        if (HIDDEN_INTERNAL.has(layer.get('title'))) return;

        if (layer.getLayers) {
          visit(layer);
        } else {
          totalOverlays++;
          if (layer.getVisible()) activeOverlays++;
        }
      });
    };
    if (this.overlayGroup) visit(this.overlayGroup);
    return { totalOverlays, activeOverlays };
  }

  /**
   * Hide every overlay layer (base maps stay on). Wired to the footer
   * "Reset overlays" button.
   */
  _resetAllOverlays() {
    const HIDDEN_INTERNAL = new Set(['__vertex_highlight__']);
    const visit = (group) => {
      group.getLayers().forEach((layer) => {
        if (layer.get('displayInLayerSwitcher') === false) return;
        if (HIDDEN_INTERNAL.has(layer.get('title'))) return;

        if (layer.getLayers) {
          visit(layer);
        } else {
          layer.setVisible(false);
        }
      });
    };
    if (this.overlayGroup) visit(this.overlayGroup);
    console.log('[MapView] Reset overlays — all hidden');
  }

  /**
   * Hook `change:visible` on every overlay leaf so the active-count badge
   * stays in sync even when ol-ext doesn't re-fire drawlist (e.g. ticking
   * a checkbox just toggles visibility without rebuilding the list).
   * Also re-hooks when a new layer is added.
   */
  _wireLayerSwitcherVisibilityHooks(layerSwitcher) {
    const refresh = () => this._refreshLayerSwitcherChrome(layerSwitcher);

    const hookLayer = (layer) => {
      if (layer._lsVisHooked) return;
      layer._lsVisHooked = true;
      layer.on('change:visible', refresh);
    };

    const visit = (group) => {
      group.getLayers().forEach((layer) => {
        if (layer.getLayers) {
          visit(layer);
          // Listen for layers added later to this group
          if (!group._lsAddHooked) {
            group._lsAddHooked = true;
            group.getLayers().on('add', (ev) => {
              const added = ev.element;
              if (added.getLayers) visit(added); else hookLayer(added);
              refresh();
            });
          }
        } else {
          hookLayer(layer);
        }
      });
    };

    if (this.overlayGroup) visit(this.overlayGroup);
  }

  // ============================================================================
  // Online-Only Layer Helper
  // ============================================================================

  /**
   * Attach a `change:visible` listener that shows an info toast when the user
   * toggles a layer ON while the device is offline. Used for layers that fetch
   * tiles or features from a remote service and therefore have no useful
   * cached state.
   *
   * The check uses navigator.onLine, which is the same signal as the rest of
   * the app's online detection.
   *
   * @param {Layer}  layer
   * @param {string} title  Display title used in the toast message
   */
  _attachOnlineOnlyHandler(layer, title) {
    layer.set('onlineOnly', true);
    layer.on('change:visible', () => {
      if (layer.getVisible() && !navigator.onLine) {
        showToast(
          `"${title}" requires an internet connection. Connect to view this layer.`,
          'info',
          5000,
        );
      }
    });
  }

  // ============================================================================
  // Legend Panel — shows legend images for visible layers that have one
  // ============================================================================

  /**
   * Create the legend panel, positioned bottom-right inside the map target.
   * Hidden when no visible layers have a registered legend.
   */
  _createLegendPanel() {
    this._legendPanel = document.createElement('div');
    this._legendPanel.className = 'map-legend-panel';
    this._legendPanel.style.cssText = `
      position:absolute;right:10px;bottom:40px;z-index:900;
      display:none;flex-direction:column;gap:6px;
      background:var(--card, #fff);color:var(--card-foreground, #1e1a4b);
      border:1px solid var(--border, #1e1a4b1f);border-radius:8px;
      box-shadow:0 4px 12px rgba(0,0,0,0.15);
      font-family:var(--font-body, 'Exo', sans-serif);font-size:11px;
      max-width:220px;max-height:60%;overflow-y:auto;
      padding:8px 10px;
    `;
    this.map.getTargetElement().appendChild(this._legendPanel);

    // Map of layer → { wrapper, title, imgUrl }
    this._legendEntries = new Map();
  }

  /**
   * Register a layer's legend image and wire up visibility tracking.
   * Called from addWMSLayer / addXYZLayer when a legendUrl is supplied.
   *
   * @param {Layer}  layer      The OpenLayers layer
   * @param {string} title      Display title for the legend header
   * @param {string} legendUrl  URL of the legend image
   */
  _registerLegend(layer, title, legendUrl) {
    if (!this._legendPanel) return;

    // Build the legend entry — a div with header + image
    const wrapper = document.createElement('div');
    wrapper.className = 'map-legend-entry';
    wrapper.style.cssText = 'border-bottom:1px solid var(--border, #1e1a4b1f);padding-bottom:6px;';
    wrapper.innerHTML = `
      <div style="font-weight:600;font-size:11px;margin-bottom:4px;line-height:1.3;">
        ${this._escapeHtml(title)}
      </div>
      <img src="${legendUrl}" alt="${this._escapeHtml(title)} legend"
           style="display:block;max-width:100%;height:auto;border-radius:3px;"
           onerror="this.style.display='none'" />
    `;

    this._legendEntries.set(layer, wrapper);

    // Listen for visibility changes. Wrap in try/catch so a DOM error here
    // cannot break the LayerSwitcher's click handler (which fires change:visible
    // synchronously and relies on a subsequent setTimeout to update the checkbox).
    const update = () => {
      try { this._updateLegendPanel(); }
      catch (err) { console.warn('[MapView] legend panel update failed:', err); }
    };
    layer.on('change:visible', update);

    // Trigger initial state
    update();
  }

  /**
   * Refresh the legend panel contents: include entries for each visible
   * registered layer, and show/hide the panel based on whether any are visible.
   */
  _updateLegendPanel() {
    if (!this._legendPanel) return;

    // Rebuild children from scratch in a stable order (Map iteration order = insertion order)
    const children = [];
    for (const [layer, wrapper] of this._legendEntries) {
      if (layer.getVisible()) children.push(wrapper);
    }

    // Remove trailing bottom-border on the last entry for a clean look
    this._legendEntries.forEach((w) => {
      w.style.borderBottom = '1px solid var(--border, #1e1a4b1f)';
      w.style.paddingBottom = '6px';
    });
    if (children.length > 0) {
      children[children.length - 1].style.borderBottom = 'none';
      children[children.length - 1].style.paddingBottom = '0';
    }

    // Swap the DOM children
    this._legendPanel.replaceChildren(...children);
    this._legendPanel.style.display = children.length > 0 ? 'flex' : 'none';
  }

  /**
   * Escape HTML special characters for safe text insertion.
   */
  _escapeHtml(str) {
    return String(str)
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#39;');
  }

  /**
   * Find a LayerGroup inside the overlay group by its layerId.
   *
   * @param {number|string} id - The layerId to search for
   * @returns {LayerGroup|null} The matching group, or null
   */
  getLayerGroup(id) {
    let found = null;
    this.overlayGroup.getLayers().forEach((layer) => {
      if (layer.get('layerId') === id) {
        found = layer;
      }
    });
    return found;
  }

  /**
   * Find a LayerGroup inside the overlay group by its title.
   *
   * @param {string} title - The group title to search for
   * @returns {LayerGroup|null} The matching group, or null
   */
  getLayerGroupByTitle(title) {
    let found = null;
    this.overlayGroup.getLayers().forEach((layer) => {
      if (layer.get('title') === title) {
        found = layer;
      }
    });
    return found;
  }

  /**
   * Get the overlay LayerGroup for advanced usage
   */
  getOverlayGroup() {
    return this.overlayGroup;
  }

  /**
   * Get the OpenLayers map instance for advanced usage
   */
  getMap() {
    return this.map;
  }

  // ============================================================================
  // Extent Helpers (used by offline-tile downloader)
  // ============================================================================

  /**
   * Get the current map view's visible extent in EPSG:3857 (Web Mercator).
   * @returns {Array<number>} [minX, minY, maxX, maxY]
   */
  getCurrentViewExtent() {
    const view = this.map.getView();
    const size = this.map.getSize();
    if (!size) return null;
    return view.calculateExtent(size);
  }

  /**
   * Get the bounding extent of the District Boundary layer (if present).
   * Searches the overlay group for a vector layer titled "District Boundary"
   * and returns the extent of its source.
   *
   * @returns {{ extent: Array<number>, title: string } | null}
   */
  getDistrictBoundaryExtent() {
    let found = null;
    const visit = (group) => {
      group.getLayers().forEach((layer) => {
        if (layer.getLayers) {
          visit(layer); // sub-group
        } else if (layer.get('title') === 'District Boundary') {
          const src = layer.getSource && layer.getSource();
          if (src && typeof src.getExtent === 'function') {
            const ex = src.getExtent();
            if (ex && Number.isFinite(ex[0])) {
              found = { extent: ex, title: layer.get('title') };
            }
          }
        }
      });
    };
    visit(this.overlayGroup);
    return found;
  }

  /**
   * Get the marker source for advanced usage
   */
  getMarkerSource() {
    return this.markerSource;
  }

  /**
   * Get the markers layer for advanced usage
   */
  getMarkersLayer() {
    return this.markersLayer;
  }

  /**
   * Update map size (call after container resize)
   */
  updateSize() {
    this.map.updateSize();
  }

  /**
   * Register a callback for when a search result is selected
   * Callback receives: { coordinate, lonLat: [lon, lat], name, searchResult }
   * Navigation to the location happens automatically
   */
  onSearchSelect(callback) {
    this.searchSelectCallbacks.push(callback);
  }

  /**
   * Navigate/fly to a specific location
   * @param {number} lon - Longitude
   * @param {number} lat - Latitude
   * @param {number} zoom - Zoom level (default: 14)
   * @param {number} duration - Animation duration in ms (default: 500)
   */
  navigateTo(lon, lat, zoom = 14, duration = 500) {
    const coordinate = fromLonLat([lon, lat]);
    this.map.getView().animate({
      center: coordinate,
      zoom: zoom,
      duration: duration,
    });
  }
}

// Export OpenLayers utilities for convenience
export { fromLonLat, toLonLat };

export default MapView;
