'use strict'
/**
 * staff-agent.js — Copilote OPS en langage naturel (Gemini function-calling), surface STAFF.
 *
 * BUT (parité UI↔NL) : le langage naturel peut faire ce que fait l'interface visuelle, en exposant
 * les ACTIONS EXISTANTES comme « outils » function-calling. NL et UI partagent UNE SEULE couche
 * d'action (les routes hub / fonctions que l'UI appelle déjà) → couverture(NL) = couverture(UI).
 *
 * RÉUTILISE l'existant (cf feedback_reuse_shared_components) :
 *   - registre d'outils = services/targo-hub/lib/agent-tools.json (audience 'staff'),
 *   - runtime Gemini function-calling = lib/ai.js `chat({tools, wantMessage})` (même boucle que
 *     lib/agent.js répondeur SMS et lib/roster-assistant.js copilote roster),
 *   - couche d'action = les routes hub EXISTANTES (roster.js/dispatch.js/conversation.js), appelées
 *     en LOOPBACK autorisé (jeton de service + identité x-authentik-email) — zéro logique réécrite.
 *
 * SÉCURITÉ (cf feedback_ppa_double_charge_incident — une action auto peut coûter cher) :
 *   - outils LECTURE : s'exécutent librement pendant le PLAN (résolution d'entités, aucun effet).
 *   - outils ÉCRITURE/conséquents : NE s'exécutent PAS via le modèle. Ils sont mis en attente
 *     (staged) avec un APERÇU. L'exécution réelle passe par POST /staff-agent/run APRÈS confirmation
 *     humaine explicite (le modèle propose ; l'humain dispose). Pas d'endpoint arbitraire : /run mappe
 *     type → exécuteur côté serveur (liste blanche), re-valide les permissions (parité usePermissions
 *     via auth.effectiveCapabilities) et agit AU NOM de l'utilisateur connecté (x-authentik-email).
 *
 * Routes :
 *   POST /staff-agent        { text }              → { ok, reply, actions:[{id,type,title,preview,params,permission,needs_confirmation}], transcript }
 *   POST /staff-agent/run    { actions }           → { ok, results:[{type,ok,message|error}] }
 *   GET  /staff-agent/tools                         → { tools } (inventaire — parité/complétude)
 */
const cfg = require('./config')
const { log, json, parseBody } = require('./helpers')
const { toolsForModel, toolsByAudience } = require('./agent')
const erp = require('./erp')

// ── Registre : outils STAFF (audience 'staff') depuis le registre UNIQUE agent-tools.json ──
const STAFF_TOOLS_RAW = toolsByAudience('staff')
const STAFF_TOOLS = toolsForModel(STAFF_TOOLS_RAW) // envoyés au modèle : {type, function} seulement
const META = {} // name → { mode, permission, title }
for (const t of STAFF_TOOLS_RAW) META[t.function.name] = { mode: t.mode || 'read', permission: t.permission || null, title: t.title || t.function.name }

// require paresseux (évite les cycles + n'alourdit pas le boot) — mêmes modules que l'UI appelle.
const roster = () => require('./roster')
const dispatch = () => require('./dispatch')
const oltOps = () => require('./olt-ops') // Do Stuff : opérations OLT à distance (plan/run, confirm obligatoire)

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

// ── Loopback AUTORISÉ vers les routes hub EXISTANTES ─────────────────────────
// Réutilise EXACTEMENT les handlers que le front appelle (couche d'action unique). On imite le proxy
// nginx /ops/hub : Authorization = jeton de service + x-authentik-email = utilisateur connecté (→
// attribution, suivi, et vérifs d'identité des handlers). Pas d'endpoint fourni par le client : seuls
// les chemins codés dans EXECUTORS sont atteignables.
const PORT = cfg.PORT || 3300
async function hubCall (method, path, body, email) {
  const headers = { 'Content-Type': 'application/json' }
  if (process.env.HUB_SERVICE_TOKEN) headers.Authorization = 'Bearer ' + process.env.HUB_SERVICE_TOKEN
  if (email) headers['x-authentik-email'] = email
  const opt = { method, headers }
  if (body != null && method !== 'GET') opt.body = JSON.stringify(body)
  const r = await fetch(`http://127.0.0.1:${PORT}${path}`, opt)
  let data = null
  try { data = await r.json() } catch { /* corps non-JSON */ }
  return { status: r.status, ok: r.ok, data: data || {} }
}
// Un résultat hub → { ok, message } normalisé (les routes renvoient {ok:false,error} ou {error}).
function hubResult (r, okMsg) {
  const ok = r.ok && r.data && r.data.ok !== false && !r.data.error
  return { ok, message: ok ? okMsg : ((r.data && r.data.error) || `échec (HTTP ${r.status})`), status: r.status, data: r.data }
}

async function resolveTechName (techId) {
  try { const techs = await roster().fetchTechnicians(); const t = techs.find(x => x.id === techId || x.name === techId); return t ? t.name : techId }
  catch { return techId }
}

// Résout l'ONU (n° de série) d'un client pour device_action — préfère un ONU/fibre non retiré ; ambigu si plusieurs.
async function resolveCustomerOnu (customerId) {
  const rows = await erp.list('Service Equipment', {
    filters: [['customer', '=', String(customerId)]],
    fields: ['name', 'serial_number', 'equipment_type', 'olt_ip', 'status'], limit: 20,
  }).catch(() => [])
  const onus = (rows || []).filter(r => r.serial_number && (r.olt_ip || /onu|ont|fib|gpon/i.test(String(r.equipment_type || ''))))
  if (!onus.length) return { error: `Aucun ONU trouvé pour le client ${customerId} — fournis le numéro de série.` }
  const active = onus.filter(o => String(o.status || '').toLowerCase() !== 'retiré')
  const pick = active.length ? active : onus
  if (pick.length > 1) return { error: `Plusieurs ONU pour ${customerId} (${pick.map(o => o.serial_number).join(', ')}) — précise le numéro de série.` }
  return { serial: pick[0].serial_number }
}

// ── Outils LECTURE (exécutés pendant le PLAN — sans effet de bord) ───────────
async function read_list_technicians ({ query, skill } = {}) {
  let techs
  try { techs = await roster().fetchTechnicians() } catch (e) { return { error: 'techniciens indisponibles : ' + e.message } }
  const q = String(query || '').trim().toLowerCase()
  const sk = String(skill || '').trim().toLowerCase()
  let out = techs || []
  if (q) out = out.filter(t => String(t.name || '').toLowerCase().includes(q) || String(t.id || '').toLowerCase().includes(q))
  if (sk) out = out.filter(t => (t.skills || []).some(s => String(s).toLowerCase().includes(sk)))
  return { count: out.length, technicians: out.slice(0, 40).map(t => ({ id: t.id, name: t.name, status: t.status, skills: t.skills || [] })) }
}
async function read_get_job ({ job } = {}) {
  if (!job) return { error: 'job requis' }
  try {
    const d = await erp.get('Dispatch Job', job, { fields: ['name', 'subject', 'status', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'customer_name', 'service_location', 'job_type', 'booking_status'] })
    if (!d || !d.name) return { error: 'job introuvable : ' + job }
    return { job: d.name, subject: d.subject, status: d.status, assigned_tech: d.assigned_tech || null, scheduled_date: d.scheduled_date || null, start_time: d.start_time || null, duration_h: d.duration_h, priority: d.priority, customer_name: d.customer_name || '', service_location: d.service_location || '', job_type: d.job_type, booking_status: d.booking_status || '' }
  } catch (e) { return { error: e.message } }
}
async function read_find_slot ({ duration_h, skill, after_date } = {}) {
  try {
    const slots = await dispatch().suggestSlots({ duration_h: Number(duration_h) || 1, skill: skill || '', after_date: after_date || todayET(), limit: 5 })
    return { count: (slots || []).length, slots: (slots || []).slice(0, 5) }
  } catch (e) { return { error: e.message } }
}
async function read_resolve_skill ({ text, department } = {}) {
  try {
    const r = await require('./skill-resolver').resolveSkills({ text: text || '', department: department || '', useAI: true })
    const skills = r.skills || []
    return { skills, primary: r.primary || '', confidence: r.confidence, reason: r.reason || '', note: skills.length ? ('Compétence(s) requise(s) : ' + skills.join(', ') + '. Enchaîne find_slot avec skill=' + (r.primary || skills[0]) + ' pour la disponibilité.') : 'Aucune compétence terrain — traitable à distance (tous les agents).' }
  } catch (e) { return { error: e.message } }
}
async function read_resource_availability ({ skill, after_date, days } = {}) {
  try {
    const o = await dispatch().techOccupancy({ after_date: after_date || todayET(), days: Number(days) || 7, skill: skill || '' })
    const techs = (o && o.techs) || []
    const freeH = Math.round(techs.reduce((a, t) => a + (t.free_h || 0), 0) * 10) / 10
    return { skill: skill || '(toute compétence)', window_days: (o && o.days) || 7, qualified_techs: techs.length, free_hours: freeH, techs: techs.slice(0, 8).map(t => ({ name: t.tech_name, free_h: Math.round((t.free_h || 0) * 10) / 10 })), note: techs.length ? (techs.length + ' tech(s) qualifié(s) « ' + (skill || 'tous') + ' » · ' + freeH + ' h libres sur ' + ((o && o.days) || 7) + ' jours') : 'Aucun tech qualifié disponible sur la période — élargis la fenêtre ou revois la compétence.' }
  } catch (e) { return { error: e.message } }
}
async function read_check_service ({ customer, query } = {}) {
  try {
    const r = await require('./ticket-collab').serviceStatus({ customer: customer || '', q: query || '' })
    if (!r || r.ok === false) return { error: (r && r.error) || 'statut de service indisponible' }
    if (r.found === false) {
      const cands = (r.matches || []).slice(0, 6).map(m => ({ id: m.name, label: m.customer_name || m.name, caption: m.address || m.phone || m.email || '', icon: 'person' }))
      return { found: false, ambiguous: !!r.ambiguous, candidates: cands, note: r.ambiguous ? "Plusieurs clients correspondent — présente-les (l'interface affiche des boutons cliquables) et demande lequel ; ne devine pas." : 'Client/service introuvable.' }
    }
    const s = r.summary || {}
    let advice
    // Le signal (Rx/Tx dBm) n'est PAS toujours dans le statut passif (fibre tech-3 non couverte par la sonde locale) →
    // NE PAS conclure « non mesurable » : la QUALITÉ du signal se mesure EN DIRECT via check_signal (comme F).
    const needSignal = s.rxPower == null ? ' Pour la FORCE du signal (Rx/Tx dBm en direct), appelle check_signal — ne dis pas « non mesurable ».' : ''
    if (s.online === true) advice = 'Équipement EN LIGNE' + (s.rxPower != null ? ' (Rx ' + s.rxPower + ' dBm)' : '') + " → tente d'abord un correctif À DISTANCE (redémarrage / reprovision) ; un déplacement n'est probablement PAS nécessaire." + needSignal
    else if (s.online === false) advice = 'Équipement HORS LIGNE' + (s.rxPower != null ? ' (Rx ' + s.rxPower + ' dBm)' : '') + ' → signal faible/nul = DÉPLACEMENT probable (réparation fibre) ; sinon propose un redémarrage avant.' + needSignal
    else advice = "Statut indéterminé — pas de lecture fiable de l'équipement." + needSignal
    return { found: true, customer: r.customer && r.customer.name, customer_name: r.customer && r.customer.customer_name, online: s.online, rx_power_dbm: s.rxPower != null ? s.rxPower : null, signal: s.signal || null, status_label: s.label || '', source: s.source || '', subscriptions: (r.subscriptions || []).map(x => (x.plan || '?') + ' · ' + (x.status || '?')), advice }
  } catch (e) { return { error: e.message } }
}
// Qualité du signal FIBRE (Rx optique, dBm). Plage normale ~ -8 à -26 ; plus négatif = plus faible.
function fibreAdvice (rx) {
  if (rx == null) return null
  if (rx > -8) return 'Rx élevé (' + rx + ' dBm) — atténuation très faible ; OK (vérifier sur-puissance si tout proche).'
  if (rx >= -25) return 'Rx BON (' + rx + ' dBm) — un correctif à distance suffit probablement, pas de déplacement.'
  if (rx >= -28) return 'Rx FAIBLE (' + rx + ' dBm) — à surveiller ; connecteur/soudure fibre à vérifier.'
  return 'Rx CRITIQUE (' + rx + ' dBm) — DÉPLACEMENT probable (réparation/soudure fibre).'
}
// Qualité du signal SANS-FIL (airOS, dBm). -50 excellent … < -78 critique.
function wifiAdvice (s) {
  if (s == null) return null
  if (s >= -60) return 'Signal BON (' + s + ' dBm).'
  if (s >= -70) return 'Signal CORRECT (' + s + ' dBm) — peut faiblir par mauvais temps.'
  if (s >= -78) return 'Signal FAIBLE (' + s + ' dBm) — réalignement d’antenne probable.'
  return 'Signal CRITIQUE (' + s + ' dBm) — DÉPLACEMENT (réalignement / ligne de vue) probable.'
}
// SIGNAL EN DIRECT (parité « Do Stuff get » de F). FIBRE : dostuff n8n (tech-3, Rx/Tx dBm) → repli poller SNMP (tech-2).
// SANS-FIL : ops_airos (pont F : signal/CCQ/débit/airMAX). Résout l'équipement du client depuis Service Equipment.
async function read_check_signal ({ customer, serial } = {}) {
  try {
    let eq = null
    const F = ['name', 'serial_number', 'mac_address', 'ip_address', 'olt_ip', 'customer', 'status', 'equipment_type', 'brand', 'model']
    if (serial) { const r = await erp.list('Service Equipment', { filters: [['serial_number', '=', String(serial)]], fields: F, limit: 1 }); eq = r && r[0] }
    if (!eq && customer) {
      const r = await erp.list('Service Equipment', { filters: [['customer', '=', String(customer)]], fields: F, limit: 20 })
      const act = (r || []).filter(e => String(e.status || '').toLowerCase() !== 'retiré')
      eq = act.find(e => e.olt_ip && e.serial_number) || act.find(e => e.ip_address && e.serial_number) || act.find(e => e.olt_ip || e.ip_address) || act[0] || (r || [])[0]
    }
    if (!eq) return { error: 'Aucun équipement réseau pour ce client — signal non récupérable. Vérifie que l’appareil est rattaché (Service Equipment).' }
    const sn = eq.serial_number || '', olt = eq.olt_ip || '', ip = eq.ip_address || '', mac = eq.mac_address || ''
    const tc = require('./ticket-collab')
    // ── FIBRE (olt_ip) : F get = dostuff (OLT en direct). tech-3 = n8n ; tech-2 = repli sonde SNMP locale. ──
    if (olt) {
      const live = await tc.dostuffStatus({ sn, olt })
      if (live && live.ok) return { found: true, kind: 'fibre', serial: sn, online: live.online, rx_dbm: live.rxPower, tx_dbm: live.txPower, signal: live.signal, distance_m: live.distance, uptime: live.uptime, wan_ip: live.wanIp, firmware: live.firmware, source: 'dostuff (OLT en direct, comme F)', advice: fibreAdvice(live.rxPower) }
      try { const onu = require('./olt-snmp').getOnuBySerial(String(sn)); if (onu && onu.rxPower != null) return { found: true, kind: 'fibre', serial: sn, rx_dbm: onu.rxPower, tx_dbm: (onu.txPower != null ? onu.txPower : null), source: 'olt-snmp (sonde locale, tech-2)', advice: fibreAdvice(onu.rxPower) } } catch (e) { /* poller indispo */ }
      return { found: true, kind: 'fibre', serial: sn, rx_dbm: null, online: (live && live.online != null) ? live.online : null, note: 'Signal live non remonté (OLT lente ou hors couverture). Réessaie, ou propose un redémarrage.', advice: null }
    }
    // ── SANS-FIL (airOS) : F get = ops_airos (pont F). Signal / CCQ / débit / capacité airMAX du CPE. ──
    if (ip || mac) {
      const air = await tc.airosSignal({ sn, ip, mac })
      if (air && air.ok) return { found: true, kind: 'sans-fil', serial: sn, model: air.model, signal_dbm: air.signal, signal_ap_dbm: air.signalAp, ccq_pct: air.ccq, tx_rate: air.txRate, rx_rate: air.rxRate, airmax_down_mbps: air.capDown, airmax_up_mbps: air.capUp, uptime: air.uptime, source: 'ops_airos (CPE en direct, comme F)', advice: wifiAdvice(air.signal) }
      return { found: true, kind: 'sans-fil', serial: sn, note: (air && air.error) || 'CPE injoignable', advice: null }
    }
    return { found: true, serial: sn, note: 'Équipement sans coordonnées réseau (ni OLT ni IP de gestion) — signal non récupérable ; vérifie le rattachement de l’appareil.' }
  } catch (e) { return { error: e.message } }
}
// Recherche floue d'un client → CANDIDATS (l'UI les rend cliquables dans le fil). Réutilise /collab/customer-search
// (même endpoint que l'ancien typeahead) MAIS c'est l'ASSISTANT qui liste les options — plus besoin d'un 2e champ.
async function read_find_customer ({ query } = {}) {
  const q = String(query || '').trim()
  if (q.length < 2) return { error: 'requête trop courte (2 lettres min)' }
  try {
    const r = await hubCall('GET', `/collab/customer-search?q=${encodeURIComponent(q)}&team=1`)
    const matches = (r.data && r.data.matches) || []
    const candidates = matches.filter(m => m.kind !== 'équipe').slice(0, 8).map(m => ({
      id: m.name, label: m.customer_name || m.name,
      caption: [m.matched, m.address, m.email].filter(Boolean).join(' · ') || m.name,
      icon: m.matched === 'adresse' ? 'location_on' : m.matched === 'téléphone' ? 'call' : m.matched === 'courriel' ? 'mail' : 'person',
    }))
    if (!candidates.length) return { count: 0, candidates: [], note: `Aucun client pour « ${q} ».` }
    if (candidates.length === 1) return { count: 1, candidates, note: `Un seul client : ${candidates[0].label} (${candidates[0].id}) — utilise cet id et continue.` }
    return { count: candidates.length, candidates, note: `${candidates.length} clients possibles — demande « lequel ? » ; l'interface affiche la liste cliquable (ne les réénumère pas en texte).` }
  } catch (e) { return { error: e.message } }
}
// ── Client 360 (LECTURE) : réutilise les getters du répondeur client (lib/agent.execTool) — solde, factures,
// abonnements, équipement — + historique tickets. Le client est résolu en amont par find_customer (id C-xxxx).
const agentExec = (name, input) => require('./agent').execTool(name, input || {})
async function read_customer_overview ({ customer } = {}) {
  const cid = String(customer || '').trim(); if (!cid) return { error: 'customer (id C-xxxx) requis — résous-le via find_customer' }
  const [info, balance, subscriptions, tickets] = await Promise.all([
    agentExec('get_customer_info', { customer_id: cid }),
    agentExec('get_outstanding_balance', { customer_id: cid }),
    agentExec('get_subscriptions', { customer_id: cid }),
    agentExec('get_open_tickets', { customer_id: cid }),
  ])
  const solde = balance && typeof balance.outstanding_balance === 'number' ? balance.outstanding_balance : null
  return { customer: cid, info, balance, subscriptions, open_tickets: tickets, note: `Aperçu 360 — résume identité, SOLDE${solde != null ? ' (' + solde.toFixed(2) + ' $)' : ''}, abonnements, tickets ouverts.` }
}
async function read_customer_balance ({ customer } = {}) {
  const cid = String(customer || '').trim(); if (!cid) return { error: 'customer (id C-xxxx) requis — résous-le via find_customer' }
  const [balance, invoices] = await Promise.all([agentExec('get_outstanding_balance', { customer_id: cid }), agentExec('get_invoices', { customer_id: cid, limit: 6 })])
  return { customer: cid, balance, invoices }
}
async function read_customer_subscriptions ({ customer } = {}) {
  const cid = String(customer || '').trim(); if (!cid) return { error: 'customer (id C-xxxx) requis — résous-le via find_customer' }
  return { customer: cid, ...(await agentExec('get_subscriptions', { customer_id: cid })) }
}
async function read_customer_equipment ({ customer } = {}) {
  const cid = String(customer || '').trim(); if (!cid) return { error: 'customer (id C-xxxx) requis — résous-le via find_customer' }
  return { customer: cid, ...(await agentExec('get_equipment', { customer_id: cid })) }
}
async function read_customer_history ({ customer } = {}) {
  const cid = String(customer || '').trim(); if (!cid) return { error: 'customer (id C-xxxx) requis — résous-le via find_customer' }
  try {
    const issues = await erp.list('Issue', { filters: [['customer', '=', cid]], fields: ['name', 'subject', 'status', 'creation'], orderBy: 'creation desc', limit: 20 })
    const tickets = (issues || []).map(t => ({ id: t.name, subject: t.subject, status: t.status, date: String(t.creation || '').slice(0, 10) }))
    const openN = tickets.filter(t => !/closed|resolved|ferm|résolu|annul/i.test(String(t.status || ''))).length
    return { customer: cid, count: tickets.length, open_count: openN, tickets, note: tickets.length ? `${tickets.length} ticket(s) au dossier (${openN} ouvert(s)) — des problèmes sont déjà survenus.` : 'Aucun ticket au dossier — aucun problème enregistré pour ce client.' }
  } catch (e) { return { error: e.message } }
}
const READERS = { list_technicians: read_list_technicians, get_job: read_get_job, find_slot: read_find_slot, resolve_skill: read_resolve_skill, resource_availability: read_resource_availability, check_service: read_check_service, check_signal: read_check_signal, find_customer: read_find_customer, customer_overview: read_customer_overview, customer_balance: read_customer_balance, customer_subscriptions: read_customer_subscriptions, customer_equipment: read_customer_equipment, customer_history: read_customer_history }

// ── Jours de la semaine — clés du weekly_schedule (= DOW_KEYS de roster.js) ──
const DAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
const DAY_FR = { mon: 'Lun', tue: 'Mar', wed: 'Mer', thu: 'Jeu', fri: 'Ven', sat: 'Sam', sun: 'Dim' }
const DAY_ALIASES = {
  mon: 'mon', tue: 'tue', wed: 'wed', thu: 'thu', fri: 'fri', sat: 'sat', sun: 'sun',
  monday: 'mon', tuesday: 'tue', wednesday: 'wed', thursday: 'thu', friday: 'fri', saturday: 'sat', sunday: 'sun',
  lun: 'mon', mar: 'tue', mer: 'wed', jeu: 'thu', ven: 'fri', sam: 'sat', dim: 'sun',
  lundi: 'mon', mardi: 'tue', mercredi: 'wed', jeudi: 'thu', vendredi: 'fri', samedi: 'sat', dimanche: 'sun',
}
function normDays (days) {
  const set = new Set()
  for (const d of (Array.isArray(days) ? days : [])) { const k = DAY_ALIASES[String(d || '').trim().toLowerCase()]; if (k) set.add(k) }
  return DAY_KEYS.filter(k => set.has(k)) // ordre lun→dim, dédupliqué
}
function normTime (t, dflt = '') {
  const m = String(t || '').trim().match(/^(\d{1,2})(?:\s*[:hH]\s*(\d{2}))?/) // "8", "8:00", "8h", "16h00"
  if (!m) return dflt
  return String(Math.min(23, parseInt(m[1], 10))).padStart(2, '0') + ':' + (m[2] || '00')
}
function buildWeeklySchedule (days, start, end) {
  const st = normTime(start), en = normTime(end)
  const sched = {}
  for (const k of normDays(days)) sched[k] = { start: st, end: en }
  return sched
}

// ── Outils ÉCRITURE — { permission, build(params)→{title,preview,params[,severity]}, exec(params,email) } ──
// build : mis en attente pendant le plan (aperçu). exec : exécution réelle après confirmation (via /run),
// chaque exec = fine enveloppe sur une route/fonction hub EXISTANTE.
const WRITES = {
  create_job: {
    permission: 'create_jobs',
    async build (p) {
      const params = { subject: String(p.subject || '').trim(), customer_id: p.customer_id || '', service_location: p.service_location || '', address: p.address || '', priority: p.priority || '', job_type: p.job_type || '', notes: p.notes || '', auto_assign: p.auto_assign !== false }
      const preview = `« ${params.subject || 'Intervention'} »${params.job_type ? ' · ' + params.job_type : ''}${params.priority ? ' · priorité ' + params.priority : ''}${params.address ? ' · ' + params.address : ''}${params.customer_id ? ' · client ' + params.customer_id : ''} · ${params.auto_assign ? 'assignation auto' : 'non assigné'}`
      return { title: META.create_job.title, preview, params }
    },
    async exec (p) {
      const r = await dispatch().agentCreateDispatchJob({ customer_id: p.customer_id || '', service_location: p.service_location || '', address: p.address || '', subject: p.subject, priority: p.priority || undefined, job_type: p.job_type || undefined, notes: p.notes || '', auto_assign: p.auto_assign })
      return { ok: !!(r && r.success), message: (r && r.message) || 'job créé', job_id: r && r.job_id, assigned_tech: r && r.assigned_tech }
    },
  },
  assign_tech: {
    permission: 'assign_jobs',
    async build (p) {
      const name = await resolveTechName(p.tech_id)
      const params = { job: p.job, tech: p.tech_id, date: p.date || '', start: p.start || '' }
      return { title: META.assign_tech.title, preview: `${p.job} → ${name} (${p.tech_id})${p.date ? ' · ' + p.date : ''}${p.start ? ' à ' + p.start : ''}`, params }
    },
    async exec (p, email) {
      const body = { job: p.job, tech: p.tech }
      if (p.date) body.date = p.date
      if (p.start) body.start = p.start
      return hubResult(await hubCall('POST', '/roster/assign-job', body, email), `${p.job} assigné à ${p.tech}`)
    },
  },
  proposer_rdv_client: {
    permission: 'assign_jobs',
    async build (p) {
      const ref = p.job || p.ticket || p.customer_id || 'le job ouvert du client'
      const params = { job: p.job || '', ticket: p.ticket || '', customer: p.customer_id || '', message: p.message || '' }
      return { title: META.proposer_rdv_client.title, preview: `Envoyer au client le lien de prise de RDV (il choisit un créneau réservé 5 min, ou propose 3 dispos) — ${ref}`, params }
    },
    async exec (p, email) {
      const body = {}
      if (p.job) body.job = p.job
      if (p.ticket) body.ticket = p.ticket
      if (p.customer) body.customer = p.customer
      if (p.message) body.message = p.message
      const r = await hubCall('POST', '/roster/book/propose', body, email)
      const ok = r.ok && r.data && r.data.ok !== false && !r.data.error
      const msg = ok
        ? (r.data.sms ? 'Lien de RDV envoyé au client par texto.' : ('Lien de RDV prêt : ' + (r.data.url || '') + (r.data.note ? ' — ' + r.data.note : '')))
        : ((r.data && r.data.error) || `échec (HTTP ${r.status})`)
      return { ok, message: msg, data: r.data }
    },
  },
  add_assistant: {
    permission: 'assign_jobs',
    async build (p) {
      const name = p.tech_name || await resolveTechName(p.tech_id)
      return { title: META.add_assistant.title, preview: `Renfort sur ${p.job} : ${name} (${p.tech_id})`, params: { job: p.job, tech_id: p.tech_id, tech_name: name } }
    },
    async exec (p, email) {
      return hubResult(await hubCall('POST', '/roster/job/team', { job: p.job, add: { tech_id: p.tech_id, tech_name: p.tech_name || '' } }, email), `Assistant ${p.tech_id} ajouté à ${p.job}`)
    },
  },
  set_job_status: {
    permission: 'assign_jobs',
    async build (p) {
      return { title: META.set_job_status.title, preview: `${p.job} → statut « ${p.status} »`, params: { job: p.job, status: p.status }, severity: p.status === 'Cancelled' ? 'high' : 'normal' }
    },
    async exec (p, email) {
      return hubResult(await hubCall('POST', '/roster/job/update', { job: p.job, patch: { status: p.status } }, email), `${p.job} → ${p.status}`)
    },
  },
  reschedule_job: {
    permission: 'assign_jobs',
    async build (p) {
      return { title: META.reschedule_job.title, preview: `${p.job} reporté au ${p.date}${p.start ? ' à ' + p.start : ''}`, params: { job: p.job, date: p.date, start: p.start || '' } }
    },
    async exec (p, email) {
      // Conserve le tech assigné si possible (re-place l'heure via /roster/assign-job) ; sinon change la date via /roster/job/update.
      let tech = null
      try { const d = await erp.get('Dispatch Job', p.job, { fields: ['assigned_tech'] }); tech = d && d.assigned_tech } catch { /* fallback ci-dessous */ }
      if (tech) {
        const body = { job: p.job, tech, date: p.date }
        if (p.start) body.start = p.start
        return hubResult(await hubCall('POST', '/roster/assign-job', body, email), `${p.job} reporté au ${p.date}`)
      }
      return hubResult(await hubCall('POST', '/roster/job/update', { job: p.job, patch: { scheduled_date: p.date } }, email), `${p.job} reporté au ${p.date}`)
    },
  },
  create_recurring_shift: {
    permission: 'assign_jobs',
    async build (p) {
      const days = normDays(p.days)
      const schedule = buildWeeklySchedule(p.days, p.start, p.end)
      const name = await resolveTechName(p.technicien_id)
      const st = normTime(p.start), en = normTime(p.end)
      const worked = days.map(k => DAY_FR[k]).join(', ') || '(aucun jour)'
      const off = DAY_KEYS.filter(k => !days.includes(k)).map(k => DAY_FR[k]).join(', ')
      const preview = `${name} (${p.technicien_id}) : ${st}–${en} le ${worked}${off ? ` · repos ${off}` : ''}`
      // schedule pré-calculé porté dans les params → /run le pousse tel quel sur la route weekly-schedule.
      return { title: META.create_recurring_shift.title, preview, params: { technicien_id: p.technicien_id, schedule } }
    },
    async exec (p, email) {
      if (!p.schedule || typeof p.schedule !== 'object' || !Object.keys(p.schedule).length) return { ok: false, message: 'horaire vide (aucun jour)' }
      return hubResult(await hubCall('POST', `/roster/technician/${encodeURIComponent(p.technicien_id)}/weekly-schedule`, { schedule: p.schedule }, email), 'Horaire récurrent enregistré (quarts matérialisés au prochain cycle)')
    },
  },
  follow_doc: {
    permission: null, // suivi = personnel/bas risque → tout staff authentifié
    async build (p) {
      const follow = p.follow !== false
      return { title: META.follow_doc.title, preview: `${follow ? 'Suivre' : 'Ne plus suivre'} ${p.doctype} ${p.name}`, params: { doctype: p.doctype, name: p.name, follow } }
    },
    async exec (p, email) {
      return hubResult(await hubCall('POST', '/conversations/follow', { doctype: p.doctype, name: p.name, follow: p.follow !== false }, email), (p.follow !== false) ? 'Suivi activé' : 'Suivi retiré')
    },
  },
  // Do Stuff : opération à distance sur l'ONU via l'OLT (olt-ops). build = APERÇU via plan() (lecture seule) ;
  // exec = run({confirm:true}) — double-verrou (confirm humain + confirm:true module) + idempotence + acteur journalisé.
  device_action: {
    permission: 'reboot_provision', // capacité « Équipement › Reboot/provisionner » (déjà au catalogue, assignable par groupe)
    async build (p) {
      const action = String(p.action || '').trim()
      if (!oltOps().ACTIONS[action]) throw new Error(`action inconnue : « ${action} » (reboot, speed, suspend, unsuspend, activate, replace, remove)`)
      let serial = String(p.serial || '').trim()
      if (!serial && p.customer_id) { const r = await resolveCustomerOnu(p.customer_id); if (r.error) throw new Error(r.error); serial = r.serial }
      if (!serial) throw new Error('serial (n° de série ONU) ou customer_id requis')
      const opts = {}
      if (p.profileid != null && p.profileid !== '') opts.profileid = String(p.profileid)
      if (p.new_sn) opts.new_sn = String(p.new_sn)
      // APERÇU = plan olt-ops (LECTURE SEULE) : effet, cible réseau, avertissements, exécutable ?
      const pl = await oltOps().plan({ serial, action, ...opts })
      const idempotencyKey = `sa-${action}-${serial}-${Date.now()}` // fixé au plan → un double-clic Confirmer rejoue la MÊME clé (dédup module)
      const who = pl.customer ? ` · client ${pl.customer}` : ''
      if (!pl.can_run) {
        // Repli REBOOT → TR-069 : les ONU tech-3 (TPLG, la plus grosse flotte) ne rebootent PAS via olt-ops (SNMP
        // Raisecom = tech-2). On passe alors par GenieACS (devices.rebootBySerial). Autres actions : non exécutables.
        if (action === 'reboot') {
          return { title: META.device_action.title, preview: `Redémarrer le modem (TR-069) — ONU ${serial}${who} · ⚠ coupe l'accès du client au moins ~3 min (le temps que le modem redémarre et se reconnecte).`, severity: 'high', params: { via: 'tr069', serial, action, idempotencyKey } }
        }
        throw new Error(`« ${pl.label || action} » non exécutable pour ${serial}${(pl.warnings && pl.warnings.length) ? ' — ' + pl.warnings.join(' ') : ''}`)
      }
      const warn = (pl.warnings && pl.warnings.length) ? ' ⚠ ' + pl.warnings.join(' ') : ''
      const preview = `${pl.label} — ONU ${pl.target.serial}${pl.target.olt ? ' · OLT ' + pl.target.olt : ''}${who}${(pl.effects && pl.effects.length) ? ' · ' + pl.effects.join(' ') : ''}${warn}`
      const severity = /^(suspend|remove|replace|activate|reboot|speed)$/.test(action) ? 'high' : 'normal' // reboot/speed = l'ONU redémarre (coupure) ⇒ conséquent
      return { title: META.device_action.title, preview, severity, params: { via: 'olt', serial, action, profileid: opts.profileid || '', new_sn: opts.new_sn || '', idempotencyKey } }
    },
    async exec (p, email) {
      if (p.via === 'tr069') { // reboot via GenieACS (ONU tech-3)
        const r = await require('./devices').rebootBySerial(p.serial)
        return { ok: !!(r && r.ok), message: (r && r.ok) ? `✅ Redémarrage (TR-069) envoyé — ${p.serial}` : `échec redémarrage TR-069 : ${(r && r.error) || 'erreur'}`, data: r }
      }
      const opts = {}
      if (p.profileid) opts.profileid = p.profileid
      if (p.new_sn) opts.new_sn = p.new_sn
      const r = await oltOps().run({ serial: p.serial, action: p.action, confirm: true, idempotencyKey: p.idempotencyKey, actor: email || 'ops', ...opts })
      const label = (oltOps().ACTIONS[p.action] && oltOps().ACTIONS[p.action].label) || p.action
      if (r && r.ok) {
        const extra = (r.mirrored && r.mirrored.length) ? ' · ' + r.mirrored.join(', ') : ''
        return { ok: true, message: `✅ ${label} — ${p.serial} (${r.method || '?'})${r.idempotent ? ' [déjà exécuté]' : ''}${extra}`, data: r }
      }
      return { ok: false, message: `échec « ${label} » : ${(r && (r.net_error || r.error)) || 'erreur réseau'}`, data: r }
    },
  },
}

// ── Runtime Gemini (function-calling) — même config/boucle que lib/agent.js & roster-assistant.js ──
async function geminiChat (messages) {
  // reasoningEffort:'none' : gemini-2.5-flash SANS « thinking » — sinon ses jetons de réflexion mangent maxTokens et
  // renvoient une réponse VIDE (« Aucune action détectée »), surtout avec beaucoup d'outils (cf feedback_gemini_thinking_tokens).
  // maxTokens relevé (1200) pour laisser la place aux résumés client 360.
  const msg = await require('./ai').chat({ task: 'nl', messages, tools: STAFF_TOOLS, maxTokens: 1200, temperature: 0.1, wantMessage: true, reasoningEffort: 'none' })
  return { choices: [{ message: msg }] }
}

function systemPrompt (email) {
  return `Tu es le COPILOTE OPS (dispatch/planification) de Gigafibre/TARGO, fournisseur Internet/TV/téléphonie au Québec.
Aujourd'hui = ${todayET()}. Tu agis AU NOM de l'utilisateur connecté${email ? ` (${email})` : ''}.
Tu transformes une commande en langage naturel en APPELS D'OUTILS.
RÈGLES :
- Utilise d'abord les outils de LECTURE (find_customer, customer_overview, customer_balance, customer_subscriptions, customer_equipment, customer_history, list_technicians, get_job, resolve_skill, resource_availability, find_slot, check_service, check_signal) pour RÉSOUDRE, RENSEIGNER et DIAGNOSTIQUER. Résous toujours un technicien nommé (« Simon ») en tech_id via list_technicians avant d'agir.
- IDENTIFIER LE CLIENT : si le client n'est pas donné par son id (C-xxxx), appelle find_customer avec ce que tu as (nom, adresse, téléphone, courriel). UN seul candidat → prends son id et continue. PLUSIEURS → NE DEVINE PAS : demande brièvement « lequel ? » ; l'interface affiche la liste cliquable des candidats, donc NE les réénumère PAS tous en texte. Utilise ensuite l'id choisi pour l'action.
- QUESTIONS CLIENT (solde, montant dû, factures, abonnements, forfait, équipement, historique, « c'est qui ? », « résume le compte », « un problème est-il déjà arrivé ? ») : résous d'abord le client (find_customer → id C-xxxx), puis appelle le bon lecteur — customer_overview (résumé 360 : identité + SOLDE + abonnements + tickets ouverts), customer_balance (solde + factures), customer_subscriptions, customer_equipment, customer_history (tickets passés = problème déjà survenu ?). LECTURE : réponds directement, en français, sans étape de confirmation. Cite le SOLDE en dollars quand on le demande.
- PROBLÈME DE CONNEXION (« pas d'internet », « lent », « hors ligne », « coupé ») : appelle check_service AVANT de proposer un déplacement. Si le modem est EN LIGNE → propose d'abord un correctif À DISTANCE via device_action(reboot) ; s'il est HORS LIGNE avec signal faible/nul → un déplacement (réparation) est justifié. Dis à l'utilisateur ce que tu as constaté (en ligne/hors ligne · Rx dBm) avant de recommander.
- QUALITÉ / FORCE DU SIGNAL (« quel est son signal ? », « signal fibre/sans-fil », « c'est bon comme signal ? », ou dès que check_service renvoie un signal nul) : appelle **check_signal** — il MESURE EN DIRECT (comme le « Do Stuff get » de F) : fibre = Rx/Tx dBm + distance via l'OLT ; sans-fil (airOS) = signal/CCQ/débit/capacité airMAX du CPE. C'est LENT (~10-30 s) → dis « un instant, je mesure le signal en direct… ». NE conclus JAMAIS « signal non mesurable / pas mesurable à distance » : appelle check_signal d'abord ; s'il ne remonte rien, dis-le précisément (OLT lente / hors couverture / CPE injoignable) et propose un redémarrage.
- CORRECTIF À DISTANCE (Do Stuff) : device_action agit sur l'ONU du client via l'OLT — reboot (redémarrer), speed (changer le forfait), suspend/unsuspend (couper/rétablir l'internet), activate/replace/remove (provisionnement). Fournis serial OU customer_id (on résout l'ONU). Utilise-le pour éviter un déplacement quand c'est réparable à distance. ÉCRITURE RÉSEAU conséquente → toujours PROPOSÉE puis CONFIRMÉE ; n'affirme jamais que c'est fait avant confirmation.
- INTERVENTION à créer/assigner : procède par ÉTAPES conversationnelles, ne saute PAS à la création. (1) Cerne la RAISON (si vague ou absente, pose UNE question courte plutôt que deviner). (2) resolve_skill → compétence requise. (3) resource_availability (combien de techs qualifiés dispo + heures libres) puis find_slot (créneaux précis) → DISPONIBILITÉ réelle. (4) SEULEMENT ensuite propose create_job / assign_tech / proposer_rdv_client. Résume ton diagnostic (raison → compétence → ressource dispo) AVANT de proposer, et inclus l'adresse dans create_job si fournie.
- Les outils d'ÉCRITURE (create_job, assign_tech, proposer_rdv_client, add_assistant, set_job_status, reschedule_job, create_recurring_shift, follow_doc, device_action) NE S'EXÉCUTENT PAS tout de suite : ils sont PROPOSÉS puis CONFIRMÉS par l'utilisateur. Chaque appel te renvoie un APERÇU (staged=true). N'affirme JAMAIS qu'une action est faite ; annonce ce qui SERA fait après confirmation.
- POUR PROPOSER une action d'écriture (même conséquente comme suspend/remove/reboot), tu DOIS APPELER l'outil : l'appel crée l'aperçu + le bouton « Confirmer et exécuter » que l'utilisateur clique. NE demande JAMAIS « voulez-vous que je procède ? » en TEXTE à la place de l'appel, et n'attends pas un « oui » avant d'appeler — l'APPEL EST la proposition, et rien ne s'exécute avant le clic de confirmation. (Poser une question ne vaut que s'il MANQUE une donnée pour construire l'appel : quel client/serial, quel job, quelle date.)
- Horaire/quart RÉCURRENT : days parmi mon,tue,wed,thu,fri,sat,sun. « jours de semaine » = mon,tue,wed,thu,fri ; retire les exceptions (« sauf le vendredi » → mon,tue,wed,thu). « fin de semaine » = sat,sun. Convertis « 8-16h » en start 08:00 / end 16:00. Il FAUT un technicien : si aucun n'est nommé, DEMANDE-le.
- S'il manque une information essentielle (quel job ? quel tech ? quelle date ?), pose UNE question courte au lieu d'appeler un outil d'écriture.
- Réponds en français, bref. Après avoir proposé des actions, résume-les en une phrase et invite à confirmer.`
}

async function execToolPlan (name, args, ctx) {
  const meta = META[name]
  if (!meta) return { error: 'outil inconnu : ' + name }
  try {
    if (meta.mode === 'read') {
      const fn = READERS[name]; if (!fn) return { error: 'lecteur manquant : ' + name }
      const out = await fn(args || {})
      if (out && Array.isArray(out.candidates) && out.candidates.length > 1) ctx.options = out.candidates // client ambigu → l'UI affiche des options cliquables
      return out
    }
    const w = WRITES[name]
    if (!w) return { error: 'exécuteur manquant : ' + name }
    const staged = await w.build(args || {}, ctx.email)
    const id = 'a' + (ctx.planned.length + 1)
    ctx.planned.push({ id, type: name, title: staged.title, preview: staged.preview, params: staged.params, permission: meta.permission || null, severity: staged.severity || 'normal', needs_confirmation: true })
    return { staged: true, requires_confirmation: true, title: staged.title, preview: staged.preview, note: "Action PROPOSÉE, NON exécutée. L'utilisateur doit la confirmer. Ne dis pas qu'elle est faite." }
  } catch (e) { return { error: String(e.message || e) } }
}

// PLAN : boucle function-calling. Les LECTURES s'exécutent (résolution), les ÉCRITURES sont mises en attente.
async function plan (text, email, history) {
  if (!cfg.AI_API_KEY) return { ok: false, error: "Le service IA n'est pas configuré (AI_API_KEY manquante)." }
  const planned = []
  const ctx = { email, planned, options: [] } // options = candidats cliquables (désambiguïsation client/entité) à afficher dans le fil
  // Chat MULTI-TOURS : les tours précédents (user/assistant) donnent le contexte pour répondre à une question de
  // clarification (« oui, crée l'intervention »). Bornage : 10 derniers tours, 4000 car. chacun.
  const hist = Array.isArray(history) ? history.filter(m => m && (m.role === 'user' || m.role === 'assistant') && m.content).slice(-10).map(m => ({ role: m.role, content: String(m.content).slice(0, 4000) })) : []
  const messages = [{ role: 'system', content: systemPrompt(email) }, ...hist, { role: 'user', content: String(text || '') }]
  let response
  try { response = await geminiChat(messages) } catch (e) { return { ok: false, error: 'IA : ' + e.message } }
  const cur = [...messages]
  for (let i = 0; i < 6; i++) {
    const msg = response.choices?.[0]?.message
    if (!msg) break
    const calls = msg.tool_calls // signal OpenAI-compat des appels d'outils (indépendant de finish_reason)
    if (!calls || !calls.length) break
    cur.push(msg)
    for (const tc of calls) {
      const args = (() => { try { return JSON.parse(tc.function.arguments || '{}') } catch { return {} } })()
      log(`[staff-agent] ${email || 'anon'} → ${tc.function.name}(${JSON.stringify(args)})`)
      const result = await execToolPlan(tc.function.name, args, ctx)
      cur.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result).slice(0, 6000) })
    }
    try { response = await geminiChat(cur) } catch (e) { return { ok: false, error: 'IA : ' + e.message } }
  }
  const reply = response.choices?.[0]?.message?.content || (planned.length ? 'Plan préparé — confirme pour exécuter.' : 'Aucune action détectée — reformule.')
  return { ok: true, reply, actions: planned, options: ctx.options, transcript: String(text || '') }
}

// RUN : exécute les actions CONFIRMÉES. type → exécuteur (liste blanche) + re-vérif permission + identité.
async function run (actions, email) {
  const list = Array.isArray(actions) ? actions.slice(0, 12) : []
  let caps = null
  try { caps = await require('./auth').effectiveCapabilities(email) } catch (e) { log('[staff-agent] perms indispo : ' + e.message) }
  const results = []
  for (const a of list) {
    const type = a && a.type
    const w = WRITES[type]
    if (!w) { results.push({ type, ok: false, error: 'action inconnue' }); continue }
    // Permissions (parité usePermissions) : appliquées si Authentik est branché (prod). En dev/aperçu
    // (pas de token → caps.configured=false) on n'enforce pas mais on journalise — le gate du hub
    // (jeton de service) garantit déjà que seul un membre du staff authentifié atteint /staff-agent.
    const need = w.permission
    if (need && caps && caps.configured) {
      const allowed = caps.is_superuser || (caps.capabilities && caps.capabilities[need] === true)
      if (!allowed) { results.push({ type, ok: false, denied: true, error: `permission requise : ${need}` }); continue }
    }
    try { results.push({ type, ...(await w.exec(a.params || {}, email)) }) }
    catch (e) { results.push({ type, ok: false, error: String(e.message || e) }) }
  }
  return { ok: results.length > 0 && results.every(r => r.ok), results }
}

async function handle (req, res, method, path) {
  const email = String(req.headers['x-authentik-email'] || '').toLowerCase()
  if (path === '/staff-agent' && method === 'POST') {
    const b = await parseBody(req)
    try { return json(res, 200, await plan(b.text || '', email, b.history)) }
    catch (e) { return json(res, 200, { ok: false, error: 'Erreur copilote : ' + (e.message || e) }) }
  }
  if (path === '/staff-agent/run' && method === 'POST') {
    const b = await parseBody(req)
    try { return json(res, 200, await run(b.actions, email)) }
    catch (e) { return json(res, 200, { ok: false, error: 'Erreur exécution : ' + (e.message || e) }) }
  }
  if (path === '/staff-agent/tools' && method === 'GET') {
    return json(res, 200, { tools: STAFF_TOOLS_RAW.map(t => ({ name: t.function.name, mode: t.mode || 'read', permission: t.permission || null, title: t.title || t.function.name, description: t.function.description })) })
  }
  return json(res, 404, { error: 'staff-agent: route inconnue ' + path })
}

module.exports = { handle, plan, run, buildWeeklySchedule, normDays, normTime, STAFF_TOOLS_RAW }
