/**
 * Main Application Entry Point
 *
 * Demonstrates integration of:
 * - Bootstrap 5.3 for UI components
 * - SQLocal (SQLite in browser via OPFS)
 * - BroadcastChannel for cross-tab sync
 * - OpenLayers map with ol-ext LayerSwitcher
 * - PWA features (Service Worker, install prompt, offline detection)
 */

// Bootstrap CSS and JS
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap-icons/font/bootstrap-icons.css';
import { Modal, Offcanvas } from 'bootstrap';

// Database module (uses SQLocal directly, BroadcastChannel for tab sync)
import {
  sql,
  dbReady,
  initSchema,
  addLocation,
  getLocations,
  getLocationCount,
  getDatabaseStatus,
  downloadDatabase,
  onDatabaseChange,
  exportToGeoJSON,
  saveRemoteData,
  getRemoteData,
  saveCollectorZones,
  getLocalCollectorZones,
  saveUpnGrid,
  getLocalUpnGrid,
  createExternalImport,
  addExternalImportFeatures,
  updateExternalImport,
  getExternalImport,
  getExternalImportFeatures,
  remapImportedFeatureProperties,
  updateExternalImportFeatureGeometry,
  deleteExternalImportFeature,
  saveParcels,
  getLocalParcels,
  updateParcel,
  insertNewParcel,
  saveBuildingFootprints,
  getLocalBuildingFootprints,
  saveOSMRoads,
  getLocalOSMRoads,
  isCachedLayerTable,
  clearTable,
  clearAllCachedLayers,
  getTableStats,
  getTableContent
} from './src/database.js';

// Map component with OpenLayers and ol-ext LayerSwitcher
import { MapView } from './src/components/MapView.js';

// OpenLayers GeoJSON format (for updating layer sources directly)
import GeoJSON from 'ol/format/GeoJSON';

// OpenLayers WKT format (for writing drawn polygon geometries to database)
import WKT from 'ol/format/WKT';

// OpenLayers KML format (for KML file import)
import KML from 'ol/format/KML';
import { Style, Stroke, Fill, Text as OlText } from 'ol/style';

// Shapefile parser (reads .zip containing .shp/.dbf/.shx/.prj)
// Lazy-loaded — only fetched the first time the user imports a shapefile.
let _shpModule = null;
async function getShp() {
  if (!_shpModule) {
    const mod = await import('shpjs');
    _shpModule = mod.default || mod;
  }
  return _shpModule;
}

// Map measurement and drawing tools
import { MapTools } from './src/components/MapTools.js';

// PWA module (registers Service Worker, handles install/offline)
import { initPWA, isOnline, onOfflineChange, getTileCacheStats, clearTileCaches, clearTileCacheForProvider, getStorageEstimate, onServiceWorkerControllerChange } from './src/pwa.js';
import {
  BASEMAP_TEMPLATES,
  GHANA_EXTENT_3857,
  countTiles,
  estimatedSizeBytes,
  OfflineTileDownloader,
} from './src/offlineTiles.js';

// Remote database API (PostgreSQL backend)
import { checkServerReachable, isServerReachable, getDistrictBoundary, getLayers, getCollectorZones, getDistrictParcels, getBuildingFootprints, getContoursHillshade, getOSMRoads, getUpnGrid, getSession } from './src/remotedb.js';

// GPS live-position + trail recording (reusable engine + LUPMIS wiring)
import { geoTracker } from './src/geotracker-lupmis.js';
import { formatCoord, formatAccuracy, formatDistance, accuracyQuality, formatUTM } from './src/geotracker/geo-utils.js';

// Iframe embed bridge (see public/embed.php + LUPMIS2_Permit_Map_Integration.docx)
import { createEmbedBridge } from './src/embed-bridge.js';

// External-dataset import → staging → upload (see LUPMIS2_Import_Upload_Design.docx)
import { openImportMappingModal } from './src/import-modal.js';
import { applyFieldMapping } from './src/import-detect.js';

// ----- Import-spinner helpers ---------------------------------------------
// Shown between "user dropped a file" and "mapping modal opens" — Shapefile
// zip decompression in particular can take several seconds on big files.
function showImportSpinner(filename) {
  const overlay = document.getElementById('import-spinner-overlay');
  const nameEl  = document.getElementById('import-spinner-filename');
  if (!overlay) return;
  if (nameEl) nameEl.textContent = filename || '';
  overlay.classList.remove('d-none');
  overlay.classList.add('d-flex');
}
function hideImportSpinner() {
  const overlay = document.getElementById('import-spinner-overlay');
  if (!overlay) return;
  overlay.classList.add('d-none');
  overlay.classList.remove('d-flex');
}

// GIS export from the analysis popups (Area / Circle)
import { openExportGisModal } from './src/export-gis-modal.js';

// Map instance (global for access across functions)
let mapView = null;
let mapTools = null;
// Module-level reference so the embed bridge can access the parcels layer
// once loadParcels() has created it.
let parcelsLayer = null;
let embedBridge = null;

// Iframe embed mode. Set by public/embed.php when serving the /embed route;
// undefined for the normal /index.php entry point.
const EMBED_CONFIG = (typeof window !== 'undefined' && window.LUPMIS_EMBED) || null;
const IS_EMBED_PERMIT = !!(EMBED_CONFIG && EMBED_CONFIG.mode === 'permit');

// Current interaction mode: 'none' | 'addLocation' | 'measureCircle' |
// 'measureLine' | 'measureArea' | 'draw' | 'embed-permit'.
// Default is 'none' — a neutral state where map clicks do not trigger
// the Add-Location popup. Users explicitly opt into Add-Location by
// pressing the Add button in the bottom dock; pressing it again toggles
// the mode back off. Measurement / Draw tools also return to 'none'
// when toggled off, rather than implicitly re-entering Add-Location.
let currentMode = IS_EMBED_PERMIT ? 'embed-permit' : 'none';

// ============================================================================
// Application Initialization
// ============================================================================

/**
 * Pre-flight: when an SSO session is present but the user has no district
 * assigned, the app cannot function (every API call is scoped to a district).
 * Show a blocking message and halt initialisation so we never silently fall
 * back to a default district.
 *
 * Local dev (no window.LUPMIS_SESSION at all) is *not* affected — that path
 * still uses the remotedb FALLBACK_DISTRICT_ID for testing.
 *
 * @returns {boolean} true if the user is blocked (init should abort)
 */
function showNoDistrictBlockerIfNeeded() {
  const session = (typeof window !== 'undefined') ? window.LUPMIS_SESSION : null;
  if (!session || typeof session !== 'object') return false;          // dev mode
  const id = session.district_id;
  if (id !== null && id !== undefined && String(id).length > 0) return false;

  // Authenticated but no district — render an overlay and abort init.
  console.warn('[App] Authenticated user has no district assigned; halting init.');
  const overlay = document.createElement('div');
  overlay.id = 'no-district-overlay';
  overlay.setAttribute('role', 'alertdialog');
  overlay.setAttribute('aria-modal', 'true');
  overlay.style.cssText =
    'position:fixed;inset:0;z-index:99999;display:flex;align-items:center;' +
    'justify-content:center;background:rgba(255,255,255,0.98);padding:24px;';
  const name = session.full_name || session.username || 'You';
  overlay.innerHTML = `
    <div style="max-width:480px;text-align:center;border:1px solid #e5e7eb;
                border-radius:12px;padding:28px 24px;box-shadow:0 8px 24px rgba(0,0,0,0.08);
                background:#fff;font-family:var(--font-body,sans-serif);">
      <div style="font-size:42px;line-height:1;margin-bottom:12px;">🛑</div>
      <h2 style="margin:0 0 12px;color:var(--primary,#005eb8);font-size:1.35rem;">
        No district assigned
      </h2>
      <p style="margin:0 0 10px;color:#333;">
        ${escapeHtml(name)}, your user profile is not associated with any
        district. LUPMIS2 cannot load the relevant map data without one.
      </p>
      <p style="margin:0 0 20px;color:#6b7280;font-size:0.95rem;">
        Please contact the system administrator to have a district assigned
        to your account.
      </p>
      <button type="button" id="no-district-portal-btn"
              style="background:var(--primary,#005eb8);color:#fff;border:0;
                     border-radius:8px;padding:10px 18px;font-weight:600;cursor:pointer;">
        Return to LUSPA portal
      </button>
    </div>`;
  document.body.appendChild(overlay);
  overlay.querySelector('#no-district-portal-btn')?.addEventListener('click', () => {
    window.location.href = 'https://lupmis4luspa.org/';
  });
  return true;
}

async function initApp() {
  console.log('[App] Initializing...');

  // Pre-flight: authenticated user must have a district assigned.
  if (showNoDistrictBlockerIfNeeded()) return;

  // 1. Initialize PWA features (Service Worker, install prompt, offline detection)
  await initPWA({
    installButton: '#install-btn',
    offlineIndicator: '#offline-indicator',
    autoRegisterSW: true
  });

  // 2. Initialize the map
  // Restore the user's preferred default base map from localStorage
  const savedBasemap = localStorage.getItem('default-basemap') || 'topo';

  mapView = new MapView('map', {
    center: [-1.5, 7.5],  // Ghana
    zoom: 7,
    basemap: savedBasemap,
  });

  // Initialize map measurement tools
  mapTools = new MapTools(mapView.getMap());

  // Wire up GPS live-position + trail recording
  initGpsTracking();

  // Handle measurement results
  mapTools.onMeasureComplete((result) => {
    console.log('[MapTools] Measurement complete:', result);

    // Only show the Polygon Attributes popup for polygons drawn with the
    // Draw tool — NOT for area measurements (which have _layerType = 'measure_area').
    if (result.type === 'polygon' && result.coordinate) {
      const lt = result.feature?.get('_layerType');
      if (lt !== 'measure_area') {
        mapView?.showDrawnPolygonPopup(result.feature, result.coordinate);
      }
    }
  });

  // Category emojis are set up in MapView:
  // 'water': '💧', 'school': '🏫', 'health': '🏥',
  // 'market': '🏪', 'default': '📍', 'other': '📌'

  // In iframe embed permit mode, install the postMessage bridge BEFORE the
  // regular handlers so its outbound parcel:select / parcel:cleared events
  // are wired up; the regular click/dblclick handlers below short-circuit in
  // that mode (the bridge owns map interaction in the embed).
  if (IS_EMBED_PERMIT) {
    embedBridge = createEmbedBridge({ mapView, embedConfig: EMBED_CONFIG });
  }

  // Set up map click handler immediately after map creation
  mapView.onClick((lon, lat, feature, evt) => {
    // Embed permit mode: the bridge handles parcel selection itself; the
    // normal popup/add-location behaviour does not apply.
    if (IS_EMBED_PERMIT) return;

    console.log('[MapClick] Clicked at:', lon.toFixed(4), lat.toFixed(4));
    console.log('[MapClick] currentMode =', currentMode);

    // In draw or measurement modes, clicks drive the tool — don't
    // open popups or select features.
    if (currentMode === 'draw' || currentMode.startsWith('measure')) {
      return;
    }

    // Check if a parcel feature was clicked
    let parcelFeature = null;
    mapView.getMap().forEachFeatureAtPixel(evt.pixel, (f) => {
      if (f.get('_layerType') === 'parcel') {
        parcelFeature = f;
        return true; // stop at first parcel hit
      }
    });

    // Parcel click: open Edit Attributes form in ANY non-draw mode.
    // The feature is NOT selected — only the popup is shown.
    if (parcelFeature) {
      console.log('[MapClick] Clicked on parcel → Edit Attributes');
      mapView.showParcelEditPopup(parcelFeature, evt.coordinate);
      return;
    }

    // UPN-grid cell click: show a popup with the upn_prefix. This runs in
    // ANY non-draw mode and is checked AFTER the parcel branch so that a
    // parcel sitting inside a grid cell wins (parcels are the specific
    // object, the grid is contextual).
    let upnGridFeature = null;
    mapView.getMap().forEachFeatureAtPixel(evt.pixel, (f) => {
      if (f.get('_layerType') === 'upn_grid') {
        upnGridFeature = f;
        return true;
      }
    });
    if (upnGridFeature) {
      console.log('[MapClick] Clicked on UPN-grid cell → Info popup');
      mapView.showInfoPopup(upnGridFeature, evt.coordinate, {
        title: 'UPN Grid Cell',
        color: '#7c3aed',
      });
      return;
    }

    // Non-parcel clicks (markers, empty space) only in addLocation mode
    if (currentMode !== 'addLocation') {
      return;
    }

    if (feature) {
      // Clicked on existing marker - select it and show details
      console.log('[MapClick] Clicked on marker:', feature.getId());
      mapView.selectMarker(feature);
      showLocationDetails(feature);
    } else {
      // Clicked on empty space - show add location popup at click position
      console.log('[MapClick] Empty space → Add Location popup');
      mapView.clearSelection();
      mapView.showAddLocationPopup(evt.coordinate);
    }
  });

  // Set up double-click handler for overlay feature info
  // Uses '_layerType' property to distinguish zone features from other layers
  mapView.onDblClick((lon, lat, feature, evt) => {
    // Embed permit mode shows no info popups (the host owns the UI).
    if (IS_EMBED_PERMIT) return;
    if (!feature) return;

    const layerType = feature.get('_layerType');
    console.log('[App] Double-click on feature, _layerType:', layerType || 'none');

    if (layerType === 'measure_circle') {
      // Circle measurement: show intersection analysis with other layers
      mapView.showCircleIntersectionPopup(feature, evt.coordinate);
    } else if (layerType === 'measure_circle_radius') {
      // Clicked on the radius line — ignore
      return;
    } else if (layerType === 'measure_area') {
      // Area measurement polygon: show intersection analysis
      mapView.showAreaIntersectionPopup(feature, evt.coordinate);
    } else if (layerType === 'collector_zone') {
      mapView.showInfoPopup(feature, evt.coordinate, {
        title: 'Zone Info',
        color: '#7c3aed',
      });
    } else if (layerType === 'parcel') {
      mapView.showInfoPopup(feature, evt.coordinate, {
        title: 'Parcel Info',
        color: '#0ea5e9',
      });
    } else {
      mapView.showInfoPopup(feature, evt.coordinate, {
        title: 'Feature Info',
        color: '#e11d48',
      });
    }
  });

  // Set up handler for the map add location popup form
  mapView.onAddLocation(async (data) => {
    console.log('[App] Add location from map popup:', data);
    try {
      const result = await addLocation(data.name, data.lon, data.lat, {
        description: data.description || null,
        category: data.category || 'default'
      });
      console.log('[App] Location added:', data.name, 'id:', result.id);

      await loadLocations();

      // Zoom to the new location on the map
      mapView?.zoomTo(data.lon, data.lat, 14);

      // Select the new marker
      if (result.id) {
        mapView?.selectMarker(result.id);
      }

      showSuccess('Location added successfully');

    } catch (error) {
      console.error('[App] Failed to add location:', error);
      showError('Failed to add location: ' + error.message);
    }
  });

  // Set up parcel edit save handler
  mapView.onParcelEdit(async (feature, updatedProps) => {
    const parcelId = updatedProps.id || updatedProps.parcelid || updatedProps.parcel_id;
    console.log('[App] Parcel edit saved:', parcelId, updatedProps);

    if (!parcelId) {
      console.warn('[App] No parcel ID found in updated properties — skipping local save');
      return;
    }

    try {
      await updateParcel(parcelId, updatedProps);
      showSuccess('Parcel updated locally');
    } catch (error) {
      console.error('[App] Failed to save parcel update:', error);
      showError('Failed to save parcel: ' + error.message);
    }
  });

  // Set up drawn polygon attribute save handler
  const wktFormat = new WKT();
  mapView.onDrawnPolygonSave(async (feature, props) => {
    console.log('[App] Drawn polygon attributes saved:', props);

    try {
      // Convert the OL geometry (EPSG:3857) to WKT in EPSG:4326 for storage
      const wktString = wktFormat.writeGeometry(feature.getGeometry(), {
        dataProjection: 'EPSG:4326',
        featureProjection: 'EPSG:3857',
      });

      const result = await insertNewParcel(wktString, props);
      console.log('[App] New parcel inserted with id:', result.id);
      showSuccess('New parcel saved (pending verification)');
    } catch (error) {
      console.error('[App] Failed to save new parcel:', error);
      showError('Failed to save parcel: ' + error.message);
    }
  });

  // When the user modifies an imported feature with the EditBar (Draw mode +
  // Select + drag a vertex/edge), persist the new geometry back to the
  // matching external_import_features row so the next upload reflects it.
  // Non-imported features (drawn parcels, server parcels, etc.) carry no
  // _externalImportId / _clientUuid tags, so this is a quick no-op for them.
  mapView.onFeatureModified(async (feature) => {
    const importId  = feature.get('_externalImportId');
    const clientUuid = feature.get('_clientUuid');
    if (importId == null || !clientUuid) return;

    try {
      const wkt = wktFormat.writeGeometry(feature.getGeometry(), {
        dataProjection:    'EPSG:4326',
        featureProjection: 'EPSG:3857',
      });
      await updateExternalImportFeatureGeometry(clientUuid, wkt);
      console.log('[App] Imported feature geometry updated in staging:', clientUuid);
    } catch (error) {
      console.warn('[App] Failed to persist imported-feature edit:', error);
      showError('Could not save the edit locally: ' + error.message);
    }
  });

  // 3. Initialize database
  try {
    console.log('[App] Initializing database...');

    // Initialize schema (creates tables if they don't exist)
    // This also resolves dbReady when complete
    await initSchema();

    // Now dbReady should be resolved
    console.log('[App] Database ready');

    // Guard against a stale district: if the authenticated user's district
    // has changed since the last load (e.g. an admin reassigned them in the
    // remote DB), every locally-cached, district-scoped layer is now wrong.
    // Wipe them so the fresh-fetch path repopulates from the correct
    // district instead of showing the previous one. Must run BEFORE any
    // district-scoped load below.
    await enforceDistrictConsistency();

    // Show database status
    const status = await getDatabaseStatus();
    console.log('[App] Database status:', status);

    // Quick server reachability check (5 s timeout) — if the API server
    // is down, all load functions will skip remote fetches and fall back
    // to local cached data immediately, keeping the app responsive.
    if (isOnline()) {
      const reachable = await checkServerReachable();
      if (!reachable) {
        console.warn('[App] API server unreachable — using local data only');
        showWarning('Server not responding — loading cached data.');
      }
    }

    // Load remote overlays (needs remote_data table from initSchema)
    // loadLayers must complete first so the layer groups exist
    // before loadDistrictBoundary adds into the Administration group.
    await loadLayers();

    // Initialise EditBar with its own "Drawings" layer group
    mapView?.initEditBar();

    loadDistrictBoundary();
    loadUpnGrid();
    loadCollectorZones();
    loadParcels();
    // In embed permit mode the parcels layer is the user's working surface,
    // so make it visible immediately and hand the layer to the bridge so it
    // can emit `ready` (and resolve any pending `set:selected` UPN) once the
    // features arrive. loadParcels() runs its synchronous prologue (creating
    // the layer and assigning the module-level reference) before returning
    // its promise, so `parcelsLayer` is already set here.
    if (IS_EMBED_PERMIT && embedBridge && parcelsLayer) {
      parcelsLayer.setVisible(true);
      embedBridge.attachParcelsLayer(parcelsLayer);
    }
    loadBuildingFootprints();
    loadContoursHillshade();
    loadOSMRoads();
    loadExternalWMSLayers();

  } catch (error) {
    console.error('[App] Database initialization failed:', error);
    showError('Failed to initialize database. Please refresh the page.');
    return;
  }

  // 4. Initialize UI
  initUI();

  // 5. Load initial data and display on map
  await loadLocations();

  // 6. Listen for database changes (local + other tabs)
  onDatabaseChange((change) => {
    console.log('[App] Database change:', change);
    if (change.table === 'locations' && !change.local) {
      // Reload locations when another tab makes changes
      loadLocations();
    }
    if (change.table === 'parcels') {
      // Refresh the Local Data stats panel if it is visible
      const statsContainer = document.getElementById('local-data-stats');
      if (statsContainer && !statsContainer.classList.contains('d-none')) {
        refreshLocalDataStats();
      }
    }
  });

  // 7. Set up offline handling
  onOfflineChange((offline) => {
    if (offline) {
      console.log('[App] Working offline - data will sync when back online');
    } else {
      console.log('[App] Back online - syncing data...');
      syncData();
    }
  });

  // 8. Fieldwork mode (high-contrast + large touch targets)
  initFieldworkMode();

  // 9. Measurement system toggle (metric / imperial)
  initMeasurementSystem();

  // 9b. GPS coordinate format (lat/lon · UTM · both)
  initCoordinateFormat();

  // 10. Dark mode
  initDarkMode();

  // 11. Default base map selector
  initDefaultBasemap();

  // 12. Offline tile-cache stats card
  initOfflineTileCache();

  // 13. Offline-download dialog
  initOfflineDownloadDialog();

  // 14. Account card (signed-in user + sign-out)
  initAccountCard();

  console.log('[App] Initialized successfully');
}

// ============================================================================
// UI Initialization
// ============================================================================

function initUI() {
  console.log('[initUI] Starting UI initialization...');

  // Message log (persistent stack in right panel)
  initMessageLog();

  // Export button
  const exportBtn = document.getElementById('export-btn');
  if (exportBtn) {
    exportBtn.addEventListener('click', handleExport);
  }

  // Local Data button — shows tables and record counts
  const localDataBtn = document.getElementById('local-data-btn');
  if (localDataBtn) {
    localDataBtn.addEventListener('click', () => refreshLocalDataStats());
  }

  // File import buttons (Shapefile, GeoJSON, KML)
  const importShpBtn = document.getElementById('import-shp-btn');
  const shpFileInput = document.getElementById('shp-file-input');
  if (importShpBtn && shpFileInput) {
    importShpBtn.addEventListener('click', () => shpFileInput.click());
    shpFileInput.addEventListener('change', handleShapefileImport);
  }

  const importGeoJSONBtn = document.getElementById('import-geojson-btn');
  const geojsonFileInput = document.getElementById('geojson-file-input');
  if (importGeoJSONBtn && geojsonFileInput) {
    importGeoJSONBtn.addEventListener('click', () => geojsonFileInput.click());
    geojsonFileInput.addEventListener('change', handleGeoJSONImport);
  }

  const importKMLBtn = document.getElementById('import-kml-btn');
  const kmlFileInput = document.getElementById('kml-file-input');
  if (importKMLBtn && kmlFileInput) {
    importKMLBtn.addEventListener('click', () => kmlFileInput.click());
    kmlFileInput.addEventListener('change', handleKMLImport);
  }

  // Drag-and-drop file import on the map
  initMapDropZone();

  // GeoJSON Export button
  const exportGeoJSONBtn = document.getElementById('exportGeoJSON-btn');
  if (exportGeoJSONBtn) {
    exportGeoJSONBtn.addEventListener('click', handleExportGeoJSON);
  }

  // Status button
  const statusBtn = document.getElementById('status-btn');
  if (statusBtn) {
    statusBtn.addEventListener('click', handleShowStatus);
  }

  // Fit to markers button
  const fitBtn = document.getElementById('fit-btn');
  if (fitBtn) {
    fitBtn.addEventListener('click', () => mapView?.fitToMarkers());
  }

  // ============================================
  // Mode Selector & Measurement Tools (Bottom Dock)
  // ============================================

  const addLocationBtn = document.getElementById('dock-btn-add-location');
  const measureCircleBtn = document.getElementById('dock-btn-measure-circle');
  const measureLineBtn = document.getElementById('dock-btn-measure-line');
  const measureAreaBtn = document.getElementById('dock-btn-measure-area');
  const drawBtn = document.getElementById('dock-btn-draw');
  const clearBtn = document.getElementById('dock-btn-clear');

  // Debug: Check if buttons are found
  console.log('[initUI] Buttons found:', {
    addLocation: !!addLocationBtn,
    measureCircle: !!measureCircleBtn,
    measureLine: !!measureLineBtn,
    measureArea: !!measureAreaBtn,
    draw: !!drawBtn,
    clear: !!clearBtn
  });

  // All mode buttons (mutually exclusive)
  const modeButtons = [addLocationBtn, measureCircleBtn, measureLineBtn, measureAreaBtn, drawBtn];

  // Helper to set active mode and update button states
  // Note: This updates the module-level currentMode variable
  const setMode = (mode, activeBtn) => {
    console.log('[setMode] Changing mode from', currentMode, 'to', mode);
    currentMode = mode;
    console.log('[setMode] currentMode is now:', currentMode);

    // Update button active states
    modeButtons.forEach(btn => {
      if (btn) btn.classList.toggle('active', btn === activeBtn);
    });

    // Deactivate any measurement tool when switching modes
    mapTools?.deactivate();

    // Leave edit mode when switching away from draw
    if (mode !== 'draw') {
      mapView?.setEditMode(false);
    }

    // Hide add location popup when leaving addLocation mode
    if (mode !== 'addLocation') {
      mapView?.hideAddLocationPopup();
    }

    // Activate the appropriate tool for the new mode
    switch (mode) {
      case 'measureCircle':
        mapTools?.startCircleMeasure();
        break;
      case 'measureLine':
        mapTools?.startLineMeasure();
        break;
      case 'measureArea':
        mapTools?.startAreaMeasure();
        break;
      case 'draw':
        mapView?.setEditMode(true);
        break;
      // addLocation mode doesn't need tool activation
    }
  };

  // Add Location mode button — toggles. Clicking it once activates the
  // mode (subsequent map-clicks open the Add-Location popup); clicking it
  // again returns to the neutral 'none' mode so accidental map taps don't
  // trigger the popup.
  if (addLocationBtn) {
    addLocationBtn.addEventListener('click', () => {
      console.log('[Button] Add Location clicked, currentMode is:', currentMode);
      if (currentMode === 'addLocation') {
        setMode('none', null);                  // toggle off
      } else {
        setMode('addLocation', addLocationBtn); // activate
      }
    });
  }

  // Circle measurement button
  if (measureCircleBtn) {
    measureCircleBtn.addEventListener('click', () => {
      console.log('[Button] Circle clicked, currentMode is:', currentMode);
      if (currentMode === 'measureCircle') {
        // Toggle off — return to the neutral 'none' mode (no implicit
        // Add-Location re-activation).
        setMode('none', null);
      } else {
        setMode('measureCircle', measureCircleBtn);
}
    });
  }

  // Line measurement button
  if (measureLineBtn) {
    measureLineBtn.addEventListener('click', () => {
      console.log('[Button] Line clicked, currentMode is:', currentMode);
      if (currentMode === 'measureLine') {
        setMode('none', null);
      } else {
        setMode('measureLine', measureLineBtn);
      }
    });
  }

  // Area measurement button
  if (measureAreaBtn) {
    measureAreaBtn.addEventListener('click', () => {
      console.log('[Button] Area clicked, currentMode is:', currentMode);
      if (currentMode === 'measureArea') {
        setMode('none', null);
      } else {
        setMode('measureArea', measureAreaBtn);
      }
    });
  }

  // Draw / Edit button
  if (drawBtn) {
    drawBtn.addEventListener('click', () => {
      console.log('[Button] Draw clicked, currentMode is:', currentMode);
      if (currentMode === 'draw') {
        setMode('none', null);
      } else {
        setMode('draw', drawBtn);
      }
    });
  }

  // Clear button - clears measurements but stays in current mode
  if (clearBtn) {
    clearBtn.addEventListener('click', () => {
      mapTools?.clearMeasurements();
      // If in a measurement mode, restart the tool
      if (currentMode.startsWith('measure')) {
      mapTools?.deactivate();
        switch (currentMode) {
          case 'measureCircle':
            mapTools?.startCircleMeasure();
            break;
          case 'measureLine':
            mapTools?.startLineMeasure();
            break;
          case 'measureArea':
            mapTools?.startAreaMeasure();
            break;
        }
      }
    });
  }
}

// ============================================================================
// Location Handlers
// ============================================================================

async function handleAddLocation(event) {
  event.preventDefault();

  const form = event.target;
  const formData = new FormData(form);

  const name = formData.get('name');
  const longitude = parseFloat(formData.get('longitude'));
  const latitude = parseFloat(formData.get('latitude'));
  const description = formData.get('description') || null;
  const category = formData.get('category') || 'default';

  if (!name || isNaN(longitude) || isNaN(latitude)) {
    showError('Please fill in all required fields');
    return;
  }

  try {
    const result = await addLocation(name, longitude, latitude, { description, category });
    console.log('[App] Location added:', name, 'id:', result.id);

    form.reset();
    await loadLocations();

    // Zoom to the new location on the map
    mapView?.zoomTo(longitude, latitude, 14);

    // Select the new marker
    if (result.id) {
      mapView?.selectMarker(result.id);
    }

    showSuccess('Location added successfully');

  } catch (error) {
    console.error('[App] Failed to add location:', error);
    showError('Failed to add location: ' + error.message);
  }
}

async function loadLocations() {
  try {
    console.log('[App] Loading locations...');
    const locations = await getLocations();
    console.log('[App] Locations loaded:', locations);

    // Update the list
    renderLocations(locations);

    // Update the map markers
    if (mapView) {
      mapView.clearMarkers();
      if (locations.length > 0) {
        mapView.addMarkers(locations);
        console.log('[App] Added', locations.length, 'markers to map');
      }
    }

    // Update count display
    const countEl = document.getElementById('location-count');
    if (countEl) {
      countEl.textContent = locations.length;
    }

  } catch (error) {
    console.error('[App] Failed to load locations:', error);
  }
}

/**
 * Show details for a selected location
 */
function showLocationDetails(feature) {
  const name = feature.get('name');
  const description = feature.get('description');
  const category = feature.get('category');
  const lon = feature.get('lon') || feature.get('longitude');
  const lat = feature.get('lat') || feature.get('latitude');

  // You could show a popup or info panel here
  // For now, just log to console
  console.log('[App] Selected location:', { name, description, category, lon, lat });

  // Optionally zoom to the location
  // mapView.zoomTo(lon, lat, 14);
}

function renderLocations(locations) {
  const container = document.getElementById('locations-list');
  if (!container) return;

  // Also update mobile count
  const mobileCount = document.getElementById('location-count-mobile');
  if (mobileCount) {
    mobileCount.textContent = locations.length;
  }

  if (locations.length === 0) {
    container.innerHTML = `
      <div class="text-center text-muted py-4">
        <p class="mb-0">No locations yet.</p>
        <small>Click the map or fill the form above!</small>
      </div>
    `;
    return;
  }

  // Category emoji mapping
  const categoryEmojis = {
    'water': '💧',
    'school': '🏫',
    'health': '🏥',
    'market': '🏪',
    'default': '📍',
    'other': '📌'
  };

  container.innerHTML = locations.map(loc => {
    const emoji = categoryEmojis[loc.category] || '📍';
    return `
      <a href="#" class="list-group-item list-group-item-action location-item py-2"
         data-id="${loc.id}" data-lon="${loc.longitude}" data-lat="${loc.latitude}">
        <div class="d-flex w-100 justify-content-between align-items-start">
          <div>
            <h6 class="mb-1">${emoji} ${escapeHtml(loc.name)}</h6>
            <small class="text-muted font-monospace">${loc.latitude.toFixed(5)}, ${loc.longitude.toFixed(5)}</small>
          </div>
          <span class="badge badge-${loc.category}">${loc.category}</span>
        </div>
        ${loc.description ? `<small class="text-secondary d-block mt-1">${escapeHtml(loc.description)}</small>` : ''}
      </a>
    `;
  }).join('');

  // Add click handlers to zoom to location
  container.querySelectorAll('.location-item').forEach(item => {
    item.addEventListener('click', (e) => {
      e.preventDefault();
      const lon = parseFloat(item.dataset.lon);
      const lat = parseFloat(item.dataset.lat);
      const id = parseInt(item.dataset.id);

      // Zoom to location on map
      mapView?.zoomTo(lon, lat, 14);

      // Select the marker
      mapView?.selectMarker(id);
    });
  });
}

// ============================================================================
// Local Data Stats
// ============================================================================

/**
 * Refresh the Local Data stats panel in the left offcanvas.
 * If the panel is already visible it updates in-place; otherwise it opens it.
 */
async function refreshLocalDataStats() {
  const statsContainer = document.getElementById('local-data-stats');
  const tbody = document.getElementById('local-data-tbody');
  const clearAllBtn = document.getElementById('clear-all-cached-btn');
  if (!statsContainer || !tbody) return;

  try {
    const stats = await getTableStats();

    tbody.innerHTML = stats.map((t) => {
      const isCached = isCachedLayerTable(t.name);
      const clearBtn = isCached
        ? `<button type="button" class="btn btn-sm btn-link text-danger p-0 table-clear-btn"
                   data-table="${escapeHtml(t.name)}"
                   title="Clear local cache (will re-download from server)">
             <i class="bi bi-trash3"></i>
           </button>`
        : '';
      return `
        <tr>
          <td class="ps-3">
            <a href="#" class="table-name-link" data-table="${escapeHtml(t.name)}">${escapeHtml(t.name)}</a>
          </td>
          <td class="text-end"><span class="badge bg-secondary">${t.count}</span></td>
          <td class="text-end pe-3">${clearBtn}</td>
        </tr>
      `;
    }).join('');
    statsContainer.classList.remove('d-none');

    // Table-name link → open content modal
    tbody.querySelectorAll('.table-name-link').forEach((link) => {
      link.addEventListener('click', (e) => {
        e.preventDefault();
        showTableContent(link.dataset.table);
      });
    });

    // Per-row clear → confirm, clear that table, refresh stats
    tbody.querySelectorAll('.table-clear-btn').forEach((btn) => {
      btn.addEventListener('click', async (e) => {
        e.preventDefault();
        const tableName = btn.dataset.table;
        if (!confirm(`Clear local cache for "${tableName}"?\n\nThe data will be re-downloaded from the server on the next app start.`)) return;
        try {
          const removed = await clearTable(tableName);
          showSuccess(`Cleared ${removed} row${removed === 1 ? '' : 's'} from "${tableName}". It will re-download on next start.`);
          await refreshLocalDataStats();
        } catch (err) {
          console.error('[App] Per-table clear failed:', err);
          showError(`Could not clear "${tableName}": ${err.message}`);
        }
      });
    });
  } catch (error) {
    console.error('[App] Failed to load table stats:', error);
    tbody.innerHTML = `<tr><td colspan="3" class="text-danger ps-3">Failed to load</td></tr>`;
    statsContainer.classList.remove('d-none');
  }

  // Bulk-clear button — wire up once
  if (clearAllBtn && !clearAllBtn._wired) {
    clearAllBtn._wired = true;
    clearAllBtn.addEventListener('click', handleClearAllCachedLayers);
  }
}

/**
 * Clear every cached layer table and offer to reload the app so the layers
 * re-download immediately. If the user dismisses the reload prompt, the
 * fresh fetch will happen on the next manual app start.
 */
async function handleClearAllCachedLayers() {
  if (!confirm(
    'Delete all cached map layers from this device?\n\n' +
    'The next time the app starts (or after a reload), every layer will be ' +
    're-downloaded from the server. Your locally drawn data is not affected.'
  )) return;

  try {
    const results = await clearAllCachedLayers();
    const total = results.reduce((s, r) => s + r.count, 0);
    showSuccess(`Cleared ${total} row${total === 1 ? '' : 's'} across ${results.length} table${results.length === 1 ? '' : 's'}.`);
    await refreshLocalDataStats();

    if (confirm('Reload the app now to re-download the layers fresh from the server?')) {
      window.location.reload();
    }
  } catch (err) {
    console.error('[App] Clear-all failed:', err);
    showError('Failed to clear cached layers: ' + err.message);
  }
}

// ============================================================================
// Table Content Viewer
// ============================================================================

/**
 * Load and display all rows of a table in a modal.
 * @param {string} tableName - The table to show
 */
async function showTableContent(tableName) {
  const modalTitle = document.getElementById('tableContentModalLabel');
  const modalBody = document.getElementById('table-content-body');
  const modalInfo = document.getElementById('table-content-info');

  // Set title and show spinner
  modalTitle.textContent = `Table: ${tableName}`;
  modalBody.innerHTML = `
    <div class="text-center py-4">
      <div class="spinner-border text-primary" role="status">
        <span class="visually-hidden">Loading...</span>
      </div>
    </div>
  `;
  modalInfo.textContent = '';

  // Open the modal
  const modal = new Modal(document.getElementById('tableContentModal'));
  modal.show();

  try {
    const { columns, rows } = await getTableContent(tableName);

    if (rows.length === 0) {
      modalBody.innerHTML = `<div class="text-center text-muted py-4">Table is empty</div>`;
      modalInfo.textContent = '0 rows';
      return;
    }

    // Build a responsive table
    const headerCells = columns.map(c => `<th class="text-nowrap">${escapeHtml(c)}</th>`).join('');
    const bodyRows = rows.map(row => {
      const cells = columns.map(c => {
        let val = row[c];
        if (val === null || val === undefined) return '<td class="text-muted fst-italic">NULL</td>';
        val = String(val);
        // Truncate long values for display
        const display = val.length > 120 ? val.substring(0, 120) + '...' : val;
        return `<td>${escapeHtml(display)}</td>`;
      }).join('');
      return `<tr>${cells}</tr>`;
    }).join('');

    modalBody.innerHTML = `
      <div class="table-responsive">
        <table class="table table-sm table-striped table-hover mb-0" style="font-size:12px;">
          <thead class="table-light">
            <tr>${headerCells}</tr>
          </thead>
          <tbody>${bodyRows}</tbody>
        </table>
      </div>
    `;

    modalInfo.textContent = `${rows.length}${rows.length >= 200 ? '+' : ''} row(s), ${columns.length} column(s)`;

  } catch (error) {
    console.error('[App] Failed to load table content:', error);
    modalBody.innerHTML = `<div class="text-danger text-center py-4">Failed to load: ${escapeHtml(error.message)}</div>`;
  }
}

// ============================================================================
// Export Handler
// ============================================================================

async function handleExport() {
  try {
    await downloadDatabase('lupmis-backup.sqlite3');
    showSuccess('Database exported successfully');
  } catch (error) {
    console.error('[App] Export failed:', error);
    showError('Export failed: ' + error.message);
  }
}

// Export as GeoJSON file
async function handleExportGeoJSON() {
  try {
	  const geojson = await exportToGeoJSON();

	  // Download as file
	  const blob = new Blob([JSON.stringify(geojson, null, 2)], { type: 'application/json' });
	  const url = URL.createObjectURL(blob);
	  const a = document.createElement('a');
	  a.href = url;
	  a.download = 'locations.geojson';
	  a.click();
	  URL.revokeObjectURL(url);

		showSuccess(`Exported ${geojson.features.length} location(s)`);
	}catch (error) {
    console.error('[App] GeoJSON Export failed:', error);
    showError('GeoJSON Export failed: ' + error.message);
  }
}

// ============================================================================
// Status Handler
// ============================================================================

async function handleShowStatus() {
  try {
    const status = await getDatabaseStatus();

    // Update modal content
    const statusContent = document.getElementById('status-content');
    if (statusContent) {
      statusContent.innerHTML = `
        <table class="table table-sm table-borderless mb-0">
          <tbody>
            <tr>
              <td class="fw-semibold">Ready:</td>
              <td><span class="badge ${status.ready ? 'bg-success' : 'bg-danger'}">${status.ready ? 'Yes' : 'No'}</span></td>
            </tr>
            <tr>
              <td class="fw-semibold">Online:</td>
              <td><span class="badge ${isOnline() ? 'bg-success' : 'bg-warning'}">${isOnline() ? 'Yes' : 'Offline'}</span></td>
            </tr>
            <tr>
              <td class="fw-semibold">Database:</td>
              <td><code>${status.databasePath || 'N/A'}</code></td>
            </tr>
            <tr>
              <td class="fw-semibold">Tables:</td>
              <td>${status.tables.map(t => `<span class="badge bg-secondary me-1">${t}</span>`).join('')}</td>
            </tr>
            <tr>
              <td class="fw-semibold">Locations:</td>
              <td><span class="badge bg-primary">${status.locationCount}</span></td>
            </tr>
          </tbody>
        </table>
      `;
    }

    // Show the modal using Bootstrap
    const statusModal = new Modal(document.getElementById('statusModal'));
    statusModal.show();

  } catch (error) {
    console.error('[App] Failed to get status:', error);
    showError('Failed to get status');
  }
}

// ============================================================================
// Remote Data Loading
// ============================================================================

/**
 * Parse a coordinate ring string into an array of [lon, lat] pairs.
 * @param {string} ringStr - e.g. "lon lat,lon lat,..."
 * @returns {Array} Array of [lon, lat]
 */
function parseCoordRing(ringStr) {
  return ringStr.replace(/^\(+/, '').replace(/\)+$/, '')
    .split(',')
    .map(pair => {
      const [lon, lat] = pair.trim().split(/\s+/).map(Number);
      return [lon, lat];
    });
}

/**
 * Parse a WKT POLYGON string into GeoJSON geometry.
 * Handles: POLYGON((lon lat,...),(hole,...))
 *
 * @param {string} wkt - WKT POLYGON string
 * @returns {Object} GeoJSON geometry { type: 'Polygon', coordinates: [...] }
 */
function parseWKTPolygon(wkt) {
  const inner = wkt.trim()
    .replace(/^POLYGON\s*\(\s*/i, '')
    .replace(/\s*\)$/, '');

  const ringStrings = inner.split('),(');
  const rings = ringStrings.map(parseCoordRing);

  return { type: 'Polygon', coordinates: rings };
}

/**
 * Parse a WKT MULTIPOLYGON string into GeoJSON geometry.
 * Handles: MULTIPOLYGON(((lon lat,...),(hole),...),((polygon2)))
 *
 * @param {string} wkt - WKT MULTIPOLYGON string
 * @returns {Object} GeoJSON geometry { type: 'MultiPolygon', coordinates: [...] }
 */
function parseWKTMultiPolygon(wkt) {
  const inner = wkt.trim()
    .replace(/^MULTIPOLYGON\s*\(\s*/i, '')
    .replace(/\s*\)$/, '');

  const polygonStrings = inner.split(')),((');

  const polygons = polygonStrings.map(polyStr => {
    const cleaned = polyStr.replace(/^\(+/, '').replace(/\)+$/, '');
    const ringStrings = cleaned.split('),(');
    return ringStrings.map(parseCoordRing);
  });

  return { type: 'MultiPolygon', coordinates: polygons };
}

/**
 * Parse any supported WKT geometry string (POLYGON or MULTIPOLYGON).
 * @param {string} wkt - WKT geometry string
 * @returns {Object|null} GeoJSON geometry or null if unsupported
 */
function parseWKT(wkt) {
  if (!wkt) return null;
  const trimmed = wkt.trim().toUpperCase();
  if (trimmed.startsWith('MULTIPOLYGON')) return parseWKTMultiPolygon(wkt);
  if (trimmed.startsWith('POLYGON')) return parseWKTPolygon(wkt);
  console.warn('[App] Unsupported WKT type:', trimmed.substring(0, 30));
  return null;
}

/**
 * Convert the API response to a GeoJSON FeatureCollection.
 * The API returns: { success, data: { boundary: "MULTIPOLYGON(...)", districtid, district_name } }
 *
 * @param {Object} apiResponse - Raw API response
 * @returns {Object|null} GeoJSON FeatureCollection or null
 */
function apiResponseToGeoJSON(apiResponse) {
  if (!apiResponse?.success || !apiResponse?.data?.boundary) {
    console.warn('[App] API response missing success or boundary data');
    return null;
  }

  const { boundary, districtid, district_name } = apiResponse.data;
  const geometry = parseWKT(boundary);

  return {
    type: 'FeatureCollection',
    features: [{
      type: 'Feature',
      properties: {
        districtid: districtid,
        district_name: district_name
      },
      geometry: geometry
    }]
  };
}

/**
 * Convert the collector zones API response to a GeoJSON FeatureCollection.
 * The API returns: { success, data: [{ id, zone_name, boundary, ... }, ...] }
 * Each zone feature gets a '_layerType' = 'collector_zone' property for identification.
 *
 * @param {Array} zones - Array of zone objects from the API
 * @returns {Object|null} GeoJSON FeatureCollection or null
 */
function zonesToGeoJSON(zones) {
  if (!Array.isArray(zones) || zones.length === 0) return null;

  const features = [];
  for (const zone of zones) {
    // API returns WKT in 'polygon' field (not 'boundary')
    const wkt = zone.polygon || zone.boundary;
    const geometry = parseWKT(wkt);
    if (!geometry) continue;

    // Collect all properties except the raw WKT geometry
    const properties = { _layerType: 'collector_zone' };
    for (const [key, value] of Object.entries(zone)) {
      if (key === 'polygon' || key === 'boundary') continue;
      properties[key] = value;
    }

    features.push({ type: 'Feature', properties, geometry });
  }

  if (features.length === 0) return null;
  return { type: 'FeatureCollection', features };
}

/** localStorage key tracking the district the local caches were filled for. */
const LAST_DISTRICT_KEY = 'lupmis-last-district';

/** Cache key for the district boundary, scoped per district id. */
function districtBoundaryCacheKey(districtId) {
  return districtId == null || String(districtId).length === 0
    ? 'district_boundary'                       // dev fallback (no session)
    : `district_boundary_${districtId}`;
}

/**
 * Detect a district change between page loads and wipe district-scoped local
 * caches when it happens, so we never display a previous district's boundary,
 * parcels, zones or UPN grid after an admin reassigns the user.
 *
 * The comparison is against the authenticated session district only — in local
 * dev (no session) there is no district to enforce, so this is a no-op.
 */
async function enforceDistrictConsistency() {
  const session = getSession();
  // Only meaningful for an authenticated user with a district.
  const current = session?.district_id;
  if (current == null || String(current).length === 0) return;
  const currentStr = String(current);

  let previous = null;
  try { previous = localStorage.getItem(LAST_DISTRICT_KEY); } catch { /* ignore */ }

  if (previous !== null && previous !== currentStr) {
    console.warn(`[App] District changed ${previous} → ${currentStr}; clearing cached layers.`);
    try {
      await clearAllCachedLayers();
      showWarning('Your district was updated — refreshing map data.');
    } catch (err) {
      console.error('[App] Failed to clear caches on district change:', err);
    }
  }

  try { localStorage.setItem(LAST_DISTRICT_KEY, currentStr); } catch { /* ignore */ }
}

/**
 * Load district boundary with local-first strategy:
 * 1. Always read from local SQLite cache (GeoJSON) first — instant, works offline
 * 2. If online, fetch from API, convert WKT → GeoJSON, cache and display
 *
 * The cache is keyed by district id, so a boundary cached for one district can
 * never be served for another even if the stale-district guard is bypassed.
 */
async function loadDistrictBoundary() {
  const districtId = getSession()?.district_id ?? null;
  const CACHE_KEY = districtBoundaryCacheKey(districtId);
  const ADMIN_GROUP_ID = 1; // Administration layer group
  const boundaryStyle = {
    strokeColor: '#e11d48',
    strokeWidth: 2.5,
    fillColor: 'rgba(225,29,72,0.08)',
    typeDescription: 'Vector / Polygon',
  };

  // Target group: Administration (id 1), fall back to root overlay group
  const adminGroup = mapView?.getLayerGroup(ADMIN_GROUP_ID) || null;

  /**
   * Remove existing District Boundary layer from a group's layers.
   */
  function removeBoundaryLayer(group) {
    if (!group) return;
    const layers = group.getLayers();
    const toRemove = [];
    layers.forEach((layer) => {
      if (layer.get('title') === 'District Boundary') {
        toRemove.push(layer);
      }
    });
    toRemove.forEach((layer) => layers.remove(layer));
  }

  /**
   * Zoom the map to fit the boundary layer's extent.
   */
  function zoomToBoundary(layer) {
    if (!layer || !mapView) return;
    const extent = layer.getSource().getExtent();
    if (extent && extent[0] !== Infinity) {
      mapView.getMap().getView().fit(extent, {
        padding: [40, 40, 40, 40],
        duration: 600,
      });
    }
  }

  try {
    // Will we be fetching fresh data below? If so, the cached layer is shown
    // for an instant paint but we DON'T zoom to it — the fresh fetch performs
    // the single authoritative zoom, avoiding a visible "fly to old extent
    // then re-fly" when the cached geometry differs.
    const willFetchFresh = isOnline() && isServerReachable();

    // Step 1: Load from local cache (already stored as GeoJSON)
    const cached = await getRemoteData(CACHE_KEY);
    if (cached) {
      console.log('[App] District boundary loaded from local cache');
      const layer = mapView?.addGeoJSONLayer(cached, 'District Boundary', boundaryStyle, adminGroup);
      if (!willFetchFresh) zoomToBoundary(layer);
    }

    // Step 2: If online and server reachable, fetch fresh data from the API
    if (willFetchFresh) {
      console.log('[App] Fetching district boundary from API...');
      const apiResponse = await getDistrictBoundary();

      // Convert WKT response to GeoJSON
      const geojson = apiResponseToGeoJSON(apiResponse);
      if (!geojson) {
        console.warn('[App] Could not convert API response to GeoJSON');
        return;
      }

      console.log('[App] District boundary:', geojson.features[0]?.properties?.district_name,
        '→', geojson.features[0]?.geometry?.coordinates?.length, 'polygon(s)');

      // Save converted GeoJSON to local cache for offline use
      await saveRemoteData(CACHE_KEY, geojson);

      // Replace old cached layer if present
      if (cached) {
        removeBoundaryLayer(adminGroup || mapView?.getOverlayGroup());
      }

      const layer = mapView?.addGeoJSONLayer(geojson, 'District Boundary', boundaryStyle, adminGroup);
      zoomToBoundary(layer);
      console.log('[App] District boundary loaded from API');

    } else if (!cached) {
      console.log('[App] District boundary not available — offline and no local cache');
    }

  } catch (error) {
    console.error('[App] Failed to load district boundary:', error);
  }
}

/**
 * Convert UPN-grid rows (either from the API or the local cache) into a
 * GeoJSON FeatureCollection. Each feature carries only `_layerType` and
 * `upn_prefix` as properties so the click popup shows nothing but the
 * prefix (no fetched_at, no internal IDs).
 */
function upnGridToGeoJSON(rows) {
  if (!Array.isArray(rows) || rows.length === 0) return null;
  const features = [];
  for (const r of rows) {
    const wkt = r.polygon || r.geometry_wkt || r.geom;
    const geometry = parseWKT(wkt);
    if (!geometry) continue;
    features.push({
      type: 'Feature',
      properties: { _layerType: 'upn_grid', upn_prefix: r.upn_prefix ?? null },
      geometry,
    });
  }
  if (features.length === 0) return null;
  return { type: 'FeatureCollection', features };
}

/**
 * Load the UPN-grid (district sub-division) layer into the Administration
 * group. The grid never changes, so it is fetched once per district and
 * served from the local cache thereafter; a fetch only happens when no
 * cells are cached for the current district (e.g. first load, or the user
 * now belongs to a different district).
 *
 * The "UPN Grid" layer is added to the Administration LayerGroup (id 1),
 * initially not visible — the user toggles it in the LayerSwitcher.
 */
async function loadUpnGrid() {
  const ADMIN_GROUP_ID = 1;
  // Base style passed to addGeoJSONLayer keeps the LayerSwitcher subtitle
  // ("Vector / Polygon") and a sensible initial render. We override the
  // visual style below via setStyle() with a zoom-aware function.
  const upnGridStyle = {
    strokeColor: '#5b21b6',
    strokeWidth: 1.5,
    fillColor: 'rgba(124,58,237,0.04)',
    typeDescription: 'Vector / Polygon',
  };

  const adminGroup = mapView?.getLayerGroup(ADMIN_GROUP_ID) || null;
  const emptyGeoJSON = { type: 'FeatureCollection', features: [] };
  const upnGridLayer = mapView?.addGeoJSONLayer(emptyGeoJSON, 'UPN Grid', upnGridStyle, adminGroup);
  if (!upnGridLayer) {
    console.warn('[App] Could not create UPN Grid layer');
    return;
  }
  upnGridLayer.setVisible(false);

  // Custom style function:
  //  - White casing under a bolder violet dashed line so the grid stays
  //    visible on top of (or under) the sky-blue Parcels layer.
  //  - Label: render the feature's upn_prefix at scales ≥ 1:25,000.
  //    Using the OGC convention scale = resolution / 0.00028, that is
  //    resolution ≤ 7 m/px in Web Mercator.
  const UPN_LABEL_MAX_RESOLUTION = 7;          // ≈ 1:25,000
  upnGridLayer.setStyle((feature, resolution) => {
    const styles = [
      // 1) White halo / casing — drawn first, so it sits underneath.
      new Style({
        stroke: new Stroke({ color: 'rgba(255,255,255,0.95)', width: 3.5 }),
      }),
      // 2) Violet dashed stroke + faint fill.
      new Style({
        stroke: new Stroke({ color: '#5b21b6', width: 1.5, lineDash: [7, 4] }),
        fill:   new Fill({ color: 'rgba(124,58,237,0.04)' }),
      }),
    ];
    // 3) Label, only when sufficiently zoomed in.
    if (resolution <= UPN_LABEL_MAX_RESOLUTION) {
      const prefix = feature.get('upn_prefix');
      if (prefix != null && String(prefix).length > 0) {
        styles.push(new Style({
          text: new OlText({
            text: String(prefix),
            font: '600 12px Arial, sans-serif',
            fill:   new Fill({ color: '#3b0764' }),
            stroke: new Stroke({ color: 'rgba(255,255,255,0.95)', width: 3 }),
            overflow: true,                  // allow drawing in tight cells
          }),
        }));
      }
    }
    return styles;
  });

  upnGridLayer.on('change:visible', () => {
    if (upnGridLayer.getVisible() && upnGridLayer.getSource().getFeatures().length === 0) {
      showError('No UPN grid available locally. Connect to the internet to download it.');
    }
  });

  function setFeatures(geojson) {
    const newFeatures = new GeoJSON().readFeatures(geojson, { featureProjection: 'EPSG:3857' });
    upnGridLayer.getSource().clear();
    upnGridLayer.getSource().addFeatures(newFeatures);
  }

  try {
    const session = getSession();
    const districtId = session?.district_id ?? null;

    // 1) Local cache for the CURRENT district → use it; skip the API call.
    //    The cache is keyed by districtid so a cache from another district
    //    (e.g. dev fallback) won't satisfy this lookup and we'll re-fetch.
    const cached = await getLocalUpnGrid(districtId);
    if (cached) {
      const geojson = upnGridToGeoJSON(cached);
      if (geojson) setFeatures(geojson);
      console.log('[App] UPN grid from cache:', cached.length, 'cells (district', districtId, ')');
      return;
    }

    // 2) Not cached for this district → fetch, save, render. If offline,
    //    leave the layer empty; the toggle handler above will explain.
    if (!isOnline() || !isServerReachable()) {
      console.log('[App] UPN grid not available — offline and no cache for district', districtId);
      return;
    }
    console.log('[App] Fetching UPN grid from API (district', districtId, ')...');
    const apiResponse = await getUpnGrid();
    if (!apiResponse?.success || !Array.isArray(apiResponse?.data)) {
      console.warn('[App] getUpnGrid invalid response:', apiResponse);
      return;
    }
    const rows = apiResponse.data;
    console.log('[App] UPN grid from API:', rows.length, 'cells');

    await saveUpnGrid(rows, districtId);

    const geojson = upnGridToGeoJSON(rows);
    if (geojson) setFeatures(geojson);
    console.log('[App] UPN grid loaded:', geojson?.features.length ?? 0, 'cells rendered');
  } catch (error) {
    console.error('[App] Failed to load UPN grid:', error);
  }
}

/**
 * Load collector zones with local-first strategy:
 * 1. Read from local collector_zones table → convert to GeoJSON → display
 * 2. If online, fetch from API → save to local table → convert → display
 *
 * The "Zones" layer is added to the Administration LayerGroup (id 1),
 * initially not visible. It becomes visible when toggled in the LayerSwitcher.
 */
async function loadCollectorZones() {
  const ADMIN_GROUP_ID = 1;
  const zoneStyle = {
    strokeColor: '#7c3aed',
    strokeWidth: 1.5,
    fillColor: 'rgba(124,58,237,0.12)',
    typeDescription: 'Vector / Polygon',
  };

  const adminGroup = mapView?.getLayerGroup(ADMIN_GROUP_ID) || null;
  console.log('[App] loadCollectorZones — adminGroup:', adminGroup ? adminGroup.get('title') : 'null');

  // Create the Zones layer immediately (empty) so it always appears
  // in the LayerSwitcher. Features will be added once data is available.
  const emptyGeoJSON = { type: 'FeatureCollection', features: [] };
  const zonesLayer = mapView?.addGeoJSONLayer(emptyGeoJSON, 'Zones', zoneStyle, adminGroup);
  if (!zonesLayer) {
    console.warn('[App] Could not create Zones layer');
    return;
  }
  zonesLayer.setVisible(false);

  // Warn when the user enables the layer but it has no data
  zonesLayer.on('change:visible', () => {
    if (zonesLayer.getVisible() && zonesLayer.getSource().getFeatures().length === 0) {
      showError('No collector zones available locally. Connect to the internet to download zone data.');
    }
  });

  /**
   * Replace the layer's source features with features parsed from GeoJSON.
   */
  function setZoneFeatures(geojson) {
    const newFeatures = new GeoJSON().readFeatures(geojson, {
      featureProjection: 'EPSG:3857',
    });
    zonesLayer.getSource().clear();
    zonesLayer.getSource().addFeatures(newFeatures);
  }

  try {
    // Step 1: Load from local table
    const cached = await getLocalCollectorZones();
    if (cached) {
      const geojson = zonesToGeoJSON(cached);
      if (geojson) {
        console.log('[App] Collector zones loaded from local cache:', geojson.features.length, 'zones');
        setZoneFeatures(geojson);
      }
    }

    // Step 2: If online and server reachable, fetch fresh data from the API
    if (isOnline() && isServerReachable()) {
      console.log('[App] Fetching collector zones from API...');
      const apiResponse = await getCollectorZones();

      if (!apiResponse?.success || !Array.isArray(apiResponse?.data)) {
        console.warn('[App] getCollectorZones API response invalid:', apiResponse);
        return;
      }

      const zones = apiResponse.data;
      console.log('[App] Collector zones from API:', zones.length, 'entries');

      // Save to local table
      await saveCollectorZones(zones);

      // Convert to GeoJSON and update the existing layer
      const geojson = zonesToGeoJSON(zones);
      if (!geojson) {
        console.warn('[App] Could not convert zones to GeoJSON');
        return;
      }

      setZoneFeatures(geojson);
      console.log('[App] Collector zones updated from API:', geojson.features.length, 'zones');

    } else if (!cached) {
      console.log('[App] Collector zones not available — offline and no local cache');
    }

  } catch (error) {
    console.error('[App] Failed to load collector zones:', error);
  }
}

/**
 * Convert parcels data to a GeoJSON FeatureCollection.
 * Each parcel feature gets a '_layerType' = 'parcel' property for identification.
 *
 * @param {Array} parcels - Array of parcel objects from the API
 * @returns {Object|null} GeoJSON FeatureCollection or null
 */
function parcelsToGeoJSON(parcels) {
  if (!Array.isArray(parcels) || parcels.length === 0) return null;

  // Deduplicate by id — the API may return the same parcel more than once
  const seen = new Set();
  const features = [];
  for (const parcel of parcels) {
    const id = parcel.id || parcel.parcelid || parcel.parcel_id;
    if (id != null) {
      if (seen.has(id)) continue;
      seen.add(id);
    }

    // Geometry sources, in order:
    //  - API path: `geom` is a GeoJSON object, `boundary` is the WKT string
    //  - local cache: `geometry_wkt` is the WKT string
    let geometry = null;
    if (parcel.geom && parcel.geom.type && parcel.geom.coordinates) {
      geometry = { type: parcel.geom.type, coordinates: parcel.geom.coordinates };
    } else if (parcel.sp_boundary && parcel.sp_boundary.type && parcel.sp_boundary.coordinates) {
      geometry = { type: parcel.sp_boundary.type, coordinates: parcel.sp_boundary.coordinates };
    } else {
      const wkt = parcel.boundary || parcel.geometry_wkt || parcel.polygon || parcel.wkt;
      geometry = parseWKT(wkt);
    }
    if (!geometry) continue;

    // Collect all properties except bulky geometry fields and local housekeeping.
    const skipKeys = new Set(['polygon', 'boundary', 'geom', 'geometry_wkt', 'wkt', 'textboundary', 'sp_boundary', 'fetched_at']);
    const properties = { _layerType: 'parcel' };
    for (const [key, value] of Object.entries(parcel)) {
      if (skipKeys.has(key)) continue;
      properties[key] = value;
    }

    features.push({ type: 'Feature', properties, geometry });
  }

  if (features.length === 0) return null;
  return { type: 'FeatureCollection', features };
}

/**
 * Load parcels with local-first strategy:
 * 1. Read from local parcels table → convert to GeoJSON → display
 * 2. If online, fetch from API → save to local table → convert → display
 *
 * The "Parcels" layer is added to the "Land Use and Land Tenure" LayerGroup (id 4),
 * initially not visible. It becomes visible when toggled in the LayerSwitcher.
 */
async function loadParcels() {
  const LAND_USE_GROUP_ID = 4;
  const parcelStyle = {
    strokeColor: '#0ea5e9',
    strokeWidth: 1.5,
    fillColor: 'rgba(14,165,233,0.12)',
    typeDescription: 'Vector / Polygon',
  };

  const landUseGroup = mapView?.getLayerGroup(LAND_USE_GROUP_ID) || null;
  console.log('[App] loadParcels — landUseGroup:', landUseGroup ? landUseGroup.get('title') : 'null');

  // Create the Parcels layer immediately (empty) so it always appears
  // in the LayerSwitcher. Features will be added once data is available.
  const emptyGeoJSON = { type: 'FeatureCollection', features: [] };
  // Assigned to the module-level `parcelsLayer` so the iframe embed bridge
  // can pick it up after loadParcels() returns from its sync prologue.
  parcelsLayer = mapView?.addGeoJSONLayer(emptyGeoJSON, 'Parcels', parcelStyle, landUseGroup);
  if (!parcelsLayer) {
    console.warn('[App] Could not create Parcels layer');
    return;
  }
  parcelsLayer.setVisible(false);

  // Warn when the user enables the layer but it has no data
  parcelsLayer.on('change:visible', () => {
    if (parcelsLayer.getVisible() && parcelsLayer.getSource().getFeatures().length === 0) {
      showError('No parcels available locally. Connect to the internet to download parcel data.');
    }
  });

  /**
   * Replace the layer's source features with features parsed from GeoJSON.
   */
  function setParcelFeatures(geojson) {
    const newFeatures = new GeoJSON().readFeatures(geojson, {
      featureProjection: 'EPSG:3857',
    });
    parcelsLayer.getSource().clear();
    parcelsLayer.getSource().addFeatures(newFeatures);
  }

  try {
    // Step 1: Load from local table
    const cached = await getLocalParcels();
    if (cached) {
      const geojson = parcelsToGeoJSON(cached);
      if (geojson) {
        console.log('[App] Parcels loaded from local cache:', geojson.features.length, 'parcels');
        setParcelFeatures(geojson);
      }
    }

    // Step 2: If online and server reachable, fetch fresh data from the API
    if (isOnline() && isServerReachable()) {
      console.log('[App] Fetching parcels from API...');
      const apiResponse = await getDistrictParcels();

      if (!apiResponse?.success || !Array.isArray(apiResponse?.data)) {
        console.warn('[App] getDistrictParcels API response invalid:', apiResponse);
        return;
      }

      const parcels = apiResponse.data;
      console.log('[App] Parcels from API:', parcels.length, 'entries');

      // Log first parcel's keys for debugging field names
      if (parcels.length > 0) {
        console.log('[App] First parcel keys:', Object.keys(parcels[0]));
      }

      // Save to local table
      await saveParcels(parcels);

      // Convert to GeoJSON and update the existing layer
      const geojson = parcelsToGeoJSON(parcels);
      if (!geojson) {
        console.warn('[App] Could not convert parcels to GeoJSON');
        return;
      }

      setParcelFeatures(geojson);
      console.log('[App] Parcels updated from API:', geojson.features.length, 'parcels');

    } else if (!cached) {
      console.log('[App] Parcels not available — offline and no local cache');
    }

  } catch (error) {
    console.error('[App] Failed to load parcels:', error);
  }
}

/**
 * Convert an array of building footprint objects to a GeoJSON FeatureCollection.
 * Each footprint's WKT geometry field is parsed; all other fields become properties.
 *
 * @param {Array} footprints - Array of footprint objects
 * @returns {Object|null} GeoJSON FeatureCollection, or null if no valid features
 */
function footprintsToGeoJSON(footprints) {
  if (!Array.isArray(footprints) || footprints.length === 0) return null;

  const geomKeys = ['polygon', 'boundary', 'geom', 'wkt', 'footprint'];

  const features = [];
  for (const fp of footprints) {
    const raw = fp.polygon || fp.boundary || fp.geom || fp.wkt || fp.footprint;
    // Geometry may be WKT string or GeoJSON object
    let geometry;
    if (typeof raw === 'object' && raw !== null && raw.type) {
      // Already a GeoJSON geometry object
      geometry = raw;
    } else {
      geometry = parseWKT(raw);
    }
    if (!geometry) continue;

    const properties = { _layerType: 'building_footprint' };
    for (const [key, value] of Object.entries(fp)) {
      if (geomKeys.includes(key)) continue;
      // Skip nested objects that aren't useful as flat properties
      if (typeof value === 'object' && value !== null) continue;
      properties[key] = value;
    }

    features.push({ type: 'Feature', properties, geometry });
  }

  if (features.length === 0) return null;
  return { type: 'FeatureCollection', features };
}

/**
 * Load building footprints with local-first strategy:
 * 1. Read from local building_footprints table → convert to GeoJSON → display
 * 2. If online, fetch from API → save to local table → convert → display
 *
 * The "Building footprints" layer is added to the "Physical Infrastructures" LayerGroup (id 5),
 * initially not visible. It becomes visible when toggled in the LayerSwitcher.
 */
async function loadBuildingFootprints() {
  const PHYS_INFRA_GROUP_ID = 5;
  const footprintStyle = {
    strokeColor: '#8b6f47',
    strokeWidth: 1,
    fillColor: 'rgba(139,111,71,0.18)',
    typeDescription: 'Vector / Polygon',
  };

  const physInfraGroup = mapView?.getLayerGroup(PHYS_INFRA_GROUP_ID) || null;
  console.log('[App] loadBuildingFootprints — physInfraGroup:', physInfraGroup ? physInfraGroup.get('title') : 'null');

  // Create the layer immediately (empty) so it always appears in the LayerSwitcher.
  const emptyGeoJSON = { type: 'FeatureCollection', features: [] };
  const footprintsLayer = mapView?.addGeoJSONLayer(emptyGeoJSON, 'Building footprints', footprintStyle, physInfraGroup);
  if (!footprintsLayer) {
    console.warn('[App] Could not create Building footprints layer');
    return;
  }
  footprintsLayer.setVisible(false);

  // Warn when the user enables the layer but it has no data
  footprintsLayer.on('change:visible', () => {
    if (footprintsLayer.getVisible() && footprintsLayer.getSource().getFeatures().length === 0) {
      showError('No building footprints available locally. Connect to the internet to download footprint data.');
    }
  });

  /**
   * Replace the layer's source features with features parsed from GeoJSON.
   */
  function setFootprintFeatures(geojson) {
    const newFeatures = new GeoJSON().readFeatures(geojson, {
      featureProjection: 'EPSG:3857',
    });
    footprintsLayer.getSource().clear();
    footprintsLayer.getSource().addFeatures(newFeatures);
  }

  try {
    // Step 1: Load from local table
    const cached = await getLocalBuildingFootprints();
    if (cached) {
      const geojson = footprintsToGeoJSON(cached);
      if (geojson) {
        console.log('[App] Building footprints loaded from local cache:', geojson.features.length, 'footprints');
        setFootprintFeatures(geojson);
      }
    }

    // Step 2: If online and server reachable, fetch fresh data from the API
    if (isOnline() && isServerReachable()) {
      console.log('[App] Fetching building footprints from API...');
      const apiResponse = await getBuildingFootprints();

      if (!apiResponse?.success || !Array.isArray(apiResponse?.data)) {
        console.warn('[App] getBuildingFootprints API response invalid:', apiResponse);
        return;
      }

      const footprints = apiResponse.data;
      console.log('[App] Building footprints from API:', footprints.length, 'entries');

      // Log first footprint's keys for debugging field names
      if (footprints.length > 0) {
        console.log('[App] First footprint keys:', Object.keys(footprints[0]));
      }

      // Save to local table
      await saveBuildingFootprints(footprints);

      // Convert to GeoJSON and update the existing layer
      const geojson = footprintsToGeoJSON(footprints);
      if (!geojson) {
        console.warn('[App] Could not convert building footprints to GeoJSON');
        return;
      }

      setFootprintFeatures(geojson);
      console.log('[App] Building footprints updated from API:', geojson.features.length, 'footprints');

    } else if (!cached) {
      console.log('[App] Building footprints not available — offline and no local cache');
    }

  } catch (error) {
    console.error('[App] Failed to load building footprints:', error);
  }
}

/**
 * Convert an array of DB rows (each with a WKT geom field) to GeoJSON.
 * Uses OpenLayers' WKT parser so LINESTRING, MULTILINESTRING, POLYGON, etc.
 * are all supported out of the box.
 *
 * @param {Array}  rows       — API rows, each having a WKT-valued geom/geometry/wkt field
 * @param {string} layerType  — value to store in each feature's _layerType property
 * @returns {Object|null} GeoJSON FeatureCollection, or null if no valid rows
 */
function wktRowsToGeoJSON(rows, layerType) {
  if (!Array.isArray(rows) || rows.length === 0) return null;

  const wktFormat = new WKT();
  const geojsonFormat = new GeoJSON();
  // Field-name fallbacks — different endpoints alias the geometry column
  // differently (e.g. get_osm_roads uses `road`, get_contours_hillshade uses
  // `geom`). The first non-null match wins.
  const geomKeys = ['geom', 'geometry', 'wkt', 'polygon', 'boundary', 'road', 'line'];

  const features = [];
  for (const row of rows) {
    const raw = row.geom || row.geometry || row.wkt || row.polygon || row.boundary || row.road || row.line;
    if (!raw) continue;

    let olGeom;
    try {
      if (typeof raw === 'object' && raw !== null && raw.type) {
        // Already a GeoJSON geometry — just pass through
        features.push({
          type: 'Feature',
          properties: flattenProps(row, geomKeys, layerType),
          geometry: raw,
        });
        continue;
      }
      olGeom = wktFormat.readGeometry(raw);
    } catch (err) {
      console.warn(`[App] Could not parse WKT for ${layerType}:`, err, raw?.toString().slice(0, 60));
      continue;
    }

    const geometry = JSON.parse(geojsonFormat.writeGeometry(olGeom));
    features.push({
      type: 'Feature',
      properties: flattenProps(row, geomKeys, layerType),
      geometry,
    });
  }

  if (features.length === 0) return null;
  return { type: 'FeatureCollection', features };
}

/**
 * Flatten a DB row into properties, skipping geometry fields and nested objects.
 */
function flattenProps(row, skipKeys, layerType) {
  const props = { _layerType: layerType };
  for (const [key, value] of Object.entries(row)) {
    if (skipKeys.includes(key)) continue;
    if (typeof value === 'object' && value !== null) continue;
    props[key] = value;
  }
  return props;
}

/**
 * Load the "Contours hillshade" layer — elevation contours derived from
 * OpenTopography, stored in PostgreSQL as `contours_hillshade`.
 *
 * Added to the "Biophysical Environment" LayerGroup, initially not visible.
 * No local caching (the server is the source of truth).
 */
async function loadContoursHillshade() {
  const contoursStyle = {
    strokeColor: '#78716c',   // warm grey — traditional contour colour
    strokeWidth: 0.8,
    typeDescription: 'Vector / Line',
    fillColor: 'rgba(0,0,0,0)',
  };

  const biophysGroup = mapView?.getLayerGroupByTitle('Biophysical Environment');
  console.log('[App] loadContoursHillshade — group:', biophysGroup ? biophysGroup.get('title') : 'null');

  // Create empty layer first so it always appears in the LayerSwitcher
  const emptyGeoJSON = { type: 'FeatureCollection', features: [] };
  const contoursLayer = mapView?.addGeoJSONLayer(emptyGeoJSON, 'Contours hillshade', contoursStyle, biophysGroup);
  if (!contoursLayer) {
    console.warn('[App] Could not create Contours hillshade layer');
    return;
  }
  contoursLayer.setVisible(false);

  // Warn when the user enables the layer but it has no data
  contoursLayer.on('change:visible', () => {
    if (contoursLayer.getVisible() && contoursLayer.getSource().getFeatures().length === 0) {
      showError('No Contours hillshade data available. Connect to the internet to download it.');
    }
  });

  // Fetch from API (only when online and server reachable — no local cache)
  if (!isOnline() || !isServerReachable()) {
    console.log('[App] Contours hillshade not available — offline or server unreachable');
    return;
  }

  try {
    console.log('[App] Fetching contours_hillshade from API...');
    const apiResponse = await getContoursHillshade();

    if (!apiResponse?.success || !Array.isArray(apiResponse?.data)) {
      console.warn('[App] getContoursHillshade API response invalid:', apiResponse);
      return;
    }

    const rows = apiResponse.data;
    console.log('[App] Contours hillshade from API:', rows.length, 'rows');
    if (rows.length > 0) {
      console.log('[App] First row keys:', Object.keys(rows[0]));
    }

    const geojson = wktRowsToGeoJSON(rows, 'contours_hillshade');
    if (!geojson) {
      console.warn('[App] Could not convert contours to GeoJSON');
      return;
    }

    const features = new GeoJSON().readFeatures(geojson, { featureProjection: 'EPSG:3857' });
    contoursLayer.getSource().clear();
    contoursLayer.getSource().addFeatures(features);
    console.log('[App] Contours hillshade loaded:', features.length, 'features');

  } catch (error) {
    console.error('[App] Failed to load contours_hillshade:', error);
  }
}

/**
 * Load the "OSM_roads" layer — OpenStreetMap road network for the district.
 *
 * Added to the "Physical Infrastructures" LayerGroup (id 5), initially not
 * visible — becomes visible when the user toggles it in the LayerSwitcher.
 *
 * Local-first caching:
 *   1. Read from the local `osm_roads` table → render immediately if available
 *   2. If online, fetch from the API → overwrite the local table → re-render
 */
async function loadOSMRoads() {
  const PHYS_INFRA_GROUP_ID = 5;
  // Cartographic road casing: a black outer stroke makes the light-coloured
  // inner stroke (the "road body") readable on every base map.
  const roadsStyle = {
    strokeColor:     '#F0F1F0',  // inner — road body
    strokeWidth:     1.5,
    lineCasingColor: '#000000',  // outer — black casing
    lineCasingWidth: 3.5,
    fillColor:       'rgba(0,0,0,0)',
    typeDescription: 'Vector / Line',
  };

  const physInfraGroup = mapView?.getLayerGroup(PHYS_INFRA_GROUP_ID) || null;
  console.log('[App] loadOSMRoads — group:', physInfraGroup ? physInfraGroup.get('title') : 'null');

  // Create the layer immediately (empty) so it appears in the LayerSwitcher
  // even when offline.
  const emptyGeoJSON = { type: 'FeatureCollection', features: [] };
  const roadsLayer = mapView?.addGeoJSONLayer(emptyGeoJSON, 'OSM_roads', roadsStyle, physInfraGroup);
  if (!roadsLayer) {
    console.warn('[App] Could not create OSM_roads layer');
    return;
  }
  roadsLayer.setVisible(false);

  // Warn only when the layer is enabled AND truly empty AND no source is reachable
  roadsLayer.on('change:visible', () => {
    if (roadsLayer.getVisible() && roadsLayer.getSource().getFeatures().length === 0) {
      showError('No OSM roads available locally. Connect to the internet to download them.');
    }
  });

  /** Replace the layer's features with those parsed from the API/cache rows. */
  function setRoadFeatures(rows) {
    const geojson = wktRowsToGeoJSON(rows, 'osm_road');
    if (!geojson) {
      console.warn('[App] Could not convert OSM roads to GeoJSON');
      return 0;
    }
    const features = new GeoJSON().readFeatures(geojson, { featureProjection: 'EPSG:3857' });
    roadsLayer.getSource().clear();
    roadsLayer.getSource().addFeatures(features);
    return features.length;
  }

  try {
    // Step 1 — local cache (works offline, instant)
    const cached = await getLocalOSMRoads();
    if (cached) {
      const n = setRoadFeatures(cached);
      console.log('[App] OSM_roads loaded from local cache:', n, 'features');
    }

    // Step 2 — fetch fresh from API when online
    if (isOnline() && isServerReachable()) {
      console.log('[App] Fetching OSM_roads from API...');
      const apiResponse = await getOSMRoads();

      if (!apiResponse?.success || !Array.isArray(apiResponse?.data)) {
        console.warn('[App] getOSMRoads API response invalid:', apiResponse);
        return;
      }

      const rows = apiResponse.data;
      console.log('[App] OSM_roads from API:', rows.length, 'rows');
      if (rows.length > 0) {
        console.log('[App] First row keys:', Object.keys(rows[0]));
      }

      // Persist to local table so it's available next time offline
      await saveOSMRoads(rows);

      const n = setRoadFeatures(rows);
      console.log('[App] OSM_roads updated from API:', n, 'features');

    } else if (!cached) {
      console.log('[App] OSM_roads not available — offline and no local cache');
    }

  } catch (error) {
    console.error('[App] Failed to load OSM_roads:', error);
  }
}

/**
 * Add external WMS/XYZ layers to the map.
 * Called after loadLayers() so the target layer groups already exist.
 */
function loadExternalWMSLayers() {
  // DEAfrica Coastlines v0.4.0 — annual shorelines & rates of change
  // Source: Digital Earth Africa GeoServer
  // Latest available version as of 2026: v0.4.0
  mapView?.addWMSLayer(
    'Biophysical Environment',
    'DEAfrica Coastlines v0.4',
    'https://geoserver.digitalearth.africa/geoserver/wms',
    'coastlines:DEAfrica_Coastlines',
    { serverType: 'geoserver', visible: false, onlineOnly: true }
  );

  // Note: OpenTopoMap is available as the "Topographic" base map —
  // no separate overlay in "Biophysical Environment" needed.

  // Digital Earth Africa — SRTM-derived Slope (30m)
  // Shows terrain steepness as a background overlay — hills and valleys stand
  // out naturally, reading like a traditional shaded-relief topographic map.
  // Service: datacube-ows (not GeoServer).
  // Layer 'srtm_deriv' styles: 'style_slope', 'style_mrvbf' (valley bottoms),
  // 'style_mrrtf' (ridge tops).
  mapView?.addWMSLayer(
    'Biophysical Environment',
    'DEAfrica Slope (SRTM 30m)',
    'https://ows.digitalearth.africa/wms',
    'srtm_deriv',
    {
      serverType: null,
      style: 'style_slope',
      visible: false,
      opacity: 0.5,
      zIndex: -50,
      onlineOnly: true,
      attributions:
        '&copy; <a href="https://www.digitalearthafrica.org/">Digital Earth Africa</a> — ' +
        'SRTM-derived Slope',
      legendUrl: 'https://ows.digitalearth.africa/legend/srtm_deriv/style_slope/legend.png',
    }
  );
}

/**
 * Load layer categories from the API and create empty VectorLayers on the map.
 * Uses local-first caching — reads from SQLite first, then refreshes from API when online.
 *
 * API response: { success: true, data: [{ id, name, description, createdt, editdt }, ...] }
 */
async function loadLayers() {
  const CACHE_KEY = 'layer_categories';

  /**
   * Create layer groups on the map from the layer category list.
   * @param {Array} layers - Array of { id, name, description, ... }
   */
  function createLayerGroupsOnMap(layers) {
    // Sort by id descending so that id 1 is pushed last → ends up
    // at the top of the layer stack and at the top of the LayerSwitcher.
    const sorted = [...layers].sort((a, b) => b.id - a.id);
    for (const layer of sorted) {
      mapView?.addLayerGroup(layer.id, layer.name, layer.description || '');
    }
    console.log('[App] Created', layers.length, 'layer groups on map');
  }

  try {
    // Step 1: Load from local cache
    const cached = await getRemoteData(CACHE_KEY);
    if (cached) {
      console.log('[App] Layer categories loaded from local cache:', cached.length, 'entries');
      createLayerGroupsOnMap(cached);
    }

    // Step 2: If online and server reachable, fetch fresh data from the API
    if (isOnline() && isServerReachable()) {
      console.log('[App] Fetching layer categories from API...');
      const apiResponse = await getLayers();

      if (!apiResponse?.success || !Array.isArray(apiResponse?.data)) {
        console.warn('[App] getLayers API response invalid:', apiResponse);
        return;
      }

      const layers = apiResponse.data;
      console.log('[App] Layer categories from API:', layers.length, 'entries');

      // Save to local cache
      await saveRemoteData(CACHE_KEY, layers);

      // Replace layers on map if we already created from cache
      if (cached) {
        // Remove previously created empty layers (keep District Boundary)
        const overlayLayers = mapView?.getOverlayGroup()?.getLayers();
        if (overlayLayers) {
          const toRemove = [];
          overlayLayers.forEach((layer) => {
            if (layer.get('layerId') !== undefined) {
              toRemove.push(layer);
            }
          });
          toRemove.forEach((layer) => overlayLayers.remove(layer));
        }
      }

      createLayerGroupsOnMap(layers);
      console.log('[App] Layer categories refreshed from API');

    } else if (!cached) {
      console.log('[App] Layer categories not available — offline and no local cache');
    }

  } catch (error) {
    console.error('[App] Failed to load layer categories:', error);
  }
}

// ============================================================================
// Sync (placeholder - implement based on your backend)
// ============================================================================

async function syncData() {
  if (!isOnline()) {
    console.log('[App] Cannot sync - offline');
    return;
  }

  // TODO: Implement sync with your backend
  // Example:
  // const unsynced = await getUnsyncedLocations();
  // for (const location of unsynced) {
  //   await fetch('/api/locations', {
  //     method: 'POST',
  //     body: JSON.stringify(location)
  //   });
  //   await markLocationsSynced([location.id]);
  // }

  console.log('[App] Sync placeholder - implement based on your backend');
}

// ============================================================================
// File Import (Shapefile, GeoJSON, KML)
// ============================================================================

/** All layers added by file imports — shared across formats. */
const importedFileLayers = [];

/** Default style for imported layers. */
const IMPORT_STYLE = {
  strokeColor: '#e11d48',
  strokeWidth: 2,
  fillColor: 'rgba(225,29,72,0.12)',
};

/**
 * Show an error message inside the left panel's file-import alert area.
 */
function showFileImportError(message) {
  logMessage('error', message);
  const el = document.getElementById('file-import-alert');
  if (el) {
    el.querySelector('.message-text').textContent = message;
    el.classList.remove('d-none');
    setTimeout(() => el.classList.add('d-none'), 8000);
  }
}

/**
 * Add a GeoJSON FeatureCollection (or array of them) to the map, zoom to
 * the data, and refresh the imported-layers info card.
 *
 * @param {Object|Object[]} geojsonInput - Single FeatureCollection or array
 * @param {string} fallbackName - Layer name when the FC has no fileName
 * @param {string} tag - Log prefix, e.g. 'ShpImport'
 */
function addImportedGeoJSON(geojsonInput, fallbackName, tag) {
  const collections = Array.isArray(geojsonInput) ? geojsonInput : [geojsonInput];

  let totalFeatures = 0;
  for (const fc of collections) {
    if (!fc || fc.type !== 'FeatureCollection' || !fc.features?.length) continue;

    const layerName = fc.fileName
      ? fc.fileName.replace(/\.[^/.]+$/, '')
      : fallbackName;

    const layer = mapView?.addGeoJSONLayer(fc, layerName, IMPORT_STYLE);
    if (layer) {
      // Imported file layers are not part of the built-in data model;
      // the user can remove them via the LayerSwitcher × button.
      layer.set('removable', true);
      layer.set('typeTag', 'GEO');
      importedFileLayers.push(layer);
      totalFeatures += fc.features.length;

      // Stage the import into external_imports + external_import_features
      // (LUPMIS2_Import_Upload_Design.docx §3 + §4). The mapping modal opens
      // automatically; the layer gets tagged with its import_id so the
      // LayerSwitcher chip can find the row.
      stageImport(fc, layerName, layer).catch((err) =>
        console.warn('[FileImport] Staging failed (layer remains view-only):', err)
      );
    }
  }

  if (totalFeatures === 0) {
    hideImportSpinner();
    showFileImportError('No features found in the file.');
    return;
  }

  console.log(`[${tag}] Added ${totalFeatures} feature(s) from ${collections.length} layer(s)`);

  // Zoom to the last imported layer
  const lastLayer = importedFileLayers[importedFileLayers.length - 1];
  if (lastLayer) {
    const extent = lastLayer.getSource().getExtent();
    mapView?.getMap().getView().fit(extent, { padding: [50, 50, 50, 50], maxZoom: 18 });
  }

  refreshImportedLayersCard();
}

// ===========================================================================
// External-dataset import staging  (see LUPMIS2_Import_Upload_Design.docx)
// ===========================================================================

const wktFormat4326 = new WKT();

/**
 * Convert an OL geometry (Map projection EPSG:3857) to a WKT string in
 * EPSG:4326 — the format the server expects.
 */
function geometryToWkt4326(geometry) {
  return wktFormat4326.writeGeometry(geometry, {
    dataProjection: 'EPSG:4326',
    featureProjection: 'EPSG:3857',
  });
}

/**
 * Stage an imported FeatureCollection into external_imports +
 * external_import_features, then open the mapping modal.
 *
 *  1. Create the external_imports row (target_type='other', status='other').
 *  2. Insert one external_import_features row per feature with the raw
 *     properties (mapping is applied later, when the user confirms).
 *  3. Tag the OL layer with _externalImportId so the LayerSwitcher chip
 *     can render its status.
 *  4. Open the mapping modal; on Save the row is updated with the chosen
 *     target type and mapping, the staged-feature properties are remapped,
 *     and (if requested) an upload is kicked off.
 */
async function stageImport(fc, displayName, layer) {
  const featureCount = fc?.features?.length ?? 0;
  if (featureCount === 0) { hideImportSpinner(); return; }

  // ── 1. Create the staging row + features ────────────────────────────────
  const { id: importId } = await createExternalImport({
    filename: displayName || 'imported dataset',
    targetType: 'other',
    featureCount,
  });
  layer.set('_externalImportId', importId);

  // Convert each OL feature to (WKT 4326 + raw source properties). We generate
  // a client_uuid up-front and tag the OL feature with it; that's how later
  // edits (geometry modifications, deletions) can find the matching staging
  // row in external_import_features.
  const olFeatures = layer.getSource().getFeatures();
  const stagedRows = olFeatures.map((f) => {
    const geom = f.getGeometry();
    const cuuid = newClientUuid();
    f.set('_externalImportId', importId);
    f.set('_clientUuid', cuuid);
    return {
      client_uuid: cuuid,
      geometry_wkt: geom ? geometryToWkt4326(geom) : '',
      properties: stripGeometryFromProps(f.getProperties()),
    };
  });
  await addExternalImportFeatures(importId, stagedRows);

  // ── 1b. When a tagged feature is removed (EditBar's Delete in Draw mode),
  //         tear down the matching staging row so the next upload doesn't
  //         re-send a deleted feature. The listener stays on the layer for
  //         the lifetime of the imported layer.
  layer.getSource().on('removefeature', async (e) => {
    const f = e.feature;
    const cuuid = f?.get('_clientUuid');
    const impId = f?.get('_externalImportId');
    if (!cuuid || impId == null) return;
    try {
      await deleteExternalImportFeature(cuuid);
      console.log('[FileImport] Removed feature from staging:', cuuid);
    } catch (err) {
      console.warn('[FileImport] Failed to remove staging row:', err);
    }
  });

  // ── 2. Open the mapping modal ───────────────────────────────────────────
  hideImportSpinner();
  openImportMappingModal({
    importId,
    filename: displayName,
    fc,
    onResult: async (result) => {
      try {
        await handleImportModalResult(importId, layer, result);
      } catch (err) {
        console.error('[FileImport] Failed to apply mapping result:', err);
        showError('Could not save the import mapping: ' + err.message);
      }
    },
  });
}

/** Generate a per-feature client_uuid. Uses crypto.randomUUID where available. */
function newClientUuid() {
  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
    return crypto.randomUUID();
  }
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
    const r = (Math.random() * 16) | 0;
    return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
  });
}

/** OL Feature#getProperties() includes the geometry; we don't want it as JSON. */
function stripGeometryFromProps(props) {
  const out = {};
  for (const [k, v] of Object.entries(props || {})) {
    if (k === 'geometry') continue;
    out[k] = v;
  }
  return out;
}

/**
 * Apply the user's choice from the mapping modal:
 *   cancel  → keep as 'other' (view-only); no further action.
 *   save    → set target_type + mapping, status='mapped', remap staged props.
 *   upload  → same as save, then run the upload stub.
 */
async function handleImportModalResult(importId, layer, result) {
  if (!result || result.action === 'cancel') {
    layer?.set('_externalImportStatus', 'other');
    refreshLayerSwitcherChip(layer);
    return;
  }

  const { action, targetType, mapping } = result;
  if (!targetType || targetType === 'other') {
    await updateExternalImport(importId, { targetType: 'other', mapping: null, status: 'other' });
    layer?.set('_externalImportStatus', 'other');
    refreshLayerSwitcherChip(layer);
    return;
  }

  // Remap each staged feature's properties to LUPMIS2 column names. The
  // helper wraps every UPDATE in a single transaction.
  await remapImportedFeatureProperties(importId, (props) =>
    applyFieldMapping(props, mapping)
  );

  await updateExternalImport(importId, { targetType, mapping, status: 'mapped' });
  layer?.set('_externalImportStatus', 'mapped');
  layer?.set('_externalImportTargetType', targetType);
  refreshLayerSwitcherChip(layer);

  if (action === 'upload') {
    await runUpload(importId, layer);
  }
}

/**
 * Upload stub. The server endpoints (upload_parcels.php, …) don't exist yet
 * (LUPMIS2_Import_Upload_Design.docx §5 is the proposal sent to the database
 * team). Until they're live we mark the row 'uploading' briefly so the chip
 * can flash a spinner, log the would-be payload for verification, then
 * revert to 'mapped' with a clear info toast — nothing is lost; the user
 * can retry once the endpoint exists.
 */
async function runUpload(importId, layer) {
  layer?.set('_externalImportStatus', 'uploading');
  refreshLayerSwitcherChip(layer);
  try {
    await updateExternalImport(importId, { status: 'uploading' });

    const imp = await getExternalImport(importId);
    const features = await getExternalImportFeatures(importId);
    const session = getSession();

    // Build the request body exactly as the server will receive it once
    // upload_<target_type>.php is live. district_id + api_token are merged
    // in by remotePost from API_CREDENTIALS; user_id_upload comes from the
    // SSO session (server may also derive it server-side — we send it for
    // logging/audit completeness as agreed with the database team).
    const body = {
      user_id_upload: session?.user_id ?? null,
      import: {
        client_import_id: imp.client_import_id,
        filename:         imp.filename,
        feature_count:    features.length,
      },
      features: features.map((f) => ({
        client_uuid: f.client_uuid,
        geom:        f.geometry_wkt,
        props:       f.properties,
      })),
    };

    // ── TODO when the database team ships upload_<target_type>.php ────────
    //   const apiResponse = await remotePost(`upload_${imp.target_type}.php`, body);
    //   then walk apiResponse.results, update each feature's upload_status,
    //   and flip the import row to 'submitted' (or 'failed' if any rows
    //   failed). For now we just log the payload and roll back.
    console.log('[Upload]', {
      endpoint: `upload_${imp.target_type}.php (not yet available on the server)`,
      target_type: imp.target_type,
      body,
    });

    await updateExternalImport(importId, {
      status: 'mapped',
      lastUploadedAt: new Date().toISOString(),
    });
    layer?.set('_externalImportStatus', 'mapped');
    refreshLayerSwitcherChip(layer);

    showWarning(
      'The server upload endpoint is not yet available. ' +
      'The data stays staged locally — you can upload again later.'
    );
  } catch (err) {
    console.error('[Upload] Stub failed:', err);
    layer?.set('_externalImportStatus', 'mapped');
    refreshLayerSwitcherChip(layer);
    showError('Upload preparation failed: ' + err.message);
  }
}

// Export GIS button on the Area / Circle Analysis popup — MapView dispatches
// this CustomEvent so main.js can own the format/rename modal + writers.
window.addEventListener('lupmis:export-gis', (e) => {
  openExportGisModal(e.detail || {});
});

// LayerSwitcher chip click — dispatched as a window CustomEvent by MapView's
// _decorateLayerListItem. We only act on the 'mapped' state today (upload);
// 'failed' will open an error-review modal once the server endpoints exist.
window.addEventListener('lupmis:import-chip-click', (e) => {
  const { importId, status, layer } = e.detail || {};
  if (status === 'mapped') {
    runUpload(importId, layer).catch((err) =>
      console.error('[FileImport] runUpload failed:', err)
    );
  }
});

/**
 * Force the LayerSwitcher row for this layer to re-render. ol-ext rebuilds
 * the panel on every render() call; the drawlist hook will read the new
 * _externalImportStatus property and render the appropriate chip.
 */
function refreshLayerSwitcherChip(layer) {
  if (!layer || !mapView) return;
  const switcher = mapView.getMap()
    ?.getControls()
    ?.getArray()
    ?.find((c) => c?.constructor?.name === 'LayerSwitcher'
                  || c?.element?.classList?.contains('ol-layerswitcher'));
  if (switcher && typeof switcher.drawPanel === 'function') {
    switcher.drawPanel();
  }
}

/**
 * Rebuild the imported-layers info card in the left panel.
 */
function refreshImportedLayersCard() {
  const infoEl = document.getElementById('imported-layers-info');
  if (!infoEl) return;

  if (importedFileLayers.length === 0) {
    infoEl.innerHTML = '';
    infoEl.classList.add('d-none');
    return;
  }

  infoEl.innerHTML = `
    <div class="card">
      <div class="card-header bg-primary py-2 d-flex justify-content-between align-items-center">
        <h6 class="mb-0"><i class="bi bi-layers me-2"></i>Imported Layers</h6>
        <button type="button" class="btn btn-sm btn-outline-light" id="remove-imported-layers" title="Remove all imported layers">
          <i class="bi bi-trash"></i>
        </button>
      </div>
      <ul class="list-group list-group-flush" id="imported-layers-list"></ul>
    </div>`;

  const listEl = infoEl.querySelector('#imported-layers-list');
  importedFileLayers.forEach((l, idx) => {
    const li = document.createElement('li');
    li.className = 'list-group-item d-flex justify-content-between align-items-center py-2';
    li.innerHTML = `<small class="text-truncate me-2">${escapeHtml(l.get('title'))}</small>
      <span class="d-flex align-items-center gap-2 flex-shrink-0">
        <span class="badge" style="background-color:var(--primary);color:var(--primary-foreground);">${l.getSource().getFeatures().length}</span>
        <button type="button" class="btn btn-sm btn-outline-danger border-0 p-0 lh-1" data-remove-idx="${idx}" title="Remove layer">
          <i class="bi bi-x-lg" style="font-size:.75rem;"></i>
        </button>
      </span>`;
    listEl.appendChild(li);
  });
  infoEl.classList.remove('d-none');

  // Per-layer remove buttons
  infoEl.querySelectorAll('[data-remove-idx]').forEach(btn => {
    btn.addEventListener('click', () => {
      removeImportedLayer(Number(btn.dataset.removeIdx));
    });
  });

  // Remove-all button
  infoEl.querySelector('#remove-imported-layers')?.addEventListener('click', () => {
    removeImportedLayers();
  });
}

/**
 * Remove a single imported layer by its index in importedFileLayers.
 */
function removeImportedLayer(idx) {
  if (idx < 0 || idx >= importedFileLayers.length) return;
  const layer = importedFileLayers[idx];
  const overlayGroup = mapView?.getOverlayGroup();
  if (overlayGroup) {
    overlayGroup.getLayers().remove(layer);
  }
  importedFileLayers.splice(idx, 1);
  refreshImportedLayersCard();
  console.log('[FileImport] Removed layer:', layer.get('title'));
}

/**
 * Remove all imported layers from the map and clear the info card.
 */
function removeImportedLayers() {
  const overlayGroup = mapView?.getOverlayGroup();
  if (overlayGroup) {
    for (const layer of importedFileLayers) {
      overlayGroup.getLayers().remove(layer);
    }
  }
  importedFileLayers.length = 0;
  refreshImportedLayersCard();
  console.log('[FileImport] All imported layers removed');
}

// ---------------------------------------------------------------------------
// Shapefile (.shp / .zip)
// ---------------------------------------------------------------------------

/**
 * Build a lookup of selected files keyed by lowercase extension.
 */
function indexFilesByExtension(files) {
  const map = {};
  for (const f of files) {
    const ext = f.name.split('.').pop().toLowerCase();
    map[ext] = f;
  }
  return map;
}

async function handleShapefileImport(evt) {
  const files = evt.target.files;
  if (!files || files.length === 0) return;

  const MAX_FILE_SIZE = 200 * 1024 * 1024;
  const totalSize = Array.from(files).reduce((s, f) => s + f.size, 0);
  if (totalSize > MAX_FILE_SIZE) {
    const sizeMB = (totalSize / (1024 * 1024)).toFixed(0);
    showFileImportError(
      `Files too large (${sizeMB} MB total). Maximum supported size is 200 MB.`
    );
    evt.target.value = '';
    return;
  }

  // Show the import spinner — hidden again either when the mapping modal opens
  // (see stageImport) or by the catch-block below on error.
  showImportSpinner(files[0]?.name || 'shapefile');
  try {
    let geojson;
    let displayName;
    const byExt = indexFilesByExtension(files);

    if (byExt.zip) {
      const file = byExt.zip;
      displayName = file.name.replace(/\.zip$/i, '');
      console.log('[ShpImport] Parsing zip', file.name, '(' + (file.size / 1024).toFixed(1) + ' KB)');
      const shp = await getShp();
      geojson = await shp(await file.arrayBuffer());

    } else if (byExt.shp) {
      displayName = byExt.shp.name.replace(/\.shp$/i, '');

      const required = ['dbf', 'shx', 'prj'];
      const missing = required.filter(ext => !byExt[ext]);
      if (missing.length > 0) {
        hideImportSpinner();
        showFileImportError('Missing required file(s): ' + missing.map(e => '.' + e).join(', ')
          + '. Please select .shp, .dbf, .shx and .prj together.');
        evt.target.value = '';
        return;
      }

      const shpObj = {};
      shpObj.shp = await byExt.shp.arrayBuffer();
      shpObj.dbf = await byExt.dbf.arrayBuffer();
      shpObj.prj = await new Response(byExt.prj).text();
      if (byExt.cpg) shpObj.cpg = await new Response(byExt.cpg).text();

      console.log('[ShpImport] Parsing loose files:',
        Object.keys(byExt).map(e => '.' + e).join(', '),
        '(' + (byExt.shp.size / 1024).toFixed(1) + ' KB .shp)');

      const shp = await getShp();
      geojson = await shp(shpObj);

    } else {
      hideImportSpinner();
      showFileImportError('Please select a .zip or at least a .shp file.');
      evt.target.value = '';
      return;
    }

    addImportedGeoJSON(geojson, displayName, 'ShpImport');
  } catch (error) {
    hideImportSpinner();
    console.error('[ShpImport] Failed:', error);
    showFileImportError('Failed to parse shapefile: ' + error.message);
  }

  evt.target.value = '';
}

// ---------------------------------------------------------------------------
// GeoJSON (.geojson / .json)
// ---------------------------------------------------------------------------

async function handleGeoJSONImport(evt) {
  const file = evt.target.files?.[0];
  if (!file) return;

  // Guard: reject files larger than 200 MB — JSON.parse cannot reliably
  // handle them in a single pass and the browser will freeze or crash.
  const MAX_FILE_SIZE = 200 * 1024 * 1024; // 200 MB
  if (file.size > MAX_FILE_SIZE) {
    const sizeMB = (file.size / (1024 * 1024)).toFixed(0);
    showFileImportError(
      `File too large (${sizeMB} MB). Maximum supported size is 200 MB. `
      + 'Consider splitting the file into smaller tiles with ogr2ogr or QGIS.'
    );
    evt.target.value = '';
    return;
  }

  showImportSpinner(file.name);
  try {
    const text = await file.text();
    console.log('[GeoJSONImport] Parsing', file.name, '(' + (file.size / 1024).toFixed(1) + ' KB)');

    const parsed = JSON.parse(text);

    // Normalise to a FeatureCollection
    let fc;
    if (parsed.type === 'FeatureCollection') {
      fc = parsed;
    } else if (parsed.type === 'Feature') {
      fc = { type: 'FeatureCollection', features: [parsed] };
    } else if (parsed.type && parsed.coordinates) {
      // Bare geometry object
      fc = { type: 'FeatureCollection', features: [{ type: 'Feature', geometry: parsed, properties: {} }] };
    } else {
      hideImportSpinner();
      showFileImportError('The file does not contain valid GeoJSON.');
      evt.target.value = '';
      return;
    }

    const displayName = file.name.replace(/\.(geo)?json$/i, '');
    addImportedGeoJSON(fc, displayName, 'GeoJSONImport');
  } catch (error) {
    hideImportSpinner();
    console.error('[GeoJSONImport] Failed:', error);
    const sizeMB = (file.size / (1024 * 1024)).toFixed(1);
    showFileImportError(
      `Failed to import "${file.name}" (${sizeMB} MB): ${error.message}`
    );
  }

  evt.target.value = '';
}

// ---------------------------------------------------------------------------
// KML (.kml)
// ---------------------------------------------------------------------------

async function handleKMLImport(evt) {
  const file = evt.target.files?.[0];
  if (!file) return;

  const MAX_FILE_SIZE = 200 * 1024 * 1024;
  if (file.size > MAX_FILE_SIZE) {
    const sizeMB = (file.size / (1024 * 1024)).toFixed(0);
    showFileImportError(
      `File too large (${sizeMB} MB). Maximum supported size is 200 MB.`
    );
    evt.target.value = '';
    return;
  }

  showImportSpinner(file.name);
  try {
    const text = await file.text();
    console.log('[KMLImport] Parsing', file.name, '(' + (file.size / 1024).toFixed(1) + ' KB)');

    const kmlFormat = new KML({ extractStyles: false });
    const features = kmlFormat.readFeatures(text, {
      featureProjection: 'EPSG:3857',
    });

    if (!features || features.length === 0) {
      hideImportSpinner();
      showFileImportError('No features found in the KML file.');
      evt.target.value = '';
      return;
    }

    // Convert OL features back to GeoJSON so we can use the shared pipeline
    const geojsonFormat = new GeoJSON();
    const fc = JSON.parse(geojsonFormat.writeFeatures(features, {
      featureProjection: 'EPSG:3857',
      dataProjection: 'EPSG:4326',
    }));

    const displayName = file.name.replace(/\.kml$/i, '');
    addImportedGeoJSON(fc, displayName, 'KMLImport');
  } catch (error) {
    hideImportSpinner();
    console.error('[KMLImport] Failed:', error);
    const sizeMB = (file.size / (1024 * 1024)).toFixed(1);
    showFileImportError(
      `Failed to import "${file.name}" (${sizeMB} MB): ${error.message}`
    );
  }

  evt.target.value = '';
}

// ---------------------------------------------------------------------------
// Drag-and-drop on the map
// ---------------------------------------------------------------------------

/**
 * Set up the map container as a drop zone for .shp/.zip, .geojson/.json, .kml
 * files.  Dragging files over the map shows a visual overlay; dropping them
 * routes to the correct import handler.
 */
function initMapDropZone() {
  const container = document.querySelector('.map-container');
  if (!container) return;

  let dragCounter = 0; // track nested enter/leave events

  container.addEventListener('dragenter', (e) => {
    e.preventDefault();
    dragCounter++;
    container.classList.add('drag-over');
  });

  container.addEventListener('dragover', (e) => {
    e.preventDefault(); // required to allow drop
  });

  container.addEventListener('dragleave', (e) => {
    e.preventDefault();
    dragCounter--;
    if (dragCounter <= 0) {
      dragCounter = 0;
      container.classList.remove('drag-over');
    }
  });

  container.addEventListener('drop', (e) => {
    e.preventDefault();
    dragCounter = 0;
    container.classList.remove('drag-over');

    const files = e.dataTransfer?.files;
    if (!files || files.length === 0) return;

    // Build extension lookup to decide which handler to use
    const byExt = indexFilesByExtension(files);
    const exts = Object.keys(byExt);

    if (byExt.zip || byExt.shp) {
      // Shapefile import (zip or loose .shp + companions)
      const fakeEvt = { target: { files, value: '' } };
      Object.defineProperty(fakeEvt.target, 'value', { writable: true });
      handleShapefileImport(fakeEvt);
    } else if (byExt.geojson || byExt.json) {
      const file = byExt.geojson || byExt.json;
      const fakeEvt = { target: { files: [file], value: '' } };
      Object.defineProperty(fakeEvt.target, 'value', { writable: true });
      handleGeoJSONImport(fakeEvt);
    } else if (byExt.kml) {
      const fakeEvt = { target: { files: [byExt.kml], value: '' } };
      Object.defineProperty(fakeEvt.target, 'value', { writable: true });
      handleKMLImport(fakeEvt);
    } else {
      showFileImportError(
        'Unsupported file type(s): ' + exts.map(e => '.' + e).join(', ')
        + '. Drop .zip, .shp, .geojson, .json, or .kml files.'
      );
    }
  });

  console.log('[FileImport] Map drop zone initialised');
}

// ============================================================================
// Utilities
// ============================================================================

function escapeHtml(text) {
  const div = document.createElement('div');
  div.textContent = text;
  return div.innerHTML;
}

// ============================================================================
// Message Log — persistent stack in the right panel
// ============================================================================

const MESSAGE_LOG_MAX = 50;

const MSG_CONFIG = {
  error:   { icon: 'bi-x-circle-fill',        color: 'var(--destructive, #dc3545)' },
  warning: { icon: 'bi-exclamation-triangle-fill', color: 'var(--warning, #ffc107)' },
  success: { icon: 'bi-check-circle-fill',     color: 'var(--success, #198754)' },
  info:    { icon: 'bi-info-circle-fill',      color: 'var(--primary, #0d6efd)' },
};

/**
 * Append a message to the persistent log in the right panel.
 * Also logs to the browser console.
 *
 * @param {'error'|'warning'|'success'|'info'} type
 * @param {string} text
 */
function logMessage(type, text) {
  const cfg = MSG_CONFIG[type] || MSG_CONFIG.info;

  // Console mirror
  const consoleFn = type === 'error' ? console.error
    : type === 'warning' ? console.warn
    : console.log;
  consoleFn('[App]', text);

  const log = document.getElementById('message-log');
  if (!log) return;

  // Remove the "No messages yet" placeholder if present
  const placeholder = log.querySelector('.text-muted');
  if (placeholder) placeholder.remove();

  // Build the entry
  const entry = document.createElement('div');
  entry.className = 'list-group-item message-log-entry py-2 px-3';
  const now = new Date();
  const time = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
  entry.innerHTML =
    `<div class="d-flex align-items-start gap-2">` +
      `<i class="bi ${cfg.icon} flex-shrink-0 mt-1" style="color:${cfg.color}"></i>` +
      `<div class="flex-grow-1 text-break"><small>${escapeHtml(text)}</small></div>` +
      `<small class="text-muted flex-shrink-0 ms-1">${time}</small>` +
    `</div>`;

  // Prepend (newest first)
  log.prepend(entry);

  // Cap the list
  while (log.children.length > MESSAGE_LOG_MAX) {
    log.lastElementChild.remove();
  }
}

/** Wire up the "clear" button */
function initMessageLog() {
  const btn = document.getElementById('clear-message-log');
  if (btn) {
    btn.addEventListener('click', () => {
      const log = document.getElementById('message-log');
      if (log) {
        log.innerHTML = '<div class="text-center text-muted py-3"><small>No messages yet.</small></div>';
      }
    });
  }
}

// ============================================================================
// GPS live-position + trail recording
//
// Wiring only — all GPS logic lives in the reusable src/geotracker/ engine and
// the LUPMIS adapter in src/geotracker-lupmis.js. Here we connect the engine's
// events to the navbar readout and the map's render/control hooks.
// ============================================================================

// Last GPS fix rendered into the navbar read-out. Kept at module scope so the
// Coordinate-Format setting can re-render the current position immediately when
// the user switches format, without waiting for the next fix.
let _lastGpsFix = null;

/** Read the user's coordinate-format preference ('latlon' | 'utm' | 'both'). */
function getCoordFormat() {
  const v = localStorage.getItem('coord-format');
  return (v === 'utm' || v === 'both') ? v : 'latlon';
}

/**
 * Paint the navbar GPS coordinate spans from `_lastGpsFix` according to the
 * current coordinate-format preference. Safe to call any time (no-op when
 * there is no fix yet, leaving the "GPS off" placeholder in place).
 */
function renderGpsCoords() {
  const coordsEl = document.getElementById('gps-coords');
  const utmEl    = document.getElementById('gps-utm');
  if (!coordsEl) return;
  if (!_lastGpsFix) return;

  const { lat, lon } = _lastGpsFix;
  const latlon = `${formatCoord(lat)}, ${formatCoord(lon)}`;
  const utm    = formatUTM(lat, lon);
  const fmt    = getCoordFormat();

  if (fmt === 'utm') {
    coordsEl.textContent = utm;
    if (utmEl) utmEl.classList.add('d-none');
  } else if (fmt === 'both') {
    coordsEl.textContent = latlon;
    if (utmEl) { utmEl.textContent = utm; utmEl.classList.remove('d-none'); }
  } else {
    coordsEl.textContent = latlon;
    if (utmEl) utmEl.classList.add('d-none');
  }
  // Keep the alternate format reachable on hover regardless of mode.
  const readout = document.getElementById('gps-readout');
  if (readout) readout.title = `Lat/Lon: ${latlon}\nUTM: ${utm}`;
}

function initGpsTracking() {
  const readout  = document.getElementById('gps-readout');
  const coordsEl = document.getElementById('gps-coords');
  const accEl    = document.getElementById('gps-accuracy');
  const satsEl   = document.getElementById('gps-sats');

  if (!geoTracker.isSupported) {
    if (coordsEl) coordsEl.textContent = 'No GPS';
    return;
  }

  // Live navbar readout — fires for every fix (one-shot Locate or watch).
  geoTracker.on('position', (fix) => {
    _lastGpsFix = fix;
    renderGpsCoords();
    if (accEl)    accEl.textContent = formatAccuracy(fix.accuracy);
    if (satsEl)   satsEl.textContent = `${fix.satellites != null ? fix.satellites : '—'} sat`;
    if (readout) {
      readout.classList.add('active');
      readout.classList.remove('quality-good', 'quality-fair', 'quality-poor');
      readout.classList.add('quality-' + accuracyQuality(fix.accuracy));
    }
    mapView?.showCurrentPosition(fix.lon, fix.lat, fix.accuracy);
  });

  // Each recorded waypoint extends the on-map trail line.
  geoTracker.on('point', (evt) => {
    mapView?.appendTrailPoint(evt.point.lon, evt.point.lat);
  });

  geoTracker.on('error', (err) => {
    console.warn('[GPS]', err?.message || err);
    if (err && err.code === 1) { // PERMISSION_DENIED
      showError('Location permission denied. Enable location access to use GPS.');
    }
  });

  // "Locate me" → one-shot position + recenter.
  mapView.onLocateMe(async () => {
    try {
      const fix = await geoTracker.getCurrentPosition();
      mapView.centerOn(fix.lon, fix.lat, 16);
    } catch (err) {
      showError('Could not get your location: ' + (err?.message || err));
    }
  });

  // "Record trail" → start/stop. Recording persists locally and syncs on stop.
  mapView.onToggleRecording(async (start) => {
    if (start) {
      try {
        await dbReady;
        mapView.startTrailRender();
        mapView.setRecordingState(true);
        readout?.classList.add('recording');
        await geoTracker.startRecording({ name: `Trail ${new Date().toLocaleString()}` });
        showSuccess('GPS trail recording started');
      } catch (err) {
        mapView.setRecordingState(false);
        readout?.classList.remove('recording');
        showError('Could not start recording: ' + (err?.message || err));
      }
    } else {
      try {
        const res = await geoTracker.stopRecording();
        mapView.setRecordingState(false);
        readout?.classList.remove('recording');
        if (res) {
          const msg = `Trail saved: ${res.pointCount} points, ${formatDistance(res.distanceM)}` +
            (res.synced ? ' — synced' : ' — will sync when online');
          showSuccess(msg);
        }
      } catch (err) {
        showError('Error stopping recording: ' + (err?.message || err));
      }
    }
  });

  // Retry uploading trails recorded while offline — on load and when back online.
  const trySync = async () => {
    if (!isOnline()) return;
    try {
      await dbReady;
      const r = await geoTracker.syncPending();
      if (r.pushed) console.log(`[GPS] Synced ${r.pushed} pending trail(s)`);
    } catch (e) {
      console.warn('[GPS] pending-sync error', e);
    }
  };
  trySync();
  onOfflineChange((offline) => { if (!offline) trySync(); });
}

// ============================================================================
// Toast-style alerts (auto-dismiss) + persistent log
// ============================================================================

function showError(message) {
  logMessage('error', message);
  const el = document.getElementById('error-message');
  if (el) {
    el.querySelector('.message-text').textContent = message;
    el.classList.remove('d-none');
    setTimeout(() => el.classList.add('d-none'), 5000);
  }
}

function showSuccess(message) {
  logMessage('success', message);
  const el = document.getElementById('success-message');
  if (el) {
    el.querySelector('.message-text').textContent = message;
    el.classList.remove('d-none');
    setTimeout(() => el.classList.add('d-none'), 3000);
  }
}

function showWarning(message) {
  logMessage('warning', message);
  const el = document.getElementById('warning-message');
  if (el) {
    el.querySelector('.message-text').textContent = message;
    el.classList.remove('d-none');
    setTimeout(() => el.classList.add('d-none'), 5000);
  }
}

// ============================================================================
// Fieldwork Mode
// ============================================================================

function initFieldworkMode() {
  const toggle = document.getElementById('fieldwork-mode-toggle');
  if (!toggle) return;

  // Restore saved preference
  const saved = localStorage.getItem('fieldwork-mode');
  if (saved === 'true') {
    document.documentElement.classList.add('fieldwork-mode');
    toggle.checked = true;
  }

  toggle.addEventListener('change', () => {
    document.documentElement.classList.toggle('fieldwork-mode', toggle.checked);
    localStorage.setItem('fieldwork-mode', toggle.checked);
    console.log('[Settings] Fieldwork mode', toggle.checked ? 'ON' : 'OFF');
  });
}

// ============================================================================
// Dark Mode
// ============================================================================

function initDarkMode() {
  const toggle = document.getElementById('dark-mode-toggle');
  if (!toggle) return;

  function applyDark(on) {
    document.documentElement.classList.toggle('dark-mode', on);
    // Bootstrap 5.3 built-in dark mode support
    document.documentElement.setAttribute('data-bs-theme', on ? 'dark' : 'light');
  }

  // Restore saved preference
  const saved = localStorage.getItem('dark-mode');
  if (saved === 'true') {
    toggle.checked = true;
    applyDark(true);
  }

  toggle.addEventListener('change', () => {
    applyDark(toggle.checked);
    localStorage.setItem('dark-mode', toggle.checked);
    console.log('[Settings] Dark mode', toggle.checked ? 'ON' : 'OFF');
  });
}

// ============================================================================
// Measurement System
// ============================================================================

function initMeasurementSystem() {
  const toggle = document.getElementById('measurement-system-toggle');
  const label = document.getElementById('measurement-system-label');
  if (!toggle) return;

  function updateLabel() {
    if (label) label.textContent = toggle.checked ? 'Imperial' : 'Metric';
  }

  // Restore saved preference
  const saved = localStorage.getItem('measurement-system');
  if (saved === 'imperial') {
    toggle.checked = true;
  }
  updateLabel();

  // Apply saved setting to the scale bar on load
  mapView?.setScaleBarUnits(saved || 'metric');

  toggle.addEventListener('change', () => {
    const system = toggle.checked ? 'imperial' : 'metric';
    localStorage.setItem('measurement-system', system);
    updateLabel();
    mapView?.setScaleBarUnits(system);
    console.log('[Settings] Measurement system:', system);
  });
}

// ============================================================================
// GPS Coordinate Format (Lat/Lon · UTM · Both)
// ============================================================================

function initCoordinateFormat() {
  const select = document.getElementById('coord-format-select');
  if (!select) return;

  // Restore saved preference (default: latlon)
  select.value = getCoordFormat();

  select.addEventListener('change', () => {
    const fmt = (select.value === 'utm' || select.value === 'both') ? select.value : 'latlon';
    localStorage.setItem('coord-format', fmt);
    renderGpsCoords();   // repaint the current fix immediately
    console.log('[Settings] GPS coordinate format:', fmt);
  });
}

/**
 * Default base map selector — persisted in localStorage.
 * Keys must match those handled by MapView.setBaseMap().
 */
function initDefaultBasemap() {
  const select = document.getElementById('default-basemap-select');
  if (!select) return;

  // Restore saved preference (default: topo)
  const saved = localStorage.getItem('default-basemap') || 'topo';
  select.value = saved;

  select.addEventListener('change', () => {
    const key = select.value;
    localStorage.setItem('default-basemap', key);
    mapView?.setBaseMap(key);
    console.log('[Settings] Default base map:', key);
  });

  // Keep the dropdown in sync when the user switches via the floating
  // base-map picker (or any other UI) — MapView fires `basemapchange`
  // from setBaseMap().
  mapView?.getMap()?.on('basemapchange', (evt) => {
    if (evt?.key && select.value !== evt.key) {
      select.value = evt.key;
      try { localStorage.setItem('default-basemap', evt.key); } catch {}
    }
  });
}

/**
 * Offline Map Tiles card — shows per-provider cache stats and offers a
 * "Clear cached tiles" button. Stats refresh whenever the Settings panel
 * is opened so the numbers are always current.
 */
function initOfflineTileCache() {
  const statsEl   = document.getElementById('tile-cache-stats');
  const clearBtn  = document.getElementById('clear-tiles-btn');
  const offcanvas = document.getElementById('offcanvasBottom');
  if (!statsEl || !clearBtn || !offcanvas) return;

  /** Format a byte count into a human-friendly string. */
  function fmtBytes(bytes) {
    if (!bytes) return '0 KB';
    if (bytes < 1024 * 1024)        return (bytes / 1024).toFixed(0) + ' KB';
    if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
    return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
  }

  // Track in-flight refresh so rapid calls don't overlap and to allow a
  // controllerchange handler to know when a refresh is already underway.
  let refreshInFlight = null;

  /** Render the stats panel. */
  async function refresh() {
    if (refreshInFlight) return refreshInFlight;

    // If the SW hasn't taken control yet, give the user a friendly hint
    // instead of immediately failing. The wait inside getTileCacheStats()
    // will resolve once the SW becomes available, at which point this
    // refresh completes normally — no reload needed.
    const swActive = !!navigator.serviceWorker?.controller;
    statsEl.innerHTML = swActive
      ? '<div class="text-muted fst-italic">Loading…</div>'
      : '<div class="text-muted fst-italic">Initialising service worker…</div>';

    refreshInFlight = (async () => {
      try {
        const stats = await getTileCacheStats();

        if (!stats) {
          statsEl.innerHTML = `
            <div class="text-muted fst-italic">
              Tile cache stats unavailable. Try reloading the page if this persists.
            </div>`;
          return;
        }

        const total = stats.totals;
        const rows = stats.byProvider
          .filter((p) => p.count > 0)
          .map((p) => `
            <tr>
              <td>${escapeHtml(p.label)}</td>
              <td class="text-end">${p.count.toLocaleString()} / ${p.limit.toLocaleString()}</td>
              <td class="text-end">${fmtBytes(p.estBytes)}</td>
              <td class="text-end pe-0" style="width:2.2rem;">
                <button type="button" class="btn btn-sm btn-link text-danger p-0 provider-clear-btn"
                        data-cache="${escapeHtml(p.key)}" data-label="${escapeHtml(p.label)}"
                        title="Clear ${escapeHtml(p.label)} tiles only">
                  <i class="bi bi-trash3"></i>
                </button>
              </td>
            </tr>`).join('');

        let storageNote = '';
        const est = await getStorageEstimate();
        if (est && est.quota > 0) {
          const pct = ((est.usage / est.quota) * 100).toFixed(1);
          storageNote = `
            <div class="mt-2 text-muted">
              Total app storage: ${fmtBytes(est.usage)} of ${fmtBytes(est.quota)} available (${pct}%)
            </div>`;
        }

        if (total.count === 0) {
          statsEl.innerHTML = `
            <div class="text-muted">
              No tiles cached yet. Pan and zoom the map to start caching tiles automatically.
            </div>${storageNote}`;
          clearBtn.disabled = true;
          return;
        }

        statsEl.innerHTML = `
          <div class="mb-1">
            <strong>${total.count.toLocaleString()}</strong> tiles cached, ~${fmtBytes(total.estBytes)} on this device
          </div>
          <table class="table table-sm mb-0" style="font-size:0.85em;">
            <thead><tr>
              <th>Base map</th>
              <th class="text-end">Cached / limit</th>
              <th class="text-end">Approx. size</th>
              <th class="text-end pe-0" style="width:2.2rem;"></th>
            </tr></thead>
            <tbody>${rows}</tbody>
          </table>${storageNote}`;
        clearBtn.disabled = false;

        // Per-provider Clear — confirm, clear that bucket only, refresh
        statsEl.querySelectorAll('.provider-clear-btn').forEach((btn) => {
          btn.addEventListener('click', async (e) => {
            e.preventDefault();
            const cacheName = btn.dataset.cache;
            const label     = btn.dataset.label || cacheName;
            if (!confirm(`Clear cached "${label}" tiles?\n\nOther providers are not affected. The tiles will re-download as you browse online.`)) {
              return;
            }
            btn.disabled = true;
            const ok = await clearTileCacheForProvider(cacheName);
            if (ok) {
              console.log(`[Settings] Cleared tile cache for ${label}`);
            } else {
              console.warn(`[Settings] Could not clear tile cache for ${label}`);
            }
            await refresh();
          });
        });
      } finally {
        refreshInFlight = null;
      }
    })();

    return refreshInFlight;
  }

  // Clear button — confirm, then clear, then refresh
  clearBtn.addEventListener('click', async () => {
    if (!confirm('Clear all cached map tiles from this device? You will need to be online to view them again.')) {
      return;
    }
    clearBtn.disabled = true;
    const ok = await clearTileCaches();
    if (ok) {
      console.log('[Settings] Tile caches cleared');
    } else {
      console.warn('[Settings] Tile-cache clear failed');
    }
    await refresh();
  });

  // Refresh stats whenever the Settings offcanvas opens
  offcanvas.addEventListener('show.bs.offcanvas', refresh);

  // Auto-refresh when a (new) service worker takes control of the page —
  // makes the panel populate as soon as the SW is available, even if the
  // user is staring at it during initial install or during an SW update.
  onServiceWorkerControllerChange(() => {
    console.log('[Settings] SW controller changed → refreshing tile-cache stats');
    refresh();
  });

  // Also do an initial render so the card isn't empty if Settings is open
  // immediately on load.
  refresh();
}

/**
 * Offline-download dialog (Phase 2). Allows users to pre-fetch tiles for a
 * chosen extent and zoom range so they can use the map without connectivity.
 */
function initOfflineDownloadDialog() {
  const triggerBtn = document.getElementById('download-tiles-btn');
  const modalEl    = document.getElementById('offline-download-modal');
  if (!triggerBtn || !modalEl) return;

  const modal = Modal.getOrCreateInstance(modalEl);

  // ----- Element refs -----
  const formView      = document.getElementById('offline-download-form-view');
  const progressView  = document.getElementById('offline-download-progress-view');
  const doneView      = document.getElementById('offline-download-done-view');
  const cancelBtn     = document.getElementById('offline-download-cancel-btn');
  const startBtn      = document.getElementById('offline-download-start-btn');
  const closeDoneBtn  = document.getElementById('offline-download-close-done-btn');
  const headerCloseBtn = document.getElementById('offline-download-close-btn');

  const basemapSelect = document.getElementById('offline-basemap-select');
  const minZoomInput  = document.getElementById('offline-min-zoom');
  const maxZoomInput  = document.getElementById('offline-max-zoom');
  const ackCheck      = document.getElementById('offline-ack-check');
  const estimateEl    = document.getElementById('offline-estimate-detail');
  const estimateBox   = document.getElementById('offline-estimate');

  const areaViewRadio     = document.getElementById('offline-area-view');
  const areaDistrictRadio = document.getElementById('offline-area-district');
  const areaGhanaRadio    = document.getElementById('offline-area-ghana');
  const areaViewInfo      = document.getElementById('offline-area-view-info');
  const areaDistrictInfo  = document.getElementById('offline-area-district-info');

  const progressBar     = document.getElementById('offline-progress-bar');
  const progressPercent = document.getElementById('offline-progress-percent');
  const progressCounts  = document.getElementById('offline-progress-counts');
  const progressOk      = document.getElementById('offline-progress-ok');
  const progressFailed  = document.getElementById('offline-progress-failed');
  const progressEta     = document.getElementById('offline-progress-eta');

  const doneTitle  = document.getElementById('offline-done-title');
  const doneDetail = document.getElementById('offline-done-detail');

  // ----- State -----
  let currentDownloader = null;

  /** Format byte count for display. */
  function fmtBytes(b) {
    if (!b) return '0 KB';
    if (b < 1024 * 1024)        return (b / 1024).toFixed(0) + ' KB';
    if (b < 1024 * 1024 * 1024) return (b / (1024 * 1024)).toFixed(1) + ' MB';
    return (b / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
  }

  /** Format ms → human-readable duration. */
  function fmtDuration(ms) {
    if (!ms || ms < 1000) return '< 1 s';
    const s = Math.round(ms / 1000);
    if (s < 60) return s + ' s';
    const m = Math.floor(s / 60);
    const r = s % 60;
    if (m < 60) return `${m} min ${r} s`;
    const h = Math.floor(m / 60);
    return `${h} h ${m % 60} min`;
  }

  /** Get the chosen extent based on the radio selection. Returns null if invalid. */
  function getSelectedExtent() {
    if (areaViewRadio.checked) {
      return mapView?.getCurrentViewExtent() || null;
    }
    if (areaDistrictRadio.checked) {
      return mapView?.getDistrictBoundaryExtent()?.extent || null;
    }
    if (areaGhanaRadio.checked) {
      return GHANA_EXTENT_3857;
    }
    return null;
  }

  /** Recalculate and update the live estimate display. */
  function updateEstimate() {
    const baseMap = basemapSelect.value;
    const minZ    = parseInt(minZoomInput.value, 10);
    const maxZ    = parseInt(maxZoomInput.value, 10);

    if (Number.isNaN(minZ) || Number.isNaN(maxZ) || minZ > maxZ) {
      estimateEl.textContent = 'Invalid zoom range';
      estimateBox.classList.replace('alert-info', 'alert-warning');
      startBtn.disabled = true;
      return;
    }

    const extent = getSelectedExtent();
    if (!extent) {
      estimateEl.textContent = 'Selected area is not available.';
      estimateBox.classList.replace('alert-info', 'alert-warning');
      startBtn.disabled = true;
      return;
    }

    const tplMaxZoom = BASEMAP_TEMPLATES[baseMap]?.maxZoom ?? 19;
    const effMaxZ = Math.min(maxZ, tplMaxZoom);
    const count = countTiles(extent, minZ, effMaxZ);
    const bytes = estimatedSizeBytes(count);

    let warningHTML = '';
    if (effMaxZ < maxZ) {
      warningHTML = `<br><span class="text-warning">Zoom ${maxZ} is above this provider's max (${tplMaxZoom}); will clamp to ${tplMaxZoom}.</span>`;
    }
    if (count > 8000) {
      warningHTML += `<br><span class="text-warning">More than 8 000 tiles — exceeds the per-provider cache limit. Earlier tiles will be evicted as new ones arrive.</span>`;
    }

    estimateEl.innerHTML =
      `<strong>${count.toLocaleString()}</strong> tiles · ` +
      `~${fmtBytes(bytes)}` +
      warningHTML;

    estimateBox.classList.toggle('alert-warning', !!warningHTML);
    estimateBox.classList.toggle('alert-info', !warningHTML);

    startBtn.disabled = !ackCheck.checked || count === 0;
  }

  /** Update the area-radio info labels (tile count + size estimate). */
  function updateAreaInfos() {
    const view = mapView?.getCurrentViewExtent();
    if (view) {
      areaViewInfo.textContent = ' · ready';
    } else {
      areaViewInfo.textContent = '';
    }

    const dist = mapView?.getDistrictBoundaryExtent();
    if (dist) {
      areaDistrictInfo.textContent = '';
      areaDistrictRadio.disabled = false;
    } else {
      areaDistrictInfo.textContent = ' (not loaded — connect online to fetch)';
      areaDistrictRadio.disabled = true;
      if (areaDistrictRadio.checked) areaViewRadio.checked = true;
    }
  }

  /** Reset the modal to its initial form state. */
  function resetModal() {
    formView.classList.remove('d-none');
    progressView.classList.add('d-none');
    doneView.classList.add('d-none');

    startBtn.classList.remove('d-none');
    cancelBtn.classList.remove('d-none');
    cancelBtn.textContent = 'Cancel';
    closeDoneBtn.classList.add('d-none');
    headerCloseBtn.disabled = false;

    ackCheck.checked = false;
    startBtn.disabled = true;

    currentDownloader = null;
  }

  // ----- Event wiring -----

  triggerBtn.addEventListener('click', () => {
    resetModal();
    updateAreaInfos();
    updateEstimate();
    modal.show();
  });

  // Recalculate estimate on any input change
  basemapSelect.addEventListener('change', updateEstimate);
  minZoomInput.addEventListener('input', updateEstimate);
  maxZoomInput.addEventListener('input', updateEstimate);
  areaViewRadio.addEventListener('change', updateEstimate);
  areaDistrictRadio.addEventListener('change', updateEstimate);
  areaGhanaRadio.addEventListener('change', updateEstimate);
  ackCheck.addEventListener('change', updateEstimate);

  // Start the download
  startBtn.addEventListener('click', async () => {
    const baseMap = basemapSelect.value;
    const minZ    = parseInt(minZoomInput.value, 10);
    const maxZ    = parseInt(maxZoomInput.value, 10);
    const extent  = getSelectedExtent();
    if (!extent) return;

    // Switch UI to progress view
    formView.classList.add('d-none');
    progressView.classList.remove('d-none');
    startBtn.classList.add('d-none');
    cancelBtn.textContent = 'Cancel download';
    headerCloseBtn.disabled = true;

    progressBar.style.width = '0%';
    progressBar.setAttribute('aria-valuenow', '0');
    progressPercent.textContent = '0%';
    progressCounts.textContent = '0 of 0 tiles';
    progressOk.textContent = '0';
    progressFailed.textContent = '0';
    progressEta.textContent = '—';

    currentDownloader = new OfflineTileDownloader({
      baseMap,
      extent3857: extent,
      minZoom:    minZ,
      maxZoom:    maxZ,
      onProgress: (s) => {
        if (s.total > 0) {
          const pct = Math.min(100, Math.round((s.done / s.total) * 100));
          progressBar.style.width = pct + '%';
          progressBar.setAttribute('aria-valuenow', String(pct));
          progressPercent.textContent = pct + '%';
          progressCounts.textContent = `${s.done.toLocaleString()} of ${s.total.toLocaleString()} tiles`;
        }
        progressOk.textContent     = s.ok.toLocaleString();
        progressFailed.textContent = s.failed.toLocaleString();
        progressEta.textContent    = s.etaMs != null ? fmtDuration(s.etaMs) : '—';
      },
    });

    let result;
    try {
      result = await currentDownloader.start();
    } catch (err) {
      console.error('[OfflineDownload] failed:', err);
      result = { phase: 'error', done: 0, total: 0, ok: 0, failed: 0 };
    }

    // Switch UI to done view
    progressView.classList.add('d-none');
    doneView.classList.remove('d-none');
    cancelBtn.classList.add('d-none');
    closeDoneBtn.classList.remove('d-none');
    headerCloseBtn.disabled = false;

    if (result.phase === 'cancelled') {
      doneTitle.textContent = 'Download cancelled';
      doneDetail.innerHTML = `Stopped after <strong>${result.done.toLocaleString()}</strong> of ${result.total.toLocaleString()} tiles.<br>` +
        `${result.ok.toLocaleString()} fetched · ${result.failed.toLocaleString()} failed.`;
    } else if (result.phase === 'error') {
      doneTitle.textContent = 'Download failed';
      doneDetail.textContent = 'See console for details.';
    } else {
      doneTitle.textContent = 'Download complete';
      doneDetail.innerHTML = `<strong>${result.ok.toLocaleString()}</strong> tiles cached` +
        (result.failed > 0 ? `, ${result.failed.toLocaleString()} failed` : '') +
        `.<br>Took ${fmtDuration(result.elapsedMs)}.`;
    }
  });

  // Cancel button — either close modal (form view) or cancel download (progress view)
  cancelBtn.addEventListener('click', () => {
    if (currentDownloader) {
      currentDownloader.cancel();
    }
  });

  // When modal is fully hidden, reset for next time
  modalEl.addEventListener('hidden.bs.modal', () => {
    if (currentDownloader) currentDownloader.cancel();
    resetModal();
  });
}

/**
 * Account card — displays the signed-in user from window.LUPMIS_SESSION
 * (injected by public/index.php) and wires the "Sign out" button.
 *
 * In local dev (no PHP), window.LUPMIS_SESSION is absent / empty and the
 * card shows "Guest (no session)" without a Sign-out button.
 */
/**
 * Account UI — populates the right-side Menu offcanvas (id="menuOffcanvas")
 * with the signed-in user's details, and wires the Sign-out button.
 * The Menu is opened from the navbar Menu button (id="menu-btn").
 *
 * Three states:
 *   • authenticated  — show name, email, district info, and "Sign out"
 *   • unauthenticated (PHP ran, no SSO cookie) — show "Sign in" link
 *   • no-session     (window.LUPMIS_SESSION undefined → dev mode) — show
 *     a warning note that the page wasn't served via index.php
 */
function initAccountCard() {
  const session    = getSession();
  const menuBtn    = document.getElementById('menu-btn');
  const avatarEl   = document.getElementById('menu-user-avatar');
  const nameEl     = document.getElementById('menu-user-name');
  const emailEl    = document.getElementById('menu-user-email');
  const detailEl   = document.getElementById('menu-user-detail');
  const signoutBtn = document.getElementById('menu-signout-btn');
  const signinLink = document.getElementById('menu-signin-link');
  const noSessNote = document.getElementById('menu-no-session-note');

  if (!menuBtn || !avatarEl || !nameEl || !emailEl || !detailEl || !signoutBtn) {
    console.warn('[AccountMenu] One or more elements missing — shell may be stale. Hard-refresh.');
    return;
  }

  const isAuthenticated = !!session && !!session.user_id;

  if (isAuthenticated) {
    // ---------- Authenticated state ----------
    const displayName = [session.title, session.full_name].filter(Boolean).join(' ').trim()
                     || session.username || 'Authenticated user';
    const initial = (session.full_name || session.username || '?').trim().charAt(0).toUpperCase();
    avatarEl.textContent = initial;
    avatarEl.style.background = 'var(--brand-navy, #1e1a4b)';
    nameEl.textContent = displayName;
    emailEl.textContent = session.email || '';

    const bits = [];
    if (session.district_id != null) bits.push(`District ${escapeHtml(String(session.district_id))}`);
    if (session.region_id   != null) bits.push(`Region ${escapeHtml(String(session.region_id))}`);
    if (session.ua_position)         bits.push(escapeHtml(session.ua_position));
    detailEl.innerHTML = bits.join(' · ') || 'No district info';

    signoutBtn.classList.remove('d-none');
    signoutBtn.addEventListener('click', () => handleSignOut(session), { once: false });
    signinLink?.classList.add('d-none');
    noSessNote?.classList.add('d-none');
    menuBtn.removeAttribute('data-state');
    menuBtn.setAttribute('title', `Menu — ${displayName}`);
  } else if (typeof window.LUPMIS_SESSION === 'undefined') {
    // ---------- Dev mode (no PHP processing) ----------
    avatarEl.innerHTML = '<i class="bi bi-exclamation"></i>';
    avatarEl.style.background = 'var(--brand-orange-warm, #ff9e1b)';
    nameEl.textContent = 'No session injected';
    emailEl.textContent = '';
    detailEl.textContent = '';

    signoutBtn.classList.add('d-none');
    signinLink?.classList.add('d-none');
    noSessNote?.classList.remove('d-none');
    menuBtn.dataset.state = 'no-session';
    menuBtn.setAttribute('title', 'Menu (no session — dev mode)');
  } else {
    // ---------- PHP ran but the user has no valid SSO session ----------
    avatarEl.innerHTML = '<i class="bi bi-person-fill"></i>';
    avatarEl.style.background = 'var(--brand-gray-medium, #7a7a7a)';
    nameEl.textContent = 'Not signed in';
    emailEl.textContent = '';
    detailEl.textContent = '';

    signoutBtn.classList.add('d-none');
    signinLink?.classList.remove('d-none');
    noSessNote?.classList.add('d-none');
    menuBtn.dataset.state = 'unauthenticated';
    menuBtn.setAttribute('title', 'Menu (not signed in)');
  }
}

// Legacy chip+popover removed — replaced by the navbar Menu button +
// right-side menuOffcanvas. See initAccountCard above.

/**
 * Logout flow:
 *   1. Refuse if offline (login needs the server — see guard below).
 *   2. Confirm with the user.
 *   3. Wipe district-scoped local caches so the next (possibly different) user
 *      on this device can't briefly see the previous user's cached map data.
 *   4. Navigate to the PWA's OWN logout endpoint (/?logout=1). That endpoint
 *      runs session_destroy() — clearing the PHPSESSID session that holds the
 *      user's district_id — and then redirects to the central portal's
 *      /user-logout, which performs the full SSO logout (invalidates the token
 *      and clears sso_auth_token). We let the server chain own the SSO side, so
 *      there is no separate client-side token-invalidation call.
 */
async function handleSignOut(session) {
  // Guard: logging back in requires the SSO server (a session can only be
  // created by validating the token online). If we let the user log out while
  // offline, they would be stranded on this device — unable to sign back in
  // until connectivity returns — and the server-side session_destroy() at
  // /?logout=1 could not run anyway. So we refuse offline logout outright,
  // which also protects against an accidental tap wiping the working session
  // during fieldwork. The app keeps functioning offline on the cached session,
  // so there is no need to log out until back online.
  if (!isOnline()) {
    alert(
      'You appear to be offline.\n\n' +
      'Logging out needs a connection to the sign-in server, and you would not ' +
      'be able to sign back in until you are online again.\n\n' +
      'Please try again once you have a connection. You can keep working — the ' +
      'app stays signed in while offline.'
    );
    return;
  }

  if (!confirm(`Log out, ${session?.full_name || session?.username || 'user'}?`)) {
    return;
  }

  // Drop locally-cached, district-scoped layers and the last-district marker
  // so a different next user starts clean.
  try {
    await clearAllCachedLayers();
    localStorage.removeItem(LAST_DISTRICT_KEY);
  } catch (err) {
    console.warn('[Logout] Cache clear failed (continuing):', err);
  }

  // Hand off to the server logout endpoint: it destroys the PWA's PHP session,
  // then redirects to https://lupmis4luspa.org/user-logout for the full SSO
  // logout (token invalidation + sso_auth_token cleared by the portal).
  window.location.href = '/?logout=1';
}

// ============================================================================
// Start Application
// ============================================================================

// Wait for DOM to be ready
if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', initApp);
} else {
  initApp();
}
