'use strict'
/**
 * roster.js — Planification (« Roster AI »).
 *
 * Orchestre le solveur d'horaires OR-Tools (service roster-solver) avec les
 * données réelles de l'ERPNext de facturation :
 *   - techniciens HUMAINS  : Dispatch Technician (resource_type='human')
 *   - compétences          : tags (_user_tags) du tech
 *   - disponibilité        : status ('En pause'), absence_from/until, Tech Availability (Approuvé)
 *   - modèles de shifts     : Shift Template
 *   - besoins de couverture : Shift Requirement (→ « dispo vs requis »)
 *   - assignations          : Shift Assignment (statut Proposé/Publié)
 *
 * Le solveur ne fait QUE proposer ; /publish écrit les Shift Assignment.
 * Aucune paie : on planifie + approuve, c'est tout.
 *
 * Routes (préfixe /roster) :
 *   GET  /roster/technicians                 → techs humains + skills + indispos
 *   GET  /roster/templates                   → modèles de shifts
 *   POST /roster/templates                   → créer un modèle
 *   GET  /roster/requirements?start=&days=   → besoins de couverture
 *   POST /roster/requirements                → créer un besoin
 *   GET  /roster/assignments?start=&days=    → assignations existantes
 *   GET  /roster/coverage?start=&days=       → dispo vs requis (par besoin)
 *   POST /roster/generate {start,days,weights} → propose un horaire (n'écrit rien)
 *   POST /roster/publish {assignments}       → écrit les Shift Assignment (Publié)
 *   POST /roster/availability {…}            → demande congé/pause (Tech Availability)
 *   POST /roster/availability/:name/approve  → approuve une demande
 *   POST /roster/technician/:id/pause {paused,reason} → met/retire un tech en pause
 */
const http = require('http')
const crypto = require('crypto')
const { json, parseBody } = require('./helpers')
const erp = require('./erp')
const cfg = require('./config')

const SOLVER_URL = cfg.ROSTER_SOLVER_URL || 'http://roster-solver:8090'
const PAUSE_STATUS = 'En pause'
const AVAIL_STATUS = 'Disponible'

// ── Date helpers (local, sans dépendance) ──────────────────────────────────
function iso (d) { return d.toISOString().slice(0, 10) }
function parseISO (s) { const [y, m, dd] = s.split('-').map(Number); return new Date(Date.UTC(y, m - 1, dd)) }
function addDays (d, n) { const r = new Date(d); r.setUTCDate(r.getUTCDate() + n); return r }
function rangeDates (start, days) {
  const s = parseISO(start); const out = []
  for (let i = 0; i < days; i++) out.push(iso(addDays(s, i)))
  return out
}
function splitCsv (s) {
  return String(s || '').split(',').map(x => x.trim()).filter(Boolean)
}

// POST au solveur via le module http natif (comme erpFetch — fiable dans le
// process long du hub, contrairement au fetch global undici).
function postSolver (path, body) {
  const data = JSON.stringify(body)
  const u = new URL(SOLVER_URL + path)
  return new Promise((resolve, reject) => {
    const req = http.request({
      hostname: u.hostname, port: u.port || 80, path: u.pathname + u.search, method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) },
      timeout: 30000,
    }, (res) => {
      let d = ''
      res.on('data', c => { d += c })
      res.on('end', () => { try { resolve(JSON.parse(d)) } catch { resolve({ status: 'ERROR', message: 'réponse solveur invalide' }) } })
    })
    req.on('error', reject)
    req.on('timeout', () => { req.destroy(); reject(new Error('solveur: timeout')) })
    req.write(data); req.end()
  })
}

const sleep = (ms) => new Promise(r => setTimeout(r, ms))
// Réessai des écritures ERPNext. Le shim frappe_pg tourne en SERIALIZABLE → sur
// les lignes chaudes (Dispatch Technician maj en continu par le GPS/dispatch) un
// SELECT…FOR UPDATE peut lever "could not serialize access due to concurrent
// update" (HTTP 500). La requête a été rollback → réessayer est sûr (idempotent).
async function retryWrite (fn, tries = 5) {
  let r
  for (let i = 0; i < tries; i++) {
    r = await fn()
    if (r.ok || (r.status && r.status < 500)) return r
    await sleep(120 * (i + 1))
  }
  return r
}

// ── Lecture des techniciens humains + compétences + indisponibilités ────────
async function fetchTechnicians () {
  const rows = await erp.list('Dispatch Technician', {
    filters: [['resource_type', '=', 'human']],
    fields: ['name', 'technician_id', 'full_name', 'status', 'color_hex', 'tech_group', 'efficiency', 'skills',
      'cost_salary_h', 'cost_charges_pct', 'cost_other_h',
      'absence_from', 'absence_until', 'employee', 'phone', '_user_tags'],
    limit: 500,
  })
  return rows.map(t => ({
    id: t.technician_id || t.name,
    name: t.full_name || t.technician_id,
    status: t.status,
    group: t.tech_group || '',
    efficiency: Number(t.efficiency) || 1,
    cost_salary_h: Number(t.cost_salary_h) || 0,
    cost_charges_pct: Number(t.cost_charges_pct) || 0,
    cost_other_h: Number(t.cost_other_h) || 0,
    cost_h: Math.round(((Number(t.cost_salary_h) || 0) * (1 + (Number(t.cost_charges_pct) || 0) / 100) + (Number(t.cost_other_h) || 0)) * 100) / 100,
    color: t.color_hex || '#1976d2',
    phone: t.phone,
    employee: t.employee,
    skills: splitCsv(t.skills || t._user_tags),  // champ skills (ou tags Frappe)
    absence_from: t.absence_from,
    absence_until: t.absence_until,
  }))
}

// Construit, pour chaque tech, la liste des dates indisponibles dans l'horizon.
async function buildUnavailability (techs, dateList) {
  const start = dateList[0]
  const end = dateList[dateList.length - 1]
  const byTech = {}
  for (const t of techs) byTech[t.id] = new Set()

  // 1) status « En pause » → indispo sur tout l'horizon (pause active)
  for (const t of techs) {
    if (t.status === PAUSE_STATUS) dateList.forEach(d => byTech[t.id].add(d))
    // 2) fenêtre d'absence du Dispatch Technician
    if (t.absence_from && t.absence_until) {
      for (const d of dateList) if (d >= t.absence_from && d <= t.absence_until) byTech[t.id].add(d)
    }
  }
  // 3) Tech Availability approuvées qui chevauchent l'horizon
  const avs = await erp.list('Tech Availability', {
    filters: [['status', '=', 'Approuvé'], ['from_date', '<=', end], ['to_date', '>=', start]],
    fields: ['technician', 'from_date', 'to_date', 'availability_type'],
    limit: 500,
  })
  for (const a of avs) {
    if (!byTech[a.technician]) continue
    for (const d of dateList) if (d >= a.from_date && d <= a.to_date) byTech[a.technician].add(d)
  }
  return byTech
}

// ── Modèles + besoins ───────────────────────────────────────────────────────
async function fetchTemplates () {
  const rows = await erp.list('Shift Template', {
    filters: [['active', '=', 1]],
    fields: ['name', 'template_name', 'start_time', 'end_time', 'hours', 'color', 'zone', 'default_required', 'required_skills'],
    limit: 100,
  })
  return rows
}

async function fetchRequirements (start, days) {
  const dates = rangeDates(start, days)
  return erp.list('Shift Requirement', {
    filters: [['requirement_date', 'in', dates]],
    fields: ['name', 'requirement_date', 'shift_template', 'zone', 'required_count', 'required_skills'],
    limit: 1000,
  })
}

async function fetchAssignments (start, days) {
  const dates = rangeDates(start, days)
  const rows = await erp.list('Shift Assignment', {
    filters: [['assignment_date', 'in', dates]],
    fields: ['name', 'technician', 'technician_name', 'assignment_date', 'shift_template', 'zone', 'hours', 'status', 'source'],
    limit: 2000,
  })
  // Normaliser vers la forme canonique {tech, date, shift} (= sortie du solveur + UI)
  return rows.map(r => ({
    name: r.name, tech: r.technician, tech_name: r.technician_name, date: r.assignment_date,
    shift: r.shift_template, zone: r.zone, hours: r.hours, status: r.status, source: r.source,
  }))
}

// ── Construit le payload du solveur + l'appelle ─────────────────────────────
async function generate (start, days, weights) {
  const dateList = rangeDates(start, days)
  // Séquentiel volontaire : le backend frappe (peu de workers) reset des
  // connexions sous rafale concurrente → erp.list renvoie [] par intermittence.
  const techs = await fetchTechnicians()
  const templates = await fetchTemplates()
  const requirements = await fetchRequirements(start, days)
  const unavail = await buildUnavailability(techs, dateList)

  const shift_templates = templates.map(t => ({
    id: t.name, name: t.template_name || t.name,
    hours: Number(t.hours) || 8,
  }))

  const technicians = techs.map(t => ({
    id: t.id, name: t.name, skills: t.skills,
    max_hours_week: 40, max_days: 5, cost_per_h: t.cost_h || 0,
    zone_home: null, preferred_off: [], time_factor: t.efficiency || 1,
    unavailable: [...unavail[t.id]],
  }))

  const coverage = requirements.map(r => ({
    date: r.requirement_date, shift: r.shift_template, zone: r.zone || '—',
    required: Number(r.required_count) || 1,
    required_skills: splitCsv(r.required_skills),
  }))

  const payload = { horizon: { start, days }, shift_templates, technicians, coverage, weights: weights || undefined, max_seconds: 12 }

  const result = await postSolver('/solve', payload)
  // enrichir avec le nom + couleur pour l'UI
  const nameById = Object.fromEntries(techs.map(t => [t.id, t.name]))
  const colorByTpl = Object.fromEntries(templates.map(t => [t.name, t.color || '#1976d2']))
  for (const a of (result.assignments || [])) {
    a.tech_name = nameById[a.tech] || a.tech
    a.color = colorByTpl[a.shift] || '#1976d2'
  }
  return { ...result, counts: { technicians: technicians.length, templates: shift_templates.length, requirements: coverage.length } }
}

// Écrit les assignations retenues comme Shift Assignment (Publié).
async function publish (assignments) {
  const created = []; const errors = []
  for (const a of assignments || []) {
    const r = await retryWrite(() => erp.create('Shift Assignment', {
      technician: a.tech, technician_name: a.tech_name || '',
      assignment_date: a.date, shift_template: a.shift, zone: a.zone || '',
      hours: Number(a.hours) || 0, status: 'Publié', source: a.source || 'solveur',
    }))
    if (r.ok) created.push(r.name); else errors.push({ a, error: r.error })
  }
  return { ok: errors.length === 0, created: created.length, errors }
}

// dispo vs requis : pour chaque besoin, compte les assignations publiées correspondantes
async function coverage (start, days) {
  const reqs = await fetchRequirements(start, days)
  const asgs = await fetchAssignments(start, days)
  const key = (d, s, z) => `${d}|${s}|${z || '—'}`
  const counts = {}
  for (const a of asgs) {
    if (a.status === 'Annulé') continue
    counts[key(a.date, a.shift, a.zone)] = (counts[key(a.date, a.shift, a.zone)] || 0) + 1
  }
  return reqs.map(r => {
    const assigned = counts[key(r.requirement_date, r.shift_template, r.zone)] || 0
    const required = Number(r.required_count) || 0
    return { date: r.requirement_date, shift: r.shift_template, zone: r.zone || '—', required, assigned, shortfall: Math.max(0, required - assigned) }
  })
}

// ── Prise de RDV : disponibilité consciente du roster ──────────────────────
// Renvoie les fenêtres libres où un tech EN SHIFT publié ce jour-là, avec la
// compétence requise, est disponible (trous dans son shift moins les jobs déjà
// pointés). Sert aux 2 canaux : on propose au client, ou on valide son choix.
function timeToH (t) { if (!t) return 0; const [h, m] = String(t).split(':').map(Number); return (h || 0) + (m || 0) / 60 }
function hToTime (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') }

async function loadBookingData (start, days) {
  const dates = rangeDates(start, days)
  const asgs = await fetchAssignments(start, days)
  const techs = await fetchTechnicians()
  const templates = await fetchTemplates()
  const techById = Object.fromEntries(techs.map(t => [t.id, t]))
  const tplByName = Object.fromEntries(templates.map(t => [t.name, t]))
  const jobs = await erp.list('Dispatch Job', {
    filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned']]],
    fields: ['assigned_tech', 'scheduled_date', 'start_time', 'duration_h'], limit: 2000,
  })
  const booked = {}
  for (const j of jobs) {
    if (!j.start_time) continue
    const k = j.assigned_tech + '|' + j.scheduled_date
    ;(booked[k] || (booked[k] = [])).push({ s: timeToH(j.start_time), e: timeToH(j.start_time) + (Number(j.duration_h) || 1) })
  }
  return { asgs, techById, tplByName, booked }
}

// Trous libres d'un tech (dans son shift, moins jobs pointés), filtrés compétence/zone.
function techGaps (a, d, skill, zone) {
  const t = d.techById[a.tech]; if (!t || t.status === PAUSE_STATUS) return null
  if (skill && !(t.skills || []).includes(skill)) return null
  if (zone && a.zone && a.zone !== zone) return null
  const tpl = d.tplByName[a.shift]; if (!tpl) return null
  const sh = timeToH(tpl.start_time) || 8; const eh = timeToH(tpl.end_time) || (sh + (Number(tpl.hours) || 8))
  const day = (d.booked[a.tech + '|' + a.date] || []).slice().sort((x, y) => x.s - y.s)
  let cursor = sh; const gaps = []
  for (const b of day) { if (b.s > cursor) gaps.push([cursor, b.s]); cursor = Math.max(cursor, b.e) }
  if (cursor < eh) gaps.push([cursor, eh])
  return { tech: t, gaps }
}

async function bookingSlots ({ skill, zone, duration = 1, start, days = 7, limit = 24, aggregate = false } = {}) {
  const dur = Number(duration) || 1
  const d = await loadBookingData(start, days)
  const out = []
  for (const a of d.asgs) {
    if (a.status === 'Annulé') continue
    const g = techGaps(a, d, skill, zone); if (!g) continue
    for (const [gs, ge] of g.gaps) { let s = gs; while (s + dur <= ge) { out.push({ date: a.date, start: hToTime(s), end: hToTime(s + dur), start_h: s, tech: a.tech, tech_name: g.tech.name, zone: a.zone || '', shift: a.shift }); s += dur } }
  }
  if (aggregate) {  // client : 1 fenêtre par (date,heure) + nb de techs dispo, sans exposer qui
    const byWin = {}
    for (const s of out) { const k = s.date + '|' + s.start; (byWin[k] || (byWin[k] = { date: s.date, start: s.start, end: s.end, start_h: s.start_h, available: 0 })).available++ }
    return Object.values(byWin).sort((x, y) => x.date.localeCompare(y.date) || x.start_h - y.start_h).slice(0, limit)
  }
  out.sort((x, y) => x.date.localeCompare(y.date) || x.start_h - y.start_h)
  return out.slice(0, limit)
}

// Fit : le client fournit 3 dispos classées → on place dans le 1er choix tenable,
// sinon 2e, sinon 3e. Si aucune ne tient → on PROPOSE nos créneaux (fallback).
async function fitBooking ({ skill, zone, duration = 1, prefs = [] } = {}) {
  const dur = Number(duration) || 1
  const dates = [...new Set((prefs || []).map(p => p.date).filter(Boolean))].sort()
  if (!dates.length) return { chosen: null, proposed: [] }
  const span = Math.max(1, Math.round((parseISO(dates[dates.length - 1]) - parseISO(dates[0])) / 86400000) + 1)
  const d = await loadBookingData(dates[0], span)
  const byDate = {}; for (const a of d.asgs) (byDate[a.date] || (byDate[a.date] = [])).push(a)
  for (let i = 0; i < prefs.length; i++) {
    const p = prefs[i]; const ps = timeToH(p.start); const pe = ps + dur
    for (const a of (byDate[p.date] || [])) {
      if (a.status === 'Annulé') continue
      const g = techGaps(a, d, skill, zone); if (!g) continue
      if (g.gaps.some(([gs, ge]) => gs <= ps && ge >= pe)) {
        return { chosen: { rank: i + 1, date: p.date, start: p.start, end: hToTime(pe), tech: a.tech, tech_name: g.tech.name, zone: a.zone || '', shift: a.shift }, proposed: [] }
      }
    }
  }
  const proposed = await bookingSlots({ skill, zone, duration: dur, start: dates[0], days: 14, limit: 6, aggregate: true })
  return { chosen: null, proposed }
}

// ── Portail public de prise de RDV (staging — PAS sur Lovable tant que non validé) ──
function todayET () { return new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) }
async function jobByToken (token) {
  if (!token) return null
  const rows = await erp.list('Dispatch Job', { filters: [['booking_token', '=', token]], fields: ['name', 'service_location', 'duration_h', 'scheduled_date', 'start_time', 'booking_status'], limit: 1 })
  return rows[0] || null
}
async function confirmWindow (jobName, date, start, duration) {
  const day = await bookingSlots({ duration, start: date, days: 1, limit: 300 })
  const slot = day.find(s => s.start === start)
  if (!slot) return { ok: false, message: 'Ce créneau vient d\'être pris — choisissez-en un autre.' }
  const st = start.length === 5 ? start + ':00' : start
  const r = await retryWrite(() => erp.update('Dispatch Job', jobName, { scheduled_date: date, start_time: st, assigned_tech: slot.tech, status: 'assigned', booking_status: 'Confirmé' }))
  return r.ok ? { ok: true, confirmed: true, date, start, tech: slot.tech_name } : { ok: false, message: r.error || 'échec' }
}

const BOOK_HTML = `<!doctype html><html lang=fr><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><title>Prendre rendez-vous — Gigafibre</title>
<style>body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;margin:0;background:#f4f6f8;color:#1a1a1a}.wrap{max-width:560px;margin:0 auto;padding:16px}.card{background:#fff;border-radius:12px;box-shadow:0 1px 4px rgba(0,0,0,.08);padding:20px;margin-bottom:12px}h1{font-size:20px;margin:0 0 4px}.sub{color:#666;font-size:13px;margin-bottom:8px}.brand{color:#1565c0;font-weight:800}.day{font-weight:700;margin:14px 0 6px;font-size:13px;color:#444}.slots{display:grid;grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:8px}.slot{border:1px solid #d0d7de;border-radius:8px;padding:10px 6px;text-align:center;cursor:pointer;font-size:14px;position:relative}.slot:hover{border-color:#1565c0}.slot.sel{background:#e3f2fd;border-color:#1565c0;font-weight:700}.rank{position:absolute;top:-8px;right:-8px;background:#1565c0;color:#fff;width:20px;height:20px;border-radius:50%;font-size:12px;line-height:20px}.btn{background:#1565c0;color:#fff;border:0;border-radius:8px;padding:13px 18px;font-size:15px;font-weight:600;cursor:pointer;width:100%;margin-top:12px}.btn:disabled{background:#b0bec5}.hint{font-size:12px;color:#666;margin:8px 0}.ok{background:#e8f5e9;color:#1b5e20;padding:16px;border-radius:8px;text-align:center}.err{background:#ffebee;color:#b71c1c;padding:12px;border-radius:8px}.week{background:#1565c0;color:#fff;font-weight:700;font-size:13px;padding:8px 12px;border-radius:8px;margin:18px 0 4px}.ampm{font-size:11px;color:#888;text-transform:uppercase;letter-spacing:.5px;margin:10px 0 4px;font-weight:700}.empty{color:#999;font-size:12px;font-style:italic}</style></head>
<body><div class=wrap><div class=card><h1>Prendre <span class=brand>rendez-vous</span></h1><div class=sub id=jobinfo>Chargement…</div><div id=content></div></div><div class=sub style=text-align:center>Gigafibre · propulsé par Targo</div></div>
<script>
const token=new URLSearchParams(location.search).get('token')||'';
const FR=['dimanche','lundi','mardi','mercredi','jeudi','vendredi','samedi'];
const MO=['janv.','févr.','mars','avr.','mai','juin','juil.','août','sept.','oct.','nov.','déc.'];
let picks=[];
function d2(iso){const a=iso.split('-').map(Number);return new Date(Date.UTC(a[0],a[1]-1,a[2]))}
function dayLabel(iso){const dt=d2(iso);return FR[dt.getUTCDay()]+' '+dt.getUTCDate()+' '+MO[dt.getUTCMonth()]}
function wkMon(iso){const dt=d2(iso);const off=(dt.getUTCDay()+6)%7;dt.setUTCDate(dt.getUTCDate()-off);return dt.toISOString().slice(0,10)}
function wkLabel(m){const a=d2(m);const b=new Date(a);b.setUTCDate(b.getUTCDate()+6);return 'Semaine du '+a.getUTCDate()+' '+MO[a.getUTCMonth()]+' – '+b.getUTCDate()+' '+MO[b.getUTCMonth()]}
async function load(){const r=await fetch('/book/api/options?token='+encodeURIComponent(token)).then(x=>x.json()).catch(()=>({ok:false}));const info=document.getElementById('jobinfo'),c=document.getElementById('content');
if(!r.ok){info.textContent='';c.innerHTML='<div class=err>Lien invalide ou expiré. Contactez-nous.</div>';return}
if(r.job.scheduled){info.innerHTML='Votre rendez-vous est déjà confirmé : <b>'+dayLabel(r.job.scheduled)+'</b>.';c.innerHTML='';return}
info.innerHTML='Choisissez vos disponibilités <b>en ordre de préférence</b> (jusqu’à 3). On confirme le 1er créneau possible.';
if(!r.windows.length){c.innerHTML='<div class=err>Aucune disponibilité pour le moment — nous vous contacterons.</div>';return}
const weeks={};r.windows.forEach(w=>{const mk=wkMon(w.date);(weeks[mk]=weeks[mk]||{});(weeks[mk][w.date]=weeks[mk][w.date]||[]).push(w)});let h='';
Object.keys(weeks).sort().forEach(mk=>{h+='<div class=week>'+wkLabel(mk)+'</div>';Object.keys(weeks[mk]).sort().forEach(d=>{const ws=weeks[mk][d].sort((a,b)=>a.start_h-b.start_h);const am=ws.filter(w=>w.start_h<12),pm=ws.filter(w=>w.start_h>=12);h+='<div class=day>'+dayLabel(d)+'</div>';const sect=(lab,arr)=>arr.length?('<div class=ampm>'+lab+'</div><div class=slots>'+arr.map(w=>'<div class=slot data-d="'+w.date+'" data-s="'+w.start+'">'+w.start+'</div>').join('')+'</div>'):'';h+=sect('Matin',am)+sect('Après-midi',pm)})});
h+='<div class=hint id=hint>Touchez 1 à 3 créneaux.</div><button class=btn id=go disabled>Confirmer mes disponibilités</button>';c.innerHTML=h;
c.querySelectorAll('.slot').forEach(el=>el.onclick=()=>toggle(el));document.getElementById('go').onclick=submit}
function toggle(el){const k=el.dataset.d+'|'+el.dataset.s,i=picks.indexOf(k);if(i>=0)picks.splice(i,1);else if(picks.length<3)picks.push(k);render()}
function render(){document.querySelectorAll('.slot').forEach(el=>{const k=el.dataset.d+'|'+el.dataset.s,i=picks.indexOf(k);el.classList.toggle('sel',i>=0);let b=el.querySelector('.rank');if(i>=0){if(!b){b=document.createElement('div');b.className='rank';el.appendChild(b)}b.textContent=i+1}else if(b)b.remove()});document.getElementById('hint').textContent=picks.length?picks.length+' choix sélectionné(s) — le 1er sera priorisé.':'Touchez 1 à 3 créneaux.';document.getElementById('go').disabled=!picks.length}
async function submit(){const go=document.getElementById('go');go.disabled=true;go.textContent='Envoi…';const prefs=picks.map(k=>({date:k.split('|')[0],start:k.split('|')[1]}));const r=await fetch('/book/api/submit?token='+encodeURIComponent(token),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode:'rank',prefs})}).then(x=>x.json()).catch(()=>({ok:false}));const c=document.getElementById('content');
if(r.ok&&r.confirmed)c.innerHTML='<div class=ok>✅ Rendez-vous confirmé : <b>'+dayLabel(r.date)+' à '+r.start+'</b>.<br>Merci !</div>';
else if(r.ok)c.innerHTML='<div class=ok>Merci ! '+(r.message||'Nous vous confirmerons sous peu.')+'</div>';
else c.innerHTML='<div class=err>'+(r.message||r.error||'Une erreur est survenue.')+'</div>'}
load();
</script></body></html>`

async function handlePublicBooking (req, res, method, path, url) {
  if (path === '/book' && method === 'GET') { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(BOOK_HTML) }
  const token = url.searchParams.get('token') || ''
  if (path === '/book/api/options' && method === 'GET') {
    const job = await jobByToken(token); if (!job) return json(res, 404, { ok: false, error: 'lien invalide' })
    const dur = Number(job.duration_h) || 1
    const windows = await bookingSlots({ duration: dur, start: todayET(), days: 21, aggregate: true, limit: 60 })
    return json(res, 200, { ok: true, job: { location: job.service_location || '', duration: dur, scheduled: job.scheduled_date || '' }, windows })
  }
  if (path === '/book/api/submit' && method === 'POST') {
    const job = await jobByToken(token); if (!job) return json(res, 404, { ok: false, error: 'lien invalide' })
    const b = await parseBody(req); const dur = Number(job.duration_h) || 1
    if (b.mode === 'rank' && Array.isArray(b.prefs) && b.prefs.length) {
      const fit = await fitBooking({ duration: dur, prefs: b.prefs })
      if (fit.chosen) { const r = await confirmWindow(job.name, fit.chosen.date, fit.chosen.start, dur); if (r.ok) return json(res, 200, { ...r, rank: fit.chosen.rank }) }
      await retryWrite(() => erp.update('Dispatch Job', job.name, { booking_prefs: JSON.stringify(b.prefs), booking_status: 'Proposé' }))
      return json(res, 200, { ok: true, confirmed: false, message: 'Vos disponibilités sont enregistrées — nous vous confirmerons sous peu.' })
    }
    return json(res, 400, { ok: false, error: 'requête invalide' })
  }
  return json(res, 404, { error: 'not found' })
}

// Stats par jour : effectif (techs distincts), heures planifiées, tickets dispatch.
async function statsByDay (start, days) {
  const dates = rangeDates(start, days)
  const asgs = await fetchAssignments(start, days)
  const jobs = await erp.list('Dispatch Job', {
    filters: [['scheduled_date', 'in', dates]],
    fields: ['name', 'scheduled_date'], limit: 3000,
  })
  const by = {}
  for (const d of dates) by[d] = { date: d, staff: new Set(), hours: 0, tickets: 0 }
  for (const a of asgs) { if (a.status === 'Annulé') continue; const x = by[a.date]; if (x) { x.staff.add(a.tech); x.hours += Number(a.hours) || 0 } }
  for (const j of jobs) { const x = by[j.scheduled_date]; if (x) x.tickets++ }
  return dates.map(d => ({ date: d, staff: by[d].staff.size, hours: by[d].hours, tickets: by[d].tickets }))
}

// ── Routeur ──────────────────────────────────────────────────────────────────
// technician_id n'est pas le docname → résoudre le docname Dispatch Technician.
async function resolveTechName (techId) {
  const f = await erp.list('Dispatch Technician', { filters: [['technician_id', '=', techId]], fields: ['name'], limit: 1 })
  return f.length ? f[0].name : null
}

async function handle (req, res, method, path, url) {
  const qs = url.searchParams
  const start = qs.get('start')
  const days = parseInt(qs.get('days') || '7', 10)

  if (path === '/roster/technicians' && method === 'GET') {
    const techs = await fetchTechnicians()
    return json(res, 200, { technicians: techs, count: techs.length })
  }
  if (path === '/roster/templates' && method === 'GET') {
    return json(res, 200, { templates: await fetchTemplates() })
  }
  if (path === '/roster/templates' && method === 'POST') {
    const b = await parseBody(req)
    const r = await erp.create('Shift Template', {
      template_name: b.template_name, start_time: b.start_time, end_time: b.end_time,
      hours: b.hours, color: b.color || '#1976d2', zone: b.zone || '',
      default_required: b.default_required || 1, required_skills: b.required_skills || '', active: 1,
    })
    return json(res, r.ok ? 200 : 500, r)
  }
  if (path === '/roster/requirements' && method === 'GET') {
    if (!start) return json(res, 400, { error: 'start requis (YYYY-MM-DD)' })
    return json(res, 200, { requirements: await fetchRequirements(start, days) })
  }
  if (path === '/roster/requirements' && method === 'POST') {
    const b = await parseBody(req)
    const r = await erp.create('Shift Requirement', {
      requirement_date: b.requirement_date, shift_template: b.shift_template, zone: b.zone || '',
      required_count: b.required_count || 1, required_skills: b.required_skills || '',
    })
    return json(res, r.ok ? 200 : 500, r)
  }
  if (path === '/roster/assignments' && method === 'GET') {
    if (!start) return json(res, 400, { error: 'start requis' })
    return json(res, 200, { assignments: await fetchAssignments(start, days) })
  }
  if (path === '/roster/coverage' && method === 'GET') {
    if (!start) return json(res, 400, { error: 'start requis' })
    return json(res, 200, { coverage: await coverage(start, days) })
  }
  if (path === '/roster/stats' && method === 'GET') {
    if (!start) return json(res, 400, { error: 'start requis' })
    return json(res, 200, { stats: await statsByDay(start, days) })
  }
  // Prise de RDV : créneaux dispo (roster + compétence + zone) pour proposer/valider
  if (path === '/roster/book/slots' && method === 'GET') {
    if (!start) return json(res, 400, { error: 'start requis' })
    return json(res, 200, { slots: await bookingSlots({ skill: qs.get('skill') || '', zone: qs.get('zone') || '', duration: qs.get('duration') || 1, start, days, limit: parseInt(qs.get('limit') || '24', 10), aggregate: qs.get('aggregate') === '1' }) })
  }
  // Jobs à planifier (worklist du répartiteur)
  if (path === '/roster/book/jobs' && method === 'GET') {
    const rows = await erp.list('Dispatch Job', {
      filters: [['status', 'in', ['open', 'assigned']]],
      fields: ['name', 'customer_name', 'service_location', 'service_type', 'duration_h', 'scheduled_date', 'start_time', 'assigned_tech', 'booking_status', 'status'],
      orderBy: 'modified desc', limit: 100,
    })
    return json(res, 200, { jobs: rows })
  }
  // Générer le lien client (token) pour un job → URL publique /book?token=
  if (path === '/roster/book/link' && method === 'POST') {
    const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
    const job = await erp.get('Dispatch Job', b.job, { fields: ['name', 'booking_token'] })
    if (!job) return json(res, 404, { error: 'job introuvable' })
    let token = job.booking_token
    if (!token) { token = crypto.randomBytes(12).toString('hex'); const r = await retryWrite(() => erp.update('Dispatch Job', b.job, { booking_token: token })); if (!r.ok) return json(res, 500, r) }
    return json(res, 200, { ok: true, token, url: (cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca') + '/book?token=' + token })
  }
  // Aviser le client d'un report : lien /book + SMS Twilio + statut « À reporter »
  if (path === '/roster/job/notify-reschedule' && method === 'POST') {
    const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' })
    const job = await erp.get('Dispatch Job', b.job, { fields: ['name', 'booking_token', 'customer', 'customer_name'] })
    if (!job) return json(res, 404, { error: 'job introuvable' })
    let token = job.booking_token
    if (!token) { token = crypto.randomBytes(12).toString('hex'); await retryWrite(() => erp.update('Dispatch Job', b.job, { booking_token: token })) }
    const url = (cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca') + '/book?token=' + token
    // Désassigner (retour au pool) : on vide le créneau pour que /book repropose des options
    await retryWrite(() => erp.update('Dispatch Job', b.job, { booking_status: 'À reporter', scheduled_date: null, start_time: null, assigned_tech: null, status: 'open' }))
    let phone = b.phone
    if (!phone && job.customer) { try { const c = await erp.get('Customer', job.customer, { fields: ['mobile_no'] }); phone = c && c.mobile_no } catch (e) {} }
    if (!phone) return json(res, 200, { ok: true, url, sms: false, note: 'Statut « À reporter » posé. Aucun téléphone trouvé — fournir "phone" pour envoyer le SMS.' })
    const msg = b.message || `Bonjour, votre rendez-vous Gigafibre doit être reporté. Choisissez un nouveau créneau qui vous convient : ${url}  Merci de votre compréhension.`
    let sid = null
    try { sid = await require('./twilio').sendSmsInternal(phone, msg, job.customer) } catch (e) { return json(res, 200, { ok: true, url, sms: false, error: String(e.message || e) }) }
    return json(res, 200, { ok: true, url, sms: !!sid, sid, phone })
  }
  // File « À reporter » (jobs à recontacter) — pour le superviseur
  if (path === '/roster/jobs-to-reschedule' && method === 'GET') {
    const rows = await erp.list('Dispatch Job', { filters: [['booking_status', '=', 'À reporter']], fields: ['name', 'customer_name', 'service_location', 'service_type', 'scheduled_date', 'assigned_tech', 'booking_token'], limit: 100 })
    return json(res, 200, { jobs: rows || [] })
  }
  // Fit : 3 dispos classées du client → 1er choix tenable, sinon proposer
  if (path === '/roster/book/fit' && method === 'POST') {
    const b = await parseBody(req)
    return json(res, 200, await fitBooking({ skill: b.skill || '', zone: b.zone || '', duration: b.duration || 1, prefs: b.prefs || [] }))
  }
  // Confirmer un RDV sur un Dispatch Job existant
  if (path === '/roster/book/confirm' && method === 'POST') {
    const b = await parseBody(req)
    if (!b.job) return json(res, 400, { error: 'job requis' })
    const st = (b.start || '').length === 5 ? b.start + ':00' : b.start
    const patch = { scheduled_date: b.date, start_time: st, status: 'assigned', booking_status: 'Confirmé', booking_prefs: JSON.stringify(b.prefs || []) }
    if (b.tech) patch.assigned_tech = b.tech
    if (b.duration) patch.duration_h = b.duration
    const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch))
    return json(res, r.ok ? 200 : 500, r)
  }
  // Créer plusieurs besoins d'un coup (depuis l'éditeur de demande)
  if (path === '/roster/requirements/bulk' && method === 'POST') {
    const b = await parseBody(req); const errors = []; let created = 0
    for (const rq of b.requirements || []) {
      const r = await retryWrite(() => erp.create('Shift Requirement', {
        requirement_date: rq.requirement_date, shift_template: rq.shift_template,
        zone: rq.zone || '', required_count: rq.required_count || 1, required_skills: rq.required_skills || '',
      }))
      if (r.ok) created++; else errors.push(rq)
    }
    return json(res, 200, { ok: errors.length === 0, created, errors: errors.length })
  }
  // Vider les besoins d'une période (avant de ré-appliquer la demande)
  if (path === '/roster/requirements/clear' && method === 'POST') {
    const b = await parseBody(req)
    const reqs = await fetchRequirements(b.start, b.days || 7)
    let deleted = 0
    for (const rq of reqs) { const r = await retryWrite(() => erp.remove('Shift Requirement', rq.name)); if (r.ok) deleted++ }
    return json(res, 200, { ok: true, deleted })
  }
  if (path === '/roster/generate' && method === 'POST') {
    const b = await parseBody(req)
    if (!b.start) return json(res, 400, { error: 'start requis' })
    try {
      return json(res, 200, await generate(b.start, b.days || 7, b.weights))
    } catch (e) {
      return json(res, 502, { error: 'solveur injoignable ou erreur: ' + e.message })
    }
  }
  if (path === '/roster/publish' && method === 'POST') {
    const b = await parseBody(req)
    return json(res, 200, await publish(b.assignments))
  }
  // Publier = réécrire la semaine (efface tout sur la période, recrée la grille).
  // Idempotent + anti-doublons (contrairement au diff par case).
  if (path === '/roster/publish-week' && method === 'POST') {
    const b = await parseBody(req)
    const existing = await fetchAssignments(b.start, b.days || 7)
    let deleted = 0
    for (const a of existing) { const r = await retryWrite(() => erp.remove('Shift Assignment', a.name)); if (r.ok) deleted++ }
    let created = 0; let errors = 0
    for (const a of (b.assignments || [])) {
      const r = await retryWrite(() => erp.create('Shift Assignment', {
        technician: a.tech, technician_name: a.tech_name || '', assignment_date: a.date,
        shift_template: a.shift, zone: a.zone || '', hours: Number(a.hours) || 0,
        status: 'Publié', source: a.source || 'solveur',
      }))
      if (r.ok) created++; else errors++
    }
    let notified = 0
    if (b.notify && created) {  // SMS opt-in aux techs (Twilio) — non bloquant
      try {
        const techs = await fetchTechnicians()
        const phoneById = Object.fromEntries(techs.map(t => [t.id, t.phone]))
        const tplName = Object.fromEntries((await fetchTemplates()).map(t => [t.name, t.template_name || t.name]))
        const byTech = {}
        for (const a of (b.assignments || [])) (byTech[a.tech] || (byTech[a.tech] = [])).push(a)
        const sendSms = require('./twilio').sendSmsInternal
        for (const tid in byTech) {
          const phone = phoneById[tid]; if (!phone) continue
          const lines = byTech[tid].slice().sort((x, y) => x.date.localeCompare(y.date)).map(a => a.date.slice(5) + ' ' + (tplName[a.shift] || a.shift)).join(' · ')
          try { await sendSms(phone, 'Targo — votre horaire publié : ' + lines); notified++ } catch (e) { /* skip ce tech */ }
        }
      } catch (e) { /* notif non bloquante */ }
    }
    return json(res, 200, { ok: errors === 0, created, deleted, errors, notified })
  }
  // Modifier / supprimer un type de shift (Shift Template)
  const mTpl = path.match(/^\/roster\/template\/(.+)$/)
  if (mTpl && method === 'PUT') {
    const name = decodeURIComponent(mTpl[1]); const b = await parseBody(req)
    const patch = {}
    for (const f of ['start_time', 'end_time', 'hours', 'color', 'zone', 'default_required', 'required_skills', 'active']) if (b[f] !== undefined) patch[f] = b[f]
    const r = await retryWrite(() => erp.update('Shift Template', name, patch))
    return json(res, r.ok ? 200 : 500, r)
  }
  if (mTpl && method === 'DELETE') {
    const name = decodeURIComponent(mTpl[1]); const r = await retryWrite(() => erp.remove('Shift Template', name))
    return json(res, r.ok ? 200 : 500, r)
  }
  if (path === '/roster/availability' && method === 'GET') {
    const status = qs.get('status') || ''
    const rows = await erp.list('Tech Availability', {
      filters: status ? [['status', '=', status]] : [],
      fields: ['name', 'technician', 'technician_name', 'availability_type', 'from_date', 'to_date', 'reason', 'status', 'approver'],
      orderBy: 'modified desc', limit: 200,
    })
    return json(res, 200, { availability: rows })
  }
  if (path === '/roster/availability' && method === 'POST') {
    const b = await parseBody(req)
    const r = await erp.create('Tech Availability', {
      technician: b.technician, technician_name: b.technician_name || '',
      availability_type: b.availability_type || 'Congé', status: 'Demandé',
      from_date: b.from_date, to_date: b.to_date, reason: b.reason || '',
    })
    return json(res, r.ok ? 200 : 500, r)
  }
  const mApprove = path.match(/^\/roster\/availability\/(.+)\/approve$/)
  if (mApprove && method === 'POST') {
    const name = decodeURIComponent(mApprove[1])
    const b = await parseBody(req)
    const r = await retryWrite(() => erp.update('Tech Availability', name, { status: b.reject ? 'Refusé' : 'Approuvé', approver: b.approver || '' }))
    return json(res, r.ok ? 200 : 500, r)
  }
  const mSkills = path.match(/^\/roster\/technician\/(.+)\/skills$/)
  if (mSkills && method === 'POST') {
    const techId = decodeURIComponent(mSkills[1]); const b = await parseBody(req)
    const techName = await resolveTechName(techId)
    if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
    const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { skills: (b.skills || '').trim() }))
    return json(res, r.ok ? 200 : 500, { ...r, technician: techId })
  }
  const mCost = path.match(/^\/roster\/technician\/(.+)\/cost$/)
  if (mCost && method === 'POST') {
    const techId = decodeURIComponent(mCost[1]); const b = await parseBody(req)
    const techName = await resolveTechName(techId)
    if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
    const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { cost_salary_h: Number(b.salary) || 0, cost_charges_pct: Number(b.charges) || 0, cost_other_h: Number(b.other) || 0 }))
    return json(res, r.ok ? 200 : 500, { ...r, technician: techId })
  }
  const mEff = path.match(/^\/roster\/technician\/(.+)\/efficiency$/)
  if (mEff && method === 'POST') {
    const techId = decodeURIComponent(mEff[1]); const b = await parseBody(req)
    const techName = await resolveTechName(techId)
    if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
    const r = await retryWrite(() => erp.update('Dispatch Technician', techName, { efficiency: Number(b.efficiency) || 1 }))
    return json(res, r.ok ? 200 : 500, { ...r, technician: techId, efficiency: Number(b.efficiency) || 1 })
  }
  const mPause = path.match(/^\/roster\/technician\/(.+)\/pause$/)
  if (mPause && method === 'POST') {
    const techId = decodeURIComponent(mPause[1])
    const b = await parseBody(req)
    // technician_id n'est pas le docname → retrouver le doc
    const techName = await resolveTechName(techId)
    if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
    const patch = { status: b.paused ? PAUSE_STATUS : AVAIL_STATUS }
    if (b.paused && b.reason) patch.absence_reason = b.reason
    const r = await retryWrite(() => erp.update('Dispatch Technician', techName, patch))
    return json(res, r.ok ? 200 : 500, { ...r, technician: techId, status: patch.status })
  }
  // Supprimer une assignation publiée
  const mDelA = path.match(/^\/roster\/assignment\/(.+)$/)
  if (mDelA && method === 'DELETE') {
    const name = decodeURIComponent(mDelA[1])
    const r = await retryWrite(() => erp.remove('Shift Assignment', name))
    return json(res, r.ok ? 200 : 500, r)
  }
  return json(res, 404, { error: 'roster: route inconnue ' + path })
}

module.exports = { handle, handlePublicBooking, generate, publish, coverage, fetchTechnicians }
