Source: tools/services/export-shp-services.js

import GeoJSON from 'ol/format/GeoJSON'
import Feature from 'ol/Feature'
import * as shpwrite from '@mapbox/shp-write'
import JSZip from 'jszip'

/**
 * Fonction récursive pour aplatir les objets imbriqués en utilisant un séparateur (ex: _)
 * Cela permet de gérer les propriétés complexes en les transformant en champs plats pour le DBF
 * @param {*} obj - objet à aplatir
 * @param {*} prefix - préfixe pour les clés imbriquées (utilisé dans la récursion)
 * @param {*} result - objet résultat qui accumule les paires clé-valeur aplaties
 * @returns - objet aplati avec des clés composées pour les propriétés imbriquées
 */
function flattenObject (obj, prefix = '', result = {}) {
  Object.entries(obj).forEach(([key, value]) => {
    const newKey = prefix ? `${prefix}_${key}` : key

    if (value === null || value === undefined) {
      result[newKey] = ''
    } else if (typeof value === 'number' || typeof value === 'string') {
      result[newKey] = value
    } else if (typeof value === 'boolean') {
      result[newKey] = value ? 'T' : 'F'
    } else if (value instanceof Date) {
      result[newKey] = value.toISOString().split('T')[0]
    } else if (Array.isArray(value)) {
      // DBF ne supporte pas les arrays → stringify
      result[newKey] = JSON.stringify(value)
    } else if (typeof value === 'object') {
      // récursif
      flattenObject(value, newKey, result)
    }
  })

  return result
}

// Gérer les doublons de noms de champs en ajoutant un suffixe numérique
function makeUniqueKey (key, used) {
  const maxFieldLength = 10 // Limite de caractères pour les noms de champs dans les shapefiles
  let newKey = key.substring(0, maxFieldLength)

  if (used.has(newKey)) {
    let i = 1

    do {
      const suffix = String(i)
      const baseLength = Math.max(0, maxFieldLength - suffix.length)
      const base = key.substring(0, baseLength)

      newKey = `${base}${suffix}`
      i++
    } while (used.has(newKey))
  }

  used.add(newKey)
  return newKey
}

// Normaliser les noms de champs pour respecter les limites de 10 caractères et éviter les caractères interdits
function normalizeFieldName (key) {
  return key
    .normalize('NFD')
    .replace(/[\u0300-\u036f]/g, '')
    .replace(/[^a-zA-Z0-9_]/g, '')
    .toUpperCase()
    .substring(0, 10)
}

/**
   * Normalise les noms de champs pour respecter les limites de 10 caractères et éviter les doublons
   * Shapefile a une limite de 10 caractères pour les noms de champs et ne gère pas les doublons, cette fonction s'assure que les propriétés respectent ces contraintes
   * @param {Object} props - Propriétés d'une feature à normaliser
   * @param {Object} [attributeMap={}] - Map des noms de champs originaux aux nouveaux noms normalisés
   * @returns {Object} Propriétés normalisées avec des noms de champs uniques et limités à 10 caractères
   */
function normalizeAttributes (props, attributeMap = {}) {
  const result = {}
  const used = new Set()

  Object.entries(props).forEach(([key, value]) => {
    // 1. normalisation du nom
    const baseKey = normalizeFieldName(attributeMap[key] || key)

    // 2. clé unique
    const newKey = makeUniqueKey(baseKey, used)

    // 3. gestion des valeurs
    if (value && typeof value === 'object') {
      const flat = flattenObject(value, newKey)

      Object.entries(flat).forEach(([k, v]) => {
        const normalized = normalizeFieldName(k)
        const finalKey = makeUniqueKey(normalized, used)

        result[finalKey] = v
      })
    } else {
      result[newKey] = value ?? ''
    }
  })

  return result
}

/**
 * Normalise les géométries pour le format SHP
 * @param {*} features - tableau de features GeoJSON à normaliser
 * @param {*} type - type de géométrie (points, lignes, polygones) pour appliquer les règles de normalisation spécifiques à chaque type
 * @returns -> tableau de géométries normalisées prêtes à être utilisées par shp-write
 */
function normalizeGeometries (features, type) {
  return features.map(f => {
    const geom = f.geometry
    let coords = geom.coordinates

    // POINT
    if (type === 'points') {
      return coords
    }

    // LINES → FORCER MULTIPART
    if (type === 'lines') {
      if (geom.type === 'LineString') {
        coords = [coords]
      }
      return coords
    }

    // POLYGONS → FORCER MULTIPART
    if (type === 'polygons') {
      if (geom.type === 'Polygon') {
        coords = [coords]
      }
      return coords
    }

    throw new Error('Unsupported geometry type: ' + type)
  })
}

/**
 * Retourne le type de géométrie SHP correspondant au type GeoJSON
 * @param {*} type - type de géométrie (points, lignes, polygones)
 * @returns - type de géométrie SHP
 */
function getShpType (type) {
  if (type === 'points') return 'POINT'
  if (type === 'lines') return 'POLYLINE'
  if (type === 'polygons') return 'POLYGON'

  throw new Error('Unsupported geometry type: ' + type)
}

/**
 * Télécharge un fichier shapefile
 * @param {*} result - Résultat de la génération du shapefile
 * @param {*} filename - Nom du fichier à télécharger
 */
function downloadShapefile (result, filename) {
  const zip = new JSZip()

  zip.file(filename + '.shp', new Uint8Array(result.shp.buffer))
  zip.file(filename + '.shx', new Uint8Array(result.shx.buffer))
  zip.file(filename + '.dbf', new Uint8Array(result.dbf.buffer))
  zip.file(filename + '.prj', result.prj)

  zip.generateAsync({ type: 'blob' })
    .then(blob => {
      const objectUrl = URL.createObjectURL(blob)
      const link = document.createElement('a')

      link.href = objectUrl
      link.download = filename + '.zip'
      document.body.appendChild(link)
      link.click()
      document.body.removeChild(link)

      setTimeout(() => URL.revokeObjectURL(objectUrl), 0)
    })
    .catch(error => {
      console.error('Erreur lors de la génération du ZIP shapefile :', error)
    })
}

/**
 * Export les couches OpenLayers au format Shapefile
 * Cette fonction prend un tableau de configurations de couches, extrait les features, les groupe par type de géométrie,
 * et utilise shp-write pour générer les fichiers SHP, SHX, DBF et PRJ.
 * Les fichiers sont ensuite regroupés dans une archive ZIP pour le téléchargement.
 * Les options permettent de spécifier les projections utilisées pour les données et les features, avec des valeurs par défaut adaptées à la plupart des cas.
 * @param {Array<Object>} layerConfigs - tableau de configurations de couches à exporter. Par défaut, toutes les couches visibles avec des features sont exportées
 * @param {ol.Layer} layerConfigs[].layer - layer OpenLayers à exporter
 * @param {string} layerConfigs[].name - Nom du fichier exporté
 * @param {Function} [layerConfigs[].filter] - Fonction de filtrage optionnelle pour les features pour cette couche. Ce callback est exécuté tel quel et doit donc provenir d'une source de confiance.
 * @param {Object} [layerConfigs[].attributeMap] - Map optionnelle pour renommer les champs d'attributs (clé : nom original, valeur : nom normalisé)
 * @param {Object} [options={}] - Options d'export
 * * @param {Viewer} options.viewer instance active du viewer, nécessaire si layerConfigs n'est pas fourni pour récupérer les couches visibles
 * * @param {string} options.featureProjection - projection des features dans OpenLayers (par défaut 'EPSG:3857')
 * * @param {string} options.dataProjection - projection des données dans le shapefile (par défaut 'EPSG:4326')
 */
function exportLayersToShapefile (layerConfigs = [], options = {}) {
  const geojsonFormat = new GeoJSON()

  // recupérer les projections depuis les options ou utiliser les valeurs par défaut
  const featureProjection = options.featureProjection || 'EPSG:3857'
  const dataProjection = options.dataProjection || 'EPSG:4326'

  if (layerConfigs.length === 0) {
    layerConfigs = options.viewer.getMap()
      .getLayers()
      .getArray()
      .filter(l => l.getVisible() && typeof l.getSource === 'function' && l.getSource() && typeof l.getSource().getFeatures === 'function')
      .map(l => ({
        layer: l,
        name: (l.get('name') || l.get('title') || `layer-${l.get('id')}`).replace(/[^a-zA-Z0-9_-]/g, '_'),
      }))
  }

  if (!layerConfigs.length) {
    console.warn('addExportSHP : aucune couche vecteur disponible à exporter')
    return
  }

  layerConfigs.forEach(config => {
    const { layer, name, filter, attributeMap = {} } = config

    if (!layer || !name) {
      console.warn('Configuration de couche invalide :', config)
      return
    }

    const source = typeof layer.getSource === 'function' ? layer.getSource() : null

    if (!source || typeof source.getFeatures !== 'function') {
      console.warn('La couche n\'a pas de source ou de features :', name)
      return
    }

    let features = source.getFeatures()

    // APPLIQUER LE FILTRE SI FOURNI
    if (filter != null && typeof filter !== 'function') {
      console.warn('Le filtre fourni n\'est pas une fonction pour la couche :', name)
    } else if (typeof filter === 'function') {
      features = features.filter(f => {
        try {
          return filter(f)
        } catch (e) {
          console.error('Erreur dans la fonction de filtre pour la couche :', name, e)
          return false
        }
      })
    }

    if (!features.length) {
      console.warn('Aucune feature pour la couche :', name)
      return
    }

    const groups = {
      points: [],
      lines: [],
      polygons: [],
    }

    // GROUPER LES FEATURES PAR TYPE DE GÉOMÉTRIE
    features.forEach(f => {
      const geom = f.getGeometry()
      if (!geom) return

      const type = geom.getType()

      let key = null

      if (type.includes('Point')) key = 'points'
      else if (type.includes('Line')) key = 'lines'
      else if (type.includes('Polygon')) key = 'polygons'

      if (!key) return

      const props = { ...f.getProperties() }
      delete props.geometry

      groups[key].push(
        new Feature({
          geometry: geom.clone(),
          ...normalizeAttributes(props, attributeMap),
        })
      )
    })

    Object.entries(groups).forEach(([type, feats]) => {
      if (!feats.length) return

      const geojson = geojsonFormat.writeFeaturesObject(feats, {
        featureProjection,
        dataProjection,
      })

      const geometrytype = getShpType(type)

      // NORMALISATION CENTRALISÉE DES GÉOMÉTRIES POUR SHP-WRITE
      const geometries = normalizeGeometries(geojson.features, type)

      // Recuperer les propriétés normalisées pour le DBF
      // const properties = geojson.features.map(f => normalizeAttributes(f.properties, attributeMap))
      const properties = geojson.features.map(f => f.properties)

      shpwrite.write(
        properties,
        geometrytype,
        geometries,
        (err, result) => {
          if (err) {
            console.error('Erreur shp-write :', err)
            return
          }

          if (!result.shp || !result.dbf) {
            console.error('Shapefile invalide')
            return
          }

          downloadShapefile(result, `${name}_${type}`)
        }
      )
    })
  })
}

export { exportLayersToShapefile }