<script setup>
/**
 * RouteMap — carte de TOURNÉES réutilisable (revue du dispatch auto, onglet « Tournées », etc.).
 *
 * props.routes = [{ id, name, color, home:{lat,lon}|null, stops:[{lat,lon,seq,subject}] }]
 *  - LIGNES = couches GL (tracé instantané droit → géométrie ROUTIÈRE réelle OSRM via hub, cache module partagé)
 *  - ARRÊTS = MARQUEURS HTML (le numéro vit DANS la pastille = même élément, même niveau → jamais de chiffre orphelin
 *    par-dessus un voisin ; occlusion correcte). Au survol : la pastille passe au 1er plan ET si des pastilles se
 *    chevauchent, le groupe s'ÉCLATE en éventail pour les rendre toutes lisibles/cliquables.
 *  - émet 'metrics' { id:{km,mins} } (routes réelles) ; expose fitTo(id) / fitAll() / setActive(id).
 */
import { watch, onMounted, onBeforeUnmount } from 'vue'
import { MAPBOX_TOKEN } from 'src/config/erpnext'
import * as roster from 'src/api/roster'

const props = defineProps({
  routes: { type: Array, default: () => [] },
  height: { type: String, default: '440px' },
})
const emit = defineEmits(['metrics', 'stop-click'])

let el = null; let map = null; let ro = null; let ready = false; let drawSeq = 0
let stopMk = []; let homeMk = [] // marqueurs HTML (arrêts + domiciles)
const _rmCache = RouteMapCache() // cache module (partagé entre instances) : signature → { geometry, km, mins }
function RouteMapCache () { const g = globalThis; if (!g.__opsRouteMapCache) g.__opsRouteMapCache = new Map(); return g.__opsRouteMapCache }
function setEl (node) { el = node }

function ensureMapbox () {
  return new Promise((resolve) => {
    if (!document.getElementById('mapbox-css')) { const l = document.createElement('link'); l.id = 'mapbox-css'; l.rel = 'stylesheet'; l.href = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css'; document.head.appendChild(l) }
    if (window.mapboxgl) return resolve(window.mapboxgl)
    let s = document.getElementById('mapbox-js')
    if (!s) { s = document.createElement('script'); s.id = 'mapbox-js'; s.src = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js'; document.head.appendChild(s) }
    s.addEventListener('load', () => resolve(window.mapboxgl))
    const iv = setInterval(() => { if (window.mapboxgl) { clearInterval(iv); resolve(window.mapboxgl) } }, 150)
  })
}
const okLL = (lon, lat) => isFinite(+lon) && isFinite(+lat) && Math.abs(+lat) > 0.01 // écarte (0,0) / invalides qui feraient dézoomer la carte
function boundsOf (routes) {
  const b = new window.mapboxgl.LngLatBounds()
  for (const r of routes) { if (r.home && okLL(r.home.lon, r.home.lat)) b.extend([+r.home.lon, +r.home.lat]); for (const s of (r.stops || [])) if (okLL(s.lon, s.lat)) b.extend([+s.lon, +s.lat]) }
  return b
}
function lineFeatures (routes) {
  const lines = []
  for (const r of routes) {
    const coords = []
    if (r.home) coords.push([+r.home.lon, +r.home.lat])
    for (const s of (r.stops || [])) coords.push([+s.lon, +s.lat])
    if (r.home && coords.length >= 2) coords.push([+r.home.lon, +r.home.lat]) // RETOUR au départ : boucle domicile→arrêts→domicile
    if (coords.length >= 2) lines.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { color: r.color, id: r.id, rid: r.id } })
  }
  return lines
}
async function realLine (r) { // géométrie routière réelle d'une tournée (OSRM via hub), cache par signature — BOUCLE avec retour au départ
  const pts = []; if (r.home) pts.push([+r.home.lon, +r.home.lat]); for (const s of (r.stops || [])) pts.push([+s.lon, +s.lat])
  if (r.home) pts.push([+r.home.lon, +r.home.lat]) // le tech revient à son point de départ → compté dans km/min
  if (pts.length < 2 || pts.length > 52) return null
  const sig = pts.map(p => p[0].toFixed(5) + ',' + p[1].toFixed(5)).join(';')
  if (_rmCache.has(sig)) return _rmCache.get(sig)
  try {
    const d = await roster.osrmRoute(pts)
    if (!d || !Array.isArray(d.geometry) || !d.geometry.length) return null
    const info = { geometry: { type: 'LineString', coordinates: d.geometry }, km: d.km, mins: d.mins }
    _rmCache.set(sig, info); return info
  } catch (e) { return null }
}

// ── Marqueurs HTML : le numéro est DANS la pastille (même élément, même niveau) ──
function clearMarkers () { for (const m of stopMk) m.mk.remove(); for (const m of homeMk) m.mk.remove(); stopMk = []; homeMk = [] }
function renderMarkers (routes) {
  clearMarkers()
  const mapboxgl = window.mapboxgl
  for (const r of routes) {
    if (r.home) {
      const wrap = document.createElement('div'); wrap.className = 'rm-mk rm-mk-home'; wrap.dataset.rid = String(r.id)
      const d = document.createElement('div'); d.className = 'rm-home'; d.style.borderColor = r.color; wrap.appendChild(d)
      const mk = new mapboxgl.Marker({ element: wrap, anchor: 'center' }).setLngLat([+r.home.lon, +r.home.lat]).addTo(map)
      wrap.title = r.name + ' — départ'
      homeMk.push({ mk, el: wrap, rid: r.id })
    }
    for (const s of (r.stops || [])) {
      // Pastille = ROUND-RECT (pill) : icône du type de job + numéro d'ordre (best practice quand 2 infos ; cf. outils de tournée).
      const wrap = document.createElement('div'); wrap.className = 'rm-mk'; wrap.dataset.rid = String(r.id)
      const disc = document.createElement('div'); disc.className = 'rm-pill'; disc.style.background = r.color
      const ic = /^[a-z0-9_]+$/.test(String(s.icon || '')) ? s.icon : 'place'
      disc.innerHTML = '<span class="material-icons rm-ic">' + ic + '</span><span class="rm-num">' + s.seq + '</span>'
      wrap.appendChild(disc)
      const mk = new mapboxgl.Marker({ element: wrap, anchor: 'center' }).setLngLat([+s.lon, +s.lat]).addTo(map)
      const rec = { mk, el: wrap, disc, rid: r.id, lngLat: [+s.lon, +s.lat], seq: s.seq, tech: r.name, subject: s.subject || '', stop: s, fan: null, members: null }
      wrap.addEventListener('mouseenter', () => onEnter(rec))
      wrap.addEventListener('mouseleave', () => onLeave(rec))
      wrap.addEventListener('click', () => emit('stop-click', rec.stop, rec.rid)) // → la page ouvre la fiche détail complète du ticket
      stopMk.push(rec)
    }
  }
  recluster()
}
// Regroupe les pastilles qui se CHEVAUCHENT à l'écran (distance pixel) → calcule pour chacune un décalage en éventail.
function recluster () {
  if (!map || !stopMk.length) return
  const px = stopMk.map(s => map.project(s.lngLat))
  const parent = stopMk.map((_, i) => i)
  const find = (i) => { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i] } return i }
  const TH = 24 // pastille ø24 → chevauchement si centres < ~24 px
  for (let i = 0; i < stopMk.length; i++) for (let j = i + 1; j < stopMk.length; j++) {
    const dx = px[i].x - px[j].x, dy = px[i].y - px[j].y
    if (dx * dx + dy * dy < TH * TH) parent[find(i)] = find(j)
  }
  const groups = {}
  for (let i = 0; i < stopMk.length; i++) { const r = find(i); (groups[r] = groups[r] || []).push(i) }
  for (const s of stopMk) { s.fan = null; s.members = null }
  for (const key in groups) {
    const ids = groups[key]; if (ids.length < 2) continue
    const cx = ids.reduce((a, i) => a + px[i].x, 0) / ids.length
    const cy = ids.reduce((a, i) => a + px[i].y, 0) / ids.length
    const R = 18 + ids.length * 6 // rayon d'éclatement croît avec le nombre
    const members = ids.map(i => stopMk[i])
    ids.forEach((i, k) => {
      const ang = (k / ids.length) * 2 * Math.PI - Math.PI / 2
      const tx = cx + R * Math.cos(ang), ty = cy + R * Math.sin(ang)
      stopMk[i].fan = [tx - px[i].x, ty - px[i].y] // décalage (px) de la pastille par rapport à son point
      stopMk[i].members = members
    })
  }
}
let openMembers = null; let clTimer = null
function fan (members, on) {
  for (const m of members) {
    if (on && m.fan) { m.disc.style.transform = `translate(${m.fan[0]}px, ${m.fan[1]}px)`; m.el.classList.add('fanned') }
    else { m.disc.style.transform = ''; m.el.classList.remove('fanned') }
  }
}
function onEnter (rec) {
  if (clTimer) { clearTimeout(clTimer); clTimer = null }
  setActive(rec.rid); rec.el.classList.add('hot')
  if (rec.members && rec.members !== openMembers) { if (openMembers) fan(openMembers, false); fan(rec.members, true); openMembers = rec.members }
}
function onLeave (rec) {
  rec.el.classList.remove('hot')
  clTimer = setTimeout(() => { setActive(null); if (openMembers) { fan(openMembers, false); openMembers = null } }, 200)
}

let _lastSig = ''
async function draw () {
  if (!map || !ready) return
  const seq = ++drawSeq
  const routes = props.routes || []
  const ls = map.getSource('rm-line'); if (!ls) return
  ls.setData({ type: 'FeatureCollection', features: lineFeatures(routes) })
  renderMarkers(routes)
  const sig = routes.map(r => r.id + ':' + (r.stops || []).length).join('|')
  if (sig !== _lastSig) { _lastSig = sig; try { const b = boundsOf(routes); if (!b.isEmpty()) map.fitBounds(b, { padding: 60, maxZoom: 13, duration: 400 }) } catch (e) {} }
  // Routes RÉELLES en asynchrone : remplace les segments droits + émet km/min réels. Ignoré si un draw plus récent est parti.
  const results = await Promise.all(routes.map(async r => ({ r, info: await realLine(r) })))
  if (seq !== drawSeq || !map || !map.getSource('rm-line')) return
  const metrics = {}
  const finalLines = results.map(({ r, info }) => {
    if (info) { metrics[r.id] = { km: info.km, mins: info.mins }; return { type: 'Feature', geometry: info.geometry, properties: { color: r.color, id: r.id, rid: r.id } } }
    const coords = []; if (r.home) coords.push([+r.home.lon, +r.home.lat]); for (const s of (r.stops || [])) coords.push([+s.lon, +s.lat])
    return coords.length >= 2 ? { type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { color: r.color, id: r.id, rid: r.id } } : null
  }).filter(Boolean)
  map.getSource('rm-line').setData({ type: 'FeatureCollection', features: finalLines })
  if (Object.keys(metrics).length) emit('metrics', metrics)
}
function fitTo (id) {
  const r = (props.routes || []).find(x => x.id === id); if (!r || !map) return
  try { const b = boundsOf([r]); if (!b.isEmpty()) map.fitBounds(b, { padding: 60, maxZoom: 13, duration: 400 }) } catch (e) {}
}
function fitAll () { try { const b = boundsOf(props.routes || []); if (!b.isEmpty()) map.fitBounds(b, { padding: 50, maxZoom: 13, duration: 400 }) } catch (e) {} }
// Tournée ACTIVE (survol carte ou chip) : ligne épaissie (couche GL filtrée) + ses marqueurs passent au 1er plan.
let _active = null
function setActive (rid) {
  if (!map || !ready || _active === rid) return
  _active = rid || null
  try { map.setFilter('rm-line-a', ['==', ['get', 'rid'], _active == null ? '___aucun___' : _active]) } catch (e) {}
  for (const m of stopMk) m.el.classList.toggle('route-hot', _active != null && m.rid === _active)
  for (const m of homeMk) m.el.classList.toggle('route-hot', _active != null && m.rid === _active)
}
defineExpose({ fitTo, fitAll, setActive })

onMounted(async () => {
  if (!MAPBOX_TOKEN || !el) return
  const mapboxgl = await ensureMapbox(); if (!mapboxgl || !el) return
  mapboxgl.accessToken = MAPBOX_TOKEN
  map = new mapboxgl.Map({ container: el, style: 'mapbox://styles/mapbox/streets-v12', center: [-73.55, 45.2], zoom: 8 })
  map.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
  ro = new ResizeObserver(() => { if (map) map.resize() }); ro.observe(el)
  map.on('load', () => {
    map.resize()
    map.addSource('rm-line', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
    map.addLayer({ id: 'rm-line', type: 'line', source: 'rm-line', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': ['get', 'color'], 'line-width': 3, 'line-opacity': 0.7 } })
    map.addLayer({ id: 'rm-line-a', type: 'line', source: 'rm-line', filter: ['==', ['get', 'rid'], '___aucun___'], layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': ['get', 'color'], 'line-width': 5.5, 'line-opacity': 0.95 } })
    map.on('mousemove', 'rm-line', (e) => { const f = e.features && e.features[0]; if (f) setActive(f.properties.rid) })
    map.on('mouseleave', 'rm-line', () => setActive(null))
    map.on('movestart', () => { if (openMembers) { fan(openMembers, false); openMembers = null } }) // décalages px périmés au déplacement
    map.on('moveend', recluster)
    ready = true
    draw()
  })
})
onBeforeUnmount(() => { clearMarkers(); if (ro) { try { ro.disconnect() } catch (e) {} ro = null } if (map) { try { map.remove() } catch (e) {} map = null } ready = false })
watch(() => props.routes, () => { draw() }, { deep: true })
</script>

<template>
  <div :ref="setEl" class="route-map" :style="{ height }" />
</template>

<style scoped>
.route-map { border-radius: 8px; overflow: hidden; border: 1px solid #cfd8dc; }
</style>

<!-- NON scoped : les marqueurs sont créés en JS et injectés dans le conteneur Mapbox (hors de l'arbre scoped). Préfixe rm- = pas de collision. -->
<style>
.rm-mk { cursor: pointer; will-change: transform; }
.rm-mk .rm-pill {
  display: inline-flex; align-items: center; gap: 3px; box-sizing: border-box;
  height: 22px; padding: 0 7px 0 5px; border-radius: 11px; white-space: nowrap;
  color: #fff; border: 2px solid #fff; box-shadow: 0 1px 3px rgba(0,0,0,.45);
  transition: transform .18s cubic-bezier(.2,.8,.3,1), box-shadow .15s;
}
.rm-mk .rm-pill .rm-ic { font-size: 14px; line-height: 1; opacity: .95; }
.rm-mk .rm-pill .rm-num { font-size: 12px; font-weight: 800; line-height: 1; }
.rm-mk .rm-home { width: 15px; height: 15px; border-radius: 50%; box-sizing: border-box; background: #fff; border: 3px solid #888; box-shadow: 0 1px 3px rgba(0,0,0,.4); }
.rm-mk.hot { z-index: 7; }
.rm-mk.hot .rm-pill { transform: scale(1.15); box-shadow: 0 3px 9px rgba(0,0,0,.55); }
.rm-mk.route-hot { z-index: 5; }
.rm-mk.fanned { z-index: 6; }
.rm-mk.fanned .rm-pill { box-shadow: 0 3px 10px rgba(0,0,0,.55); }
</style>
