// ── Map composable: MapLibre GL + tuiles OSM auto-hébergées (Protomaps pmtiles), markers, routes, geo-fix, map-drag ────────
import { ref, watch, nextTick } from 'vue'
import { localDateStr, jobSpansDate, jobSvcCode, SVC_COLORS } from './useHelpers'
import * as roster from 'src/api/roster' // tracés routiers via NOTRE OSRM (plus de Mapbox Directions)
import { installMaplibre, basemapStyle } from 'src/config/basemap' // fond de carte MapLibre/OSM (plus de Mapbox)

export function useMap (deps) {
  const {
    store, MAPBOX_TOKEN, TECH_COLORS,
    currentView, periodStart, filteredResources, mapVisible,
    routeLegs, routeGeometry,
    getJobDate, jobColor, pushUndo, smartAssign, invalidateRoutes,
    dragJob, dragIsAssist, rightPanel, openCtxMenu, openTechCtx,
    saveTechHome,
  } = deps

  let map = null
  let mapResizeObs = null
  const mapContainer   = ref(null)
  const selectedTechId = ref(null)
  const mapMarkers     = ref([])
  const mapPanelW      = ref(parseInt(localStorage.getItem('sbv2-mapW')) || 340)
  const geoFixJob      = ref(null)
  const geoFixTech     = ref(null)   // ← analog of geoFixJob, but for tech home base
  const mapDragJob     = ref(null)
  let _mapGhost = null

  // ── Geo-fix ──────────────────────────────────────────────────────────────────
  function startGeoFix (job) {
    geoFixJob.value = job
    if (!mapVisible.value) mapVisible.value = true
    if (map) map.getCanvas().style.cursor = 'crosshair'
  }
  function cancelGeoFix () {
    geoFixJob.value = null
    if (map) map.getCanvas().style.cursor = ''
  }
  watch(geoFixJob, v => { if (map) map.getCanvas().style.cursor = v ? 'crosshair' : '' })

  // Tech home-base "click on the map" picker. Same pattern as geoFixJob:
  // user enters the mode, cursor turns to crosshair, next click on the
  // map captures the lng/lat and persists it to ERPNext.
  function startTechGeoFix (tech) {
    geoFixTech.value = tech
    if (!mapVisible.value) mapVisible.value = true
    if (map) map.getCanvas().style.cursor = 'crosshair'
  }
  function cancelTechGeoFix () {
    geoFixTech.value = null
    if (map) map.getCanvas().style.cursor = ''
  }
  watch(geoFixTech, v => { if (map) map.getCanvas().style.cursor = v ? 'crosshair' : '' })

  // ── Panel resize ─────────────────────────────────────────────────────────────
  function startMapResize (e) {
    e.preventDefault()
    const startX = e.clientX, startW = mapPanelW.value
    function onMove (ev) {
      mapPanelW.value = Math.max(220, Math.min(window.innerWidth * 0.65, startW - (ev.clientX - startX)))
    }
    function onUp () {
      document.removeEventListener('mousemove', onMove)
      document.removeEventListener('mouseup', onUp)
      localStorage.setItem('sbv2-mapW', String(mapPanelW.value))
      if (map) map.resize()
    }
    document.addEventListener('mousemove', onMove)
    document.addEventListener('mouseup', onUp)
  }

  // ── Init ─────────────────────────────────────────────────────────────────────
  async function initMap () {
    if (!mapContainer.value || map) return
    const mapboxgl = installMaplibre() // MapLibre bundlé + protocole pmtiles:// (alias window.mapboxgl pour compat)
    map = new mapboxgl.Map({
      container: mapContainer.value,
      style: basemapStyle('dark'), // Dispatch = mode sombre
      // Default centered on Gigafibre HQ (1867 chemin de la Rivière,
      // Sainte-Clotilde, QC). Zoom 10 shows Sainte-Clotilde + the
      // surrounding service area (Châteauguay, Napierville, Hemmingford…).
      center: [-73.6756177, 45.1599145], zoom: 10,
    })
    if (mapResizeObs) mapResizeObs.disconnect()
    mapResizeObs = new ResizeObserver(() => { if (map) map.resize() })
    mapResizeObs.observe(mapContainer.value)

    map.on('load', () => {
      map.resize()
      // Route layers
      map.addSource('sb-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
      map.addLayer({ id: 'sb-route-halo', type: 'line', source: 'sb-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#6366f1', 'line-width': 12, 'line-opacity': 0.18 } })
      map.addLayer({ id: 'sb-route-line', type: 'line', source: 'sb-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#6366f1', 'line-width': 3.5, 'line-opacity': 0.85 } })
      // Job layers
      map.addSource('sb-jobs', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
      map.addLayer({ id: 'sb-jobs-halo', type: 'circle', source: 'sb-jobs', paint: { 'circle-radius': 22, 'circle-color': ['get', 'color'], 'circle-opacity': ['*', ['get', 'opacity'], 0.18], 'circle-blur': 0.7 } })
      map.addLayer({ id: 'sb-jobs-circle', type: 'circle', source: 'sb-jobs', paint: { 'circle-radius': 15, 'circle-color': ['get', 'color'], 'circle-opacity': ['get', 'opacity'], 'circle-stroke-width': 2, 'circle-stroke-color': ['case', ['get', 'unassigned'], 'rgba(255,255,255,0.4)', 'rgba(255,255,255,0.85)'], 'circle-stroke-opacity': ['get', 'opacity'] } })
      map.addLayer({ id: 'sb-jobs-label', type: 'symbol', source: 'sb-jobs', layout: { 'text-field': ['get', 'label'], 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-size': 9, 'text-allow-overlap': true, 'text-ignore-placement': true }, paint: { 'text-color': '#ffffff', 'text-opacity': ['get', 'opacity'] } })

      // Event handlers
      map.on('mouseenter', 'sb-jobs-circle', () => { if (!mapDragJob.value && !geoFixJob.value) map.getCanvas().style.cursor = 'grab' })
      map.on('mouseleave', 'sb-jobs-circle', () => { if (!mapDragJob.value && !geoFixJob.value) map.getCanvas().style.cursor = '' })
      map.on('mousedown', 'sb-jobs-circle', e => {
        if (geoFixJob.value) return
        e.preventDefault()
        const job = store.jobs.find(j => j.id === e.features[0].properties.id)
        if (job) startMapDrag(e.originalEvent, job)
      })
      map.on('click', 'sb-jobs-circle', e => {
        if (geoFixJob.value) return
        const job = store.jobs.find(j => j.id === e.features[0].properties.id)
        if (job) {
          const tech = job.assignedTech ? store.technicians.find(t => t.id === job.assignedTech) : null
          rightPanel.value = { mode: 'details', data: { job, tech } }
        }
      })
      map.on('contextmenu', 'sb-jobs-circle', e => {
        const job = store.jobs.find(j => j.id === e.features[0].properties.id)
        if (job) {
          const tech = job.assignedTech ? store.technicians.find(t => t.id === job.assignedTech) : null
          openCtxMenu(e.originalEvent, job, tech?.id || null)
        }
      })
      map.on('mouseenter', 'sb-route-line', () => { if (mapDragJob.value) map.getCanvas().style.cursor = 'copy' })
      map.on('mouseleave', 'sb-route-line', () => { if (!mapDragJob.value) map.getCanvas().style.cursor = '' })

      // Geo-fix click — handles BOTH job geofix and tech home-base pick.
      // Tech mode wins if both are somehow active simultaneously (defensive;
      // shouldn't happen in practice).
      map.on('click', async e => {
        if (geoFixTech.value) {
          const tech = geoFixTech.value
          geoFixTech.value = null
          map.getCanvas().style.cursor = ''
          if (typeof saveTechHome === 'function') {
            try { await saveTechHome(tech, e.lngLat.lng, e.lngLat.lat) } catch (_e) {}
          }
          nextTick(() => drawMapMarkers())
          return
        }
        if (!geoFixJob.value) return
        const job = geoFixJob.value
        const saved = JSON.parse(localStorage.getItem('dispatch-job-coords') || '{}')
        saved[job.id] = [e.lngLat.lng, e.lngLat.lat]
        localStorage.setItem('dispatch-job-coords', JSON.stringify(saved))
        store.updateJobCoords(job.id, e.lngLat.lng, e.lngLat.lat)
        routeLegs.value = {}; routeGeometry.value = {}
        geoFixJob.value = null
        map.getCanvas().style.cursor = ''
        nextTick(() => {
          drawMapMarkers()
          const dayStr = localDateStr(periodStart.value)
          filteredResources.value.forEach(tech => computeDayRoute(tech, dayStr))
          drawSelectedRoute()
        })
      })

      drawMapMarkers()
      drawSelectedRoute()
    })
  }

  // ── Draw markers ─────────────────────────────────────────────────────────────
  function drawMapMarkers () {
    if (!map || !window.mapboxgl) return
    const dayStr = localDateStr(periodStart.value)
    const mbgl = window.mapboxgl

    const jobFeatures = store.jobs
      .filter(j => j.coords && !(j.coords[0] === 0 && j.coords[1] === 0))
      .filter(j => {
        if (!j.assignedTech) return (j.scheduledDate || null) === dayStr
        const tech = store.technicians.find(t => t.id === j.assignedTech)
        return jobSpansDate(j, dayStr, tech)
      })
      .map(job => {
        const isUnassigned = !job.assignedTech
        const isCompleted = (job.status || '').toLowerCase() === 'completed'
        const isSelected = selectedTechId.value && job.assignedTech === selectedTechId.value
        const opacity = isCompleted ? 0.4 : (isSelected || isUnassigned || !selectedTechId.value ? 0.92 : 0.4)
        let label = jobSvcCode(job)
        if (!isUnassigned) {
          const tech = store.technicians.find(t => t.id === job.assignedTech)
          if (tech) { const idx = tech.queue.filter(j2 => getJobDate(j2.id) === dayStr).indexOf(job); if (idx >= 0) label = String(idx + 1) }
        }
        return { type: 'Feature', geometry: { type: 'Point', coordinates: job.coords }, properties: { id: job.id, color: jobColor(job), label, title: job.subject, opacity, unassigned: isUnassigned, completed: isCompleted } }
      })
    if (map.getSource('sb-jobs')) map.getSource('sb-jobs').setData({ type: 'FeatureCollection', features: jobFeatures })

    // Tech avatar markers
    mapMarkers.value.forEach(m => m.remove())
    mapMarkers.value = []

    // Pre-compute: which techs are assistants on which lead tech's jobs today
    const groupCounts = {}  // leadTechId → total crew size (1 + assistants)
    store.technicians.forEach(tech => {
      const todayJobs = tech.queue.filter(j => jobSpansDate(j, dayStr, tech))
      const assistIds = new Set()
      todayJobs.forEach(j => (j.assistants || []).forEach(a => assistIds.add(a.techId)))
      if (assistIds.size > 0) groupCounts[tech.id] = 1 + assistIds.size
    })

    filteredResources.value.forEach(tech => {
      const pos = tech.gpsCoords || tech.coords
      if (!pos || (pos[0] === 0 && pos[1] === 0)) return
      const initials = tech.fullName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
      const color = TECH_COLORS[tech.colorIdx]

      // Calculate daily workload + completion
      const todayJobs = tech.queue.filter(j => jobSpansDate(j, dayStr, tech))
      const todayAssist = (tech.assistJobs || []).filter(j => jobSpansDate(j, dayStr, tech))
      const allToday = [...todayJobs, ...todayAssist]
      const totalHours = allToday.reduce((s, j) => s + (j.duration || 1), 0)
      const doneHours = allToday.filter(j => (j.status || '').toLowerCase() === 'completed')
                                .reduce((s, j) => s + (j.duration || 1), 0)
      const loadPct = Math.min(totalHours / 8, 1)
      const donePct = totalHours > 0 ? Math.min(doneHours / 8, 1) : 0
      const loadColor = loadPct < 0.5 ? '#10b981' : loadPct < 0.75 ? '#f59e0b' : loadPct < 0.9 ? '#f97316' : '#ef4444'

      // Ring + avatar in a fixed-size container so Mapbox anchor stays consistent
      const PIN = 36, STROKE = 3.5, SIZE = PIN + STROKE * 2 + 2  // ~45px
      const R = (SIZE - STROKE) / 2, CIRC = 2 * Math.PI * R
      const completedJobs = allToday.filter(j => (j.status || '').toLowerCase() === 'completed').length
      const totalJobs = allToday.length
      const completionPct = totalJobs > 0 ? completedJobs / totalJobs : 0

      // Fixed-size outer wrapper — Mapbox anchors to this. DO NOT set
      // `position:relative` inline here: Mapbox GL's `.mapboxgl-marker`
      // class applies `position:absolute` and is what gets the
      // `transform: translate(...)` at every zoom/pan frame. An inline
      // `relative` overrides that and the pin appears to drift away from
      // its lat/lng on zoom. The svg + avatar children are positioned
      // absolutely; that still works against an absolutely-positioned
      // parent (any positioned element is a containing block).
      const outer = document.createElement('div')
      outer.style.cssText = `cursor:pointer;width:${SIZE}px;height:${SIZE}px;`
      outer.dataset.techId = tech.id

      // SVG ring (load arc + completion arc) — fills entire container
      if (totalHours > 0) {
        const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
        svg.setAttribute('width', SIZE); svg.setAttribute('height', SIZE)
        svg.style.cssText = 'position:absolute;top:0;left:0;transform:rotate(-90deg);pointer-events:none;'
        const loadArc = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
        loadArc.setAttribute('cx', SIZE/2); loadArc.setAttribute('cy', SIZE/2); loadArc.setAttribute('r', R)
        loadArc.setAttribute('fill', 'none'); loadArc.setAttribute('stroke', loadColor)
        loadArc.setAttribute('stroke-width', STROKE); loadArc.setAttribute('opacity', '0.3')
        loadArc.setAttribute('stroke-dasharray', `${CIRC * loadPct} ${CIRC}`)
        loadArc.setAttribute('stroke-linecap', 'round')
        svg.appendChild(loadArc)
        if (completionPct > 0) {
          const doneArc = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
          doneArc.setAttribute('cx', SIZE/2); doneArc.setAttribute('cy', SIZE/2); doneArc.setAttribute('r', R)
          doneArc.setAttribute('fill', 'none'); doneArc.setAttribute('stroke', '#10b981')
          doneArc.setAttribute('stroke-width', STROKE); doneArc.setAttribute('opacity', '1')
          doneArc.setAttribute('stroke-dasharray', `${CIRC * completionPct * loadPct} ${CIRC}`)
          doneArc.setAttribute('stroke-linecap', 'round')
          svg.appendChild(doneArc)
        }
        outer.appendChild(svg)
      }

      // Avatar circle — absolutely centered in container
      const el = document.createElement('div')
      el.className = 'sb-map-tech-pin'
      const offset = (SIZE - PIN) / 2
      el.style.cssText = `background:${color};border-color:${color};position:absolute;top:${offset}px;left:${offset}px;width:${PIN}px;height:${PIN}px;`
      el.textContent = initials
      el.title = `${tech.fullName} — ${completedJobs}/${totalJobs} jobs (${doneHours.toFixed(1)}h / ${totalHours.toFixed(1)}h)`
      outer.appendChild(el)

      // Group badge (crew size)
      const crew = groupCounts[tech.id]
      if (crew && crew > 1) {
        const badge = document.createElement('div')
        badge.className = 'sb-map-crew-badge'
        badge.textContent = String(crew)
        badge.title = `Équipe de ${crew}`
        el.appendChild(badge)
      }

      // Right-click → open tech context menu (DispatchPage handles the
      // q-menu). We pre-position by passing the original click event;
      // useContextMenus reads e.clientX/Y to anchor the menu.
      outer.addEventListener('contextmenu', e => {
        e.preventDefault()
        e.stopPropagation()
        if (typeof openTechCtx === 'function') openTechCtx(e, tech)
      })

      // Drag & drop handlers
      outer.addEventListener('dragover', e => { e.preventDefault(); el.style.transform = 'scale(1.25)' })
      outer.addEventListener('dragleave', () => { el.style.transform = '' })
      outer.addEventListener('drop', e => {
        e.preventDefault(); el.style.transform = ''
        const job = dragJob.value
        if (job) {
          pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants || [])] })
          smartAssign(job, tech.id, dayStr)
          dragJob.value = null
          invalidateRoutes()
        }
      })
      outer.addEventListener('mouseenter', () => { if (mapDragJob.value) el.style.transform = 'scale(1.3)' })
      outer.addEventListener('mouseleave', () => { el.style.transform = '' })

      if (tech.gpsCoords) {
        el.classList.add('sb-map-gps-active')
        el.title += ' (GPS)'
      }
      const m = new mbgl.Marker({ element: outer, anchor: 'center' }).setLngLat(pos).addTo(map)
      mapMarkers.value.push(m)
    })
  }

  // ── Map drag (job pin → tech) ────────────────────────────────────────────────
  function startMapDrag (e, job) {
    e.preventDefault()
    mapDragJob.value = job
    if (map) map.dragPan.disable()
    _mapGhost = document.createElement('div')
    _mapGhost.className = 'sb-map-drag-ghost'
    _mapGhost.textContent = job.subject
    _mapGhost.style.cssText = `position:fixed;pointer-events:none;z-index:9999;left:${e.clientX + 14}px;top:${e.clientY + 14}px`
    document.body.appendChild(_mapGhost)
    document.addEventListener('mousemove', _onMapDragMove)
    document.addEventListener('mouseup', _onMapDragEnd)
  }
  function _onMapDragMove (e) { if (_mapGhost) { _mapGhost.style.left = (e.clientX + 14) + 'px'; _mapGhost.style.top = (e.clientY + 14) + 'px' } }
  function _onMapDragEnd (e) {
    document.removeEventListener('mousemove', _onMapDragMove)
    document.removeEventListener('mouseup', _onMapDragEnd)
    if (_mapGhost) { _mapGhost.remove(); _mapGhost = null }
    if (map) { map.getCanvas().style.cursor = ''; map.dragPan.enable() }
    const job = mapDragJob.value; mapDragJob.value = null
    if (!job) return
    const els = document.elementsFromPoint(e.clientX, e.clientY)
    const dateStr = localDateStr(periodStart.value)
    function assignFromMap (tech) {
      if (dragIsAssist.value) { dragIsAssist.value = false; return }
      pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants || [])] })
      smartAssign(job, tech.id, dateStr)
      invalidateRoutes()
    }
    const domTarget = els.find(el => el.dataset?.techId)
    if (domTarget) { const tech = store.technicians.find(t => t.id === domTarget.dataset.techId); if (tech) assignFromMap(tech); return }
    if (map && selectedTechId.value) {
      const canvas = map.getCanvas(), rect = canvas.getBoundingClientRect()
      if (e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom) {
        const tech = store.technicians.find(t => t.id === selectedTechId.value)
        if (tech) assignFromMap(tech)
      }
    }
  }

  // ── Route computation ────────────────────────────────────────────────────────
  async function computeDayRoute (tech, dateStr) {
    const key = `${tech.id}||${dateStr}`
    if (routeLegs.value[key] !== undefined) return
    const points = []
    if (tech.coords?.[0] && tech.coords?.[1]) points.push(`${tech.coords[0]},${tech.coords[1]}`)
    const allJobs = [...tech.queue.filter(j => jobSpansDate(j, dateStr, tech)), ...(tech.assistJobs || []).filter(j => jobSpansDate(j, dateStr, tech))]
    allJobs.forEach(j => { if (j.coords && (j.coords[0] !== 0 || j.coords[1] !== 0)) points.push(`${j.coords[0]},${j.coords[1]}`) })
    function setCache (legs, geom) {
      routeLegs.value = { ...routeLegs.value, [key]: legs }
      routeGeometry.value = { ...routeGeometry.value, [key]: geom }
    }
    if (points.length < 2) { setCache([], null); return }
    try {
      const d = await roster.osrmRoute(points.map(p => p.split(',').map(Number))) // NOTRE OSRM (via hub) : geometry + legs (min par arrêt)
      if (d && Array.isArray(d.geometry) && d.geometry.length) setCache((d.legs || []).map(l => l.mins), d.geometry)
      else setCache([], null)
    } catch (e) { console.warn('[route] osrm error', e); setCache([], null) }
  }

  // ── Draw route ───────────────────────────────────────────────────────────────
  function drawSelectedRoute () {
    if (!map || !mapVisible.value) return
    const src = map.getSource('sb-route'); if (!src) return
    const empty = { type: 'FeatureCollection', features: [] }
    if (currentView.value !== 'day') { src.setData(empty); return }
    const dayStr = localDateStr(periodStart.value)
    const features = []
    const techs = selectedTechId.value ? filteredResources.value.filter(t => t.id === selectedTechId.value) : filteredResources.value
    techs.forEach(tech => {
      const coords = routeGeometry.value[`${tech.id}||${dayStr}`]
      if (coords?.length) features.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { color: TECH_COLORS[tech.colorIdx] } })
    })
    src.setData({ type: 'FeatureCollection', features })
    map.setPaintProperty('sb-route-halo', 'line-color', ['get', 'color'])
    map.setPaintProperty('sb-route-line', 'line-color', ['get', 'color'])
  }

  // ── Select tech on board ─────────────────────────────────────────────────────
  function selectTechOnBoard (tech) {
    const wasSelected = selectedTechId.value === tech.id
    selectedTechId.value = wasSelected ? null : tech.id
    if (!wasSelected && currentView.value === 'day') {
      if (!mapVisible.value) {
        mapPanelW.value = Math.round(window.innerWidth * 0.5)
        localStorage.setItem('sbv2-mapW', String(mapPanelW.value))
        mapVisible.value = true
      }
      // Live GPS wins over the ERPNext-saved home base, so the rep
      // sees where the tech actually IS right now if Traccar reports
      // them online. Fall back to home coords when offline. We
      // flyTo (animated pan+zoom) so the dispatcher gets a clear
      // visual cue, instead of a hard jump.
      const pos = tech.gpsCoords || tech.coords
      if (pos && map && Number.isFinite(pos[0]) && Number.isFinite(pos[1])) {
        // Defer one tick so the map panel has time to be visible
        // and `map.resize()` has run before the camera animation.
        nextTick(() => {
          try { map.flyTo({ center: pos, zoom: Math.max(map.getZoom(), 12), speed: 1.2, essential: true }) } catch (_e) {}
        })
      }
    }
    if (map) { drawMapMarkers(); drawSelectedRoute() }
  }

  // ── Watchers ─────────────────────────────────────────────────────────────────
  watch([selectedTechId, () => periodStart.value?.getTime(), currentView, routeGeometry], () => { if (map) { drawMapMarkers(); drawSelectedRoute() } })
  watch(mapVisible, async v => {
    if (v) {
      if (map) { try { map.remove() } catch (_) {} map = null }
      await nextTick(); await initMap()
      if (map) {
        const r = () => { if (!map) return; map.resize(); drawMapMarkers(); drawSelectedRoute() }
        await nextTick(); r(); setTimeout(r, 100); setTimeout(r, 300); setTimeout(r, 600)
      }
    } else {
      if (mapResizeObs) { mapResizeObs.disconnect(); mapResizeObs = null }
      if (map) { try { map.remove() } catch (_) {} map = null }
    }
  })
  watch([() => periodStart.value?.getTime(), filteredResources], () => {
    if (currentView.value === 'day' && mapVisible.value && map) { drawMapMarkers(); drawSelectedRoute() }
  })
  watch(
    () => store.technicians.map(t => t.gpsCoords),
    () => { if (map) drawMapMarkers() },
    { deep: true }
  )

  // ── Lifecycle helpers ────────────────────────────────────────────────────────
  function destroyMap () {
    if (map) { map.remove(); map = null }
    if (mapResizeObs) { mapResizeObs.disconnect(); mapResizeObs = null }
  }
  function loadMapboxCss () { /* no-op : le CSS MapLibre est bundlé via src/config/basemap.js (plus de CDN Mapbox) */ }
  function getMap () { return map }

  return {
    mapContainer, selectedTechId, mapMarkers, mapPanelW, geoFixJob, geoFixTech, mapDragJob,
    startGeoFix, cancelGeoFix, startTechGeoFix, cancelTechGeoFix,
    startMapResize, initMap,
    drawMapMarkers, drawSelectedRoute, computeDayRoute,
    selectTechOnBoard, destroyMap, loadMapboxCss, getMap,
  }
}
