'use strict'
/**
 * AI Service — Internal ops intelligence powered by Gemini Flash
 *
 * This module handles all internal AI decision-making:
 *   - Dispatch optimization (best tech, redistribution, schedule review)
 *   - Customer diagnostics (troubleshooting suggestions)
 *   - Marketing insights (segmentation, churn risk, offer suggestions)
 *   - Natural language → structured actions
 *
 * PRIVACY: Uses Google Gemini API which does NOT train on API data.
 * Customer PII is minimized — only operational context is sent (no full addresses,
 * no payment info, no personal identifiers beyond first name + city).
 *
 * Architecture:
 *   - Reuses same AI_API_KEY / AI_BASE_URL / AI_MODEL from config
 *   - All responses are structured JSON (response_format: json_object)
 *   - Model-agnostic: swap to Ollama by changing AI_BASE_URL to http://gpu-server:11434/v1/
 *
 * Endpoints:
 *   POST /ai/dispatch-suggest    -> best tech recommendation for a job
 *   POST /ai/dispatch-redistribute -> redistribute jobs when tech unavailable
 *   POST /ai/dispatch-nlp        -> natural language → dispatch action
 *   POST /ai/diagnose            -> customer troubleshooting from symptoms
 *   POST /ai/summarize-day       -> daily dispatch summary for a tech
 */

const cfg = require('./config')
const { log, json, parseBody, erpFetch, readJsonFile, writeJsonFile } = require('./helpers')

// ── Core Gemini call (reuses same config as agent.js) ───────────────────────

async function aiCall (systemPrompt, userPrompt, { jsonMode = true, maxTokens = 8192, temperature = 0.3, task = 'dispatch' } = {}) {
  // Délègue au routeur par tâche (profils + repli Gemini auto). Conserve le parsing JSON tolérant des appelants existants.
  const content = await chat({ task, jsonMode, maxTokens, temperature, messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }] })
  if (!content) throw new Error('Empty AI response')
  if (!jsonMode) return content
  try { return JSON.parse(content) } catch {
    const match = content.match(/```(?:json)?\s*([\s\S]*?)```/)
    if (match) { try { return JSON.parse(match[1].trim()) } catch { /* fall through */ } }
    return { raw: content.substring(0, 300), error: 'Failed to parse JSON' }
  }
}

// ── Routeur de modèle PAR TÂCHE ─────────────────────────────────────────────
// Le plan de CONTRÔLE reste déterministe (scripts) ; ici on ne route que le plan de JUGEMENT (classer/rédiger/suggérer).
// gemini = défaut cloud. local = vLLM/Ollama (QWEN/Hermes) — actif seulement si AI_LOCAL_BASE_URL+MODEL définis, sinon repli Gemini.
// Routage/local persistés (éditables depuis le panneau Ops) — le FICHIER prime sur l'env. La CLÉ reste en .env (jamais en UI).
const ROUTES_FILE = '/app/data/ai_routes.json'
function loadRoutesFile () { return readJsonFile(ROUTES_FILE, {}) }
function saveRoutesFile (obj) { return writeJsonFile(ROUTES_FILE, obj) }
function buildProfiles () {
  const loc = (loadRoutesFile().local) || {}
  return {
    gemini: { name: 'gemini', baseUrl: cfg.AI_BASE_URL, model: cfg.AI_MODEL, fallbackModel: cfg.AI_FALLBACK_MODEL, key: cfg.AI_API_KEY, label: 'Gemini (cloud)' },
    local: { name: 'local', baseUrl: loc.baseUrl || cfg.AI_LOCAL_BASE_URL, model: loc.model || cfg.AI_LOCAL_MODEL, fallbackModel: '', key: cfg.AI_LOCAL_API_KEY, label: 'Local (vLLM/QWEN)' },
  }
}
const profileAvailable = (p) => !!(p && p.baseUrl && p.model && p.key)
// Intention de routage par tâche (surchargée par AI_ROUTES env PUIS par le fichier). Défaut: tout gemini.
const DEFAULT_ROUTES = { triage: 'gemini', nl: 'gemini', split: 'gemini', sms: 'gemini', roster: 'gemini', dispatch: 'gemini', diagnose: 'gemini', outage: 'gemini', default: 'gemini' }
function effectiveRoutes () { return { ...DEFAULT_ROUTES, ...(cfg.AI_ROUTES || {}), ...((loadRoutesFile().routes) || {}) } }
function routeFor (task) {
  const profiles = buildProfiles()
  let want = effectiveRoutes()[task] || 'gemini'
  if (!profileAvailable(profiles[want])) want = 'gemini' // profil débranché → repli déterministe sur Gemini
  return profiles[want] || profiles.gemini
}
// État du routage (diagnostic + panneau)
function routeStatus () {
  const profiles = buildProfiles()
  const tasks = Object.keys(DEFAULT_ROUTES).filter(t => t !== 'default')
  const routes = effectiveRoutes()
  const routing = {}
  for (const t of tasks) routing[t] = { wanted: routes[t] || 'gemini', effective: routeFor(t).name } // wanted = choix ; effective = réel (repli si indispo)
  return {
    profiles: Object.values(profiles).map(p => ({ name: p.name, label: p.label, model: p.model || null, available: profileAvailable(p), key_set: !!p.key, base: p.baseUrl ? p.baseUrl.replace(/\/+$/, '') : null })),
    tasks, routing, fallback: 'gemini', local_key_env: 'AI_LOCAL_API_KEY',
  }
}

// Un appel chat OpenAI-compatible sur un PROFIL donné (un seul modèle), avec retries 429/503. Renvoie le message brut.
async function callProfileOnce (profile, model, { messages, jsonMode, maxTokens, temperature, tools, reasoningEffort }) {
  const body = { model, max_tokens: maxTokens, temperature, messages } // max_tokens = le plus compatible (Gemini-compat + vLLM + Ollama)
  if (jsonMode) body.response_format = { type: 'json_object' }
  if (tools) body.tools = tools
  if (reasoningEffort) body.reasoning_effort = reasoningEffort // gemini-2.5 : 'none' désactive le « thinking » (sinon ses jetons de réflexion mangent max_tokens → réponse tronquée sur un long contexte). Champ ignoré par les profils qui ne le connaissent pas.
  for (let attempt = 0; attempt < 3; attempt++) {
    const res = await fetch(`${profile.baseUrl}chat/completions`, {
      method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${profile.key}` }, body: JSON.stringify(body),
    })
    if ((res.status === 429 || res.status === 503) && attempt < 2) { await new Promise(r => setTimeout(r, (attempt + 1) * 2000)); continue }
    if (!res.ok) { const t = await res.text().catch(() => ''); throw new Error(`${profile.name} ${res.status}: ${t.slice(0, 160)}`) }
    const data = await res.json()
    const msg = data.choices?.[0]?.message
    if (!msg) throw new Error('réponse IA vide')
    msg._model = data.model || model
    return msg
  }
  throw new Error(`${profile.name}: indisponible (429/503)`)
}
async function callProfile (profile, opts) {
  const models = [profile.model, profile.fallbackModel].filter(Boolean)
  let lastErr
  for (const m of models) { try { return await callProfileOnce(profile, m, opts) } catch (e) { lastErr = e } }
  throw lastErr || new Error(`${profile.name}: échec`)
}
// Point d'entrée message-based avec ROUTAGE + repli Gemini. Renvoie la string (ou le message complet si wantMessage, pour les tool-calls).
async function chat ({ task = 'default', messages, jsonMode = false, maxTokens = 700, temperature = 0.2, tools, wantMessage = false, reasoningEffort } = {}) {
  const primary = routeFor(task)
  if (!profileAvailable(primary)) throw new Error('AI non configurée')
  let msg
  try { msg = await callProfile(primary, { messages, jsonMode, maxTokens, temperature, tools, reasoningEffort }) }
  catch (e) {
    const gem = buildProfiles().gemini
    if (primary.name !== 'gemini' && profileAvailable(gem)) { log(`AI[${task}] ${primary.name} échec (${e.message}) → repli gemini`); msg = await callProfile(gem, { messages, jsonMode, maxTokens, temperature, tools, reasoningEffort }) }
    else throw e
  }
  return wantMessage ? msg : (msg.content || '')
}

// ── PII minimizer — strip sensitive fields before sending to AI ─────────────

function minimizeCustomer (c) {
  if (!c) return null
  return {
    id: c.name,
    first_name: (c.customer_name || '').split(' ')[0],
    type: c.customer_type,
    language: c.language,
    is_commercial: c.is_commercial,
    territory: c.territory,
  }
}

function minimizeTech (t) {
  return {
    id: t.id || t.technician_id,
    name: t.name || t.full_name,
    status: t.status,
    load_h: t.load,
    remaining_h: t.remainingCap || Math.max(0, 8 - (t.load || 0)),
    distance_km: t.distance,
    has_gps: !!t.gpsOnline,
    skills: t.tags || t.skills || [],
    queue_count: t.queue?.length || 0,
  }
}

function minimizeJob (j) {
  return {
    id: j.name || j.ticket_id,
    subject: j.subject,
    type: j.job_type,
    priority: j.priority,
    duration_h: j.duration_h,
    status: j.status,
    city: j.city || '',
    assigned_tech: j.assigned_tech,
    scheduled_date: j.scheduled_date,
    tags: j.tags || [],
  }
}

// ── Dispatch: Best tech suggestion ──────────────────────────────────────────

const DISPATCH_SUGGEST_PROMPT = `Tu es un assistant de planification pour un fournisseur internet (ISP) au Québec.
On te donne un travail à assigner et la liste des techniciens disponibles avec leur charge, distance et compétences.

Analyse et retourne un JSON avec:
{
  "recommendation": "tech_id du meilleur choix",
  "ranking": [
    { "tech_id": "...", "tech_name": "...", "score": 1-10, "reasons_fr": ["raison 1", "raison 2"] }
  ],
  "reasoning_fr": "Explication courte de ton choix",
  "warnings": ["avertissement si surcharge ou conflit"]
}

Critères par ordre d'importance:
1. Compétences requises (skill match) — éliminatoire si manquant
2. Proximité géographique — moins de déplacement = plus efficace
3. Charge de travail — équilibrer entre techniciens
4. GPS en direct — préférer les techs localisés en temps réel
5. Priorité du travail — urgent = tech le plus proche même si chargé

Réponds UNIQUEMENT en JSON valide, en français pour les textes.`

async function dispatchSuggest (job, techs) {
  const userPrompt = JSON.stringify({
    job: minimizeJob(job),
    technicians: techs.map(minimizeTech),
    current_date: new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }),
  })
  return aiCall(DISPATCH_SUGGEST_PROMPT, userPrompt)
}

// ── Dispatch: Redistribute jobs (tech sick/absent) ──────────────────────────

const REDISTRIBUTE_PROMPT = `Tu es un assistant de planification pour un ISP au Québec.
Un technicien est indisponible et ses travaux doivent être redistribués aux autres techniciens.

Analyse et retourne un JSON:
{
  "plan": [
    { "job_id": "...", "from_tech": "...", "to_tech": "...", "to_tech_name": "...", "reason_fr": "..." }
  ],
  "unassignable": [
    { "job_id": "...", "reason_fr": "pourquoi aucun tech ne peut le prendre" }
  ],
  "summary_fr": "Résumé du plan de redistribution",
  "impact": { "overtime_risk": ["tech_ids qui risquent le surtemps"], "customer_delays": 0 }
}

Règles:
- Ne pas surcharger un tech au-delà de 9h
- Priorité: travaux urgents d'abord
- Proximité: assigner au tech le plus proche du lieu
- Si impossible: mettre dans unassignable avec raison`

async function dispatchRedistribute (absentTech, jobs, availableTechs) {
  const userPrompt = JSON.stringify({
    absent_tech: { id: absentTech.id, name: absentTech.name },
    jobs_to_redistribute: jobs.map(minimizeJob),
    available_techs: availableTechs.map(minimizeTech),
  })
  return aiCall(REDISTRIBUTE_PROMPT, userPrompt)
}

// ── Dispatch: Natural language → action ─────────────────────────────────────

const NLP_DISPATCH_PROMPT = `Tu es un assistant de planification pour un ISP au Québec.
L'utilisateur décrit une action en langage naturel. Extrais l'intention structurée.

Retourne un JSON:
{
  "action": "create_job" | "move_job" | "assign_tech" | "find_slot" | "cancel_job" | "unknown",
  "params": {
    "subject": "description du travail",
    "job_type": "Installation" | "Dépannage" | "Maintenance" | "Activation" | "Désactivation" | null,
    "priority": "low" | "medium" | "high" | "urgent" | null,
    "date": "YYYY-MM-DD" | "demain" | "lundi prochain" | null,
    "time_preference": "matin" | "après-midi" | "soir" | null,
    "zone": "zone ou ville mentionnée" | null,
    "tech_name": "nom du technicien si mentionné" | null,
    "duration_h": number | null,
    "customer_hint": "nom ou ID client si mentionné" | null,
    "job_id": "ID du travail si modification" | null
  },
  "confidence": 0.0-1.0,
  "clarification_fr": "question à poser si info manquante" | null
}

Contexte: les types de travaux courants sont Installation Fibre, Installation Camera IP, Dépannage Internet, Activation, Maintenance préventive.
Les zones sont des villes/quartiers du Québec (Laval, Montréal, Longueuil, Terrebonne, etc.)`

async function dispatchNlp (text, context = {}) {
  const userPrompt = JSON.stringify({
    user_input: text,
    current_date: new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }),
    available_techs: context.techNames || [],
  })
  return aiCall(NLP_DISPATCH_PROMPT, userPrompt, { temperature: 0.1 })
}

// ── Customer diagnostic ─────────────────────────────────────────────────────

const DIAGNOSE_PROMPT = `Tu es un assistant technique pour un ISP fibre optique au Québec.
On te donne les symptômes du client et les données techniques de son équipement.

Retourne un JSON:
{
  "diagnosis_fr": "Explication simple du problème pour l'agent",
  "severity": "low" | "medium" | "high" | "critical",
  "steps_fr": ["Étape 1", "Étape 2", "..."],
  "likely_cause_fr": "Cause probable",
  "needs_dispatch": true/false,
  "dispatch_type": "Dépannage" | "Maintenance" | null,
  "auto_fixable": true/false,
  "auto_fix_action": "reboot" | "config_push" | "firmware_update" | null
}

Causes courantes:
- Dying Gasp = perte d'alimentation, vérifier prise + UPS
- LOSi / Branch Fiber Cut = coupure fibre, dispatch obligatoire
- Rx power < -28 dBm = signal faible, possible fibre sale ou courbée
- Uptime très faible = redémarrages fréquents, modem défectueux probable
- WiFi OK mais Internet non = problème WAN/provisioning`

async function diagnose (symptoms, deviceData, customer) {
  const userPrompt = JSON.stringify({
    symptoms,
    device: deviceData ? {
      model: deviceData.model,
      online: deviceData.online,
      uptime: deviceData.uptime,
      rx_power: deviceData.rx_power_dbm,
      tx_power: deviceData.tx_power_dbm,
      wifi_clients: deviceData.wifi_clients,
      wan_ip: deviceData.wan_ip ? 'present' : 'none',  // don't send actual IP
      snmp_status: deviceData.snmp_status,
      last_offline_cause: deviceData.alarm_type || null,
    } : null,
    customer: minimizeCustomer(customer),
  })
  return aiCall(DIAGNOSE_PROMPT, userPrompt, { task: 'diagnose' })
}

// ── Daily summary for tech ──────────────────────────────────────────────────

const SUMMARY_PROMPT = `Tu es un assistant de planification pour un ISP au Québec.
Génère un résumé SMS court (max 300 caractères) de la journée d'un technicien.

Retourne un JSON:
{
  "sms_fr": "le message SMS",
  "total_jobs": number,
  "total_hours": number,
  "first_job_time": "HH:MM",
  "zones": ["villes/quartiers uniques"]
}`

async function summarizeDay (techName, jobs) {
  const userPrompt = JSON.stringify({
    tech_name: techName,
    date: new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }),
    jobs: jobs.map(j => ({
      subject: j.subject, type: j.job_type, duration_h: j.duration_h,
      city: j.city || '', time: j.scheduled_time || '',
      priority: j.priority,
    })),
  })
  return aiCall(SUMMARY_PROMPT, userPrompt, { maxTokens: 300 })
}

// ── Outage analysis — OLT port neighbor correlation ─────────────────────────

const OUTAGE_PROMPT = `Tu es un expert réseau fibre optique (GPON) pour un ISP au Québec.
On te donne les données SNMP d'un ONU (modem client) et de ses voisins sur le même port OLT.

Analyse et retourne un JSON:
{
  "severity": "low" | "medium" | "high" | "critical",
  "outage_type": "single_customer" | "power_outage" | "drop_cable" | "branch_fiber_cut" | "backbone_cut" | "olt_port_failure" | "unknown",
  "affected_count": number,
  "diagnosis_fr": "Explication claire du problème",
  "cause_fr": "Cause probable",
  "action_required": "none" | "customer_reboot" | "simple_tech" | "fiber_splicer" | "escalate_noc",
  "tech_skill_needed": null | "general" | "fiber_splicer",
  "auto_notify_customers": true/false,
  "notification_msg_fr": "Message SMS à envoyer aux clients affectés" | null,
  "evidence": ["fait 1", "fait 2"],
  "priority": "low" | "medium" | "high" | "urgent"
}

Règles de diagnostic:
1. UN SEUL client hors ligne + Dying Gasp → perte de courant chez le client (severity: low, action: none)
2. UN SEUL client + LOSi ou Branch Fiber Cut → câble de desserte coupé (severity: medium, action: simple_tech)
3. PLUSIEURS clients sur même port + Dying Gasp mixte → panne de courant secteur (severity: medium, action: none, notify)
4. PLUSIEURS clients + LOSi/Branch Fiber Cut → coupure fibre en amont du splitter (severity: high, action: fiber_splicer)
5. TOUS les clients du port hors ligne → panne OLT port ou coupure backbone (severity: critical, action: escalate_noc)
6. Client hors ligne SANS cause → modem éteint ou défectueux (severity: low, action: customer_reboot)
7. Signal optique faible (rx < -28 dBm) mais en ligne → fibre dégradée, maintenance préventive
8. Clients avec batterie (UPS) restent en ligne pendant panne de courant → confirme hypothèse power outage

Note: Dying Gasp signifie que l'ONU a eu le temps d'envoyer un dernier message avant de perdre le courant.
Les clients avec UPS/batterie continuent de fonctionner pendant une panne de courant et ne génèrent PAS de Dying Gasp.`

async function analyzeOutage (serial) {
  const oltSnmp = require('./olt-snmp')

  // Get target ONU and port neighbors
  const portData = oltSnmp.getPortNeighbors(serial)
  if (!portData) return { error: 'ONU not found in SNMP cache', serial }

  const { target, neighbors, port, oltName } = portData
  const allOnPort = [target, ...neighbors]
  const total = allOnPort.length
  const offlineOnus = allOnPort.filter(o => o.status === 'offline')
  const onlineOnus = allOnPort.filter(o => o.status === 'online')

  // Build port summary for AI (no customer PII — only serial prefixes + technical data)
  const portSummary = {
    olt: oltName,
    port,
    total_onus: total,
    online_count: onlineOnus.length,
    offline_count: offlineOnus.length,
    target_onu: {
      serial_prefix: target.serial?.substring(0, 8) || '?',
      status: target.status,
      offline_cause: target.lastOfflineCause || null,
      rx_power_olt: target.rxPowerOlt != null ? (target.rxPowerOlt / 100) : null,
      rx_power_onu: target.rxPowerOnu != null ? (target.rxPowerOnu / 100) : null,
      distance_m: target.distance || null,
      uptime: target.uptime || null,
    },
    offline_neighbors: offlineOnus.filter(o => o.serial !== target.serial).map(o => ({
      serial_prefix: o.serial?.substring(0, 8) || '?',
      offline_cause: o.lastOfflineCause || null,
      rx_power_olt: o.rxPowerOlt != null ? (o.rxPowerOlt / 100) : null,
      distance_m: o.distance || null,
    })),
    online_neighbors_sample: onlineOnus.slice(0, 5).map(o => ({
      rx_power_olt: o.rxPowerOlt != null ? (o.rxPowerOlt / 100) : null,
      distance_m: o.distance || null,
      uptime: o.uptime || null,
    })),
  }

  // Classify offline causes for quick stats
  const causes = {}
  for (const o of offlineOnus) {
    const cause = oltSnmp.classifyOfflineCause(o.lastOfflineCause)
    causes[cause] = (causes[cause] || 0) + 1
  }
  portSummary.offline_causes_summary = causes

  // If simple case (only target offline, clear cause) — skip AI, return deterministic
  if (offlineOnus.length === 1 && offlineOnus[0].serial === target.serial) {
    const cause = oltSnmp.classifyOfflineCause(target.lastOfflineCause)
    if (cause === 'dying_gasp') {
      return {
        severity: 'low', outage_type: 'single_customer',
        affected_count: 1,
        diagnosis_fr: 'Le modem du client a perdu le courant (Dying Gasp). Aucun autre client sur ce port n\'est affecté.',
        cause_fr: 'Perte d\'alimentation électrique chez le client',
        action_required: 'none', tech_skill_needed: null,
        auto_notify_customers: false, notification_msg_fr: null,
        evidence: [`1 seul ONU hors ligne sur ${total}`, 'Cause: Dying Gasp', `${onlineOnus.length} voisins en ligne`],
        priority: 'low', port_summary: portSummary, ai_used: false,
      }
    }
    if (cause === 'branch_fiber_cut' || cause === 'loss_of_signal') {
      return {
        severity: 'medium', outage_type: 'drop_cable',
        affected_count: 1,
        diagnosis_fr: `Coupure de fibre détectée sur le câble de desserte du client (${target.lastOfflineCause}). Un technicien peut réparer.`,
        cause_fr: 'Câble de desserte endommagé ou déconnecté',
        action_required: 'simple_tech', tech_skill_needed: 'general',
        auto_notify_customers: false, notification_msg_fr: null,
        evidence: [`1 seul ONU hors ligne sur ${total}`, `Cause: ${target.lastOfflineCause}`, `${onlineOnus.length} voisins en ligne — pas une coupure en amont`],
        priority: 'medium', port_summary: portSummary, ai_used: false,
      }
    }
  }

  // Complex case — multiple customers affected or unclear cause → use AI
  const result = await aiCall(OUTAGE_PROMPT, JSON.stringify(portSummary), { task: 'outage' })
  result.port_summary = portSummary
  result.ai_used = true
  return result
}

// ── HTTP handler ────────────────────────────────────────────────────────────

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

  // POST /ai/dispatch-suggest
  if (sub === 'dispatch-suggest' && method === 'POST') {
    try {
      const body = await parseBody(req)
      if (!body.job) return json(res, 400, { error: 'Missing job' })

      // If techs not provided, fetch them
      let techs = body.technicians
      if (!techs) {
        const dispatch = require('./dispatch')
        const dateStr = body.job.scheduled_date || new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
        techs = await dispatch.getTechsWithLoad(dateStr)
        await dispatch.enrichWithGps(techs)

        // Compute distances to job
        const jobCoords = body.job.latitude && body.job.longitude
          ? [parseFloat(body.job.longitude), parseFloat(body.job.latitude)]
          : null
        if (jobCoords) {
          techs.forEach(t => {
            const pos = t.gpsOnline ? t.gpsCoords : t.coords
            const dx = (pos[0] - jobCoords[0]) * 80, dy = (pos[1] - jobCoords[1]) * 111
            t.distance = Math.sqrt(dx * dx + dy * dy)
            t.remainingCap = Math.max(0, 8 - (t.load || 0))
          })
        }
      }

      const result = await dispatchSuggest(body.job, techs)
      return json(res, 200, result)
    } catch (e) {
      log('AI dispatch-suggest error:', e.message)
      return json(res, 500, { error: e.message })
    }
  }

  // POST /ai/dispatch-redistribute
  if (sub === 'dispatch-redistribute' && method === 'POST') {
    try {
      const body = await parseBody(req)
      if (!body.absent_tech_id) return json(res, 400, { error: 'Missing absent_tech_id' })

      const dispatch = require('./dispatch')
      const dateStr = body.date || new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
      const allTechs = await dispatch.getTechsWithLoad(dateStr)
      await dispatch.enrichWithGps(allTechs)

      const absentTech = allTechs.find(t => t.id === body.absent_tech_id)
      if (!absentTech) return json(res, 404, { error: 'Tech not found' })

      const jobsToMove = absentTech.queue || []
      const availableTechs = allTechs.filter(t => t.id !== body.absent_tech_id && t.status !== 'off')

      // Add distance info relative to each job
      availableTechs.forEach(t => {
        t.remainingCap = Math.max(0, 8 - (t.load || 0))
      })

      const result = await dispatchRedistribute(absentTech, jobsToMove, availableTechs)
      return json(res, 200, result)
    } catch (e) {
      log('AI redistribute error:', e.message)
      return json(res, 500, { error: e.message })
    }
  }

  // POST /ai/dispatch-nlp
  if (sub === 'dispatch-nlp' && method === 'POST') {
    try {
      const body = await parseBody(req)
      if (!body.text) return json(res, 400, { error: 'Missing text' })

      // Provide tech names for context
      let techNames = body.tech_names || []
      if (!techNames.length) {
        try {
          const techRes = await erpFetch('/api/resource/Dispatch Technician?fields=["full_name"]&limit_page_length=50')
          if (techRes.status === 200) techNames = (techRes.data.data || []).map(t => t.full_name)
        } catch (_) {}
      }

      const result = await dispatchNlp(body.text, { techNames })
      return json(res, 200, result)
    } catch (e) {
      log('AI NLP error:', e.message)
      return json(res, 500, { error: e.message })
    }
  }

  // POST /ai/diagnose
  if (sub === 'diagnose' && method === 'POST') {
    try {
      const body = await parseBody(req)
      if (!body.symptoms) return json(res, 400, { error: 'Missing symptoms' })
      const result = await diagnose(body.symptoms, body.device || null, body.customer || null)
      return json(res, 200, result)
    } catch (e) {
      log('AI diagnose error:', e.message)
      return json(res, 500, { error: e.message })
    }
  }

  // POST /ai/summarize-day
  if (sub === 'summarize-day' && method === 'POST') {
    try {
      const body = await parseBody(req)
      if (!body.tech_id) return json(res, 400, { error: 'Missing tech_id' })

      const dispatch = require('./dispatch')
      const dateStr = body.date || new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
      const techs = await dispatch.getTechsWithLoad(dateStr)
      const tech = techs.find(t => t.id === body.tech_id)
      if (!tech) return json(res, 404, { error: 'Tech not found' })

      const result = await summarizeDay(tech.name, tech.queue || [])
      return json(res, 200, result)
    } catch (e) {
      log('AI summarize error:', e.message)
      return json(res, 500, { error: e.message })
    }
  }

  // POST /ai/outage-analysis — analyze outage with port neighbor correlation
  if (sub === 'outage-analysis' && method === 'POST') {
    try {
      const body = await parseBody(req)
      if (!body.serial) return json(res, 400, { error: 'Missing serial' })
      const result = await analyzeOutage(body.serial)
      return json(res, 200, result)
    } catch (e) {
      log('AI outage-analysis error:', e.message)
      return json(res, 500, { error: e.message })
    }
  }

  // GET /ai/port-health-by-serial?serial=X — port neighbor context from a serial number
  if (sub === 'port-health-by-serial' && method === 'GET') {
    try {
      const url = new URL(req.url, 'http://localhost')
      const serial = url.searchParams.get('serial')
      if (!serial) return json(res, 400, { error: 'Missing serial' })
      const oltSnmp = require('./olt-snmp')
      const neighbors = oltSnmp.getPortNeighbors(serial)
      if (!neighbors || !neighbors.length) return json(res, 200, { total: 0, online: 0, offline: 0, neighbors: [], is_mass_outage: false })

      // The target serial's port info
      const target = neighbors.find(n => n.serial === serial || n.serial?.replace(/-/g, '') === serial?.replace(/-/g, ''))
      const total = neighbors.length
      const onlineCount = neighbors.filter(n => n.status === 'online').length
      const offlineCount = total - onlineCount

      return json(res, 200, {
        total, online: onlineCount, offline: offlineCount,
        is_mass_outage: offlineCount >= 3,
        is_warning: offlineCount >= 2,
        target_status: target?.status || 'unknown',
        olt: target?.oltName || null,
        port: target?.port || null,
        neighbors: neighbors.map(n => ({
          serial_prefix: n.serial?.substring(0, 10) + '...',
          is_self: n.serial === serial || n.serial?.replace(/-/g, '') === serial?.replace(/-/g, ''),
          status: n.status,
          cause: n.lastOfflineCause || null,
          distance_m: n.distance || null,
        })),
      })
    } catch (e) {
      log('AI port-health-by-serial error:', e.message)
      return json(res, 200, { total: 0, online: 0, offline: 0, neighbors: [], is_mass_outage: false })
    }
  }

  // GET /ai/port-health/:olt/:port — raw port health without AI
  if (sub.startsWith('port-health/') && method === 'GET') {
    try {
      const parts = sub.replace('port-health/', '').split('/')
      const oltName = decodeURIComponent(parts[0])
      const port = decodeURIComponent(parts.slice(1).join('/'))
      const oltSnmp = require('./olt-snmp')
      const health = oltSnmp.getPortHealth(oltName, port)
      if (!health) return json(res, 404, { error: 'OLT or port not found' })
      // Strip full serials for privacy — only return prefixes
      health.onus = health.onus.map(o => ({
        serial_prefix: o.serial?.substring(0, 8),
        status: o.status,
        offline_cause: o.lastOfflineCause || null,
        rx_power_olt: o.rxPowerOlt != null ? (o.rxPowerOlt / 100) : null,
        distance_m: o.distance || null,
        uptime: o.uptime || null,
      }))
      return json(res, 200, health)
    } catch (e) {
      log('AI port-health error:', e.message)
      return json(res, 500, { error: e.message })
    }
  }

  // GET /ai/active-outages — list currently tracked outage incidents
  if (sub === 'active-outages' && method === 'GET') {
    try {
      const { getActiveIncidents } = require('./outage-monitor')
      return json(res, 200, { incidents: getActiveIncidents() })
    } catch (e) {
      return json(res, 200, { incidents: [] })
    }
  }

  // POST /ai/customer-by-serial — reverse lookup: serial → customer
  if (sub === 'customer-by-serial' && method === 'POST') {
    try {
      const body = await parseBody(req)
      if (!body.serial) return json(res, 400, { error: 'Missing serial' })
      const { getCustomerBySerial } = require('./outage-monitor')
      const cust = await getCustomerBySerial(body.serial)
      if (!cust) return json(res, 404, { error: 'No customer linked to this serial' })
      return json(res, 200, cust)
    } catch (e) {
      return json(res, 500, { error: e.message })
    }
  }

  // GET /ai/outage-log?since=ISO&limit=200 — telemetry event log (CrateDB staging)
  if (sub === 'outage-log' && method === 'GET') {
    try {
      const { getEventLog } = require('./outage-monitor')
      const url = new URL(req.url, 'http://localhost')
      const since = url.searchParams.get('since') || null
      const limit = parseInt(url.searchParams.get('limit')) || 200
      return json(res, 200, { events: getEventLog(since, limit) })
    } catch (e) {
      return json(res, 200, { events: [] })
    }
  }

  // GET /ai/privacy — returns privacy policy for AI features
  if (sub === 'privacy' && method === 'GET') {
    return json(res, 200, PRIVACY_INFO)
  }

  // GET /ai/route-status — profils de modèle + routage par tâche (diagnostic + panneau)
  if (sub === 'route-status' && method === 'GET') {
    return json(res, 200, routeStatus())
  }
  // POST /ai/route-test — ping un profil (petit appel réel) pour valider la connexion avant de router dessus.
  if (sub === 'route-test' && method === 'POST') {
    const b = await parseBody(req)
    const profiles = buildProfiles()
    const p = profiles[(b && b.profile) || 'local']
    if (!p) return json(res, 400, { error: 'profil inconnu' })
    if (!profileAvailable(p)) return json(res, 200, { ok: false, error: 'profil non configuré (base/modèle/clé manquants)' })
    const t0 = Date.now()
    try {
      const msg = await callProfile(p, { messages: [{ role: 'user', content: 'ping' }], maxTokens: 5, temperature: 0 })
      return json(res, 200, { ok: true, model: msg._model || p.model, latency_ms: Date.now() - t0, sample: (msg.content || '').slice(0, 40) })
    } catch (e) { return json(res, 200, { ok: false, error: e.message, latency_ms: Date.now() - t0 }) }
  }

  // POST /ai/routes — enregistre le routage par tâche + base/modèle du profil local. La CLÉ reste en .env (AI_LOCAL_API_KEY).
  if (sub === 'routes' && method === 'POST') {
    try {
      const b = await parseBody(req)
      const cur = loadRoutesFile()
      const routes = {}
      if (b.routes && typeof b.routes === 'object') { for (const [t, p] of Object.entries(b.routes)) if (p === 'gemini' || p === 'local') routes[t] = p } // valide les valeurs
      const next = {
        routes: Object.keys(routes).length ? routes : (cur.routes || {}),
        local: { baseUrl: (b.local && b.local.baseUrl != null) ? String(b.local.baseUrl).trim() : (cur.local && cur.local.baseUrl) || '', model: (b.local && b.local.model != null) ? String(b.local.model).trim() : (cur.local && cur.local.model) || '' },
      }
      if (!saveRoutesFile(next)) return json(res, 500, { error: 'écriture échouée' })
      return json(res, 200, { ok: true, ...routeStatus() })
    } catch (e) { return json(res, 500, { error: e.message }) }
  }

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

// ── Privacy disclosure (served via API for UI display) ──────────────────────

const PRIVACY_INFO = {
  provider: 'Google Gemini API',
  model: 'gemini-2.5-flash',
  data_training: false,
  data_retention_days: 0,
  pii_sent: false,
  summary_fr: "L'intelligence artificielle est utilisée uniquement pour optimiser les opérations internes (planification, diagnostic). Aucune donnée personnelle de client (adresse complète, téléphone, courriel, paiement) n'est transmise au service d'IA. Seules des informations opérationnelles anonymisées (prénom, ville, type de service) sont utilisées. Google confirme que les données envoyées via son API ne sont pas utilisées pour entraîner ses modèles.",
  summary_en: "AI is used solely to optimize internal operations (scheduling, diagnostics). No customer personal data (full address, phone, email, payment info) is sent to the AI service. Only anonymized operational info (first name, city, service type) is used. Google confirms that data sent via its API is not used to train its models.",
  google_policy_url: 'https://ai.google.dev/gemini-api/terms',
  controls: [
    'PII stripping before every AI call (lib/ai.js minimizeCustomer)',
    'No payment or billing data ever sent to AI',
    'All AI suggestions require human confirmation',
    'AI never auto-executes actions on customer accounts',
    'Full audit log of AI calls available',
    'Can switch to self-hosted model (Ollama) for full data sovereignty',
  ],
}

// ── Exports ─────────────────────────────────────────────────────────────────

module.exports = {
  handle,
  aiCall,
  chat,
  routeFor,
  routeStatus,
  dispatchSuggest,
  dispatchRedistribute,
  dispatchNlp,
  diagnose,
  analyzeOutage,
  summarizeDay,
  minimizeCustomer,
  minimizeJob,
  minimizeTech,
  PRIVACY_INFO,
}
