'use strict'
const cfg = require('./config')
const { log, json, parseBody, erpFetch, readJsonFile } = require('./helpers')
// Prédicat « ce tech a-t-il la compétence ? » PARTAGÉ (même sémantique que capacityByDay/solveur). Le champ ERP `skills`
// (ou `_user_tags`) est une CSV → techHasSkill l'accepte telle quelle. SOURCE UNIQUE : lib/skill-resolver.js.
const { techHasSkill, techHasSkills } = require('./skill-resolver')
// Techs archivés (store hub partagé avec roster.js) → exclus des créneaux + occupation. Lu à chaud (change rarement).
const archivedTechSet = () => new Set((readJsonFile('/app/data/archived_techs.json', []) || []).map(String))

const SCORE_WEIGHTS = {
  proximityMultiplier: 4,   // distance (km, capped at 100) * this = proximity penalty
  proximityMax: 100,        // cap distance at this km value
  loadMultiplier: 30,       // tech.load * this = load penalty
  overloadPenalty: 500,     // added when tech has insufficient capacity
  gpsFreshnessBonus: 20,    // subtracted when GPS is live
}

function todayET () {
  return new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
}

function nowHoursET () {
  const parts = new Date().toLocaleString('en-CA', { timeZone: 'America/Toronto', hour12: false }).split(' ')
  const [hh, mm] = parts[1].split(':').map(Number)
  return hh + (mm || 0) / 60
}

function timeToHours (t) {
  if (!t) return 0
  const [h, m] = t.split(':').map(Number)
  return h + (m || 0) / 60
}

function hoursToTime (h) {
  const hh = Math.floor(h)
  const mm = Math.round((h - hh) * 60)
  return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0')
}

function dateAddDays (baseStr, n) {
  const d = new Date(baseStr + 'T12:00:00')
  d.setDate(d.getDate() + n)
  return d.toISOString().slice(0, 10)
}

// Euclidean approximation, km at Montreal latitude
function distKm (a, b) {
  if (!a || !b) return 999
  const dx = (a[0] - b[0]) * 80, dy = (a[1] - b[1]) * 111
  return Math.sqrt(dx * dx + dy * dy)
}

async function getTechsWithLoad (dateStr) {
  const [techRes, jobRes] = await Promise.all([
    erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify(['name', 'technician_id', 'full_name', 'status', 'longitude', 'latitude', 'traccar_device_id', 'phone']))}&limit_page_length=500`), // 500 : couvre tout l'effectif (>50) — sinon les techs au-delà du 50e sont invisibles au dispatch/ranking
    erpFetch(`/api/resource/Dispatch Job?filters=${encodeURIComponent(JSON.stringify({ status: ['in', ['open', 'assigned']], scheduled_date: dateStr }))}&fields=${encodeURIComponent(JSON.stringify(['name', 'assigned_tech', 'duration_h', 'longitude', 'latitude', 'status', 'route_order']))}&limit_page_length=200`),
  ])
  if (techRes.status !== 200) throw new Error('Failed to fetch technicians')
  const techs = techRes.data.data || []
  const jobs = jobRes.status === 200 ? (jobRes.data.data || []) : []

  return techs.map(t => {
    const queue = jobs
      .filter(j => j.assigned_tech === t.technician_id)
      .sort((a, b) => (a.route_order || 0) - (b.route_order || 0))
    const load = queue.reduce((s, j) => s + (parseFloat(j.duration_h) || 1), 0)
    return {
      id: t.technician_id, name: t.full_name, status: t.status || 'available',
      coords: [t.longitude || -73.5673, t.latitude || 45.5017],
      traccarDeviceId: t.traccar_device_id, phone: t.phone,
      queue, load, tags: [],
    }
  })
}

async function enrichWithGps (techs) {
  try {
    const { getDevices, getPositions } = require('./traccar')
    const devices = await getDevices()
    const deviceMap = {}
    techs.forEach(t => {
      if (!t.traccarDeviceId) return
      const dev = devices.find(d => d.id === parseInt(t.traccarDeviceId) || d.uniqueId === t.traccarDeviceId)
      if (dev) deviceMap[dev.id] = t
    })
    const deviceIds = Object.keys(deviceMap).map(Number)
    if (!deviceIds.length) return
    const positions = await getPositions(deviceIds)
    positions.forEach(p => {
      const tech = deviceMap[p.deviceId]
      if (tech && p.latitude && p.longitude) {
        tech.gpsCoords = [p.longitude, p.latitude]
        tech.gpsOnline = true
      }
    })
  } catch (e) { log('GPS enrichment error:', e.message) }
}

function rankTechs (techs, jobCoords, jobDuration = 1) {
  const hasCoords = jobCoords && (jobCoords[0] || jobCoords[1])
  const candidates = techs.filter(t => t.status !== 'off' && t.status !== 'unavailable')

  return candidates.map(tech => {
    const pos = tech.gpsOnline ? tech.gpsCoords : tech.coords
    const distance = hasCoords ? distKm(pos, jobCoords) : 999
    const remainingCap = Math.max(0, 8 - tech.load)

    let score = 0
    const reasons = []

    score += Math.min(distance, SCORE_WEIGHTS.proximityMax) * SCORE_WEIGHTS.proximityMultiplier
    reasons.push(distance < 5 ? `${distance.toFixed(1)} km (très proche)` :
                 distance < 15 ? `${distance.toFixed(1)} km` : `${distance.toFixed(1)} km (loin)`)

    score += tech.load * SCORE_WEIGHTS.loadMultiplier
    if (remainingCap < jobDuration) {
      score += SCORE_WEIGHTS.overloadPenalty
      reasons.push(`Surchargé (${tech.load.toFixed(1)}h/8h)`)
    } else if (tech.load < 4) {
      reasons.push(`Dispo (${tech.load.toFixed(1)}h/8h)`)
    } else {
      reasons.push(`Chargé (${tech.load.toFixed(1)}h/8h)`)
    }

    if (tech.gpsOnline) { score -= SCORE_WEIGHTS.gpsFreshnessBonus; reasons.push('GPS en direct') }

    return { techId: tech.id, techName: tech.name, phone: tech.phone, score, distance, load: tech.load, remainingCap, reasons }
  }).sort((a, b) => a.score - b.score)
}

// ── Slot suggestion ────────────────────────────────────────────────────────
// Finds open time windows across techs for the next N days. Keeps the logic
// simple & predictable: gaps between pinned jobs within a default shift
// window, minus a travel buffer before the new job, minus a "not in the
// past" cutoff. Scores surface the most natural inserts (earliest first,
// then shortest travel), with a 2-slot-per-tech cap to diversify results.
const SLOT_TRAVEL_BUFFER_H = 0.25   // repli de trajet (h) quand une coordonnée manque = 15 min
const SLOT_HORIZON_DAYS = 7
const SLOT_MAX_PER_TECH = 2

// Trajet estimé (en HEURES) entre 2 points [lng,lat] — même barème que les créneaux (≈ distance × 1,5 min/km, borné 5–90 min).
// distKm renvoie 999 si une coordonnée manque → on retombe sur SLOT_TRAVEL_BUFFER_H. Le trajet ENTRE deux jobs
// N'EST PAS du temps libre : il doit être réservé, sinon on suroffre un tech dont la journée est déjà pleine de route.
function travelH (a, b) {
  const km = distKm(a, b)
  if (!isFinite(km) || km >= 999) return SLOT_TRAVEL_BUFFER_H
  return Math.max(5, Math.min(90, Math.round(km * 1.5))) / 60
}

async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date, limit = 5, skill = '', ignoreReserved = false } = {}) {
  const baseDate = after_date || todayET()
  const duration = parseFloat(duration_h) || 1
  const dates = Array.from({ length: SLOT_HORIZON_DAYS }, (_, i) => dateAddDays(baseDate, i))
  const jobCoords = latitude && longitude ? [parseFloat(longitude), parseFloat(latitude)] : null
  const wantSkill = String(skill || '').trim().toLowerCase()
  const isRepair = /r[ée]par|d[ée]pann|panne|bris/.test(wantSkill) // les WEEK-ENDS sont RÉSERVÉS aux réparations → sautés pour les autres compétences

  const [techRes, jobRes, tplRes, shiftRes, availRes] = await Promise.all([
    erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify([
      'name', 'technician_id', 'full_name', 'status', 'longitude', 'latitude',
      'absence_from', 'absence_until', 'skills', '_user_tags',
    ]))}&limit_page_length=50`),
    erpFetch(`/api/resource/Dispatch Job?filters=${encodeURIComponent(JSON.stringify([
      ['status', 'in', ['open', 'assigned']],
      ['scheduled_date', '>=', dates[0]],
      ['scheduled_date', '<=', dates[dates.length - 1]],
    ]))}&fields=${encodeURIComponent(JSON.stringify([
      'name', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h',
      'longitude', 'latitude', 'job_type', 'priority',
    ]))}&limit_page_length=500`),
    erpFetch(`/api/resource/Shift Template?fields=${encodeURIComponent(JSON.stringify(['name', 'start_time', 'end_time', 'on_call']))}&limit_page_length=100`),
    erpFetch(`/api/resource/Shift Assignment?filters=${encodeURIComponent(JSON.stringify([['assignment_date', 'in', dates]]))}&fields=${encodeURIComponent(JSON.stringify(['technician', 'assignment_date', 'shift_template']))}&limit_page_length=2000`),
    // Congés/absences APPROUVÉS (Tech Availability) — où vivent les vacances (import calendrier, dialogue congés, grille). Sinon un tech en congé mais avec un quart matérialisé ressort quand même.
    erpFetch(`/api/resource/Tech Availability?filters=${encodeURIComponent(JSON.stringify([['status', '=', 'Approuvé'], ['from_date', '<=', dates[dates.length - 1]], ['to_date', '>=', dates[0]]]))}&fields=${encodeURIComponent(JSON.stringify(['technician', 'from_date', 'to_date']))}&limit_page_length=500`),
  ])
  if (techRes.status !== 200) throw new Error('Failed to fetch technicians')
  // FILTRE COMPÉTENCE : ne garder que les techs qui ONT le skill demandé (champ skills ou tags Frappe).
  const hasSkill = (t) => techHasSkill(t.skills || t._user_tags, wantSkill)
  const techs = (() => { const _arch = archivedTechSet(); return (techRes.data.data || []).filter(t => t.status !== 'unavailable' && t.status !== 'En pause' && !_arch.has(t.technician_id) && !_arch.has(t.name) && hasSkill(t)) })()
  // Jours de congé approuvé par tech (clé = technician, qui correspond au technician_id ou au docname). Aligné sur roster.buildUnavailability.
  const vacBy = {}
  for (const a of ((availRes.data && availRes.data.data) || [])) {
    const set = vacBy[a.technician] || (vacBy[a.technician] = new Set())
    for (const d of dates) if (d >= a.from_date && d <= a.to_date) set.add(d)
  }
  const vacDays = (t) => vacBy[t.technician_id] || vacBy[t.name] || null
  const allJobs = jobRes.status === 200 ? (jobRes.data.data || []) : []
  // Fenêtres de quart RÉELLES par tech×jour (Shift Assignment × Shift Template) — plus de fenêtre 8-17 par défaut : un tech SANS quart ce jour = pas de créneau.
  const tpl = {}; for (const t of ((tplRes.data && tplRes.data.data) || [])) tpl[t.name] = t
  const winBy = {} // 'technician|date' → { start_h, end_h } (quart réel, garde exclue)
  for (const a of ((shiftRes.data && shiftRes.data.data) || [])) {
    const tp = tpl[a.shift_template]; if (!tp || tp.on_call) continue
    const s = timeToHours(tp.start_time), e = timeToHours(tp.end_time); if (s == null || e == null || !(e > s)) continue
    const k = a.technician + '|' + a.assignment_date
    const w = winBy[k]; winBy[k] = w ? { start_h: Math.min(w.start_h, s), end_h: Math.max(w.end_h, e) } : { start_h: s, end_h: e }
  }

  const today = todayET()
  const nowH = nowHoursET()
  const slots = []

  for (const tech of techs) {
    const homeCoords = tech.longitude && tech.latitude
      ? [parseFloat(tech.longitude), parseFloat(tech.latitude)] : null
    const techVac = vacDays(tech) // jours de congé approuvé (Tech Availability)

    for (const dateStr of dates) {
      // WEEK-END réservé aux réparations : on saute sam/dim pour toute autre compétence.
      const dow = new Date(dateStr + 'T12:00:00').getUTCDay()
      if ((dow === 0 || dow === 6) && !isRepair) continue
      // Congé approuvé (Tech Availability) OU fenêtre d'absence du Dispatch Technician → pas de créneau.
      if (techVac && techVac.has(dateStr)) continue
      if (tech.absence_from && tech.absence_until &&
          dateStr >= tech.absence_from && dateStr <= tech.absence_until) continue
      // QUART RÉEL du tech ce jour (Shift Assignment) — pas de quart = pas de créneau (créer un quart pour en ouvrir).
      const shift = winBy[tech.technician_id + '|' + dateStr] || winBy[tech.name + '|' + dateStr]
      if (!shift) continue

      // Day's pinned jobs (only those with a real start_time) — servent à découper les trous.
      // Match par technician_id OU docname (les jobs legacy portent parfois l'un ou l'autre).
      // BLOC DE TEMPS interne (formation/réunion/projet…) = job_type Interne/Réservation. En mode URGENT (ignoreReserved),
      // la PRIORITÉ est le curseur : un bloc de priorité ≤ moyenne est TRAVERSÉ (l'urgence peut prendre le tech) ; un bloc
      // « Urgent » (high) RÉSISTE = bloc dur. Les vrais jobs client ne sont JAMAIS traversés (jamais de double-booking).
      const isSoftBlock = (j) => (j.job_type === 'Réservation' || j.job_type === 'Interne') && String(j.priority || 'medium').toLowerCase() !== 'high'
      const belongs = (j) => (j.assigned_tech === tech.technician_id || j.assigned_tech === tech.name) &&
        j.scheduled_date === dateStr && !(ignoreReserved && isSoftBlock(j))
      const dayJobs = allJobs
        .filter(j => belongs(j) && j.start_time)
        .map(j => {
          const s = timeToHours(j.start_time)
          return {
            start_h: s,
            end_h: s + (parseFloat(j.duration_h) || 1),
            coords: j.latitude && j.longitude ? [parseFloat(j.longitude), parseFloat(j.latitude)] : null,
          }
        })
        .sort((a, b) => a.start_h - b.start_h)

      // « Humainement possible » : un tech ne peut pas dépasser son quart. Les jobs assignés SANS heure fixée
      // (legacy osTicket) occupent quand même la journée → on retranche leur charge + celle déjà placée ; si la
      // capacité restante ne suffit pas pour cette durée, PAS de créneau ce jour (sinon on suroffre un tech déjà plein).
      const shiftH = shift.end_h - shift.start_h
      const clampD = (j) => Math.max(0, Math.min(j.end_h, shift.end_h) - Math.max(j.start_h, shift.start_h))
      const timedBusy = dayJobs.reduce((a, j) => a + clampD(j), 0)
      const untimedLoad = allJobs.filter(j => belongs(j) && !j.start_time)
        .reduce((a, j) => a + (parseFloat(j.duration_h) || 1), 0)
      if (timedBusy + untimedLoad + duration > shiftH + 0.01) continue

      // Build gaps bounded by shift_start/end (shift = quart réel du tech ce jour, résolu ci-dessus).
      const gaps = []
      let cursor = shift.start_h, prevCoords = homeCoords
      for (const j of dayJobs) {
        gaps.push({
          start_h: cursor, end_h: j.start_h, prev_coords: prevCoords, next_coords: j.coords,
          position: gaps.length === 0 ? 'first' : 'between',
        })
        cursor = j.end_h
        prevCoords = j.coords
      }
      gaps.push({
        start_h: cursor, end_h: shift.end_h, prev_coords: prevCoords, next_coords: null,
        position: dayJobs.length === 0 ? 'free_day' : 'last',
      })

      for (const g of gaps) {
        const gapLen = g.end_h - g.start_h
        // TRAJET réel réservé : ARRIVER (depuis le job précédent) + REPARTIR (vers le job suivant).
        // Bord = début/fin de QUART → aucun trajet de ce côté ; bord = JOB → réserver le déplacement.
        // Sans coordonnées du nouveau job (jobCoords absent) → repli forfaitaire SLOT_TRAVEL_BUFFER_H par bord-job.
        const bordersPrevJob = g.position === 'between' || g.position === 'last'
        const bordersNextJob = g.position === 'between' || g.position === 'first'
        const travelIn = bordersPrevJob ? (jobCoords ? travelH(g.prev_coords, jobCoords) : SLOT_TRAVEL_BUFFER_H) : 0
        const travelOut = bordersNextJob ? (jobCoords ? travelH(jobCoords, g.next_coords) : SLOT_TRAVEL_BUFFER_H) : 0
        if (gapLen < travelIn + duration + travelOut) continue // le trou doit contenir : arriver + faire le job + repartir

        const startH = g.start_h + travelIn
        const endH = startH + duration
        if (endH + travelOut > g.end_h + 1e-6) continue // laisser le temps de rejoindre le job suivant

        // Skip slots already in the past (or within 30 min).
        if (dateStr === today && startH < nowH + 0.5) continue

        const distanceKm = jobCoords && g.prev_coords ? distKm(g.prev_coords, jobCoords) : null
        // Minutes de trajet AFFICHÉES = déplacement depuis le lieu précédent (domicile au 1er créneau, sinon job précédent).
        const travelMin = Math.round((jobCoords && g.prev_coords ? travelH(g.prev_coords, jobCoords) : SLOT_TRAVEL_BUFFER_H) * 60)

        const reasons = []
        if (g.position === 'free_day') reasons.push('Journée libre')
        else if (g.position === 'first') reasons.push('Début de journée')
        else if (g.position === 'last') reasons.push('Fin de journée')
        else reasons.push('Entre 2 rendez-vous')
        if (distanceKm != null) reasons.push(`${distanceKm.toFixed(1)} km du précédent`)

        slots.push({
          tech_id: tech.technician_id,
          tech_name: tech.full_name,
          date: dateStr,
          start_time: hoursToTime(startH),
          end_time: hoursToTime(endH),
          travel_min: travelMin,
          distance_km: distanceKm != null ? +distanceKm.toFixed(1) : null,
          gap_h: +gapLen.toFixed(1),
          reasons,
          position: g.position,
        })
      }
    }
  }

  // Sort: earliest first, then shortest travel.
  slots.sort((a, b) => {
    if (a.date !== b.date) return a.date < b.date ? -1 : 1
    if (a.start_time !== b.start_time) return a.start_time < b.start_time ? -1 : 1
    return (a.travel_min || 0) - (b.travel_min || 0)
  })

  // Diversify: cap slots-per-tech so we don't return 5 options from the
  // same person. Dispatchers want to compare across resources.
  const byTech = {}
  const picked = []
  for (const s of slots) {
    byTech[s.tech_id] = byTech[s.tech_id] || 0
    if (byTech[s.tech_id] >= SLOT_MAX_PER_TECH) continue
    picked.push(s)
    byTech[s.tech_id]++
    if (picked.length >= limit) break
  }

  return picked
}

// Occupation par ressource (tech) sur l'horizon, FILTRÉE par compétence — même modèle de données que suggestSlots
// (quart réel = capacité, jobs planifiés = occupé), mais renvoie l'OCCUPATION plutôt que les trous. Alimente la
// visualisation « bandes verticales » de « Trouver un créneau » : on voit d'un coup qui a de la place pour le skill choisi.
async function techOccupancy ({ after_date, days = SLOT_HORIZON_DAYS, skill = '' } = {}) {
  const baseDate = after_date || todayET()
  const span = Math.max(1, Math.min(42, parseInt(days, 10) || SLOT_HORIZON_DAYS)) // jusqu'à 6 semaines (calendrier mois de l'horaire tech)
  const dates = Array.from({ length: span }, (_, i) => dateAddDays(baseDate, i))
  // `skill` accepte UNE compétence OU plusieurs en CSV (« réparation,sans-fil ») → le tech doit les avoir TOUTES.
  const wantSkills = String(skill || '').split(',').map(s => s.trim()).filter(Boolean)

  const [techRes, jobRes, tplRes, shiftRes, availRes] = await Promise.all([
    erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify([
      'name', 'technician_id', 'full_name', 'status', 'absence_from', 'absence_until', 'skills', '_user_tags',
    ]))}&limit_page_length=500`), // 500 : couvre tout l'effectif (56+ techs) — le cap 50 laissait les techs au-delà du 50e SANS occupation → calendrier mois de leur horaire tout gris (bug Philippe Bourdon & co.)
    erpFetch(`/api/resource/Dispatch Job?filters=${encodeURIComponent(JSON.stringify([
      ['status', 'in', ['open', 'assigned']],
      ['scheduled_date', '>=', dates[0]],
      ['scheduled_date', '<=', dates[dates.length - 1]],
    ]))}&fields=${encodeURIComponent(JSON.stringify([
      'name', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'job_type', 'longitude', 'latitude',
    ]))}&limit_page_length=500`),
    erpFetch(`/api/resource/Shift Template?fields=${encodeURIComponent(JSON.stringify(['name', 'start_time', 'end_time', 'on_call']))}&limit_page_length=100`),
    erpFetch(`/api/resource/Shift Assignment?filters=${encodeURIComponent(JSON.stringify([['assignment_date', 'in', dates]]))}&fields=${encodeURIComponent(JSON.stringify(['technician', 'assignment_date', 'shift_template']))}&limit_page_length=2000`),
    erpFetch(`/api/resource/Tech Availability?filters=${encodeURIComponent(JSON.stringify([['status', '=', 'Approuvé'], ['from_date', '<=', dates[dates.length - 1]], ['to_date', '>=', dates[0]]]))}&fields=${encodeURIComponent(JSON.stringify(['technician', 'from_date', 'to_date']))}&limit_page_length=500`),
  ])
  if (techRes.status !== 200) throw new Error('Failed to fetch technicians')
  const hasSkill = (t) => techHasSkills(t.skills || t._user_tags, wantSkills)
  const techs = (() => { const _arch = archivedTechSet(); return (techRes.data.data || []).filter(t => t.status !== 'unavailable' && t.status !== 'En pause' && !_arch.has(t.technician_id) && !_arch.has(t.name) && hasSkill(t)) })()
  const allJobs = jobRes.status === 200 ? (jobRes.data.data || []) : []
  // Congés approuvés (Tech Availability) → jours « off » dans l'occupation (aligné sur suggestSlots + roster).
  const vacBy = {}
  for (const a of ((availRes.data && availRes.data.data) || [])) {
    const set = vacBy[a.technician] || (vacBy[a.technician] = new Set())
    for (const d of dates) if (d >= a.from_date && d <= a.to_date) set.add(d)
  }
  const tpl = {}; for (const t of ((tplRes.data && tplRes.data.data) || [])) tpl[t.name] = t
  const winBy = {} // 'tech|date' → { start_h, end_h } (quart réel)
  for (const a of ((shiftRes.data && shiftRes.data.data) || [])) {
    const tp = tpl[a.shift_template]; if (!tp || tp.on_call) continue
    const s = timeToHours(tp.start_time), e = timeToHours(tp.end_time); if (s == null || e == null || !(e > s)) continue
    const k = a.technician + '|' + a.assignment_date
    const w = winBy[k]; winBy[k] = w ? { start_h: Math.min(w.start_h, s), end_h: Math.max(w.end_h, e) } : { start_h: s, end_h: e }
  }

  const out = techs.map(tech => {
    const techVac = vacBy[tech.technician_id] || vacBy[tech.name] || null
    const cells = dates.map(dateStr => {
      const dow = new Date(dateStr + 'T12:00:00').getUTCDay()
      const onLeave = techVac && techVac.has(dateStr)
      const absent = tech.absence_from && tech.absence_until && dateStr >= tech.absence_from && dateStr <= tech.absence_until
      const shift = winBy[tech.technician_id + '|' + dateStr] || winBy[tech.name + '|' + dateStr]
      if (onLeave) return { date: dateStr, dow, off: true, reason: 'vacation', occupancy: null }
      if (absent) return { date: dateStr, dow, off: true, reason: 'absence', occupancy: null }
      if (!shift) return { date: dateStr, dow, off: true, reason: (dow === 0 || dow === 6) ? 'weekend' : 'no_shift', occupancy: null }
      const shiftH = shift.end_h - shift.start_h
      // Match par technician_id OU docname (comme les quarts/congés) : les jobs legacy portent parfois l'un ou l'autre.
      const dayJobsRaw = allJobs
        .filter(j => (j.assigned_tech === tech.technician_id || j.assigned_tech === tech.name) && j.scheduled_date === dateStr)
        .map(j => { const s = j.start_time ? timeToHours(j.start_time) : null; const dur = parseFloat(j.duration_h) || 1; return { start_h: s, dur, end_h: s != null ? s + dur : null, reserved: j.job_type === 'Réservation', coords: (j.latitude && j.longitude) ? [parseFloat(j.longitude), parseFloat(j.latitude)] : null } })
      const timed = dayJobsRaw.filter(j => j.start_h != null).sort((a, b) => a.start_h - b.start_h)
      const untimed = dayJobsRaw.filter(j => j.start_h == null) // jobs assignés SANS heure fixée (legacy osTicket) — occupent quand même la journée
      const clamp = (j) => Math.max(0, Math.min(j.end_h, shift.end_h) - Math.max(j.start_h, shift.start_h))
      const timedBusy = timed.reduce((acc, j) => acc + clamp(j), 0)
      const untimedBusy = untimed.reduce((acc, j) => acc + j.dur, 0)
      const busyH = timedBusy + untimedBusy // ⇐ correctif : les jobs sans heure comptent (avant : ignorés ⇒ « 40 h libre » alors que plein)
      // DÉPLACEMENT le long de la tournée = temps INDISPONIBLE (le trajet n'est PAS du temps libre) → retranché du « libre ».
      // Corrige « le tech a de la place » alors que ses trous sont mangés par la route entre 2 villes (ex. Saint-Anicet ↔ Huntingdon).
      // Ordre estimé : jobs pointés dans l'ordre horaire, puis jobs SANS heure fixée à la suite — sinon une journée de
      // jobs legacy « sans heure » éparpillés paraît sans trajet, donc « libre » à tort (cas Houssam : 5 jobs, 0 pointé).
      const routeOrder = [...timed, ...untimed].filter(j => j.coords)
      let travelBusy = 0
      for (let i = 1; i < routeOrder.length; i++) travelBusy += travelH(routeOrder[i - 1].coords, routeOrder[i].coords)
      travelBusy = Math.min(travelBusy, Math.max(0, shiftH - busyH)) // borné au temps restant (jamais de free_h négatif)
      const blocks = timed.map(j => ({
        start: hoursToTime(Math.max(j.start_h, shift.start_h)), end: hoursToTime(Math.min(j.end_h, shift.end_h)),
        top: shiftH > 0 ? +Math.max(0, (Math.max(j.start_h, shift.start_h) - shift.start_h) / shiftH).toFixed(3) : 0,
        height: shiftH > 0 ? +Math.max(0, clamp(j) / shiftH).toFixed(3) : 0,
        reserved: j.reserved,
      }))
      // Jobs sans heure : empilés à partir de la charge horaire déjà placée, marqués « untimed » (rendu hachuré côté SPA).
      let cum = timedBusy
      for (const j of untimed) {
        blocks.push({
          start: null, end: null,
          top: shiftH > 0 ? +Math.min(1, cum / shiftH).toFixed(3) : 0,
          height: shiftH > 0 ? +Math.max(0.03, j.dur / shiftH).toFixed(3) : 0,
          reserved: j.reserved, untimed: true,
        })
        cum += j.dur
      }
      return {
        date: dateStr, dow, off: false, shift_start: hoursToTime(shift.start_h), shift_end: hoursToTime(shift.end_h),
        shift_h: +shiftH.toFixed(1), busy_h: +busyH.toFixed(1), travel_h: +travelBusy.toFixed(1), free_h: +Math.max(0, shiftH - busyH - travelBusy).toFixed(1),
        jobs: dayJobsRaw.length, untimed_jobs: untimed.length,
        occupancy: shiftH > 0 ? Math.min(1, +((busyH + travelBusy) / shiftH).toFixed(2)) : 0, blocks,
      }
    })
    const totShift = cells.reduce((a, c) => a + (c.shift_h || 0), 0)
    const totBusy = cells.reduce((a, c) => a + (c.busy_h || 0), 0)
    const totTravel = cells.reduce((a, c) => a + (c.travel_h || 0), 0)
    return {
      tech_id: tech.technician_id, tech_name: tech.full_name,
      shift_h: +totShift.toFixed(1), busy_h: +totBusy.toFixed(1), travel_h: +totTravel.toFixed(1), free_h: +Math.max(0, totShift - totBusy - totTravel).toFixed(1),
      occupancy: totShift > 0 ? Math.min(1, +((totBusy + totTravel) / totShift).toFixed(2)) : null, days: cells,
    }
  })
  // Le moins occupé d'abord = meilleurs candidats ; les techs sans aucun quart sur l'horizon en dernier.
  out.sort((a, b) => (a.occupancy == null ? 2 : a.occupancy) - (b.occupancy == null ? 2 : b.occupancy) || String(a.tech_name).localeCompare(b.tech_name))
  return { start: baseDate, days: span, dates, skill: wantSkills.join(','), skills: wantSkills, techs: out }
}

// Numéro de job STANDARDISÉ « YYYYMMDD-XXX » (compteur PAR JOUR). Le nom du Dispatch Job = ce champ (autoname field:ticket_id).
// Best-effort (max du jour + 1) ; le VRAI garde-fou anti-collision = l'unicité du nom à l'insertion (le créateur réessaie au besoin).
async function nextJobRef () {
  const ymd = String(todayET()).slice(0, 10).replace(/-/g, '')
  let max = 0
  try {
    const filters = encodeURIComponent(JSON.stringify([['name', 'like', ymd + '-%']]))
    const fields = encodeURIComponent(JSON.stringify(['name']))
    const r = await erpFetch(`/api/resource/Dispatch Job?filters=${filters}&fields=${fields}&limit_page_length=0`)
    const re = new RegExp('^' + ymd + '-(\\d{3})$')
    for (const j of ((r && r.data && r.data.data) || [])) { const m = re.exec(String(j.name || '')); if (m) { const n = parseInt(m[1], 10); if (n > max) max = n } }
  } catch (e) { log('nextJobRef: ' + e.message) }
  return ymd + '-' + String(max + 1).padStart(3, '0')
}

// Crée une CHAÎNE d'installation (projet + sous-tâches) en UN geste depuis un template (project-templates.js).
// Numéro du projet = ref YYYYMMDD-XXX partagé ; étapes = ref-s1..sN. Chaînage = patron éprouvé de checkout.js
// (étape racine 'open', dépendantes 'On Hold' jusqu'à complétion via depends_on ; parent_job = étape racine pour le regroupement).
async function createInstallChain ({ customer, customer_name, template, scheduled_date, priority, assigned_tech, address }) {
  const { getTemplateSteps } = require('./project-templates')
  const steps = getTemplateSteps(template || 'fiber_install')
  if (!steps.length) return { ok: false, error: 'template inconnu: ' + (template || 'fiber_install') }
  const ref = await nextJobRef()
  const created = [] // noms = ticket_id (autoname field:ticket_id → name === ticket_id) → chaînage déterministe, sans dépendre de la réponse POST
  try {
    for (let i = 0; i < steps.length; i++) {
      const step = steps[i]
      const ticketId = `${ref}-s${i + 1}`
      const dependsOn = (step.depends_on_step != null && created[step.depends_on_step]) ? created[step.depends_on_step] : ''
      const payload = {
        ticket_id: ticketId,
        subject: step.subject,
        customer: customer || '',
        address: address || '',
        duration_h: step.duration_h || 1,
        priority: priority || step.priority || 'medium',
        status: dependsOn ? 'On Hold' : 'open', // racine 'open', dépendantes 'On Hold' jusqu'à complétion (unblockDependents)
        job_type: step.job_type || 'Autre',
        assigned_group: step.assigned_group || '',
        assigned_tech: (i === 0 && assigned_tech) ? assigned_tech : '',
        depends_on: dependsOn,
        parent_job: created.length ? created[0] : '', // regroupement sous l'étape racine
        step_order: i + 1,
        scheduled_date: scheduled_date || '',
      }
      await erpFetch('/api/resource/Dispatch Job', { method: 'POST', body: JSON.stringify(payload) })
      created.push(ticketId)
    }
  } catch (e) {
    return { ok: false, error: 'échec étape ' + (created.length + 1) + ': ' + e.message, ref, jobs: created }
  }
  return { ok: true, ref, project: ref, jobs: created, count: created.length }
}

async function createDispatchJob ({ subject, address, priority, duration_h, job_type, customer, service_location, source_issue, notes, latitude, longitude, assigned_tech, scheduled_date }) {
  const ticketId = await nextJobRef()
  const payload = {
    ticket_id: ticketId,
    subject: subject || 'Travail urgent',
    address: address || '',
    duration_h: parseFloat(duration_h) || 1,
    priority: priority || 'high',
    status: assigned_tech ? 'assigned' : 'open',
    job_type: job_type || 'Dépannage',
    customer: customer || '',
    service_location: service_location || '',
    source_issue: source_issue || '',
    notes: notes || '',
    latitude: latitude || '',
    longitude: longitude || '',
    assigned_tech: assigned_tech || '',
    scheduled_date: scheduled_date || todayET(),
  }
  const r = await erpFetch('/api/resource/Dispatch Job', { method: 'POST', body: JSON.stringify(payload) })
  if (r.status < 200 || r.status >= 300) throw new Error('Failed to create dispatch job')
  return { success: true, job_id: r.data?.data?.name || ticketId, ...payload }
}

// ── Chain-walk helpers ──────────────────────────────────────────────────────
// A Dispatch Job can have `depends_on` → another Dispatch Job. When a job is
// created as part of a chain (acceptance.js → createDeferredJobs), the
// dependent child is born with status='On Hold' so it doesn't show up in the
// tech's active list. When the parent flips to 'Completed', we walk the chain:
// find all `depends_on == parent && status == 'On Hold'` and flip them to
// 'open' so the next step becomes visible.
//
// We also handle fan-out (multiple children of one parent) and fire SSE
// `job-unblocked` so the tech SPA can refresh without a poll.

async function unblockDependents (jobName) {
  if (!jobName) return []
  const filters = encodeURIComponent(JSON.stringify([
    ['depends_on', '=', jobName],
    ['status', '=', 'On Hold'],
  ]))
  const fields = encodeURIComponent(JSON.stringify(['name', 'subject', 'assigned_tech', 'scheduled_date']))
  const r = await erpFetch(`/api/resource/Dispatch%20Job?filters=${filters}&fields=${fields}&limit_page_length=20`)
  const deps = (r.status === 200 && Array.isArray(r.data?.data)) ? r.data.data : []

  const unblocked = []
  for (const j of deps) {
    try {
      const up = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(j.name)}`, {
        method: 'PUT', body: JSON.stringify({ status: 'open' }),
      })
      if (up.status < 400) {
        unblocked.push(j.name)
        require('./sse').broadcast('dispatch', 'job-unblocked', {
          job: j.name, subject: j.subject, tech: j.assigned_tech, unblocked_by: jobName,
        })
        log(`  ↳ unblocked ${j.name} (depends_on=${jobName})`)
      }
    } catch (e) { log(`  ! unblock failed for ${j.name}: ${e.message}`) }
  }
  return unblocked
}

// Chain terminal check — is `jobName` the LAST still-open job in its chain?
// A chain is identified by its root (parent_job === '' or self). A job is
// terminal when every sibling under the same root is Completed or Cancelled.
async function _isChainTerminal (jobName) {
  const jRes = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(jobName)}?fields=${encodeURIComponent(JSON.stringify(['parent_job']))}`)
  if (jRes.status !== 200 || !jRes.data?.data) return false
  const rootName = jRes.data.data.parent_job || jobName

  const stillOpen = ['open', 'Scheduled', 'In Progress', 'On Hold']
  const rootRes = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(rootName)}?fields=${encodeURIComponent(JSON.stringify(['status']))}`)
  if (rootRes.status === 200 && stillOpen.includes(rootRes.data?.data?.status)) return false

  const childFilter = encodeURIComponent(JSON.stringify([
    ['parent_job', '=', rootName],
    ['name', '!=', jobName],
    ['status', 'in', stillOpen],
  ]))
  const childRes = await erpFetch(`/api/resource/Dispatch%20Job?filters=${childFilter}&fields=${encodeURIComponent(JSON.stringify(['name']))}&limit_page_length=1`)
  return !(childRes.status === 200 && Array.isArray(childRes.data?.data) && childRes.data.data.length)
}

// Memoized company default income account — Sales Invoice items require
// an income_account that belongs to the company, and the default line-item
// resolution leaves it as None (→ 417 at validation time). We read the
// Company.default_income_account once per process and reuse it.
let _cachedIncomeAccount = null
async function _defaultIncomeAccount (company) {
  if (_cachedIncomeAccount) return _cachedIncomeAccount
  try {
    const r = await erpFetch(`/api/resource/Company/${encodeURIComponent(company)}?fields=${encodeURIComponent(JSON.stringify(['default_income_account']))}`)
    const acc = r.data?.data?.default_income_account
    if (acc) { _cachedIncomeAccount = acc; return acc }
  } catch (_) { /* fall through */ }
  // Hard-coded fallback for TARGO if the lookup failed for some reason
  _cachedIncomeAccount = 'Ventes - T'
  return _cachedIncomeAccount
}

// Activate every 'En attente' Service Subscription for (customer, service_location)
// belonging to the completed job, and emit ONE CONSOLIDATED prorated Sales Invoice
// (all lines on a single draft) via the proration engine — replacing the previous
// one-invoice-per-subscription behaviour. The invoice is built type-aware (recurring
// prorated, one-time full, discounts follow their line) with largest-remainder rounding.
// Returns { activated, invoice, invoices, written, prorated_total }.
async function activateSubscriptionForJob (jobName) {
  const jRes = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(jobName)}?fields=${encodeURIComponent(JSON.stringify(['customer', 'service_location']))}`)
  if (jRes.status !== 200 || !jRes.data?.data) return { activated: [], invoice: null, invoices: [], written: false }
  const { customer, service_location } = jRes.data.data
  if (!customer || !service_location) return { activated: [], invoice: null, invoices: [], written: false }

  // Subscriptions waiting on THIS activation — strictly 'En attente' so we NEVER
  // re-bill subscriptions that are already Actif from a prior (partial) activation.
  const subFilter = encodeURIComponent(JSON.stringify([
    ['customer', '=', customer],
    ['service_location', '=', service_location],
    ['status', '=', 'En attente'],
  ]))
  const subRes = await erpFetch(`/api/resource/Service%20Subscription?filters=${subFilter}&fields=${encodeURIComponent(JSON.stringify(['name']))}&limit_page_length=50`)
  const pending = (subRes.status === 200 && Array.isArray(subRes.data?.data)) ? subRes.data.data : []
  if (!pending.length) return { activated: [], invoice: null, invoices: [], written: false }

  // 1. ONE consolidated, type-aware, prorated draft invoice for exactly these pending subs.
  //    Built BEFORE flipping (the engine filters on status='En attente'). Draft-only and
  //    DOUBLE-GATED (commit:true + env PRORATION_WRITE=on): with the gate off it computes the
  //    preview but writes NOTHING, so activation never bills until explicitly armed.
  let billing = { written: false }
  try {
    billing = await require('./proration').commitActivationInvoice({
      customer, service_location, statuses: ['En attente'], commit: true,
    })
  } catch (e) { log(`  ! consolidated activation invoice failed: ${e.message}`) }

  // 2. Flip each pending subscription to Actif (start_date=tomorrow — activation day is free).
  const now = new Date()
  const tm = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
  const tomorrowStr = `${tm.getFullYear()}-${String(tm.getMonth() + 1).padStart(2, '0')}-${String(tm.getDate()).padStart(2, '0')}`
  const activated = []
  for (const sub of pending) {
    const up = await erpFetch(`/api/resource/Service%20Subscription/${encodeURIComponent(sub.name)}`, {
      method: 'PUT', body: JSON.stringify({ status: 'Actif', start_date: tomorrowStr }),
    })
    if (up.status >= 400) { log(`  ! activate ${sub.name} returned ${up.status}`); continue }
    activated.push(sub.name)
    log(`  ✓ Service Subscription ${sub.name} → Actif (start_date=${tomorrowStr})`)
  }

  const invoices = (billing.written && billing.draft_invoice)
    ? [{ name: billing.draft_invoice, amount: billing.prorated_total, consolidated: true }]
    : []
  if (billing.written && billing.draft_invoice) {
    log(`  ✓ Facture consolidée (BROUILLON) ${billing.draft_invoice} — ${billing.prorated_total}$ · ${billing.billed_days}/${billing.period_days} j · ${activated.length} abonnement(s)`)
  } else {
    log(`  • ${activated.length} abonnement(s) activé(s) — facture consolidée NON émise (écriture désarmée: ${billing.note || 'PRORATION_WRITE off'}) ; aperçu = ${billing.prorated_total != null ? billing.prorated_total + '$' : 'n/a'}`)
  }
  require('./sse').broadcast('dispatch', 'subscription-activated', {
    subscriptions: activated, invoice: billing.draft_invoice || null,
    amount: billing.prorated_total != null ? billing.prorated_total : null,
    customer, consolidated: true, preview_only: !billing.written,
  })

  return { activated, invoice: billing.draft_invoice || null, invoices, written: !!billing.written, prorated_total: billing.prorated_total != null ? billing.prorated_total : null }
}

// Delete a Dispatch Job safely by rewiring its chain neighbours first.
//
// Problem: ERPNext throws LinkExistsError if any other Dispatch Job has
// `depends_on = <victim>` or `parent_job = <victim>`. The UI's generic
// "Supprimer cette tâche" button hits this whenever the user tries to
// drop a middle step from a chain.
//
// Strategy: before DELETE, repoint the chain so the victim is isolated:
//   1. Children (depends_on = victim) → depends_on = victim.depends_on
//      (skips the victim in the chain; the successor's status recomputes
//      so it unblocks if it was On Hold and its new parent is Completed
//      or empty).
//   2. Descendants (parent_job = victim, only fires when victim was the
//      chain root) → parent_job = new root. The new root is chosen as the
//      lowest-step_order former child (now a chain root itself).
//   3. Finally DELETE the victim — should succeed since no FK references it.
//
// Returns { ok, deleted, rewired: [...], note }.
async function deleteJobSafely (jobName) {
  if (!jobName) throw new Error('jobName required')

  const vRes = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(jobName)}`)
  if (vRes.status !== 200 || !vRes.data?.data) throw new Error(`job ${jobName} not found`)
  const victim = vRes.data.data
  const victimDependsOn = victim.depends_on || ''
  const victimParentJob = victim.parent_job || ''

  // 1. Immediate successors in the chain
  const childFilters = encodeURIComponent(JSON.stringify([['depends_on', '=', jobName]]))
  const childFields  = encodeURIComponent(JSON.stringify(['name', 'status', 'step_order']))
  const cRes = await erpFetch(`/api/resource/Dispatch%20Job?filters=${childFilters}&fields=${childFields}&limit_page_length=50`)
  const children = (cRes.status === 200 && Array.isArray(cRes.data?.data)) ? cRes.data.data : []

  const rewired = []
  for (const child of children) {
    const patch = { depends_on: victimDependsOn }
    // Unblock if the new parent is empty or already Completed — otherwise the
    // child stays On Hold waiting on the new parent.
    if (child.status === 'On Hold') {
      let newParentDone = !victimDependsOn   // no parent → chain root → unblock
      if (victimDependsOn) {
        const pRes = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(victimDependsOn)}?fields=${encodeURIComponent(JSON.stringify(['status']))}`)
        const pStatus = pRes.data?.data?.status
        if (pStatus === 'Completed' || pStatus === 'done' || pStatus === 'Cancelled') newParentDone = true
      }
      if (newParentDone) patch.status = 'open'
    }
    const up = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(child.name)}`, {
      method: 'PUT', body: JSON.stringify(patch),
    })
    if (up.status < 400) {
      rewired.push({ name: child.name, depends_on: victimDependsOn, status: patch.status })
      log(`  ↻ ${child.name}: depends_on=${jobName} → ${victimDependsOn || '(none)'}${patch.status ? ` + status→${patch.status}` : ''}`)
    } else {
      log(`  ! rewire failed for ${child.name}: ${up.status}`)
    }
  }

  // 2. Was the victim a chain root? If so, any job with parent_job=victim
  //    needs a new root. Pick the former lowest-step child (now the new head).
  if (!victimParentJob) {
    const descFilters = encodeURIComponent(JSON.stringify([['parent_job', '=', jobName]]))
    const descFields  = encodeURIComponent(JSON.stringify(['name']))
    const dRes = await erpFetch(`/api/resource/Dispatch%20Job?filters=${descFilters}&fields=${descFields}&limit_page_length=100`)
    const descendants = (dRes.status === 200 && Array.isArray(dRes.data?.data)) ? dRes.data.data : []
    if (descendants.length) {
      // Former children became roots; pick the lowest-step one as the new
      // canonical root (so a chain with fan-out survives as a single chain).
      const sortedKids = [...children].sort((a, b) => (a.step_order || 0) - (b.step_order || 0))
      const newRoot = (sortedKids[0] && sortedKids[0].name) || ''
      for (const d of descendants) {
        if (d.name === newRoot) continue   // the new root has parent_job='' (done above via children loop when we rewired depends_on; parent_job still references victim though — set to '')
        const up = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(d.name)}`, {
          method: 'PUT', body: JSON.stringify({ parent_job: newRoot || '' }),
        })
        if (up.status < 400) log(`  ↻ ${d.name}: parent_job=${jobName} → ${newRoot || '(none)'}`)
      }
      // And blank the new root's parent_job (it's the root now)
      if (newRoot) {
        await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(newRoot)}`, {
          method: 'PUT', body: JSON.stringify({ parent_job: '' }),
        }).catch(() => {})
      }
    }
  }

  // 3. Delete the victim
  const dRes = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(jobName)}`, { method: 'DELETE' })
  if (dRes.status >= 400) {
    const msg = dRes.data?.exception || dRes.data?._error_message || `HTTP ${dRes.status}`
    throw new Error(msg)
  }
  require('./sse').broadcast('dispatch', 'job-deleted', { job: jobName, rewired: rewired.map(r => r.name) })
  log(`  ✗ deleted ${jobName} (rewired ${rewired.length} child${rewired.length === 1 ? '' : 'ren'})`)
  return { ok: true, deleted: jobName, rewired, note: children.length ? `Rewired ${children.length} dependent job(s) before deletion.` : 'No dependents — clean delete.' }
}

// Thin wrapper that PUTs the status + runs unblock logic. Used by
// tech-mobile (token auth) and the ops SPA (session auth) through a single
// code path — status changes always walk the chain, no matter who wrote them.
async function setJobStatusWithChain (jobName, status) {
  const r = await erpFetch(`/api/resource/Dispatch%20Job/${encodeURIComponent(jobName)}`, {
    method: 'PUT', body: JSON.stringify({ status }),
  })
  if (r.status >= 400) throw new Error(`ERPNext ${r.status}`)
  require('./sse').broadcast('dispatch', 'job-status', { job: jobName, status })
  const unblocked = status === 'Completed' ? await unblockDependents(jobName) : []

  // Terminal-node detection: if this Completed job is the last open one in
  // the chain (no unblocked dependents AND no other siblings pending),
  // activate the linked Service Subscription and emit a prorated invoice.
  let activation = { activated: [], invoices: [] }
  if (status === 'Completed' && unblocked.length === 0) {
    if (await _isChainTerminal(jobName)) {
      if (process.env.BILLING_APPROVAL_GATE === 'on') {
        // P1 : la complétion ne facture/n'active plus directement — elle place l'activation
        // « à approuver ». L'activation (puis la facturation, gatée PRORATION_WRITE) se font
        // à l'approbation explicite dans Ops (cf docs/design/activation-billing-automation.md).
        const pending = require('./billing-approvals').markPending(jobName)
        activation = { pending_approval: pending, activated: [], invoices: [] }
        log(`  ⏸ ${jobName} terminal → EN ATTENTE D'APPROBATION facturation (aucune activation/facture)`)
      } else {
        activation = await activateSubscriptionForJob(jobName)
      }
    }
  }

  return { ok: true, job: jobName, status, unblocked, ...activation }
}

async function handle (req, res, method, path) {
  const sub = path.replace('/dispatch/', '')

  // POST /dispatch/job-status — update status + auto-unblock dependents
  // This is the canonical status-write endpoint. Anything that flips a
  // Dispatch Job status (tech SPA, dispatcher SPA, tech-mobile token page)
  // should go through here so the chain-walk runs in exactly one place.
  if (sub === 'job-status' && method === 'POST') {
    try {
      const body = await parseBody(req)
      if (!body?.job || !body?.status) return json(res, 400, { error: 'job and status required' })
      const result = await setJobStatusWithChain(body.job, body.status)
      return json(res, 200, result)
    } catch (e) {
      log('job-status error:', e.message)
      return json(res, 500, { error: e.message })
    }
  }

  // POST /dispatch/job-delete — safe delete that rewires chain neighbours.
  // The raw ERPNext DELETE fails with LinkExistsError whenever any sibling
  // references the victim via depends_on/parent_job. This endpoint unlinks
  // descendants first (see deleteJobSafely doc comment) then deletes.
  if (sub === 'job-delete' && method === 'POST') {
    try {
      const body = await parseBody(req)
      if (!body?.job) return json(res, 400, { error: 'job required' })
      const result = await deleteJobSafely(body.job)
      return json(res, 200, result)
    } catch (e) {
      log('job-delete error:', e.message)
      return json(res, 500, { error: e.message })
    }
  }

  // GET /dispatch/history?date=YYYY-MM-DD[&tech=TECH-x] — TOUS les jobs d'une journée, statuts INCLUS (Completed/Cancelled), groupés par technicien.
  // Corrige la perte de visibilité : le board n'affiche que open/assigned/in_progress → une fois un job terminé/annulé (ou son ticket fermé/réassigné) on ne voyait plus où le tech était allé.
  if (sub.startsWith('history') && method === 'GET') {
    try {
      const params = require('url').parse(req.url, true).query
      const date = params.date || todayET()
      const filters = [['scheduled_date', '=', date]]
      if (params.tech) filters.push(['assigned_tech', '=', params.tech])
      const fields = ['name', 'subject', 'status', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'job_type', 'source_issue', 'customer', 'actual_start', 'actual_end']
      const qs = new URLSearchParams({ filters: JSON.stringify(filters), fields: JSON.stringify(fields), limit_page_length: '500', order_by: 'assigned_tech asc, start_time asc, modified asc' })
      const r = await erpFetch(`/api/resource/Dispatch Job?${qs}`)
      if (r.status !== 200) return json(res, 500, { error: 'ERPNext ' + r.status })
      const jobs = r.data?.data || []
      const tr = await erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify(['technician_id', 'full_name']))}&limit_page_length=200`)
      const names = {}; for (const t of (tr.data?.data || [])) names[t.technician_id] = t.full_name || t.technician_id
      const groups = {}
      for (const j of jobs) { const k = j.assigned_tech || '__none__'; (groups[k] = groups[k] || { tech: k, tech_name: k === '__none__' ? 'Non assigné' : (names[k] || k), jobs: [] }).jobs.push(j) }
      return json(res, 200, { date, count: jobs.length, techs: Object.values(groups).sort((a, b) => String(a.tech_name).localeCompare(String(b.tech_name))) })
    } catch (e) { log('dispatch history error: ' + e.message); return json(res, 500, { error: e.message }) }
  }

  // GET /dispatch/audit?job=<name>[&tech=TECH-x][&from=ISO][&to=ISO] — journal d'audit d'assignation (qui a eu le job, quand, chaque changement horodaté).
  if (sub.startsWith('audit') && method === 'GET') {
    const q = require('url').parse(req.url, true).query
    const rows = require('./dispatch-audit').read({ job: q.job, tech: q.tech, from: q.from, to: q.to, limit: Math.min(2000, parseInt(q.limit || '500', 10)) })
    return json(res, 200, { count: rows.length, rows })
  }

  // GET /dispatch/leaderboard?from=YYYY-MM-DD&to=YYYY-MM-DD — classement des techs par jobs COMPLÉTÉS (reconnaissance / gamification).
  if (sub.startsWith('leaderboard') && method === 'GET') {
    try {
      const params = require('url').parse(req.url, true).query
      const to = params.to || todayET(); const from = params.from || dateAddDays(to, -30)
      const filters = [['status', '=', 'Completed'], ['scheduled_date', '>=', from], ['scheduled_date', '<=', to]]
      const qs = new URLSearchParams({ filters: JSON.stringify(filters), fields: JSON.stringify(['assigned_tech']), limit_page_length: '5000' })
      const r = await erpFetch(`/api/resource/Dispatch Job?${qs}`)
      if (r.status !== 200) return json(res, 500, { error: 'ERPNext ' + r.status })
      const counts = {}; for (const j of (r.data?.data || [])) { if (j.assigned_tech) counts[j.assigned_tech] = (counts[j.assigned_tech] || 0) + 1 }
      const tr = await erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify(['technician_id', 'full_name']))}&limit_page_length=200`)
      const names = {}; for (const t of (tr.data?.data || [])) names[t.technician_id] = t.full_name || t.technician_id
      const board = Object.keys(counts).map(k => ({ tech: k, tech_name: names[k] || k, completed: counts[k] })).sort((a, b) => b.completed - a.completed)
      return json(res, 200, { from, to, board })
    } catch (e) { log('dispatch leaderboard error: ' + e.message); return json(res, 500, { error: e.message }) }
  }

  // POST /dispatch/best-tech — find optimal tech for a job location
  if (sub === 'best-tech' && method === 'POST') {
    try {
      const body = await parseBody(req)
      const dateStr = body.date || todayET()
      const jobCoords = body.latitude && body.longitude ? [parseFloat(body.longitude), parseFloat(body.latitude)] : null
      const techs = await getTechsWithLoad(dateStr)
      await enrichWithGps(techs)
      const ranked = rankTechs(techs, jobCoords, parseFloat(body.duration_h) || 1)
      return json(res, 200, { ranking: ranked })
    } catch (e) {
      log('best-tech error:', e.message)
      return json(res, 500, { error: e.message })
    }
  }

  // POST /dispatch/suggest-slots — return 5 best available time windows
  if (sub === 'suggest-slots' && method === 'POST') {
    try {
      const body = await parseBody(req)
      const slots = await suggestSlots(body)
      return json(res, 200, { slots })
    } catch (e) {
      log('suggest-slots error:', e.message)
      return json(res, 500, { error: e.message })
    }
  }

  // POST /dispatch/occupancy — occupation par ressource (tech) sur l'horizon, filtrée par compétence → bandes « Trouver un créneau »
  if (sub === 'occupancy' && method === 'POST') {
    try {
      const body = await parseBody(req)
      return json(res, 200, await techOccupancy(body))
    } catch (e) {
      log('occupancy error:', e.message)
      return json(res, 500, { error: e.message })
    }
  }

  // GET /dispatch/group-jobs?group=Tech+Targo&exclude_tech=TECH-001
  //   List unassigned jobs that a tech can self-claim. The tech PWA uses this
  //   to render the "Tâches du groupe" subscription feed.
  //
  //   Filters applied server-side:
  //     - status in ['open', 'Scheduled']   (no 'On Hold' — those are chain-gated)
  //     - assigned_tech empty/null
  //     - optional: assigned_group == group (when provided)
  //     - exclude current tech so the list only shows claimable work
  // Mint un numéro de job standardisé YYYYMMDD-XXX (le front crée directement dans ERPNext → il frappe le numéro ici d'abord).
  if (sub === 'next-ref' && method === 'GET') {
    return json(res, 200, { ref: await nextJobRef() })
  }
  // Crée une chaîne d'installation (projet + sous-tâches) en un geste depuis un template.
  if (sub === 'create-install' && method === 'POST') {
    return json(res, 200, await createInstallChain((await parseBody(req)) || {}))
  }

  if (sub === 'group-jobs' && method === 'GET') {
    try {
      const params = require('url').parse(req.url, true).query
      const group = params.group || ''
      const filters = [
        ['status', 'in', ['open', 'Scheduled']],
        // Frappe API requires `is not set` for empty-link queries (not = '').
        ['assigned_tech', 'is', 'not set'],
      ]
      if (group) filters.push(['assigned_group', '=', group])
      // `_name` fields (customer_name, service_location_name) are "fetched"
      // (fetch_from → Customer.customer_name) and Frappe blocks them from
      // list queries. Pull the base links here and enrich client-side via
      // a second batch query below. We also keep `scheduled_time` out of
      // the list query — it's not marked queryable on this doctype — and
      // re-add it per-job via the enrichment loop.
      const fields = ['name', 'subject', 'customer', 'service_location',
        'scheduled_date', 'priority', 'assigned_group',
        'job_type', 'duration_h', 'source_issue']
      const qs = new URLSearchParams({
        filters: JSON.stringify(filters),
        fields: JSON.stringify(fields),
        limit_page_length: '50',
        order_by: 'scheduled_date asc, modified desc',
      })
      const r = await erpFetch(`/api/resource/Dispatch Job?${qs}`)
      if (r.status !== 200) return json(res, 500, { error: 'ERPNext ' + r.status, body: r.data?.exception?.slice(0, 200) })

      const jobs = r.data?.data || []
      // Enrich with customer_name + service_location_name so the tech PWA can
      // render address/customer without N extra round-trips. Parallel fetch
      // with a small cache in case multiple jobs share the same customer.
      const custNames = new Map()
      const locNames = new Map()
      await Promise.all(jobs.map(async j => {
        if (j.customer && !custNames.has(j.customer)) {
          try {
            const cr = await erpFetch(`/api/resource/Customer/${encodeURIComponent(j.customer)}?fields=["customer_name"]`)
            if (cr.status === 200) custNames.set(j.customer, cr.data?.data?.customer_name || j.customer)
          } catch { custNames.set(j.customer, j.customer) }
        }
        if (j.service_location && !locNames.has(j.service_location)) {
          try {
            const lr = await erpFetch(`/api/resource/Service%20Location/${encodeURIComponent(j.service_location)}?fields=["address_line_1","city"]`)
            if (lr.status === 200) {
              const d = lr.data?.data || {}
              locNames.set(j.service_location, [d.address_line_1, d.city].filter(Boolean).join(', ') || j.service_location)
            }
          } catch { locNames.set(j.service_location, j.service_location) }
        }
      }))
      for (const j of jobs) {
        j.customer_name = custNames.get(j.customer) || ''
        j.service_location_name = locNames.get(j.service_location) || ''
      }
      return json(res, 200, { jobs })
    } catch (e) {
      log('group-jobs error:', e.message)
      return json(res, 500, { error: e.message })
    }
  }

  // POST /dispatch/claim-job { job, tech_id }
  //   Tech self-assignment. Accepts only jobs that are truly up for grabs
  //   (status open/Scheduled + no assigned_tech). Idempotent per-tech: a tech
  //   re-claiming a job they already own returns 200 (no-op). Another tech
  //   trying to grab it returns 409 so the UI can refresh.
  if (sub === 'claim-job' && method === 'POST') {
    try {
      const body = await parseBody(req)
      if (!body.job || !body.tech_id) return json(res, 400, { error: 'job and tech_id required' })
      const jobName = body.job
      const techId = body.tech_id

      const r = await erpFetch(`/api/resource/Dispatch Job/${encodeURIComponent(jobName)}`)
      if (r.status !== 200) return json(res, 404, { error: 'Job not found' })
      const job = r.data.data
      if (job.assigned_tech && job.assigned_tech !== techId) {
        return json(res, 409, { error: 'Déjà pris par un autre technicien', assigned_to: job.assigned_tech })
      }
      if (job.assigned_tech === techId) {
        return json(res, 200, { ok: true, job: jobName, tech: techId, note: 'already yours' })
      }
      if (!['open', 'Scheduled'].includes(job.status)) {
        return json(res, 409, { error: `Statut "${job.status}" — ce travail n'est pas disponible` })
      }

      const u = await erpFetch(`/api/resource/Dispatch Job/${encodeURIComponent(jobName)}`, {
        method: 'PUT',
        body: JSON.stringify({ assigned_tech: techId, status: 'assigned' }),
      })
      if (u.status !== 200) return json(res, 500, { error: 'Update failed', erp: u.data })

      // Broadcast so other techs' "Tâches du groupe" feeds refresh and the job
      // disappears from their claimable list.
      try {
        require('./sse').broadcast('dispatch', 'job-claimed', {
          job: jobName, tech: techId, subject: job.subject || '',
        })
      } catch { /* sse best-effort */ }

      log(`[dispatch] ${techId} claimed ${jobName}`)
      return json(res, 200, { ok: true, job: jobName, tech: techId })
    } catch (e) {
      log('claim-job error:', e.message)
      return json(res, 500, { error: e.message })
    }
  }

  // POST /dispatch/create-job — create + optionally auto-assign to best tech
  if (sub === 'create-job' && method === 'POST') {
    try {
      const body = await parseBody(req)
      let assignedTech = body.assigned_tech

      if (body.auto_assign) {
        const dateStr = body.scheduled_date || todayET()
        const jobCoords = body.latitude && body.longitude ? [parseFloat(body.longitude), parseFloat(body.latitude)] : null
        const techs = await getTechsWithLoad(dateStr)
        await enrichWithGps(techs)
        const ranked = rankTechs(techs, jobCoords, parseFloat(body.duration_h) || 1)
        if (ranked.length) {
          assignedTech = ranked[0].techId
          log(`Auto-assigned to ${ranked[0].techName} (score: ${ranked[0].score.toFixed(0)}, ${ranked[0].reasons.join(', ')})`)
        }
      }

      const result = await createDispatchJob({ ...body, assigned_tech: assignedTech })
      return json(res, 200, result)
    } catch (e) {
      log('create-job error:', e.message)
      return json(res, 500, { error: e.message })
    }
  }

  json(res, 404, { error: 'Dispatch endpoint not found' })
}

async function agentCreateDispatchJob ({ customer_id, service_location, address: addressIn, subject, priority, job_type, notes, auto_assign }) {
  let address = addressIn || '', latitude = null, longitude = null
  if (service_location) {
    const locRes = await erpFetch(`/api/resource/Service Location/${encodeURIComponent(service_location)}`)
    if (locRes.status === 200) {
      const loc = locRes.data.data
      address = [loc.address_line, loc.city, loc.postal_code].filter(Boolean).join(', ') || address
      latitude = loc.latitude || null
      longitude = loc.longitude || null
    }
  }

  const dateStr = todayET()
  let assignedTech = null, techInfo = null

  if (auto_assign !== false) {
    try {
      const jobCoords = latitude && longitude ? [parseFloat(longitude), parseFloat(latitude)] : null
      const techs = await getTechsWithLoad(dateStr)
      await enrichWithGps(techs)
      const ranked = rankTechs(techs, jobCoords, 1)
      if (ranked.length) {
        assignedTech = ranked[0].techId
        techInfo = { name: ranked[0].techName, phone: ranked[0].phone, distance: ranked[0].distance, reasons: ranked[0].reasons }
        log(`Agent auto-assigned to ${ranked[0].techName}`)
      }
    } catch (e) { log('Agent auto-assign error:', e.message) }
  }

  const result = await createDispatchJob({
    subject: subject || 'Intervention urgente',
    address, latitude, longitude,
    priority: priority || 'high',
    duration_h: 1,
    job_type: job_type || 'Dépannage',
    customer: customer_id || '',
    service_location: service_location || '',
    notes: notes || '',
    assigned_tech: assignedTech,
    scheduled_date: dateStr,
  })

  return {
    success: true,
    job_id: result.job_id,
    assigned_tech: techInfo ? `${techInfo.name} (${techInfo.reasons.join(', ')})` : 'Aucun tech disponible — job créé en attente',
    address: result.address,
    message: techInfo ? `Travail créé et assigné à ${techInfo.name}` : 'Travail créé, en attente d\'assignation',
  }
}

module.exports = { handle, agentCreateDispatchJob, rankTechs, getTechsWithLoad, enrichWithGps, suggestSlots, techOccupancy, unblockDependents, setJobStatusWithChain, activateSubscriptionForJob, deleteJobSafely, nextJobRef, createInstallChain }
