'use strict'
const crypto = require('crypto')
const fs = require('fs')
const path = require('path')
const cfg = require('./config')
const { log, json, parseBody, lookupCustomerByPhone, lookupCustomersByPhone, lookupCustomersByEmail, createCommunication, readJsonFile, writeJsonFile, stripDataUrl, OWN_DOMAINS } = require('./helpers')
const erp = require('./erp')
const sse = require('./sse')
const store = require('./store-pg') // magasin Postgres DURABLE (miroir best-effort + repli JSON si indispo)

const DATA_DIR = path.join(__dirname, '..', 'data')
const CONV_FILE = path.join(DATA_DIR, 'conversations.json')
const conversations = new Map()
const pushSubscriptions = new Map()
const agentTimers = new Map()

// Jeton d'ingestion webhook (courriel/canal) : env sinon fichier — lu UNE seule fois (mémoïsé) au lieu d'un
// fs.readFileSync SYNCHRONE à CHAQUE webhook entrant (bloquait la boucle d'événements). Redémarrage requis pour une rotation.
let _mailIngestToken
function mailIngestToken () {
  if (_mailIngestToken === undefined) {
    _mailIngestToken = (process.env.MAIL_INGEST_TOKEN || '').trim()
    if (!_mailIngestToken) { try { _mailIngestToken = fs.readFileSync('/app/data/mail_ingest.token', 'utf8').trim() } catch (e) { _mailIngestToken = '' } }
  }
  return _mailIngestToken
}

function loadFromDisk () {
  try {
    if (!fs.existsSync(CONV_FILE)) return
    for (const conv of JSON.parse(fs.readFileSync(CONV_FILE, 'utf8'))) conversations.set(conv.token, conv)
    log(`Loaded ${conversations.size} conversations from disk`)
  } catch (e) { log('Failed to load conversations:', e.message) }
}

// Les conversations COURRIEL sont DURABLES (comme des tickets) → jamais expirées ; SMS/web-chat gardent l'expiration 30 j (lien éphémère).
function isExpired (conv) { return !!conv && conv.channel !== 'email' && new Date(conv.expiresAt) < new Date() }

// Synchro Postgres DIRTY-ONLY (durable) : au plus toutes les 3 s, n'upsert QUE les conversations dont la
// sérialisation a changé depuis la dernière synchro (détection automatique, sans toucher aux ~40 appels saveToDisk).
const _syncedJson = new Map() // token → dernière sérialisation persistée en base
let _pgTimer = null
function syncPg () {
  if (!store.available() || _pgTimer) return
  _pgTimer = setTimeout(async () => {
    _pgTimer = null
    try {
      for (const c of conversations.values()) {
        const j = JSON.stringify(c)
        if (_syncedJson.get(c.token) === j) continue
        await store.upsert(c)            // entête + messages (normalisé), transaction
        _syncedJson.set(c.token, j)      // marqué synchronisé SEULEMENT après upsert réussi
      }
    } catch (e) { /* best-effort ; ré-essai au prochain tick */ }
  }, 3000)
}

let saveTimer = null
function saveToDisk () {
  syncPg() // miroir Postgres durable (best-effort, throttlé) — EN PLUS du JSON (repli / rollback instantané)
  if (saveTimer) return
  saveTimer = setTimeout(() => {
    saveTimer = null
    try {
      if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true })
      fs.writeFileSync(CONV_FILE + '.tmp', JSON.stringify([...conversations.values()], null, 0))
      fs.renameSync(CONV_FILE + '.tmp', CONV_FILE)
    } catch (e) { log('Failed to save conversations:', e.message) }
  }, 500)
}

loadFromDisk()
// Démarrage Postgres — MAÎTRE (système de record, comme les tickets) :
//  1) crée / migre le schéma normalisé `comms` (conversations + messages) ;
//  2) Postgres NON VIDE → il fait foi : on (re)charge la Map depuis Postgres ;
//  3) Postgres VIDE (1er boot / juste après migration) → on AMORCE depuis le JSON ;
//  4) Postgres indisponible → repli total sur le JSON (le hub reste 100 % fonctionnel).
// Le JSON reste écrit en double (sauvegarde / rollback). _syncedJson est amorcé pour ne pas tout ré-upserter au 1er changement.
;(async () => {
  try {
    if (!(await store.init())) { log('comms-pg: indisponible → lecture sur le JSON'); return }
    const rows = await store.loadAll()
    if (rows && rows.length) {
      conversations.clear()
      for (const c of rows) if (c && c.token) conversations.set(c.token, c)
      log(`comms-pg: ${conversations.size} conversations chargées depuis Postgres (MAÎTRE) — ${await store.msgCount()} messages`)
    } else if (conversations.size) {
      log(`comms-pg: base vide → amorçage de ${conversations.size} conversations depuis le JSON…`)
      const done = await store.upsertAll([...conversations.values()])
      log(`comms-pg: amorçage terminé — ${done} conversations + ${await store.msgCount()} messages en base`)
    }
    for (const c of conversations.values()) _syncedJson.set(c.token, JSON.stringify(c)) // évite de tout ré-upserter au 1er changement
  } catch (e) { log('comms-pg bootstrap:', e.message) }
})()

function generateToken () { return crypto.randomBytes(4).toString('base64url') }

function createConversation ({ phone, customer, customerName, agentEmail, subject }) {
  const token = generateToken()
  const now = new Date().toISOString()
  const conv = {
    token, phone, customer: customer || null, customerName: customerName || 'Client',
    agentEmail: agentEmail || null, subject: subject || 'Conversation',
    status: 'active', messages: [], createdAt: now, lastActivity: now,
    expiresAt: new Date(Date.now() + 30 * 24 * 3600000).toISOString(), // 30 j (lien web-chat persistant ; le client peut toujours répondre par SMS)
    smsCount: 0, pushSub: null,
  }
  conversations.set(token, conv)
  saveToDisk()
  log(`Conversation created: ${token} for ${phone} (${customerName})`)
  return conv
}

// Crée une conversation COURRIEL avec un brouillon pré-rempli (ex. récompense cadeau) à éditer/envoyer
// depuis la messagerie centralisée. Le corps contient déjà NOTRE lien /g/ (jamais le lien Giftbit brut).
function createDraftConversation ({ customer, customerName, email, subject, html, text, agentEmail }) {
  const conv = createConversation({ phone: null, customer: customer || null, customerName: customerName || email || 'Client', agentEmail: agentEmail || null, subject: subject || 'Courriel' })
  conv.channel = 'email'
  conv.email = email
  conv.initiatedBy = 'agent'
  conv.draft = (html || text) ? { agent: agentEmail || 'system', sid: 'reward', html: html || '', text: text || '', ts: new Date().toISOString() } : null
  saveToDisk()
  return conv
}

// P1 (convergence messages) — lie UN ticket osTicket legacy à UNE conversation comms, find-or-create IDEMPOTENT par legacy_ticket_id.
// Réutilise conv.linkedTickets (déjà la clé conv↔ticket pour les Issue ERPNext) avec une entrée { legacy_ticket_id, name:'LEG-…' }.
// LAZY par design : appelé quand l'agent veut RÉPONDRE au client d'un ticket (P2) — PAS à l'ouverture d'une feuille (sinon on créerait
// une conversation vide par ticket consulté). channel = 'email' si courriel connu, sinon 'sms'. initiatedBy='agent' → l'IA n'auto-répond pas.
function getOrCreateConvForLegacyTicket (legacyTicketId, { customer, customerName, email, phone, subject } = {}) {
  const lid = String(legacyTicketId || '').replace(/[^0-9]/g, '')
  if (!lid) return null
  for (const c of conversations.values()) {
    if ((c.linkedTickets || []).some(t => t && String(t.legacy_ticket_id) === lid)) return c
  }
  const em = (email && /@/.test(String(email))) ? String(email).trim() : null
  const ph = phone ? String(phone).replace(/[^\d+]/g, '') : null
  const conv = createConversation({
    phone: ph || null, customer: customer || null,
    customerName: customerName || em || ph || ('Ticket ' + lid),
    agentEmail: null, subject: subject || ('Ticket ' + lid),
  })
  if (em) { conv.email = em; conv.channel = 'email' } else if (ph) { conv.channel = 'sms' }
  conv.initiatedBy = 'agent'
  conv.linkedTickets = conv.linkedTickets || []
  conv.linkedTickets.push({ legacy_ticket_id: lid, name: 'LEG-' + lid })
  saveToDisk()
  log(`Conv ${conv.token} liée au ticket legacy ${lid} (canal ${conv.channel || 'aucun'})`)
  return conv
}

// Variante ERPNext Issue : conversation courriel liée à un ticket ERPNext (Issue name = ISS-…).
// Clé = conv.linkedTickets[].name. Idempotent. La réponse du client re-thread ici (subject [Ticket #ISS-…] → TICKET_REF_RX).
function getOrCreateConvForIssue (issueName, { customer, customerName, email, subject } = {}) {
  const n = String(issueName || '').trim(); if (!n) return null
  for (const c of conversations.values()) {
    if ((c.linkedTickets || []).some(t => t && String(t.name) === n)) { if (email && !c.email) c.email = email; if (!c.channel) c.channel = 'email'; return c }
  }
  const conv = createConversation({ phone: null, customer: customer || null, customerName: customerName || email || ('Ticket ' + n), agentEmail: null, subject: subject || ('Ticket ' + n) })
  conv.channel = 'email'
  conv.email = email || null
  conv.linkedTickets = [{ name: n, subject: subject || '', ts: new Date().toISOString() }]
  saveToDisk()
  require('./erp').update('Issue', n, { conversation_token: conv.token }).catch(() => {}) // back-link best-effort
  log(`Conv ${conv.token} liée au ticket ERPNext ${n}`)
  return conv
}

// Variante ERPNext-NATIF (jobs sans ticket osTicket, ex. chaînes d'installation FR-…) : conversation liée à un Dispatch Job.
// Clé = nom du job dans conv.linkedJobs[]. Même logique lazy/idempotente/canal que getOrCreateConvForLegacyTicket.
function findConvByJob (jobName) {
  const jn = String(jobName || '').trim(); if (!jn) return null
  for (const c of conversations.values()) { if ((c.linkedJobs || []).includes(jn)) return c }
  return null
}
function getOrCreateConvForJob (jobName, { customer, customerName, email, phone, subject } = {}) {
  const jn = String(jobName || '').trim(); if (!jn) return null
  const existing = findConvByJob(jn); if (existing) return existing
  const em = (email && /@/.test(String(email))) ? String(email).trim() : null
  const ph = phone ? String(phone).replace(/[^\d+]/g, '') : null
  const conv = createConversation({ phone: ph || null, customer: customer || null, customerName: customerName || em || ph || jn, agentEmail: null, subject: subject || jn })
  if (em) { conv.email = em; conv.channel = 'email' } else if (ph) { conv.channel = 'sms' }
  conv.initiatedBy = 'agent'
  conv.linkedJobs = conv.linkedJobs || []
  conv.linkedJobs.push(jn)
  saveToDisk()
  log(`Conv ${conv.token} liée au job ${jn} (canal ${conv.channel || 'aucun'})`)
  return conv
}

function getConversation (token) {
  const conv = conversations.get(token)
  if (!conv) return null
  if (isExpired(conv)) { conv.status = 'expired'; return null }
  return conv
}

function addMessage (conv, { from, text, type = 'text', via = 'web', media = '', html = '', agent = '', agentName = '', fromName = '', fromEmail = '', toEmail = '', toRaw = '', ccRaw = '', emailDate = '', gmail_id = '', sendAs = '' }) {
  const msg = { id: crypto.randomUUID(), from, text, type, via, media, ts: new Date().toISOString() }
  if (html) msg.html = html // courriel : HTML complet (rendu WYSIWYG iframe côté Ops)
  if (from === 'agent' && agent) msg.agent = agent // identité de l'agent qui répond → stats « réponses par employé » (classement Inbox)
  if (from === 'agent' && agentName) msg.agentName = agentName // NOM COMPLET de l'agent (sender_full_name + affichage fil), résolu du courriel via Authentik
  // fromName = expéditeur RÉEL par message. Priorité : en-tête From (courriel ingéré) ;
  // sinon, pour un message d'agent composé chez nous, le NOM COMPLET de l'agent → le fil
  // affiche « Louis-Paul Bourdon » (le collègue) et non le login dérivé « Louis ».
  if (fromName) msg.fromName = fromName
  else if (from === 'agent' && agentName) msg.fromName = agentName
  if (fromEmail) msg.fromEmail = fromEmail
  if (toEmail) msg.toEmail = toEmail   // adresse de DESTINATION principale (mailbox reçue, ex. support@targo.ca) → affichée « à <…> »
  if (toRaw) msg.toRaw = toRaw         // en-tête To COMPLET (tous les destinataires) → affichage « à moi, Dominique » au dépli (style Gmail)
  if (ccRaw) msg.ccRaw = ccRaw         // en-tête Cc complet
  if (emailDate) msg.emailDate = emailDate // date d'ARRIVÉE du courriel (en-tête Date) → utilisée dans la citation de réponse
  if (gmail_id) msg.gmail_id = gmail_id   // mapping message↔Gmail (backfill / robustesse)
  if (from === 'agent' && sendAs) msg.sendAs = sendAs // identité d'expédition choisie (From) → lue par notifyCustomer
  conv.messages.push(msg)
  if (!conv.initiatedBy) conv.initiatedBy = from // qui a OUVERT le fil : 'agent' = nous (notif/relance → l'IA n'auto-répond pas) ; 'customer' = demande entrante
  conv.lastActivity = msg.ts
  saveToDisk()
  sse.broadcast('conv:' + conv.token, 'conv-message', { token: conv.token, message: msg })
  sse.broadcast('conv-client:' + conv.token, 'conv-message', { message: msg })
  sse.broadcast('conversations', 'conv-message', { token: conv.token, channel: conv.channel, customer: conv.customer, customerName: conv.customerName, message: msg })
  return msg
}

function registerPush (token, subscription) {
  const conv = getConversation(token)
  if (!conv) return false
  conv.pushSub = subscription
  pushSubscriptions.set(token, subscription)
  saveToDisk()
  log(`Push subscription registered for conv ${token}`)
  return true
}

async function sendPushNotification (conv, message) {
  if (!conv.pushSub || !cfg.VAPID_PUBLIC_KEY || !cfg.VAPID_PRIVATE_KEY) return false
  try {
    const webpush = require('web-push')
    webpush.setVapidDetails('mailto:support@gigafibre.ca', cfg.VAPID_PUBLIC_KEY, cfg.VAPID_PRIVATE_KEY)
    await webpush.sendNotification(conv.pushSub, JSON.stringify({
      title: 'Gigafibre', body: message.text?.substring(0, 100) || 'Nouveau message',
      url: `${cfg.CLIENT_PUBLIC_URL}/c/${conv.token}`, tag: 'conv-' + conv.token,
    }))
    log(`Push sent for conv ${conv.token}`)
    return true
  } catch (e) {
    log(`Push failed for conv ${conv.token}:`, e.message)
    if (e.statusCode === 410 || e.statusCode === 404) {
      conv.pushSub = null; pushSubscriptions.delete(conv.token); saveToDisk()
    }
    return false
  }
}

async function sendSmsFallback (conv, text, media) {
  // Conversation texto : pas de plafond bas sur les réponses MANUELLES de l'agent (chaque envoi = clic humain).
  // On garde un plafond de sécurité élevé pour éviter tout emballement.
  if ((conv.smsCount || 0) >= 50) { log(`SMS safety cap reached for conv ${conv.token}, skipping`); return false }
  const { sendSmsInternal } = require('./twilio')
  const success = await sendSmsInternal(conv.phone, text, conv.customer, media)
  if (success) { conv.smsCount = (conv.smsCount || 0) + 1; saveToDisk() }
  return success
}

// Résout des réfs de pièces jointes [{asset, filename, mime}] → [{filename, mime, content(base64)}] depuis le store d'actifs.
// Garde-fous : max 10 pièces, ~20 Mo cumulés.
function resolveAttachments (list) {
  if (!Array.isArray(list) || !list.length) return []
  const campaigns = require('./campaigns')
  const out = []; let total = 0
  for (const a of list.slice(0, 10)) {
    try {
      if (!a || !a.asset) continue
      const r = campaigns.readUpload(a.asset)
      if (!r || !r.buffer) continue
      total += r.buffer.length
      if (total > 20 * 1024 * 1024) break
      out.push({ filename: String(a.filename || a.asset).slice(0, 200), mime: a.mime || r.mime, content: r.buffer.toString('base64') })
    } catch (e) { /* pièce illisible → ignorée */ }
  }
  return out
}

async function notifyCustomer (conv, message) {
  // Conversation EMAIL → on RÉPOND par courriel dans le MÊME fil Gmail (pas SMS/push).
  if (conv.channel === 'email' && conv.email) {
    try {
      const gmail = require('./gmail')
      const base = conv.lastSubject || conv.subject || ''
      const subj = withTicketTag(conv, /^re\s*:/i.test(base) ? base : ('Re: ' + base))
      const outHtml = await require('./rating').expandRatingMarker(message.html || '', conv)
      const r = await gmail.sendMessage({ to: message.replyToOverride || conv.email, cc: message.cc || '', subject: subj, body: message.text || '', html: outHtml, attachments: resolveAttachments(message.attachments), threadId: conv.threadId, inReplyTo: conv.lastMessageId, from: await resolveSendFrom(message.sendAs, message.agent) })
      if (r && r.id) { conv.lastGmailId = r.id; conv._gmailIds = conv._gmailIds || []; if (!conv._gmailIds.includes(r.id)) conv._gmailIds.push(r.id) } // mémorise l'ID Gmail du sortant → la re-synchro du fil ne le ré-ajoute pas en double
      if (r && r.threadId && !conv.threadId) conv.threadId = r.threadId // 1er envoi → mémorise le fil Gmail pour re-threader la réponse (en + du tag [Ticket #…])
      log(`Email reply sent for conv ${conv.token} → ${conv.email} (gmail ${r.id})`)
      return 'email'
    } catch (e) { log(`Email reply error for conv ${conv.token}: ${e.message}`); return 'none' }
  }
  // Canaux chat externes : la réponse agent est déjà diffusée en direct via SSE (addMessage → conv-client). Pas de SMS/push.
  if (conv.channel === 'webchat' || conv.channel === '3cx') return 'webchat'
  if (conv.channel === 'facebook') { log(`FB reply pending (Send API à brancher) conv ${conv.token}`); return 'facebook-pending' } // phase 2 : Messenger Send API
  // Si le client a ouvert le web-chat et s'est abonné aux push → notification push.
  const pushed = await sendPushNotification(conv, message)
  if (pushed) return 'push'
  // Sinon : SMS/MMS simple — messagerie texte habituelle, PAS de lien web.
  return (await sendSmsFallback(conv, message.text || '', message.media || '')) ? 'sms' : 'none'
}

function findConversationByPhone (phone) {
  const digits = phone.replace(/\D/g, '').slice(-10)
  for (const [, conv] of conversations) {
    if (conv.status !== 'active') continue
    if (conv.phone.replace(/\D/g, '').slice(-10) === digits) return conv
  }
  return null
}

async function autoLinkIncomingSms (phone, text) {
  const existing = findConversationByPhone(phone)
  if (existing) return existing

  const matches = await lookupCustomersByPhone(phone, 6)
  const customer = matches.length === 1 ? matches[0] : null // 1 fiche = lien auto ; >1 = l'agent choisit (candidates)
  const { sendSmsInternal } = require('./twilio')

  if (!customer) {
    const conv = createConversation({ phone, customer: null, customerName: phone, agentEmail: null, subject: matches.length > 1 ? `SMS entrant (${matches.length} fiches — à rattacher)` : 'SMS entrant (inconnu)' })
    conv.smsCount = 0
    if (matches.length > 1) conv.candidates = matches // plusieurs fiches partagent ce numéro → choix dans le panneau OPS
    addMessage(conv, { from: 'customer', text, via: 'sms' })
    const link = `${cfg.CLIENT_PUBLIC_URL}/c/${conv.token}`
    const visibleText = 'Bonjour, merci pour votre message! Un agent Gigafibre vous répondra sous peu.'
    // Always add the greeting to the conversation (even if SMS fails)
    addMessage(conv, { from: 'agent', text: visibleText, via: 'sms' })
    sendSmsInternal(phone, `${visibleText}\n\nPour continuer en ligne:\n${link}`, null).then(sid => {
      if (sid) conv.smsCount++
    }).catch(e => log('Auto-reply SMS error:', e.message))
    log(`Auto-created conv ${conv.token} for unknown caller ${phone}`)
    return conv
  }

  const conv = createConversation({
    phone, customer: customer.name, customerName: customer.customer_name || customer.name,
    agentEmail: null, subject: 'SMS entrant',
  })
  conv.smsCount = 0
  addMessage(conv, { from: 'customer', text, via: 'sms' })
  const link = `${cfg.CLIENT_PUBLIC_URL}/c/${conv.token}`
  const firstName = (customer.customer_name || '').split(' ')[0] || ''
  const greeting = firstName ? `Bonjour ${firstName}, merci` : 'Bonjour, merci'
  const visibleText = `${greeting} pour votre message! Un agent vous répondra sous peu.`
  // Always add the greeting to the conversation (even if SMS fails)
  addMessage(conv, { from: 'agent', text: visibleText, via: 'sms' })
  sendSmsInternal(phone, `${visibleText}\n\nPour continuer la conversation en ligne:\n${link}`, customer.name).then(sid => {
    if (sid) {
      conv.smsCount++
      log(`Auto-reply sent to ${phone} (${sid})`)
    }
  }).catch(e => log('Auto-reply SMS error:', e.message))
  log(`Auto-linked SMS from ${phone} → customer ${customer.name}, conv ${conv.token}`)
  return conv
}

const todayET = () => new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) // YYYY-MM-DD (heure de l'Est)

// Accusé de réception trivial (« ok », « merci », « c'est bon », un emoji…) → rien à traiter, l'IA ne répond pas.
const ACK_WORDS = new Set(['ok', 'okay', 'oki', 'dac', 'daccord', 'accord', 'parfait', 'merci', 'beaucoup', 'nickel', 'super', 'genial', 'bien', 'recu', 'bienrecu', 'compris', 'cest', 'bon', 'cestbon', 'oui', 'ouais', 'yes', 'yep', 'yeah', 'thanks', 'thank', 'you', 'thx', 'ty', 'noted', 'correct', 'exact', 'tres'])
function isAck (text) {
  const t = String(text || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '') // minuscules, sans accents
  if (!t.replace(/[^a-z]/g, '')) return true // emojis/ponctuation seuls (« 👍 », « . », « ✅ ») → accusé
  const words = t.replace(/['’\-]/g, '').replace(/[^a-z\s]/g, ' ').split(/\s+/).filter(Boolean) // ' et - collés : c'est→cest, d'accord→daccord
  return words.length > 0 && words.length <= 3 && words.every(w => ACK_WORDS.has(w))
}

function triggerAgent (conv) {
  if (agentTimers.has(conv.token)) clearTimeout(agentTimers.get(conv.token))
  agentTimers.set(conv.token, setTimeout(() => {
    agentTimers.delete(conv.token)
    runAgentForConv(conv)
  }, 1500))
}

async function runAgentForConv (conv) {
  if (!cfg.AI_API_KEY || !conv.customer || conv.status !== 'active') return
  // RÈGLE 1 : l'IA n'auto-répond QUE sur un fil INITIÉ PAR LE CLIENT. Si NOUS l'avons ouvert (notif « maintenance
  // planifiée », relance, campagne…), un agent gère — l'IA reste muette (un « ok » du client n'appelle aucune réponse auto).
  const initiator = conv.initiatedBy || (conv.messages && conv.messages[0] && conv.messages[0].from) || 'customer'
  if (initiator !== 'customer') { log(`AI auto-reply supprimée (fil initié par nous) pour conv ${conv.token}`); return }
  // RÈGLE 2 : ne pas répondre à un simple accusé de réception (« ok », « merci »…) — rien à traiter, ça crée du bruit.
  const lastCust = [...(conv.messages || [])].reverse().find(m => m.from === 'customer')
  if (lastCust && isAck(lastCust.text)) { log(`AI auto-reply supprimée (accusé « ${String(lastCust.text || '').slice(0, 20)} ») pour conv ${conv.token}`); return }
  // RÈGLE 3 : si un HUMAIN (agent OPS) a écrit dans ce fil AUJOURD'HUI, l'IA NE répond PAS le même jour (un humain gère).
  if (conv.lastHumanDate && conv.lastHumanDate === todayET()) { log(`AI auto-reply supprimée (fil mené par un humain aujourd'hui) pour conv ${conv.token}`); return }
  sse.broadcast('conv-client:' + conv.token, 'conv-typing', { typing: true })
  try {
    const { runAgent } = require('./agent')
    const recentMessages = conv.messages.slice(-20).filter(m => m.from === 'customer' || m.from === 'agent')
    const reply = await runAgent(conv.customer, conv.customerName, recentMessages, { token: conv.token })
    if (reply && conv.status === 'active') {
      addMessage(conv, { from: 'agent', text: reply, type: 'text', via: 'ai' })

      // If the last customer message came via SMS, send AI reply back via SMS + append web chat link
      const lastCustomerMsg = [...conv.messages].reverse().find(m => m.from === 'customer')
      if (lastCustomerMsg?.via === 'sms' && conv.phone) {
        const chatLink = `${cfg.CLIENT_PUBLIC_URL}/c/${conv.token}`
        const smsBody = `${reply}\n\nContinuer en ligne: ${chatLink}`
        const { sendSmsInternal } = require('./twilio')
        sendSmsInternal(conv.phone, smsBody, conv.customer).then(sid => {
          if (sid) { conv.smsCount = (conv.smsCount || 0) + 1; log(`AI SMS reply sent to ${conv.phone} (${sid})`) }
        }).catch(e => log('AI SMS reply error:', e.message))
      }

      createCommunication({
        communication_type: 'Communication', communication_medium: 'Chat',
        sent_or_received: 'Sent', sender: 'ai@gigafibre.ca', sender_full_name: 'Gigafibre Assistant',
        phone_no: conv.phone, content: reply, subject: `Chat AI: ${conv.subject}`,
        reference_doctype: 'Customer', reference_name: conv.customer, status: 'Linked',
      }).catch(() => {})
    }
  } catch (e) { log('Agent error for conv ' + conv.token + ':', e.message) }
  sse.broadcast('conv-client:' + conv.token, 'conv-typing', { typing: false })
}

// ── Coordination (shared inbox) : files = 4 LABELS F (fiable vs 30 départements) + claim ──
const { QUEUES, QUEUE_OF, ISSUE_TYPE_QUEUE } = require('./categories') // taxonomie = SOURCE UNIQUE (voir lib/categories.js)
const PIPELINE_STAGES = ['Nouveau', 'Qualifié', 'Devis', 'Gagné', 'Perdu'] // pipeline de leads (conversations vente)
// ── Garde-fous anti-spam de file (ingest courriel) ────────────────────────────
// Empêche le blast d'une file pour : nos PROPRES notifs réingérées (boucle), les expéditeurs de NOS domaines
// (staff/systèmes internes), et les RÉPONSES à un ticket existant (→ rattacher au fil, pas un nouveau routage).
// OWN_DOMAINS = source unique dans lib/helpers.js (inclut les SOUS-DOMAINES infra ; jamais un « client »).
const OWN_NOTIF_SUBJECT = /^\s*(re\s*:\s*)?\[(inbox|ticket)\s*[·:]/i // nos sujets [Inbox · …] / [Ticket · …]
const TICKET_REPLY_SUBJECT = /\[ticket\s*#?\s*[\w-]+\]/i            // réponse à un ticket existant : [Ticket #ISS-2026-001] ou [Ticket #250819]
// Expéditeur MACHINE (jamais un humain à qui répondre dans l'inbox client) : no-reply, daemon de courriel, panneau d'hébergement…
const AUTOMATED_SENDER = /(no-?reply|do-?not-?reply|mailer-daemon)|^(postmaster|bounces?|daemon|cron)@|@(directadmin|bounces?)\./i // « noreply » n'importe où dans le local-part (ex. mail-noreply@google.com) ; daemon/postmaster en début ; sous-domaines infra
// Sujet caractéristique d'une notif AUTOMATISÉE (monitoring / panneau d'hébergement) — marqueurs HAUTE PRÉCISION seulement.
// NE PAS ajouter de phrases génériques (« service down », « currently down ») : un client peut écrire « mon internet est down » → ne JAMAIS masquer ça.
const AUTOMATED_SUBJECT = /automated message generated by|\bdirectadmin\b|cmd_ticket|freshclam|avis de sécurité.*(serveur mandataire|proxy)/i
const TICKET_REF_RX = /\[ticket\s*#?\s*([\w-]+)\]/i                 // extrait le NOM du ticket → route le fil vers la bonne conversation
// Préfixe le sujet sortant avec [Ticket #N] quand la conversation est promue en ticket : la réponse du client se rattache au bon fil/ticket.
function withTicketTag (conv, subject) {
  const lt = conv && conv.linkedTickets
  const tName = (lt && lt.length) ? lt[lt.length - 1].name : null
  let s = String(subject || conv.lastSubject || '(sans objet)')
  if (!tName) return s
  if (new RegExp('\\[ticket\\s*#?\\s*' + tName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\]', 'i').test(s)) return s // déjà étiqueté
  const m = s.match(/^(re\s*:\s*)/i); const prefix = m ? m[1] : ''
  const rest = s.slice(prefix.length).replace(/\[ticket\s*#?\s*[\w-]+\]\s*/i, '').trim()
  return prefix + `[Ticket #${tName}] ` + rest
}
// Nom d'affichage de l'expéditeur depuis l'en-tête From (« Michaël Brisson <x@y> » → « Michaël Brisson »). '' si pas de nom (juste un courriel) → on retombe sur l'email.
function fromDisplayName (from) {
  const s = String(from || '').trim()
  const m = /^\s*"?([^"<]*?)"?\s*<[^>]+>/.exec(s) // « Nom <email> » ou « "Nom" <email> »
  let n = m ? m[1].trim() : (!/@/.test(s) ? s : '')
  n = n.replace(/^['"]|['"]$/g, '').trim()
  return (n && !/@/.test(n)) ? n.slice(0, 80) : ''
}
// Nom d'affichage de l'agent pour le From sortant, dérivé de son courriel SSO : gilles.drolet@targo.ca → « Gilles Drolet » (gilles@ → « Gilles »).
function agentDisplayName (email) {
  const local = String(email || '').split('@')[0]
  return local.split(/[._-]+/).filter(Boolean).map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' ')
}
// From sortant PERSONNALISÉ : « Gilles Drolet » <support@targo.ca>. L'ADRESSE reste l'alias surveillé (les réponses reviennent dans l'inbox partagé) ; seul le nom affiché change. Sans nom/agent → undefined ⇒ défaut Gmail (« Service TARGO »).
async function agentSendFrom (email) {
  // NOM CANONIQUE d'abord (Authentik « name » : « Louis-Paul Bourdon »), puis
  // REPLI sur la dérivation du préfixe courriel (« louis@ » → « Louis »). Corrige
  // les courriels courts (louis@) qui n'exposaient que le prénom.
  let name = ''
  try { name = await require('./auth').getDisplayNameByEmail(email) } catch { /* repli ci-dessous */ }
  if (!name) name = agentDisplayName(email)
  if (!name) return undefined
  const base = require('./gmail').sendFrom()
  const addr = (String(base).match(/<([^>]+)>/) || [null, base])[1].trim()
  return `"${name.replace(/["<>]/g, '')}" <${addr}>`
}
// ── Identités d'expédition (anonymisation « groupe » par DÉFAUT) ──────────────
// Avant : le client voyait le nom PERSONNEL de l'agent (« Louis » <support@targo.ca>).
// Maintenant : par défaut on expédie sous le GROUPE (« Support TARGO » <support@targo.ca>).
// L'agent peut choisir une autre identité (alias Gmail VÉRIFIÉ) ou 'personal' (signer de son nom).
// Adresses bornées aux alias vérifiés (sinon Gmail refuse). Configurable :
// GMAIL_SENDER_IDENTITIES (JSON [{name,address}]) + GMAIL_DEFAULT_SENDER.
function parseIdentities (s) { try { const a = JSON.parse(s); return Array.isArray(a) && a.length ? a.filter(x => x && x.address) : null } catch { return null } }
const SENDER_IDENTITIES = parseIdentities(process.env.GMAIL_SENDER_IDENTITIES) || [
  { name: 'Support TARGO', address: 'support@targo.ca' },
  { name: 'Facturation TARGO', address: 'facturation@targo.ca' },
]
function identityToFrom (i) { if (!i || !i.address) return ''; const n = String(i.name || '').replace(/["<>\r\n]/g, '').trim(); return n ? `"${n}" <${i.address}>` : String(i.address) }
const SEND_ALIASES = SENDER_IDENTITIES.map(i => String(i.address).toLowerCase())
const DEFAULT_SENDER = process.env.GMAIL_DEFAULT_SENDER || identityToFrom(SENDER_IDENTITIES[0]) || require('./gmail').sendFrom()
// From sortant : 'personal' = nom de l'agent ; "Nom <alias vérifié>" = tel quel ; sinon défaut groupe.
async function resolveSendFrom (from, agentEmail) {
  const v = String(from || '').trim()
  if (v === 'personal') return (await agentSendFrom(agentEmail)) || DEFAULT_SENDER
  if (v) { const addr = (v.match(/<([^>]+)>/) || [null, v])[1].trim().toLowerCase(); if (SEND_ALIASES.includes(addr)) return v.replace(/[\r\n]/g, '') }
  return DEFAULT_SENDER
}
function ingestGuard (email, subject) {
  const s = String(subject || '')
  const e = String(email || '').trim()
  if (OWN_NOTIF_SUBJECT.test(s)) return 'own_notif'                 // notre propre courriel de file → boucle
  if (e && OWN_DOMAINS.test(e)) return 'internal'                   // expéditeur interne (incl. sous-domaines infra), pas un client
  if ((e && AUTOMATED_SENDER.test(e)) || AUTOMATED_SUBJECT.test(s)) return 'automated' // notif machine (no-reply / monitoring / hébergement) → masquée de l'inbox client
  if (TICKET_REPLY_SUBJECT.test(s)) return 'ticket_reply'          // suivi d'un ticket → fil existant, pas de blast
  return null
}
function gmailReplyUrl (conv) { return (conv && conv.channel === 'email' && conv.lastMessageId) ? 'https://mail.google.com/mail/u/0/#search/rfc822msgid:' + encodeURIComponent(String(conv.lastMessageId).replace(/^<|>$/g, '')) : null }
// Appartenance agent ↔ file : config éditable /app/data/queue_members.json = { "Supports": ["a@targo.ca", …], … }. Vide = aucune notif (sûr).
function loadQueueMembers () { return readJsonFile('/app/data/queue_members.json', {}) }
// Visibilité PAR PERSONNE (réduction du bruit) : { email: { mode:'personnel'|'department'|'tags', tags:[...] } }.
// personnel = seulement assignés/assistant/suivis · department = toute ma file · tags = ma file filtrée à mes sous-tags. Défaut absent = 'department'.
function loadVisibility () { return readJsonFile('/app/data/queue_visibility.json', {}) }
// Suivi de tickets PAR PERSONNE (watch perso) : { email: [ticketName,...] } → toujours visibles dans « Mes tickets ».
function loadTicketFollows () { return readJsonFile('/app/data/ticket_follows.json', {}) }
// Suivi GÉNÉRALISÉ (au-delà des tickets) : { email: { doctype: [name,...] } }. Migre une seule fois l'ancien ticket_follows.json sous « Issue ».
function loadFollows () {
  const all = readJsonFile('/app/data/follows.json', null)
  if (all) return all
  const legacy = loadTicketFollows() // { email: [ticketName] }
  const migrated = {}
  for (const [email, arr] of Object.entries(legacy || {})) migrated[email] = { Issue: Array.isArray(arr) ? arr.slice() : [] }
  writeJsonFile('/app/data/follows.json', migrated)
  return migrated
}
function saveFollows (all) {
  writeJsonFile('/app/data/follows.json', all)
  // Miroir rétro-compatible : garde ticket_follows.json à jour (schéma email → [ticketName]) pour tout lecteur legacy.
  const tf = {}; for (const [email, byType] of Object.entries(all || {})) tf[email] = (byType && byType.Issue) ? byType.Issue.slice() : []
  writeJsonFile('/app/data/ticket_follows.json', tf)
}
function followsFor (all, email, doctype) { return (email && all[email] && all[email][doctype]) || [] }
// Qui suit ce doc ? Balaie tous les abonnés → liste des courriels qui suivent (doctype,name). Sert aux pastilles « # follower ».
function followersOf (all, doctype, name) {
  const out = []
  for (const [email, byType] of Object.entries(all || {})) {
    if (byType && Array.isArray(byType[doctype]) && byType[doctype].includes(name)) out.push(email)
  }
  return out
}
// Règles d'inbox définies par l'agent (« Filtrer comme ceci ») : [{ id, field:'from'|'subject', contains, action:'mask'|'allow' }]. « allow » prime sur « mask ».
function loadInboxRules () { return readJsonFile('/app/data/inbox_rules.json', []) }
function matchInboxRule (email, subject) {
  const rules = loadInboxRules(); if (!Array.isArray(rules) || !rules.length) return null
  const e = String(email || '').toLowerCase(); const s = String(subject || '').toLowerCase()
  let masked = false
  for (const r of rules) {
    const needle = String(r.contains || '').toLowerCase().trim(); if (!needle) continue
    const hay = r.field === 'subject' ? s : e
    if (hay.includes(needle)) { if (r.action === 'allow') return 'allow'; if (r.action === 'mask') masked = true }
  }
  return masked ? 'mask' : null
}
// Libellés d'AFFICHAGE des files (ex. « Facturation » au singulier) — la CLÉ reste le nom réel (pluriel, = F). Cosmétique uniquement.
function loadQueueLabels () { return readJsonFile('/app/data/queue_labels.json', {}) }
// Réponses pré-écrites (canned) : /app/data/canned_responses.json = [{id,title,body,category}]. Variables {{client}} {{agent}} {{ticket}} résolues à l'insertion (front).
function loadCanned () { const d = readJsonFile('/app/data/canned_responses.json', []); return Array.isArray(d) ? d : (d.responses || []) }
function saveCanned (list) { return writeJsonFile('/app/data/canned_responses.json', list || []) }
// Pousse un courriel (via Gmail) aux MEMBRES de la file quand une demande y arrive (1 fois). Remplace le fan-out support@ par un ciblage par équipe.
async function notifyQueue (conv) {
  if (!conv || !conv.queue) return
  conv._notifiedQueues = conv._notifiedQueues || []
  if (conv._notifiedQueues.includes(conv.queue)) return
  const members = (loadQueueMembers()[conv.queue] || []).filter(e => /.+@.+\..+/.test(e))
  if (!members.length) return // aucun membre configuré → on ne notifie pas
  const lastCust = [...(conv.messages || [])].reverse().find(m => m.from === 'customer')
  const link = gmailReplyUrl(conv) || ''
  const who = conv.customerName || conv.email || conv.phone || 'Client'
  const body = `Nouvelle demande dans la file « ${conv.queue} ».\n\nDe : ${who}\nSujet : ${conv.subject || '(courriel)'}\n\n${String((lastCust && lastCust.text) || '').slice(0, 600)}\n\n${link ? 'Ouvrir le fil : ' + link : 'À prendre en charge dans OPS — Inbox.'}\n\n— TARGO Inbox (assignation automatique)`
  try { await require('./outbox').enqueue({ to: members.join(','), subject: `[Inbox · ${conv.queue}] ${who}: ${String(conv.subject || '').slice(0, 60)}`, body }, { kind: 'queue-notif', label: conv.queue }); conv._notifiedQueues.push(conv.queue); saveToDisk() } catch (e) { log('notifyQueue ' + conv.token + ': ' + e.message) }
}
// Notifie une file PRÉCISE pour un ticket donné (utilisé par le split : chaque équipe reçoit SON ticket, indépendamment de conv.queue).
async function notifyQueueLabel (label, conv, ticketSubject) {
  const members = (loadQueueMembers()[label] || []).filter(e => /.+@.+\..+/.test(e))
  if (!members.length) return
  const who = (conv && (conv.customerName || conv.email || conv.phone)) || 'Client'
  const link = gmailReplyUrl(conv) || ''
  const body = `Nouveau ticket assigné à la file « ${label} » (issu d'une conversation scindée).\n\nDe : ${who}\nTicket : ${ticketSubject}\n\n${link ? 'Fil d\'origine : ' + link : 'À traiter dans OPS — Tickets / Inbox.'}\n\n— TARGO Inbox`
  try { await require('./outbox').enqueue({ to: members.join(','), subject: `[Inbox · ${label}] ${ticketSubject}`.slice(0, 90), body }, { kind: 'queue-notif', label }) } catch (e) { log('notifyQueueLabel ' + label + ': ' + e.message) }
}

function listConversations ({ includeAll = false, lean = false } = {}) {
  const out = []
  // Aperçu LÉGER d'un message pour la liste : pas de HTML ni pièces jointes complètes (c'était l'essentiel du payload).
  const leanMsg = m => m ? { id: m.id, from: m.from, via: m.via || '', ts: m.ts, text: String(m.text || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 240), hasMedia: !!((m.media && m.media.length) || (m.attachments && m.attachments.length)) } : null
  for (const [, conv] of conversations) {
    const expired = isExpired(conv)
    if (!includeAll && (conv.status !== 'active' || expired)) continue
    out.push({
      token: conv.token, phone: conv.phone, customer: conv.customer,
      customerName: conv.customerName, agentEmail: conv.agentEmail, subject: conv.subject,
      channel: conv.channel || 'sms', email: conv.email || null, threadId: conv.threadId || null, noise: !!conv.noise,
      queue: conv.queue || '', assignee: conv.assignee || null, priority: conv.priority || '',
      suggestedTicket: conv.suggestedTicket || null, linkedTickets: conv.linkedTickets || [],
      status: expired ? 'expired' : conv.status, messageCount: conv.messages.length,
      lastActivity: conv.lastActivity, createdAt: conv.createdAt,
      lastMessage: conv.messages.length ? (lean ? leanMsg(conv.messages[conv.messages.length - 1]) : conv.messages[conv.messages.length - 1]) : null,
    })
  }
  out.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity))

  const discussions = {}
  for (const c of out) {
    const isEmail = c.channel === 'email'
    const phoneKey = c.phone ? c.phone.replace(/\D/g, '').slice(-10) : '' // null-safe (les convs EMAIL n'ont pas de téléphone)
    // Regroupement courriel : par FIL GMAIL d'abord (client + réponses équipe + copies CC = même thread), sinon par courriel CLIENT (jamais une de NOS adresses → support@targointernet.com ne fusionne plus des clients distincts), sinon fil isolé.
    // P2 : courriel = par FIL GMAIL (thread_id) ; SINON fil ISOLÉ (par token). On NE re-fusionne PLUS par adresse
    // (`email:adresse` regroupait dans UNE ligne des sujets sans rapport du même expéditeur). SMS = par numéro (continu, voulu).
    const baseKey = isEmail
      ? (c.threadId ? ('thr:' + c.threadId) : ('t:' + c.token))
      : (phoneKey || ('t:' + c.token))
    const key = c.status === 'active' ? `${baseKey}:active` : `${baseKey}:${c.createdAt.slice(0, 10)}`
    if (!discussions[key]) {
      const day = c.status === 'active' ? c.lastActivity.slice(0, 10) : c.createdAt.slice(0, 10)
      discussions[key] = {
        id: key, phone: c.phone, email: c.email || null, channel: c.channel || 'sms',
        customer: c.customer, customerName: c.customerName, queue: c.queue || '', assignee: c.assignee || null,
        date: day, conversations: [], messages: [], lastActivity: c.lastActivity, status: 'closed',
      }
    }
    discussions[key].conversations.push(c)
    if (c.status === 'active') discussions[key].status = 'active'
    if (c.queue && !discussions[key].queue) discussions[key].queue = c.queue
    if (c.assignee) discussions[key].assignee = c.assignee
    if (c.lastActivity > discussions[key].lastActivity) {
      discussions[key].lastActivity = c.lastActivity
      if (discussions[key].status === 'active') discussions[key].date = c.lastActivity.slice(0, 10)
    }
  }

  for (const d of Object.values(discussions)) {
    let latest = null; let count = 0; let lastMsg = null; const allMsgs = lean ? null : []
    for (const c of d.conversations) {
      const conv = conversations.get(c.token)
      if (!conv) continue
      if (!latest || conv.lastActivity > latest.lastActivity) latest = conv
      count += conv.messages.length
      if (lean) { const lm = conv.messages.length ? conv.messages[conv.messages.length - 1] : null; if (lm && (!lastMsg || String(lm.ts || '').localeCompare(String(lastMsg.ts || '')) > 0)) lastMsg = lm }
      else for (const m of conv.messages) allMsgs.push({ ...m, convToken: c.token })
    }
    if (!lean) { allMsgs.sort((a, b) => a.ts.localeCompare(b.ts)); lastMsg = allMsgs.length ? allMsgs[allMsgs.length - 1] : null }
    // LISTE LÉGÈRE (lean) : on n'envoie PAS tous les messages (payload ~45 Mo → petit) — chargés à l'ouverture de la discussion.
    d.messages = lean ? [] : allMsgs
    d.messageCount = lean ? count : allMsgs.length
    d.subject = (latest && (latest.lastSubject || latest.subject)) || '' // sujet du courriel pour la liste
    d.lastMessage = lean ? leanMsg(lastMsg) : lastMsg
    d.token = latest ? latest.token : (d.conversations[0] && d.conversations[0].token) // token actionnable (claim/file/réponse)
    d.suggestedTicket = (latest && latest.suggestedTicket) || null
    d.linkedTickets = (latest && latest.linkedTickets) || []
    d.gmailReplyUrl = gmailReplyUrl(latest)
    d.noise = !!(latest && latest.noise)
    d.readBy = (latest && latest.readBy) || {} // liste : « vu par » + détection non-lu par soi
    d.draftAgent = (latest && latest.draft && latest.draft.agent) || null // brouillon en cours → pastille « X rédige » + filtre Brouillons (au chargement)
    d.draftTs = (latest && latest.draft && latest.draft.ts) || null
    d.priority = (latest && latest.priority) || '' // drapeau de priorité (style ClickUp) pour la liste
  }

  return { conversations: out, discussions: Object.values(discussions).sort((a, b) => b.lastActivity.localeCompare(a.lastActivity)) }
}

// Met à la CORBEILLE Gmail les courriels du fil (réversible 30 j) — fil entier si threadId connu, sinon par message.
async function trashConvGmail (conv) {
  if (!conv || conv.channel !== 'email') return
  const gmail = require('./gmail')
  if (conv.threadId) { await gmail.trashThread(conv.threadId); return }
  for (const id of (conv._gmailIds && conv._gmailIds.length ? conv._gmailIds : (conv.lastGmailId ? [conv.lastGmailId] : []))) { try { await gmail.trashMessage(id) } catch (e) { /* */ } }
}
// Supprime la conversation. opts.trashGmail = sort aussi les courriels liés de la boîte (suppression EXPLICITE par l'agent).
function deleteConversation (token, { trashGmail = false } = {}) {
  const conv = conversations.get(token)
  if (!conv) return false
  if (trashGmail) trashConvGmail(conv).catch(e => log('trashConvGmail ' + token + ': ' + e.message)) // best-effort, ne bloque pas la suppression
  conversations.delete(token)
  store.remove(token).catch(() => {}) // miroir Postgres : retire aussi la ligne
  saveToDisk()
  return true
}

function deleteDiscussionByTokens (tokens) {
  let count = 0
  for (const t of tokens) if (conversations.delete(t)) { count++; store.remove(t).catch(() => {}) }
  if (count) saveToDisk()
  return count
}

function deleteDiscussion (phone, date) {
  const phoneDigits = phone.replace(/\D/g, '').slice(-10)
  const tokensToDelete = []
  for (const [token, conv] of conversations) {
    if (conv.phone.replace(/\D/g, '').slice(-10) !== phoneDigits) continue
    if (date === 'active') { if (conv.status === 'active') tokensToDelete.push(token) }
    else if (conv.createdAt.slice(0, 10) === date && conv.status !== 'active') tokensToDelete.push(token)
  }
  for (const t of tokensToDelete) { conversations.delete(t); store.remove(t).catch(() => {}) }
  if (tokensToDelete.length) saveToDisk()
  return tokensToDelete.length
}

async function archiveDiscussion (tokens) {
  const allMsgs = []
  let customer = null, customerName = null, phone = null, subject = null
  for (const token of tokens) {
    const conv = conversations.get(token)
    if (!conv) continue
    if (!customer && conv.customer) customer = conv.customer
    if (!customerName && conv.customerName) customerName = conv.customerName
    if (!phone) phone = conv.phone
    if (!subject && conv.subject) subject = conv.subject
    for (const m of conv.messages) allMsgs.push(m)
  }
  if (!allMsgs.length) return null

  // If no customer linked, try to lookup by phone number
  if (!customer && phone) {
    try {
      const found = await lookupCustomerByPhone(phone)
      if (found) {
        customer = found.name
        if (!customerName || customerName === phone) customerName = found.customer_name || found.name
      }
    } catch {}
  }

  allMsgs.sort((a, b) => a.ts.localeCompare(b.ts))

  const lines = allMsgs.map(m => {
    if (m.from === 'system') return `--- ${m.text} ---`
    const sender = m.from === 'customer' ? (customerName || phone || 'Client')
      : m.via === 'ai' ? 'AI Assistant' : 'Agent'
    const time = new Date(m.ts).toLocaleString('fr-CA', { dateStyle: 'short', timeStyle: 'short' })
    const via = m.via === 'sms' ? ' [SMS]' : m.via === 'ai' ? ' [AI]' : ''
    return `**${sender}**${via} (${time}):\n${m.text}`
  })

  const issueSubject = `[Archivé] ${subject || 'Discussion'} — ${customerName || phone}`
  const issueData = {
    subject: issueSubject, status: 'Closed', priority: 'Low', // issue_type omis (doctype « SMS/Chat » absent → LinkValidationError)
    description: (phone ? `Téléphone : ${phone}\n\n` : '') + lines.join('\n\n'),
  }
  // Only set customer if we have a valid one — avoid sending null/undefined to ERPNext
  if (customer) issueData.customer = customer
  if (phone && /.+@.+\..+/.test(phone)) issueData.raised_by = phone // raised_by = EMAIL valide seulement

  try {
    const result = await erp.create('Issue', issueData)
    if (!result.ok) throw new Error(result.error || 'Issue create failed')
    const issueName = result.name
    log(`Archived ${tokens.length} conversations → Issue ${issueName} (customer: ${customer || 'none'})`)
    deleteDiscussionByTokens(tokens)
    return { issue: issueName, messagesArchived: allMsgs.length, customer: customer || null }
  } catch (e) { log('Archive error:', e.message); throw e }
}

// Crée un ERPNext Issue OUVERT depuis une conversation, SANS la supprimer (≠ archiveDiscussion qui ferme+supprime).
// La conversation RESTE dans l'historique du client ; lien bidirectionnel (conv.linkedTickets ↔ Issue.conversation_token).
async function createTicketFromConversation (token, { title, category, priority, summary } = {}) {
  const conv = getConversation(token)
  if (!conv) return { ok: false, error: 'Conversation introuvable' }
  const who = (m) => m.from === 'customer' ? (conv.customerName || conv.phone || 'Client') : (m.via === 'ai' ? 'AI Assistant' : 'Agent')
  // Nettoie un corps de message pour le ticket : décode les entités HTML (&gt;…), COUPE l'historique cité (réponses Gmail/Outlook), retire les lignes « > … ». Garde le NOUVEAU texte lisible.
  const clean = (text) => {
    let s = String(text || '').replace(/^✉️[^\n]*\n+/, '')
    s = s.replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&nbsp;/g, ' ').replace(/&#39;/g, "'").replace(/&quot;/g, '"').replace(/&amp;/g, '&')
    s = s.split(/(?:On .{0,90}?wrote\s*:|Le .{0,90}?a écrit\s*:|-{2,}\s*Original Message|_{5,})/i)[0] // jette la citation, garde la réponse
    s = s.split('\n').filter(l => !/^\s*>+/.test(l)).join('\n')
    return s.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim()
  }
  const cleanSubject = (s) => String(s || '').replace(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/g, '&').split(/\s+On [A-Z]\w+,|\s+Le \d|[\r\n]/)[0].replace(/\s+/g, ' ').trim()
  const msgs = (conv.messages || []).slice().sort((a, b) => String(a.ts).localeCompare(String(b.ts)))
  const lines = msgs.filter(m => m.from !== 'system').map(m => {
    const time = new Date(m.ts).toLocaleString('fr-CA', { dateStyle: 'short', timeStyle: 'short' })
    const via = m.via === 'sms' ? 'SMS' : m.via === 'ai' ? 'IA' : m.via === 'email' ? 'Courriel' : 'Web'
    let body = clean(m.text)
    if (m.media) body += (body ? '\n' : '') + '🖼️ Image : ' + m.media // URL conservée (cliquable depuis le ticket)
    if (!body && m.html) body = '[courriel HTML — voir le fil de la conversation]'
    return `── ${who(m)} · ${via} · ${time}\n${body || '[vide]'}`
  })
  const cat = String(category || '').trim()
  const subject = (cat ? `[${cat}] ` : '') + (cleanSubject(title || conv.lastSubject || conv.subject) || 'Demande client').slice(0, 130)
  const issueData = {
    subject, status: 'Open', priority: priority || 'Medium', // issue_type omis : le doctype Issue Type « SMS/Chat » n'existe pas → la catégorie est dans le sujet [Cat]
    description: (summary ? 'Résumé IA :\n' + clean(summary) + '\n\n═══════════\n\n' : '') + `Source : conversation #${conv.token}` + (conv.customerName ? ` · ${conv.customerName}` : '') + (conv.phone ? ` · ${conv.phone}` : '') + '\n\n' + lines.join('\n\n'),
  }
  if (conv.customer) issueData.customer = conv.customer
  if (conv.queue && QUEUES.includes(conv.queue)) issueData.issue_type = conv.queue // tag DÉPARTEMENT = la file (les 4 files sont des Issue Type valides) → « Mes départements » sur Tickets
  if (conv.email && /.+@.+\..+/.test(conv.email)) issueData.raised_by = conv.email // raised_by = EMAIL valide seulement (sinon InvalidEmailAddressError)
  const r = await erp.create('Issue', issueData)
  if (!r.ok) return { ok: false, error: r.error || 'Création Issue échouée' }
  conv.linkedTickets = conv.linkedTickets || []
  conv.linkedTickets.push({ name: r.name, subject, ts: new Date().toISOString() })
  saveToDisk()
  erp.update('Issue', r.name, { conversation_token: conv.token }).catch(() => {}) // lien retour (best-effort si champ custom présent)
  addMessage(conv, { from: 'system', text: `🎫 Ticket ${r.name} créé depuis cette conversation` })
  sse.broadcast('conversations', 'conv-ticket-linked', { token: conv.token, issue: r.name, subject })
  log(`Ticket ${r.name} créé depuis conv ${conv.token} (customer: ${conv.customer || 'none'})`)
  return { ok: true, issue: r.name, subject }
}

// CHAÎNE DE TICKET : quand une conversation est liée à un Issue, chaque courriel (entrant/sortant) y est journalisé
// comme Communication (reference_doctype Issue) → le ticket porte tout le fil, sans doublon de saisie.
async function logToLinkedTicket (conv, msg, subject) {
  if (!conv || !conv.linkedTickets || !conv.linkedTickets.length) return
  const issue = conv.linkedTickets[conv.linkedTickets.length - 1] // le plus récent
  if (!issue || !issue.name) return
  // Sortant seulement si NOUS écrivons (from=agent) ET pas depuis l'adresse destinataire du ticket
  // (sinon une réponse depuis un domaine interne @targo — ex. test/employé destinataire — serait mal classée « Sent »).
  const custEmail = String(conv.email || '').toLowerCase()
  const fromEmail = String(msg.fromEmail || '').toLowerCase()
  const sent = msg.from === 'agent' && (!custEmail || fromEmail !== custEmail)
  try {
    await createCommunication({
      communication_type: 'Communication', communication_medium: 'Email',
      sent_or_received: sent ? 'Sent' : 'Received',
      sender: sent ? (cfg.MAIL_FROM || 'cc@targointernet.com') : (conv.email || ''),
      sender_full_name: sent ? (msg.agentName || 'Targo Ops') : (conv.customerName || ''), // sortie → prénom+nom de l'agent (jamais le login « louis »)
      recipients: sent ? (conv.email || '') : (cfg.MAIL_FROM || 'cc@targointernet.com'),
      subject: 'Re: ' + (subject || conv.lastSubject || conv.subject || ''),
      content: msg.html || (msg.text || '').replace(/\n/g, '<br>'),
      reference_doctype: 'Issue', reference_name: issue.name, status: 'Linked',
    })
  } catch (e) { log('logToLinkedTicket ' + issue.name + ': ' + e.message) }
}

// ALLER-RETOUR COURRIEL depuis un ticket : lie l'Issue à une conversation email, envoie dans un fil Gmail
// avec sujet [Ticket #ISS-…] + lien direct. La réponse du client re-thread ici (TICKET_REF_RX) → logToLinkedTicket
// → Communication (Received) sur l'Issue → visible dans le panneau ticket. Envoi RÉEL : gaté (feu vert humain).
async function emailTicket ({ issue, to, cc, message, agent, agentName, link } = {}) {
  const iss = String(issue || '').trim()
  const dest = String(to || '').trim()
  if (!iss || !/.+@.+\..+/.test(dest)) return { ok: false, error: 'issue + destinataire courriel valides requis' }
  let subject = 'Ticket ' + iss, customer = null
  try {
    const rows = await require('./erp').list('Issue', { filters: [['name', '=', iss]], fields: ['subject', 'customer'], limit: 1 })
    if (rows && rows[0]) { subject = rows[0].subject || subject; customer = rows[0].customer || null }
  } catch (e) { log('emailTicket lookup ' + iss + ': ' + e.message) }
  const conv = getOrCreateConvForIssue(iss, { customer, email: dest, subject })
  if (!conv) return { ok: false, error: 'conversation non créée' }
  conv.email = dest; conv.channel = 'email'; if (!conv.lastSubject) conv.lastSubject = subject
  const text = String(message || '').trim()
  const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
  let html = esc(text).replace(/\n/g, '<br>')
  if (link) html += `<br><br>— <a href="${esc(link)}">Ouvrir le ticket ${esc(iss)} dans OPS</a>`
  const msg = addMessage(conv, { from: 'agent', text, via: 'email', html, agent: agent || '', agentName: agentName || '', toEmail: dest, ccRaw: cc || '' })
  const chan = await notifyCustomer(conv, { text, html, cc: cc || '', replyToOverride: dest, agent })
  await logToLinkedTicket(conv, msg, subject).catch(() => {})
  saveToDisk()
  return { ok: chan === 'email', channel: chan, conv: conv.token, issue: iss }
}

// P2 (convergence) — MIROIR osTicket : quand un AGENT répond au client sur une conversation liée à un ticket osTicket
// legacy (linkedTickets[].legacy_ticket_id), on consigne le texte dans le fil osTicket (ticket_msg) pour que F reste synchro.
// Note INTERNE (public=0) + préfixe : l'envoi RÉEL au client est DÉJÀ parti par l'Outbox (notifyCustomer) — cette entrée est un
// MIROIR de visibilité, pas un 2e envoi (insert brut via ops_reassign.php → ne déclenche PAS le mailer osTicket). Best-effort.
async function mirrorAgentReplyToLegacyTicket (conv, msg, agentEmail) {
  if (!conv || !conv.linkedTickets) return
  const lt = conv.linkedTickets.find(t => t && t.legacy_ticket_id)
  if (!lt) return
  const text = String(msg.text || '').trim() || String(msg.html || '').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim()
  if (!text) return
  const via = conv.channel === 'email' ? 'courriel' : (conv.channel === 'sms' ? 'SMS' : 'Ops')
  try { await require('./legacy-dispatch-sync').postTicketLegacy(lt.legacy_ticket_id, `[Réponse envoyée au client par ${via} via Ops]\n${text}`, false, agentEmail) }
  catch (e) { log('mirrorAgentReplyToLegacyTicket ' + lt.legacy_ticket_id + ': ' + e.message) }
}

// PREUVE DE PAIEMENT : OCR (vision Gemini) d'une image envoyée par le client → extrait montant/date/réf → AFFICHE pour décider.
// NE écrit RIEN en facturation (F autoritaire) et NE réactive pas (GenieACS pas prêt) : on route vers Facturation, l'humain décide.
async function analyzePaymentProof (token, { image, messageId } = {}) {
  const conv = getConversation(token); if (!conv) return { ok: false, error: 'Conversation introuvable' }
  let src = image
  if (!src && messageId) { const m = (conv.messages || []).find(x => x.id === messageId); src = m && m.media }
  if (!src) { const m = [...(conv.messages || [])].reverse().find(x => x.media); src = m && m.media }
  if (!src) return { ok: false, error: 'Aucune image à analyser' }
  let base64
  try {
    if (/^https?:\/\//.test(src)) { const r = await fetch(src); const buf = Buffer.from(await r.arrayBuffer()); base64 = buf.toString('base64') }
    else base64 = stripDataUrl(src)
  } catch (e) { return { ok: false, error: 'image illisible: ' + e.message } }
  let result
  try { result = await require('./vision').extractPayment(base64) } catch (e) { return { ok: false, error: 'OCR: ' + e.message } }
  conv.paymentProof = { ...result, ts: new Date().toISOString(), messageId: messageId || null }
  saveToDisk()
  sse.broadcast('conversations', 'conv-payment', { token: conv.token, payment: conv.paymentProof })
  log(`Payment proof conv ${conv.token}: is_payment=${result.is_payment} amount=${result.amount} conf=${result.confidence}`)
  return { ok: true, payment: conv.paymentProof }
}

// Trouve une conversation EMAIL existante par adresse (canal email) — pour regrouper le fil d'un même expéditeur.
// Normalise un sujet pour comparer des fils : minuscules, retire les préfixes Re:/Ré:/Fwd:/Fw:/Tr: répétés, espaces compactés.
function normSubject (s) { return String(s || '').toLowerCase().replace(/^(\s*(re|ré|fwd|fw|tr)\s*:\s*)+/i, '').replace(/\s+/g, ' ').trim() }
// Repli de regroupement PAR COURRIEL (quand le fil Gmail thread_id n'a PAS matché) : on n'unit QUE si le SUJET concorde.
// Sinon deux courriels SANS RAPPORT du même expéditeur (sujets différents) étaient consolidés à tort dans un seul fil.
function findConversationByEmail (email, subject) {
  if (!email) return null
  const e = String(email).toLowerCase()
  const subjN = normSubject(subject)
  let best = null
  for (const c of conversations.values()) {
    if (c.status === 'expired') continue
    if (String(c.email || '').toLowerCase() !== e) continue
    const cSubjN = normSubject(c.subject || c.lastSubject)
    if (!subjN || !cSubjN || subjN !== cSubjN) continue // P2: fusionner SEULEMENT si les DEUX sujets sont présents ET identiques (sujet vide inclus → fils distincts ; on sur-sépare plutôt que sur-fusionner)
    if (!best || String(c.lastActivity) > String(best.lastActivity)) best = c
  }
  return best
}

// Regroupement PAR FIL GMAIL (thread_id) — la VRAIE clé d'une discussion : le client, nos réponses et les copies CC à support@ partagent le même thread_id Gmail, peu importe l'expéditeur de chaque message. Évite aussi de fusionner des fils distincts qui partagent une adresse interne (support@targointernet.com).
function findConversationByThreadId (threadId) {
  if (!threadId) return null
  let best = null
  for (const c of conversations.values()) {
    if (c.status === 'expired') continue
    if (c.threadId && String(c.threadId) === String(threadId)) { if (!best || String(c.lastActivity) > String(best.lastActivity)) best = c }
  }
  return best
}

// Trouve la conversation liée à un ticket donné — pour rattacher une réponse « [Ticket #N] » au bon fil, même hors fil Gmail.
function findConversationByTicketName (name) {
  if (!name) return null
  const n = String(name).toLowerCase()
  let best = null
  for (const c of conversations.values()) {
    if (c.status === 'expired' || c.status === 'merged') continue
    if ((c.linkedTickets || []).some(t => String(t.name || '').toLowerCase() === n)) { if (!best || String(c.lastActivity) > String(best.lastActivity)) best = c }
  }
  return best
}

// INGESTION d'un courriel entrant (poussé par n8n ou un poller IMAP). Crée/append une conversation canal EMAIL,
// lance le triage IA, et si pertinent attache une SUGGESTION de ticket (affichée dans l'Inbox : Créer / Ignorer).
async function ingestEmail ({ from, to, cc, date, subject, body, text, html, message_id, thread_id, gmail_id } = {}) {
  const triage = require('./inbox-triage')
  const email = triage.extractEmail(from)
  const toEmail = triage.extractEmail(to) || ''  // adresse de DESTINATION (mailbox reçue, ex. support@targo.ca) → affichée « à <…> » comme Gmail
  const content = String(body || text || '').slice(0, 8000)
  if (message_id) { for (const c of conversations.values()) { if (Array.isArray(c._emailMsgIds) && c._emailMsgIds.includes(message_id)) return { ok: true, dedup: true, token: c.token } } } // idempotent
  const guard = ingestGuard(email, subject) // garde-fous anti-spam de file (boucle / interne / réponse-ticket)
  if (guard === 'own_notif') { log('ingestEmail: skip own notif loop — ' + String(subject || '').slice(0, 60)); return { ok: true, skipped: 'own_notif' } } // nos propres courriels de file → ne pas réingérer
  const t = await triage.classifyEmail({ from, subject, body: content }).catch(e => { log('ingestEmail triage error:', e.message); return null })
  // Réponse à un ticket promu : « [Ticket #N] » dans le sujet → rattache au fil exact (robuste même hors fil Gmail).
  const tref = (String(subject || '').match(TICKET_REF_RX) || [])[1]
  // On ne REGROUPE jamais par une de NOS adresses (support@targo.ca, *@targointernet.com…) : sinon tout le sortant ré-ingéré (From: support@targo.ca) se collapse dans un seul fil → des conversations sans lien fusionnent + héritent d'un mauvais nom. Voir OWN_DOMAINS.
  let conv = (tref && findConversationByTicketName(tref))
    || (thread_id && findConversationByThreadId(thread_id))   // MÊME fil Gmail = MÊME discussion (client + nos réponses + copies CC à support@), peu importe l'expéditeur de chaque message
    || (email && !OWN_DOMAINS.test(email) ? findConversationByEmail(email, subject) : null)
  const isNewConv = !conv
  if (!conv) {
    // Identité d'affichage = qui a VRAIMENT envoyé (en-tête From) en priorité, puis une fiche client liée PAR COURRIEL (fiable), puis le courriel.
    // On n'utilise PAS le nom DEVINÉ par l'IA depuis le CONTENU (t.customer_name sans fiche liée) comme identité → sinon un courriel « gilles@ » s'affiche « Sylvie Juteau ».
    const linkedName = (t && t.customer) ? t.customer_name : ''
    conv = createConversation({ phone: null, customer: (t && t.customer) || null, customerName: fromDisplayName(from) || linkedName || email || 'Courriel', agentEmail: null, subject: subject || '(courriel)' })
    conv.channel = 'email'; conv.email = email
  }
  conv._emailMsgIds = conv._emailMsgIds || []; if (message_id) conv._emailMsgIds.push(message_id)
  if (thread_id) conv.threadId = thread_id // pour répondre DANS le même fil Gmail
  if (message_id) conv.lastMessageId = message_id // In-Reply-To/References
  if (gmail_id) { conv.lastGmailId = gmail_id; conv._gmailIds = conv._gmailIds || []; if (!conv._gmailIds.includes(gmail_id)) conv._gmailIds.push(gmail_id) } // pour spam/corbeille Gmail ciblés
  conv.lastSubject = subject || conv.lastSubject || conv.subject
  // Dédup DOUBLE-ENVOI : même expéditeur, corps QUASI identique, < 3 min (Message-ID différent → la dédup par ID ne l'attrape pas).
  // FLOU : certains clients renvoient le même courriel à ~2 s d'intervalle avec 1 caractère de différence (encodage) → comparer les 140 premiers car. + écart de longueur < 12 %, pas un === strict.
  const _norm = s => String(s || '').replace(/\s+/g, ' ').trim()
  const _last = conv.messages.length ? conv.messages[conv.messages.length - 1] : null
  if (_last && _last.from === 'customer') {
    const a = _norm(_last.text), b = _norm(content)
    const near = !!a && !!b && (a === b || (a.length > 80 && b.length > 80 && a.slice(0, 140) === b.slice(0, 140) && Math.abs(a.length - b.length) <= Math.max(a.length, b.length) * 0.12))
    if (near && (Date.now() - new Date(_last.ts).getTime()) < 180000) {
      log('ingestEmail: skip double-envoi (quasi-identique < 3 min) — ' + email); saveToDisk()
      return { ok: true, skipped: 'double_send', token: conv.token }
    }
  }
  // Corps = message du fil (le sujet est affiché comme EN-TÊTE → on ne le recolle PAS dans le corps : évite la double en-tête).
  // Expéditeur d'un de NOS domaines (membre de l'équipe qui répond via Gmail, en copie CC à support@) → message AGENT, pas « customer » (sinon il s'affiche comme un message du client dans le fil).
  const senderIsOwn = !!(email && OWN_DOMAINS.test(email))
  const emailMsg = addMessage(conv, { from: senderIsOwn ? 'agent' : 'customer', text: content, via: 'email', html: html || '', agent: senderIsOwn ? email : '', fromName: fromDisplayName(from), fromEmail: email, toEmail, toRaw: to || '', ccRaw: cc || '', emailDate: date || '', gmail_id: gmail_id || '' })
  if (conv.linkedTickets && conv.linkedTickets.length) logToLinkedTicket(conv, emailMsg, subject).catch(() => {}) // chaîne : alimente le ticket lié
  // ── MASQUAGE + ROUTAGE : décidés UNE SEULE FOIS, à la CRÉATION du fil ──
  // Sur une RÉPONSE à un fil existant (même thread Gmail), on NE ré-évalue PAS : sinon une réponse interne de l'équipe (guard 'internal') pourrait MASQUER un fil client visible, ou re-router / écraser le triage du client.
  if (isNewConv) {
    // Auto-masque le bruit machine (no-reply/infra) et l'interne, MAIS JAMAIS un vrai lead détecté par l'IA (vente/support/facturation/installation…). Les RÈGLES utilisateur priment.
    const LEAD_TYPES = ['vente', 'support', 'facturation', 'installation', 'telephonie', 'television']
    const isLead = !!(t && !t.noise && LEAD_TYPES.includes(t.type))
    let masked = !!(t && t.noise) || guard === 'automated' || (guard === 'internal' && !isLead)
    const rule = matchInboxRule(email, subject) // 'allow' | 'mask' | null
    if (rule === 'allow') masked = false
    else if (rule === 'mask') masked = true
    conv.noise = masked
    if (t) {
      conv.triage = t
      if (!conv.noise && guard !== 'ticket_reply') { // visible et pas une réponse à un ticket existant → routage/suggestion auto OK
        if (!conv.queue) conv.queue = QUEUE_OF[t.type] || '' // route vers le LABEL (file) ; réorientable d'un clic
        if (t.suggest_ticket && !(conv.linkedTickets && conv.linkedTickets.length)) {
          conv.suggestedTicket = { title: t.suggested_title, category: t.category, priority: 'Medium', reason: t.reason, is_customer: t.is_customer, ts: new Date().toISOString() }
        }
        if (t.type === 'vente' && !conv.pipeline) conv.pipeline = { stage: 'Nouveau', value: 0, updatedAt: new Date().toISOString() } // lead → entre dans le pipeline
      }
    }
  } else {
    // Fil existant : une règle utilisateur EXPLICITE peut tout de même ré-afficher/masquer ; sinon on ne touche pas à la visibilité ni au routage.
    const rule = matchInboxRule(email, subject)
    if (rule === 'allow') conv.noise = false
    else if (rule === 'mask') conv.noise = true
  }
  try { require('./coupon-triage').onEmail({ from, subject, body: content, conv }) } catch (e) { log('coupon onEmail: ' + e.message) } // Levier 1 : alerte coupons (ne bloque pas l'ingest)
  saveToDisk()
  sse.broadcast('conversations', 'conv-email', { token: conv.token, email, triage: t })
  if (conv.queue && !conv.noise && !conv.assignee && !guard) notifyQueue(conv).catch(() => {}) // pousse aux membres de la file ; JAMAIS sur interne/réponse-ticket/notif (anti-spam)
  return { ok: true, token: conv.token, triage: t, suggested_ticket: !!(conv.suggestedTicket) }
}

// RE-SYNCHRONISE une conversation EMAIL depuis Gmail : récupère TOUT le fil (getThread = reçus + envoyés) et AJOUTE les messages
// manquants — réponses envoyées DIRECTEMENT dans Gmail (hors hub), courriels arrivés pendant une panne du hub ou sortis de la boîte
// (jamais vus par le poller `in:inbox newer_than:3d`). Dédup STRICTE par ID Gmail + Message-Id (donc nos propres réponses, déjà dans
// `_gmailIds`, ne sont pas dupliquées). Re-trie le fil en ordre chronologique (les messages récupérés peuvent être plus ANCIENS que
// ceux déjà présents) sans bouger `lastActivity` (un backfill n'est pas une « nouvelle activité » qui remonte le fil dans l'Inbox).
async function resyncThread (token) {
  const conv = getConversation(token)
  if (!conv) return { ok: false, error: 'Conversation introuvable' }
  if (conv.channel !== 'email' || !conv.threadId) return { ok: false, error: 'Pas un fil courriel (threadId Gmail manquant)' }
  const gmail = require('./gmail')
  const triage = require('./inbox-triage')
  let msgs
  try { msgs = await gmail.getThread(conv.threadId) } catch (e) { return { ok: false, error: 'Gmail: ' + e.message } }
  conv._gmailIds = conv._gmailIds || []
  conv._emailMsgIds = conv._emailMsgIds || []
  const haveGid = new Set(conv._gmailIds.concat(conv.lastGmailId ? [conv.lastGmailId] : []).concat((conv.messages || []).map(x => x.gmail_id).filter(Boolean)))
  const haveMid = new Set(conv._emailMsgIds)
  const byGid = {}; for (const x of (conv.messages || [])) { if (x.gmail_id) byGid[x.gmail_id] = x } // pour ENRICHIR les messages déjà présents
  const origActivity = conv.lastActivity // un backfill ne doit PAS remonter le fil
  let added = 0, backfilled = 0
  for (const m of msgs) {
    const existing = m.id ? byGid[m.id] : null
    if (existing) { // message DÉJÀ présent → on enrichit ses destinataires (To/Cc) pour l'affichage « à moi, … », sans le dupliquer
      let chg = false
      if (!existing.toRaw && m.to) { existing.toRaw = m.to; chg = true }
      if (!existing.ccRaw && m.cc) { existing.ccRaw = m.cc; chg = true }
      if (!existing.toEmail && m.to) { existing.toEmail = triage.extractEmail(m.to) || ''; chg = true }
      if (chg) backfilled++
      continue
    }
    if ((m.id && haveGid.has(m.id)) || (m.messageId && haveMid.has(m.messageId))) continue
    const fromEmail = triage.extractEmail(m.from)
    const toEmail = triage.extractEmail(m.to) || ''
    const senderIsOwn = !!(fromEmail && OWN_DOMAINS.test(fromEmail)) // un de NOS domaines = réponse de l'équipe → message AGENT (pas « customer »)
    addMessage(conv, { from: senderIsOwn ? 'agent' : 'customer', text: m.body || m.snippet || '', via: 'email', html: m.html || '', agent: senderIsOwn ? fromEmail : '', fromName: fromDisplayName(m.from), fromEmail, toEmail, toRaw: m.to || '', ccRaw: m.cc || '', emailDate: m.date || '', gmail_id: m.id || '' })
    if (m.id) { conv._gmailIds.push(m.id); haveGid.add(m.id) }
    if (m.messageId) { conv._emailMsgIds.push(m.messageId); haveMid.add(m.messageId) }
    added++
  }
  if (added || backfilled) {
    if (added) conv.messages.sort((a, b) => (Date.parse(a.emailDate || '') || Date.parse(a.ts) || 0) - (Date.parse(b.emailDate || '') || Date.parse(b.ts) || 0))
    conv.lastActivity = origActivity || conv.lastActivity
    saveToDisk()
    sse.broadcast('conv:' + conv.token, 'conv-resync', { token: conv.token, added, backfilled })
    sse.broadcast('conversations', 'conv-resync', { token: conv.token, added, backfilled })
  }
  return { ok: true, added, backfilled, total: conv.messages.length }
}

// INGESTION CANAL GÉNÉRIQUE — web chat (nouveau site), 3CX, Facebook Messenger… Tout tombe dans l'INBOX PARTAGÉ
// (file + claim), donc AUCUNE capture mono-agent : si l'agent est absent et que le client réécrit, le fil reste visible par tous.
// Clé de regroupement = channel + external_id (session web / PSID Facebook) → un seul fil par interlocuteur, réponses routées.
function findConversationByChannelKey (key) {
  if (!key) return null
  let best = null
  for (const c of conversations.values()) { if (c.channelKey === key && (!best || String(c.lastActivity) > String(best.lastActivity))) best = c }
  return best
}
async function channelIngest ({ channel, external_id, from, name, text, html, customer, message_id } = {}) {
  const ch = String(channel || 'webchat').toLowerCase()
  const key = ch + ':' + String(external_id || from || '').trim()
  const content = String(text || '').slice(0, 8000)
  if (message_id) { for (const c of conversations.values()) { if (Array.isArray(c._chanMsgIds) && c._chanMsgIds.includes(message_id)) return { ok: true, dedup: true, token: c.token } } }
  let conv = findConversationByChannelKey(key)
  if (!conv) {
    conv = createConversation({ phone: null, customer: customer || null, customerName: name || from || ({ webchat: 'Visiteur web', facebook: 'Messenger', '3cx': 'Chat 3CX' }[ch] || 'Chat'), subject: ({ webchat: 'Chat site', facebook: 'Facebook Messenger', '3cx': 'Chat 3CX' }[ch] || 'Chat') })
    conv.channel = ch; conv.channelKey = key; conv.channelExternalId = String(external_id || from || '')
  }
  conv._chanMsgIds = conv._chanMsgIds || []; if (message_id) conv._chanMsgIds.push(message_id)
  const msg = addMessage(conv, { from: 'customer', text: content, via: ch, html: html || '' })
  // Triage IA léger (réutilise le classifieur courriel) → file + suggestion de ticket, comme un courriel.
  try {
    const t = await require('./inbox-triage').classifyEmail({ from: from || name || ch, subject: conv.subject, body: content })
    if (t) { conv.triage = t; conv.noise = !!t.noise; if (!t.noise && !conv.queue) conv.queue = QUEUE_OF[t.type] || '' ; if (!t.noise && t.suggest_ticket && !(conv.linkedTickets && conv.linkedTickets.length)) conv.suggestedTicket = { title: t.suggested_title, category: t.category, priority: 'Medium', reason: t.reason, ts: new Date().toISOString() } }
  } catch (e) { /* triage best-effort */ }
  if (conv.linkedTickets && conv.linkedTickets.length) logToLinkedTicket(conv, msg, conv.subject).catch(() => {})
  saveToDisk()
  sse.broadcast('conversations', 'conv-message', { token: conv.token, channel: conv.channel, customer: conv.customer, customerName: conv.customerName, message: msg })
  if (conv.queue && !conv.noise && !conv.assignee) notifyQueue(conv).catch(() => {}) // file notifiée → personne n'est « propriétaire » unique
  return { ok: true, token: conv.token, channel: ch }
}

// Envoi SMS sortant réutilisable (orchestrateur NL, FAB…) : crée/append une conversation par téléphone + envoie (notifyCustomer).
async function sendSms ({ phone, customer, customerName, message, media } = {}) {
  const digits = String(phone || '').replace(/\D/g, '')
  if (digits.length < 10) return { ok: false, error: 'numéro invalide' }
  if (!message && !media) return { ok: false, error: 'message requis' }
  let conv = findConversationByPhone(digits)
  if (!conv) conv = createConversation({ phone: digits, customer: customer || null, customerName: customerName || digits, subject: 'SMS' })
  else if (customer && !conv.customer) { conv.customer = customer; conv.customerName = customerName || conv.customerName }
  const msg = addMessage(conv, { from: 'agent', text: message || '', via: 'web', type: media ? 'image' : 'text', media: media || '' })
  msg.notifiedVia = await notifyCustomer(conv, msg)
  conv.lastHumanDate = todayET(); saveToDisk()
  return { ok: true, token: conv.token, via: msg.notifiedVia }
}

// Compose un NOUVEAU courriel SORTANT (≠ réponse) : envoie via Gmail + crée/append une conversation EMAIL suivie.
async function sendNewEmail ({ to, cc, bcc, subject, body, html, attachments, customer, customerName, agentEmail, sendAs } = {}) {
  const triage = require('./inbox-triage')
  const dest = triage.extractEmail(to) || String(to || '').trim()
  if (!dest || !/.+@.+\..+/.test(dest)) return { ok: false, error: 'destinataire courriel invalide' }
  const gmail = require('./gmail')
  let conv = findConversationByEmail(dest)
  if (!conv) { conv = createConversation({ phone: null, customer: customer || null, customerName: customerName || dest, subject: subject || 'Courriel' }); conv.channel = 'email'; conv.email = dest }
  // réponse dans le fil si la conversation existe déjà (threadId connu)
  const outHtml = await require('./rating').expandRatingMarker(html || '', conv)
  const r = await gmail.sendMessage({ to: dest, cc: cc || '', bcc: bcc || '', subject: withTicketTag(conv, subject || conv.lastSubject || '(sans objet)'), body: body || '', html: outHtml, attachments: resolveAttachments(attachments), threadId: conv.threadId, inReplyTo: conv.lastMessageId, from: await resolveSendFrom(sendAs, agentEmail) })
  if (r && r.threadId) conv.threadId = r.threadId
  if (r && r.id) conv.lastGmailId = r.id
  conv.lastSubject = subject || conv.lastSubject
  const agentName = agentEmail ? await require('./auth').getDisplayNameByEmail(agentEmail).catch(() => '') : ''
  const m = addMessage(conv, { from: 'agent', text: (body || ''), via: 'email', html: html || '', agent: agentEmail || '', agentName, toRaw: dest || '', ccRaw: cc || '', gmail_id: (r && r.id) || '' })
  if (conv.linkedTickets && conv.linkedTickets.length) logToLinkedTicket(conv, m, subject).catch(() => {})
  conv.lastHumanDate = todayET(); saveToDisk()
  log(`Nouveau courriel sortant → ${dest} (gmail ${r && r.id})`)
  return { ok: true, token: conv.token, gmail: r }
}

// ── SPLIT : détecte les sujets distincts d'une conversation (ex. « facturation » + « télévision ») ──
// detectTopics ne crée RIEN (aperçu) ; splitConversation crée 1 ticket par sujet (réutilise createTicketFromConversation).
async function detectSplitTopics (token) {
  const conv = getConversation(token); if (!conv) return { ok: false, error: 'Conversation introuvable' }
  if (!cfg.AI_API_KEY) return { ok: false, error: 'IA indisponible' }
  const msgs = (conv.messages || []).filter(m => m.from !== 'system').slice(-12).map(m => (m.from === 'customer' ? 'CLIENT' : 'AGENT') + ': ' + String(m.text || '').slice(0, 400)).join('\n')
  const sys = 'Tu analyses une conversation de support TARGO/Gigafibre (FAI). Détecte les PROBLÈMES/DEMANDES DISTINCTS qu\'elle contient. Renvoie UNIQUEMENT du JSON : {"topics":[{"title":"titre court du problème","category":"Facturation|Support|Vente|Installation|Television|Telephonie|Autre","queue":"Facturations|Service à la clientèle|Supports|Technicien"}]}. UN SEUL sujet ⇒ un seul élément. Ne scinde QUE si les sujets sont réellement indépendants (ex. une question de facture ET un problème de télé). N\'invente rien.'
  let out
  try {
    out = await require('./ai').chat({ task: 'split', maxTokens: 500, temperature: 0.1, messages: [{ role: 'system', content: sys }, { role: 'user', content: 'CONVERSATION :\n' + msgs }] })
  } catch (e) { return { ok: false, error: 'IA : ' + e.message } }
  let parsed; try { parsed = JSON.parse((out.match(/\{[\s\S]*\}/) || ['{}'])[0]) } catch (e) { return { ok: false, error: 'réponse IA illisible' } }
  const topics = Array.isArray(parsed.topics) ? parsed.topics.filter(t => t && t.title).slice(0, 6) : []
  return { ok: true, topics, multi: topics.length > 1 }
}

// Crée 1 ticket par sujet. La conversation RESTE (lien vers tous les tickets via linkedTickets). Notifie la file de chaque sujet.
async function splitConversation (token, { topics } = {}) {
  const conv = getConversation(token); if (!conv) return { ok: false, error: 'Conversation introuvable' }
  let list = Array.isArray(topics) && topics.length ? topics : null
  if (!list) { const d = await detectSplitTopics(token); if (!d.ok) return d; list = d.topics }
  if (!list.length) return { ok: false, error: 'Aucun sujet détecté' }
  const tickets = []
  for (const t of list) {
    const r = await createTicketFromConversation(token, { title: t.title, category: t.category }).catch(e => ({ ok: false, error: e.message }))
    if (r && r.ok) {
      tickets.push({ issue: r.issue, subject: r.subject, queue: t.queue || '' })
      if (t.queue && QUEUES.includes(t.queue)) { try { await notifyQueueLabel(t.queue, conv, r.subject) } catch (e) { /* */ } } // chaque équipe notifiée de SON ticket
    }
  }
  if (tickets.length) { addMessage(conv, { from: 'system', text: `✂️ Conversation scindée en ${tickets.length} ticket(s) : ${tickets.map(x => x.issue).join(', ')}` }); saveToDisk() }
  return { ok: tickets.length > 0, tickets, count: tickets.length }
}

// ── MERGE : fusionne la conversation SOURCE dans la conversation CIBLE (doublons) ──
// Les messages/notes/tickets de la source sont rapatriés dans la cible ; la source est marquée fusionnée (masquée).
function mergeConversations (targetToken, sourceToken) {
  if (targetToken === sourceToken) return { ok: false, error: 'Identiques' }
  const target = getConversation(targetToken); const source = getConversation(sourceToken)
  if (!target) return { ok: false, error: 'Cible introuvable' }
  if (!source) return { ok: false, error: 'Source introuvable' }
  const srcLabel = source.customerName || source.email || source.phone || source.token
  target.messages = (target.messages || []).concat((source.messages || []).filter(m => m.from !== 'system'))
    .sort((a, b) => String(a.ts).localeCompare(String(b.ts)))
  if (source.notes && source.notes.length) target.notes = (target.notes || []).concat(source.notes)
  if (source.linkedTickets && source.linkedTickets.length) target.linkedTickets = (target.linkedTickets || []).concat(source.linkedTickets)
  if (!target.customer && source.customer) { target.customer = source.customer; target.customerName = target.customerName || source.customerName }
  if (!target.email && source.email) target.email = source.email
  if (!target.threadId && source.threadId) target.threadId = source.threadId
  if (Array.isArray(source._emailMsgIds)) target._emailMsgIds = (target._emailMsgIds || []).concat(source._emailMsgIds)
  addMessage(target, { from: 'system', text: `🔗 Conversation « ${srcLabel} » (#${source.token}) fusionnée ici` })
  source.status = 'merged'; source.mergedInto = target.token; source.noise = true // disparaît de l'Inbox
  saveToDisk()
  sse.broadcast('conversations', 'conv-merged', { target: target.token, source: source.token })
  log(`Merge ${source.token} → ${target.token}`)
  return { ok: true, target: target.token, source: source.token, messages: target.messages.length }
}

// ── COMMANDE EN LANGAGE NATUREL (copilote) ──
// Gemini interprète la commande de l'agent + le contexte → exécute les actions SÛRES (file, claim, note, ticket)
// et RÉDIGE un brouillon de réponse (JAMAIS auto-envoyé — l'agent valide d'un clic). Réutilise gmail/createTicket/claim.
async function nlCommand (token, command, agentEmail) {
  const conv = getConversation(token); if (!conv) return { ok: false, error: 'Conversation introuvable' }
  if (!cfg.AI_API_KEY) return { ok: false, error: 'IA indisponible' }
  if (!String(command || '').trim()) return { ok: false, error: 'commande vide' }
  const msgs = (conv.messages || []).slice(-8).map(m => (m.from === 'customer' ? 'CLIENT' : (m.via === 'ai' ? 'IA' : 'AGENT')) + ': ' + String(m.text || '').slice(0, 300)).join('\n')
  const ctx = `Conversation #${conv.token} · canal ${conv.channel || 'sms'} · client ${conv.customerName || '?'} (${conv.customer ? 'connu' : 'inconnu'}) · file actuelle ${conv.queue || '(aucune)'} · assigné ${conv.assignee || '(personne)'}\nDerniers messages :\n${msgs}`
  const sys = 'Tu assistes un agent support TARGO/Gigafibre (fournisseur Internet). À partir de la COMMANDE de l\'agent et du CONTEXTE, renvoie UNIQUEMENT du JSON (aucun texte autour) : {"queue":"Facturations|Service à la clientèle|Supports|Technicien|null","claim":true|false,"note":"note interne en français ou null","reply":"brouillon de réponse au CLIENT en français, ton professionnel, ou null","create_ticket":{"title":"...","category":"Support|Facturation|Vente|Installation|Commercial|Autre","priority":"Low|Medium|High|Urgent"} ou null,"summary":"une phrase résumant ce que tu as fait"}. N\'invente jamais d\'information (montants, dates, identités). Mets "reply" SEULEMENT si la commande demande explicitement d\'écrire/répondre au client.'
  let out
  try {
    out = await require('./ai').chat({ task: 'nl', maxTokens: 700, temperature: 0.2, messages: [{ role: 'system', content: sys }, { role: 'user', content: 'CONTEXTE :\n' + ctx + '\n\nCOMMANDE : ' + command }] })
  } catch (e) { return { ok: false, error: 'IA : ' + e.message } }
  let plan; try { plan = JSON.parse((out.match(/\{[\s\S]*\}/) || ['{}'])[0]) } catch (e) { return { ok: false, error: 'réponse IA illisible' } }
  const applied = []
  if (plan.queue && QUEUES.includes(plan.queue)) { conv.queue = plan.queue; applied.push('File → ' + plan.queue); sse.broadcast('conversations', 'conv-queue', { token, queue: conv.queue }) }
  if (plan.claim) { conv.assignee = agentEmail || 'agent'; conv.assigneeAt = new Date().toISOString(); applied.push('Assigné à ' + (agentEmail || 'vous')); sse.broadcast('conversations', 'conv-claimed', { token, assignee: conv.assignee }) }
  if (plan.note) { conv.notes = conv.notes || []; const note = { id: crypto.randomUUID(), agent: agentEmail || 'IA', text: String(plan.note), ts: new Date().toISOString() }; conv.notes.push(note); applied.push('Note interne ajoutée'); sse.broadcast('conversations', 'conv-note', { token, note }) }
  let ticket = null
  if (plan.create_ticket && plan.create_ticket.title) { const r = await createTicketFromConversation(token, { title: plan.create_ticket.title, category: plan.create_ticket.category, priority: plan.create_ticket.priority }).catch(e => ({ ok: false })); if (r && r.ok) { ticket = r.issue; applied.push('Ticket ' + r.issue + ' créé') } }
  if (applied.length) { conv.lastHumanDate = todayET(); saveToDisk() }
  return { ok: true, applied, draft_reply: plan.reply || null, summary: plan.summary || '', ticket }
}

const TK = /^\/conversations\/([A-Za-z0-9_-]{5,10})$/
const TK_SUB = /^\/conversations\/([A-Za-z0-9_-]{5,10})\//

// RÉSUMÉ IA d'un fil (style « Summarise this email » de Gmail) — pour l'agent, pas envoyé au client. PII → routé 'triage'.
async function summarizeConversation (token) {
  const conv = getConversation(token); if (!conv) return { ok: false, error: 'Conversation introuvable' }
  const who = m => m.from === 'agent' ? (m.via === 'ai' ? 'IA' : 'Agent') : (conv.customerName || conv.email || 'Client')
  const msgs = (conv.messages || []).map(m => {
    const body = String(m.text || '').replace(/^✉️[^\n]*\n+/, '').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 1200)
    return body ? `${who(m)} (${String(m.ts || '').slice(0, 16).replace('T', ' ')}): ${body}` : ''
  }).filter(Boolean).join('\n')
  if (!msgs.trim()) return { ok: true, summary: '' }
  const sys = 'Tu es l\'assistant d\'un fournisseur Internet (TARGO/Gigafibre). Résume ce fil pour un agent du service à la clientèle, en français, en 2 à 4 puces COURTES : le sujet, la demande/le problème, ce qui a déjà été fait, et la PROCHAINE action requise (s\'il y en a une). Factuel et bref. N\'invente rien. Réponds uniquement avec les puces (préfixe « • »).'
  try {
    // reasoning_effort:'none' désactive le « thinking » de gemini-2.5-flash (sinon ses jetons de réflexion mangent maxTokens et tronquent le résumé sur un long fil). Un résumé n'a pas besoin de chaîne de raisonnement → plus rapide + complet + ~200 jetons.
    const out = await require('./ai').chat({ task: 'triage', maxTokens: 500, temperature: 0.2, reasoningEffort: 'none', messages: [{ role: 'system', content: sys }, { role: 'user', content: 'FIL :\n' + msgs.slice(0, 6000) }] })
    return { ok: true, summary: String(out || '').trim() }
  } catch (e) { log('summarizeConversation error: ' + e.message); return { ok: false, error: 'Résumé IA indisponible' } }
}

// #2 DISPONIBILITÉ DU SERVICE (réponse de vente) : l'IA extrait l'adresse, la fibre est vérifiée DE FAÇON DÉTERMINISTE dans la base RQA locale
// (JAMAIS inventée par l'IA → on ne promet jamais à tort un service disponible), puis on PRÉPARE un brouillon. N'ENVOIE RIEN (l'agent valide).
async function serviceabilityDraft (token, { address: providedAddr } = {}) {
  const conv = getConversation(token); if (!conv) return { ok: false, error: 'Conversation introuvable' }
  let address = String(providedAddr || '').trim()
  if (!address) { // extraction IA (sans raisonnement) depuis les messages du CLIENT
    const text = (conv.messages || []).filter(m => m.from === 'customer').map(m => String(m.text || '').replace(/^✉️[^\n]*\n+/, '').replace(/<[^>]+>/g, ' ')).join('\n').slice(0, 3000)
    try {
      const sys = 'Extrais UNIQUEMENT l\'adresse postale complète (numéro civique, rue, ville, code postal) mentionnée par le client dans un fil de courriels. Réponds en JSON STRICT : {"address":"..."} — ou {"address":""} si aucune adresse claire.'
      const out = await require('./ai').chat({ task: 'triage', maxTokens: 150, temperature: 0, reasoningEffort: 'none', messages: [{ role: 'system', content: sys }, { role: 'user', content: text }] })
      address = String((JSON.parse((out.match(/\{[\s\S]*\}/) || ['{}'])[0]).address) || '').trim()
    } catch (e) { log('serviceability extract: ' + e.message) }
  }
  if (!address) return { ok: true, needs_address: true, message: 'Aucune adresse détectée dans le fil — précisez-la.' }
  let match = null
  try { const rows = await require('./address-db').searchLocal(address, 1); match = rows && rows[0] } catch (e) { log('serviceability searchLocal: ' + e.message); return { ok: false, error: 'Recherche d\'adresse indisponible' } }
  if (!match) return { ok: true, needs_address: true, address, message: 'Adresse introuvable dans la base RQA — vérifiez l\'orthographe.' }
  const fiber = !!match.fiber_available
  const adr = match.adresse_formatee || address
  const name = conv.customerName && !/@/.test(conv.customerName) ? String(conv.customerName).split(' ')[0] : ''
  const portal = cfg.CLIENT_PORTAL_URL || 'https://portal.gigafibre.ca'
  const link = portal + '/?adresse=' + encodeURIComponent(adr)
  const hi = name ? 'Bonjour ' + name + ',' : 'Bonjour,'
  const draft = fiber
    ? hi + '\n\nMerci de votre intérêt envers Gigafibre. Bonne nouvelle : notre service de fibre optique est disponible à votre adresse (' + adr + ')' + (match.max_speed ? ', avec des vitesses pouvant atteindre ' + match.max_speed + '.' : '.') + '\n\nPour vous abonner en quelques minutes, cliquez ici : ' + link + '\n\nN\'hésitez pas si vous avez des questions.\n\nCordialement,\nL\'équipe Gigafibre'
    : hi + '\n\nMerci de votre intérêt envers Gigafibre. Pour le moment, notre fibre optique n\'est pas encore disponible à votre adresse (' + adr + ').\n\nNous prenons note de votre intérêt et vous contacterons dès qu\'elle sera déployée dans votre secteur.\n\nCordialement,\nL\'équipe Gigafibre'
  return { ok: true, address: adr, fiber_available: fiber, max_speed: match.max_speed || null, similarity: match.similarity_score, funnel_link: fiber ? link : null, draft_text: draft, draft_html: draft.replace(/\n/g, '<br>') }
}

async function handle (req, res, method, p, url) {
  // INBOX UNIFIÉ — tickets (Issues ERPNext) ACTIFS RÉCENTS (file agent), fusionnés avec les conversations côté front.
  // ⚠️ 897 Issues Open existent (backlog legacy importé, jamais retouché) → on filtre sur l'ACTIVITÉ RÉCENTE (modifié < N j)
  //    pour ne montrer que la vraie file (les tickets créés depuis conversations apparaissent ici). ?days= et ?all=1 (backlog) en option.
  if (p === '/conversations/inbox-tickets' && method === 'GET') {
    try {
      const all = url && url.searchParams && url.searchParams.get('all') === '1'
      const days = Math.max(1, Math.min(365, Number(url && url.searchParams && url.searchParams.get('days')) || 45))
      const filters = [['status', 'in', ['Open', 'Replied']]]
      if (!all) { const since = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10); filters.push(['modified', '>', since]) }
      const rows = await erp.list('Issue', { filters, fields: ['name', 'subject', 'customer', 'status', 'priority', 'modified', 'creation', '_assign', 'owner'], limit: 200, orderBy: 'modified desc' })
      // _assign Frappe = chaîne JSON '["a@b.com",…]' → tableau d'emails (pour le filtre « assignés à moi » côté Ops). Lien conv↔ticket : token du fil qui a créé/rattaché le ticket (clic = ouvrir le fil).
      const tok = {}; for (const c of conversations.values()) { for (const t of (c.linkedTickets || [])) { if (t && t.name && !tok[t.name]) tok[t.name] = c.token } }
      const tickets = (rows || []).map(r => { let asn = []; try { asn = JSON.parse(r._assign || '[]') } catch (e) { asn = [] } return { ...r, assignees: Array.isArray(asn) ? asn : [], convToken: tok[r.name] || null } })
      return json(res, 200, { tickets, recent_days: all ? null : days })
    } catch (e) { log('inbox-tickets error:', e.message); return json(res, 200, { tickets: [], error: e.message }) }
  }

  // Décompte pour l'accueil — chiffres HONNÊTES tirés des VRAIES sources (pas d'_assign ERPNext, quasi vide ici) :
  //  • inboxActive = conversations ACTIVES (store PG en mémoire) = vrai volume de boîte.
  //  • mine / mineOverdue = tickets que JE SUIS encore ouverts (le suivi = « Mes tickets » d'OPS), en retard si ouvert > 48 h.
  if (p === '/conversations/my-inbox-counts' && method === 'GET') {
    try {
      const email = String((url && url.searchParams && url.searchParams.get('email')) || req.headers['x-authentik-email'] || '').toLowerCase()
      // awaiting = conversations actives dont le DERNIER message vient du client (= en attente de NOTRE réponse) → ACTIONNABLE.
      let inboxActive = 0, awaiting = 0
      try {
        for (const c of conversations.values()) {
          if ((c.status || 'active') !== 'active') continue
          inboxActive++
          const m = c.messages || []; const last = m[m.length - 1]
          if (last && (last.from === 'customer' || last.from === 'client' || last.direction === 'inbound')) awaiting++
        }
      } catch (e) {}
      let mine = 0, mineOverdue = 0
      if (email) {
        const follows = (followsFor(loadFollows(), email, 'Issue') || []).slice(0, 500)
        if (follows.length) {
          const cntIssue = async (f) => {
            try { const r = await erp.raw('/api/method/frappe.client.get_count?doctype=Issue&filters=' + encodeURIComponent(JSON.stringify(f))); return (r && r.data && typeof r.data.message === 'number') ? r.data.message : 0 } catch (e) { return 0 }
          }
          const base = [['name', 'in', follows], ['status', 'in', ['Open', 'Replied']]]
          const cut = new Date(Date.now() - 2 * 86400000).toISOString().slice(0, 19).replace('T', ' ') // ouvert > 48 h
          const r = await Promise.all([cntIssue(base), cntIssue(base.concat([['creation', '<', cut]]))])
          mine = r[0]; mineOverdue = r[1]
        }
      }
      return json(res, 200, { inboxActive, awaiting, mine, mineOverdue })
    } catch (e) { return json(res, 200, { inboxActive: 0, awaiting: 0, mine: 0, mineOverdue: 0, error: e.message }) }
  }

  // TRIAGE IA (Phase 3) — aperçu : classe un courriel (client connu ? type ? suggérer ticket ?). Réutilisé par le poller mail.
  if (p === '/conversations/triage-preview' && method === 'POST') {
    try { const b = await parseBody(req); const r = await require('./inbox-triage').classifyEmail(b || {}); return json(res, 200, r) } catch (e) { return json(res, 500, { error: e.message }) }
  }

  // PIPELINE de leads : board (colonnes par étape) des conversations en pipeline.
  if (p === '/conversations/pipeline-board' && method === 'GET') {
    const cols = {}; PIPELINE_STAGES.forEach(s => { cols[s] = [] })
    for (const conv of conversations.values()) {
      if (!conv.pipeline || !conv.pipeline.stage) continue
      if (isExpired(conv)) continue
      const st = PIPELINE_STAGES.includes(conv.pipeline.stage) ? conv.pipeline.stage : 'Nouveau'
      cols[st].push({ token: conv.token, customerName: conv.customerName, contact: conv.email || conv.phone || '', subject: conv.subject || '', value: conv.pipeline.value || 0, queue: conv.queue || '', assignee: conv.assignee || null, channel: conv.channel || 'sms', is_customer: !!(conv.triage && conv.triage.is_customer), lastActivity: conv.lastActivity })
    }
    for (const s in cols) cols[s].sort((a, b) => String(b.lastActivity).localeCompare(String(a.lastActivity)))
    return json(res, 200, { stages: PIPELINE_STAGES, columns: cols })
  }

  // Appartenance agent ↔ file (config éditable). GET = lire ; POST {members:{...}} = écrire.
  if (p === '/conversations/queue-members' && (method === 'GET' || method === 'POST')) {
    if (method === 'GET') return json(res, 200, { members: loadQueueMembers(), queues: QUEUES, labels: loadQueueLabels(), issueTypeQueue: ISSUE_TYPE_QUEUE, visibility: loadVisibility() })
    try {
      const b = await parseBody(req)
      if (b.members) writeJsonFile('/app/data/queue_members.json', b.members)
      if (b.labels) writeJsonFile('/app/data/queue_labels.json', b.labels) // libellés d'affichage (nom réel = clé inchangée)
      if (b.visibility) writeJsonFile('/app/data/queue_visibility.json', b.visibility) // mode de visibilité par personne (personnel/department/tags)
      return json(res, 200, { ok: true, members: b.members || loadQueueMembers(), labels: b.labels || loadQueueLabels(), visibility: b.visibility || loadVisibility() })
    } catch (e) { return json(res, 500, { error: e.message }) }
  }
  // Suivi PERSO d'un ticket (watch) : GET → mes tickets suivis ; POST {ticket, follow} → (dé)suivre. Toujours visibles dans « Mes tickets ».
  // Rétro-compatible : délègue au store généralisé (doctype « Issue »).
  if (p === '/conversations/ticket-follow' && (method === 'GET' || method === 'POST')) {
    const email = String(req.headers['x-authentik-email'] || '').toLowerCase()
    const all = loadFollows()
    if (method === 'GET') return json(res, 200, { follows: followsFor(all, email, 'Issue') })
    try {
      const b = await parseBody(req); const t = String(b.ticket || '').trim()
      if (!email || !t) return json(res, 400, { error: 'email + ticket requis' })
      const arr = new Set(followsFor(all, email, 'Issue'))
      if (b.follow === false) arr.delete(t); else arr.add(t)
      ;(all[email] || (all[email] = {})).Issue = [...arr]; saveFollows(all)
      return json(res, 200, { ok: true, follows: all[email].Issue, following: arr.has(t) })
    } catch (e) { return json(res, 500, { error: e.message }) }
  }
  // Suivi GÉNÉRALISÉ (tout doctype) : GET ?doctype=X → mes noms suivis ; POST {doctype, name, follow} → (dé)suivre.
  // Ex. suivre un Dispatch Job, un Customer… au-delà des tickets (Issue). Le suivi « Issue » reste synchronisé avec ticket-follow.
  if (p === '/conversations/follow' && (method === 'GET' || method === 'POST')) {
    const email = String(req.headers['x-authentik-email'] || '').toLowerCase()
    const all = loadFollows()
    if (method === 'GET') {
      const dt = String(url.searchParams.get('doctype') || '').trim()
      if (dt) return json(res, 200, { doctype: dt, follows: followsFor(all, email, dt) })
      return json(res, 200, { follows: (email && all[email]) || {} }) // tous doctypes → { doctype: [names] }
    }
    try {
      const b = await parseBody(req); const dt = String(b.doctype || '').trim(); const nm = String(b.name || '').trim()
      // Cible = courriel explicite (abonner N'IMPORTE QUEL utilisateur — champ d'assignation « # follower ») OU, à défaut, l'identité de l'appelant.
      const target = String(b.email || email || '').toLowerCase()
      if (!target || !dt || !nm) return json(res, 400, { error: 'email + doctype + name requis' })
      const arr = new Set(followsFor(all, target, dt))
      if (b.follow === false) arr.delete(nm); else arr.add(nm)
      ;(all[target] || (all[target] = {}))[dt] = [...arr]; saveFollows(all)
      return json(res, 200, { ok: true, doctype: dt, name: nm, email: target, follows: all[target][dt], following: arr.has(nm) })
    } catch (e) { return json(res, 500, { error: e.message }) }
  }
  // Qui suit ce doc : GET ?doctype=X&name=Y → { followers: [courriels] }. Alimente les pastilles « # » du champ d'assignation.
  if (p === '/conversations/followers' && method === 'GET') {
    const dt = String(url.searchParams.get('doctype') || '').trim()
    const nm = String(url.searchParams.get('name') || '').trim()
    if (!dt || !nm) return json(res, 400, { error: 'doctype + name requis' })
    return json(res, 200, { doctype: dt, name: nm, followers: followersOf(loadFollows(), dt, nm) })
  }
  // Règles d'inbox (« Filtrer comme ceci ») : masquer/afficher par expéditeur ou sujet — l'agent choisit ce qui est masqué.
  if (p === '/conversations/inbox-rules' && (method === 'GET' || method === 'POST')) {
    if (method === 'GET') return json(res, 200, { rules: loadInboxRules() })
    try {
      const b = await parseBody(req)
      writeJsonFile('/app/data/inbox_rules.json', Array.isArray(b.rules) ? b.rules : [])
      // Rétro-application aux conversations EXISTANTES : masquer/afficher tout de suite ce qui correspond (sinon les règles n'agiraient qu'au prochain courriel).
      let changed = 0
      for (const c of conversations.values()) {
        if (c.channel !== 'email') continue
        const r = matchInboxRule(c.email, c.lastSubject || c.subject)
        if (r === 'mask' && !c.noise) { c.noise = true; changed++ } else if (r === 'allow' && c.noise) { c.noise = false; changed++ }
      }
      if (changed) saveToDisk()
      return json(res, 200, { ok: true, rules: loadInboxRules(), retro: changed })
    } catch (e) { return json(res, 500, { error: e.message }) }
  }

  // Réponses pré-écrites (canned) : liste + sauvegarde (remplacement complet du tableau, comme queue-members).
  if (p === '/conversations/canned' && (method === 'GET' || method === 'POST')) {
    if (method === 'GET') return json(res, 200, { responses: loadCanned() })
    try { const b = await parseBody(req); const list = Array.isArray(b) ? b : (b.responses || []); const ok = saveCanned(list); return json(res, ok ? 200 : 500, { ok, responses: list }) } catch (e) { return json(res, 500, { error: e.message }) }
  }

  // NOUVEAU courriel SORTANT depuis l'Inbox (agent). Gaté (Authentik/proxy).
  if (p === '/conversations/email-new' && method === 'POST') {
    try { const b = await parseBody(req); const r = await sendNewEmail({ ...(b || {}), agentEmail: req.headers['x-authentik-email'] || (b && b.agentEmail) || '' }); return json(res, r.ok ? 200 : 400, r) } catch (e) { return json(res, 500, { error: e.message }) }
  }

  // ALLER-RETOUR COURRIEL depuis un TICKET (Issue). Gaté (Authentik/proxy). La réponse du client re-thread → Communication sur l'Issue.
  if (p === '/conversations/ticket-email' && method === 'POST') {
    try { const b = await parseBody(req); const r = await emailTicket({ ...(b || {}), agent: req.headers['x-authentik-email'] || (b && b.agent) || '', agentName: (b && b.agentName) || '' }); return json(res, r.ok ? 200 : 400, r) } catch (e) { return json(res, 500, { error: e.message }) }
  }

  // INGESTION COURRIEL (Phase 3) — poussé par n8n (Gmail Trigger sur cc@) ou un poller. Protégé par X-Mail-Token (PAS Authentik).
  if (p === '/conversations/email-ingest' && method === 'POST') {
    const tok = req.headers['x-mail-token'] || ''
    const want = mailIngestToken()
    if (!want || tok !== want) return json(res, 401, { error: 'Unauthorized' })
    try { const b = await parseBody(req); const r = await ingestEmail(b || {}); return json(res, r.ok ? 200 : 500, r) } catch (e) { return json(res, 500, { error: e.message }) }
  }

  // INGESTION CANAL (web chat nouveau site / 3CX / Facebook) — webhook externe. Protégé par X-Mail-Token (même jeton que l'ingest courriel).
  if (p === '/conversations/channel-ingest' && method === 'POST') {
    const tok = req.headers['x-mail-token'] || ''
    const want = mailIngestToken()
    if (!want || tok !== want) return json(res, 401, { error: 'Unauthorized' })
    try { const b = await parseBody(req); const r = await channelIngest(b || {}); return json(res, r.ok ? 200 : 500, r) } catch (e) { return json(res, 500, { error: e.message }) }
  }

  if (p === '/conversations' && method === 'POST') {
    const body = await parseBody(req)
    const { phone, customer, customerName, subject, message, media } = body
    if (!phone) return json(res, 400, { error: 'Missing phone' })
    const agentEmail = req.headers['x-authentik-email'] || null
    const conv = createConversation({ phone, customer, customerName, agentEmail, subject: subject || (message ? String(message).slice(0, 60) : '') })
    conv.lastHumanDate = todayET() // fil INITIÉ depuis notre interface (humain) → l'IA ne répond pas le même jour
    const link = `${cfg.CLIENT_PUBLIC_URL}/c/${conv.token}`
    const { sendSmsInternal } = require('./twilio')
    let outText
    if (message || media) {
      // Texto chat : on envoie LE TEXTE DE L'AGENT (+ image MMS), pas un gabarit de lien.
      outText = String(message || '')
      addMessage(conv, { from: 'agent', text: outText, via: 'sms', type: media ? 'image' : 'text', media: media || '' })
      try { await sendSmsInternal(phone, outText, customer, media || ''); conv.smsCount++; saveToDisk() } catch (e) { log('SMS/MMS send failed:', e.message) }
    } else {
      outText = subject ? `Gigafibre — ${subject}\n\nContinuez la conversation ici:\n${link}` : `Gigafibre — Vous avez un nouveau message.\n\nRépondre ici:\n${link}`
      try { await sendSmsInternal(phone, outText, customer); conv.smsCount++; saveToDisk() } catch (e) { log('Failed to send link SMS:', e.message) }
    }
    if (customer) {
      await createCommunication({
        communication_type: 'Communication', communication_medium: 'SMS',
        sent_or_received: 'Sent', sender: 'sms@gigafibre.ca', sender_full_name: 'Targo Ops',
        phone_no: phone, content: outText + (media ? '\n[image jointe]' : ''), subject: 'SMS sortant',
        reference_doctype: 'Customer', reference_name: customer, status: 'Linked',
      }).catch(() => {})
    }
    return json(res, 201, { ok: true, token: conv.token, link })
  }

  if (p === '/conversations' && method === 'GET') {
    const _t0 = Date.now(); const _all = url.searchParams.get('all') === '1'
    const result = listConversations({ includeAll: _all, lean: true }) // liste = résumé léger ; messages chargés à l'ouverture
    // Pagination OPTIONNELLE (infinite-scroll Boîte) : ?limit=&offset= sur les DISCUSSIONS (déjà triées lastActivity desc).
    // Additif/rétrocompatible : sans `limit`, comportement inchangé (liste complète). Le payload lourd = discussions[].messages → on n'en sert qu'une page.
    const limit = parseInt(url.searchParams.get('limit'), 10)
    if (Number.isFinite(limit) && limit > 0) {
      const offset = Math.max(0, parseInt(url.searchParams.get('offset'), 10) || 0)
      const all = result.discussions || []
      const page = all.slice(offset, offset + limit)
      log(`conversations list: page ${offset}–${offset + page.length}/${all.length} fils · ${Date.now() - _t0}ms (all=${_all})`)
      // `conversations` (plat, LÉGER, sans corps de messages) renvoyé en entier pour les lookups/compteurs globaux ; seules les discussions sont paginées.
      return json(res, 200, { discussions: page, conversations: result.conversations, total: all.length, hasMore: offset + page.length < all.length, offset, limit })
    }
    const _msgs = (result.discussions || []).reduce((s, d) => s + ((d.messages && d.messages.length) || 0), 0)
    log(`conversations list: ${(result.discussions || []).length} fils · ${(result.conversations || []).length} convs · ${_msgs} msgs · ${Date.now() - _t0}ms (all=${_all})`) // mesure C
    return json(res, 200, result)
  }

  // GET /conversations/for-customer?customer=&email=&phone= — fils (email+SMS) d'UN client pour la fiche (timeline customer-360)
  if (p === '/conversations/for-customer' && method === 'GET') {
    const cust = (url.searchParams.get('customer') || '').trim()
    const email = (url.searchParams.get('email') || '').trim().toLowerCase()
    const phone = (url.searchParams.get('phone') || '').replace(/\D/g, '').slice(-10)
    if (!cust && !email && !phone) return json(res, 200, { discussions: [] })
    const { discussions } = listConversations({ includeAll: true })
    const mine = discussions.filter(d => {
      // Lien EXPLICITE à ce client = toujours inclus (source de vérité).
      if (cust && d.customer === cust) return true
      // Déjà lié à un AUTRE client = jamais (sinon fuite inter-fiches via email/tél partagé ou interne).
      if (cust && d.customer && d.customer !== cust) return false
      // Conversation NON liée : repli par email/tél — mais JAMAIS via une de NOS adresses (staff/support @targo.ca…),
      // sinon les fils internes/partagés remontent sur toutes les fiches.
      const de = String(d.email || '').toLowerCase()
      if (email && de === email && !OWN_DOMAINS.test(de)) return true
      const dp = String(d.phone || '').replace(/\D/g, '').slice(-10)
      if (phone && dp.length === 10 && dp === phone) return true
      return false
    })
    return json(res, 200, { discussions: mine })
  }

  // GET /conversations/leaderboard?from=YYYY-MM-DD&to=YYYY-MM-DD — classement des agents Inbox (reconnaissance / motivation).
  // « pris » = conversations dont l'agent est assigné (claim, via assigneeAt) ; « réponses » = messages agent horodatés à son nom (stamping msg.agent — historique avant stamping = unattributed).
  // Identités d'expédition pour le sélecteur « De : » (défaut groupe + alias vérifiés).
  if (p === '/conversations/senders' && method === 'GET') {
    return json(res, 200, { default: DEFAULT_SENDER, identities: SENDER_IDENTITIES.map(i => ({ name: i.name || '', address: i.address, from: identityToFrom(i) })) })
  }
  // Rafraîchir À LA DEMANDE : force un pull Gmail immédiat (≠ fetchList qui ne lit que l'état hub déjà ingéré).
  if (p === '/conversations/poll-now' && method === 'POST') {
    try { const r = await require('./gmail').poll(); return json(res, 200, { ok: true, scanned: (r && r.scanned) || 0, ingested: (r && r.ingested) || 0 }) } catch (e) { return json(res, 200, { ok: false, error: e.message }) }
  }
  // Préférences de notification PAR UTILISATEUR (clé = courriel agent) : activer/désactiver chaque feed (cloche).
  if (p === '/conversations/notif-prefs' && (method === 'GET' || method === 'PUT')) {
    const NOTIF_DEFAULTS = { sms: true, webchat: true, '3cx': true, call: true, facebook: true, email: false, ratings: true }
    const email = String(req.headers['x-authentik-email'] || '').toLowerCase()
    const all = readJsonFile('/app/data/notif_prefs.json', {})
    if (method === 'PUT') {
      if (!email) return json(res, 400, { error: 'utilisateur inconnu' })
      const b = await parseBody(req); const inp = (b && b.prefs) || {}
      const clean = {}; for (const k of Object.keys(NOTIF_DEFAULTS)) clean[k] = typeof inp[k] === 'boolean' ? inp[k] : NOTIF_DEFAULTS[k]
      all[email] = clean; writeJsonFile('/app/data/notif_prefs.json', all)
      return json(res, 200, { ok: true, prefs: clean })
    }
    return json(res, 200, { prefs: { ...NOTIF_DEFAULTS, ...(all[email] || {}) }, defaults: NOTIF_DEFAULTS })
  }
  // Abonnement PAR AGENT aux files + notif par file (self-service) : membership = queue_members.json ; notif = queue_notify.json.
  // GET → mes files + état ; POST {queue, subscribe?, notify?} → me (dés)abonner / (dé)activer la notif de CETTE file.
  if (p === '/conversations/my-queues' && (method === 'GET' || method === 'POST')) {
    const email = String(req.headers['x-authentik-email'] || '').toLowerCase()
    if (!email) return json(res, 400, { error: 'utilisateur inconnu' })
    const members = loadQueueMembers()
    const notifyAll = readJsonFile('/app/data/queue_notify.json', {})
    if (method === 'POST') {
      const b = await parseBody(req); const queue = String(b.queue || '')
      if (!QUEUES.includes(queue)) return json(res, 400, { error: 'file inconnue' })
      if (typeof b.subscribe === 'boolean') {
        const arr = (members[queue] || []).filter(e => String(e).toLowerCase() !== email)
        if (b.subscribe) arr.push(email)
        members[queue] = arr; writeJsonFile('/app/data/queue_members.json', members)
      }
      if (typeof b.notify === 'boolean') {
        notifyAll[email] = notifyAll[email] || {}; notifyAll[email][queue] = b.notify; writeJsonFile('/app/data/queue_notify.json', notifyAll)
      }
    }
    const mine = QUEUES.filter(q => (members[q] || []).map(e => String(e).toLowerCase()).includes(email))
    return json(res, 200, { queues: QUEUES, labels: loadQueueLabels(), mine, notify: notifyAll[email] || {} })
  }
  if (p === '/conversations/leaderboard' && method === 'GET') {
    const to = url.searchParams.get('to') || new Date().toISOString().slice(0, 10)
    const from = url.searchParams.get('from') || new Date(Date.now() - 30 * 864e5).toISOString().slice(0, 10)
    const inRange = (iso) => { const d = String(iso || '').slice(0, 10); return !!d && d >= from && d <= to }
    const agents = {}
    const A = (k) => (agents[k] = agents[k] || { agent: k, handled: 0, replies: 0 })
    let unattributed = 0
    for (const [, conv] of conversations) {
      if (conv.assignee && (!conv.assigneeAt || inRange(conv.assigneeAt))) A(conv.assignee).handled++
      for (const m of (conv.messages || [])) {
        if (m.from !== 'agent' || !inRange(m.ts)) continue
        if (m.agent) A(m.agent).replies++; else unattributed++
      }
    }
    const board = Object.values(agents).sort((a, b) => (b.handled - a.handled) || (b.replies - a.replies)).slice(0, 20)
    return json(res, 200, { from, to, board, unattributed })
  }

  // POST /conversations/backfill-senders — rétro-remplit msg.fromName/fromEmail des courriels
  // ingérés AVANT la capture per-message de l'en-tête From, en relisant Gmail. En-process
  // (mute l'état mémoire + saveToDisk → pas de course de fichier). Idempotent : ne touche
  // que les messages courriel SANS fromName. Mappe par msg.gmail_id, sinon par ordre via _gmailIds.
  if (p === '/conversations/backfill-senders' && method === 'POST') {
    const gmail = require('./gmail')
    const { extractEmail } = require('./inbox-triage')
    let patched = 0, convsTouched = 0
    for (const [, conv] of conversations) {
      if (conv.channel !== 'email') continue
      const gids = conv._gmailIds || []
      const emailMsgs = (conv.messages || []).filter(m => m.via === 'email' || m.html)
      let touched = false
      for (let i = 0; i < emailMsgs.length; i++) {
        const m = emailMsgs[i]
        if (m.fromName) continue
        const gid = m.gmail_id || gids[i]
        if (!gid) continue
        try {
          const gm = await gmail.getMessage(gid)
          const nm = gm && gm.from ? fromDisplayName(gm.from) : ''
          if (nm) { m.fromName = nm; m.fromEmail = extractEmail(gm.from); m.gmail_id = gid; patched++; touched = true }
        } catch (e) { /* message Gmail introuvable → ignorer */ }
      }
      if (touched) convsTouched++
    }
    saveToDisk()
    return json(res, 200, { ok: true, patched, convsTouched })
  }

  // Cherche les fiches Customer par téléphone → l'OPS propose de lier / de choisir parmi plusieurs.
  if (p === '/conversations/resolve' && method === 'GET') {
    const emailQ = url.searchParams.get('email')
    const matches = emailQ ? await lookupCustomersByEmail(emailQ, 8) : await lookupCustomersByPhone(url.searchParams.get('phone') || '', 8)
    return json(res, 200, { ok: true, count: matches.length, matches })
  }

  // RECHERCHE FLOUE omnichannel (SQL pg_trgm) : nom client / courriel / sujet + corps des messages, typo-tolérante.
  // Repli MÉMOIRE (sous-chaîne) si Postgres est indisponible → la recherche fonctionne toujours.
  if (p === '/conversations/search' && method === 'GET') {
    const q = String(url.searchParams.get('q') || '').trim()
    if (!q) return json(res, 200, { ok: true, results: [], source: 'none' })
    let results = await store.search(q, 30)
    if (results === null) {
      const ql = q.toLowerCase()
      const mem = []
      for (const conv of conversations.values()) {
        if (isExpired(conv)) continue
        const direct = [conv.customerName, conv.email, conv.lastSubject || conv.subject].filter(Boolean).join(' ').toLowerCase().includes(ql)
        let snippet = null
        let hit = direct
        if (!hit) { const m = (conv.messages || []).find(x => String(x.text || '').toLowerCase().includes(ql)); if (m) { hit = true; snippet = String(m.text || '').replace(/\s+/g, ' ').slice(0, 140) } }
        if (hit) mem.push({ token: conv.token, customerName: conv.customerName, email: conv.email, subject: conv.lastSubject || conv.subject, channel: conv.channel, queue: conv.queue, status: conv.status, lastActivity: conv.lastActivity, score: 0, direct, snippet })
      }
      mem.sort((a, b) => (Number(b.direct) - Number(a.direct)) || String(b.lastActivity || '').localeCompare(String(a.lastActivity || '')))
      results = mem.slice(0, 30)
    }
    return json(res, 200, { ok: true, results, source: store.available() ? 'sql' : 'memory' })
  }

  // Résout un NUMÉRO DE RÉFÉRENCE de ticket (ex. « 251382 » dans un courriel) → fiche client, via le Dispatch Job lié
  // (legacy_ticket_id = n° osTicket ; repli sur le nom du job qui contient « #<ref> »). Pour le sélecteur « Lier une fiche ».
  if (p === '/conversations/resolve-ref' && method === 'GET') {
    const ref = String(url.searchParams.get('ref') || '').replace(/\D/g, '')
    if (ref.length < 3) return json(res, 200, { ok: true, ref, matches: [] })
    const out = []; const seen = new Set(); let ticket = null
    const push = (m, via, subject) => { if (m && m.name && !seen.has(m.name)) { seen.add(m.name); out.push({ name: m.name, customer_name: m.customer_name || m.name, email: m.email, phone: m.phone, address: m.address, via, subject }) } }
    try {
      // n° de référence = legacy_ticket_id du Dispatch Job (n° osTicket), ou présent dans le sujet « … · #<ref> »
      let jobs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', ref]], fields: ['name', 'customer', 'service_location', 'subject', 'address', 'legacy_detail', 'status'], limit: 5 })
      if (!jobs || !jobs.length) jobs = await erp.list('Dispatch Job', { filters: [['subject', 'like', '%' + ref + '%']], fields: ['name', 'customer', 'service_location', 'subject', 'address', 'legacy_detail', 'status'], limit: 5 })
      for (const j of (jobs || [])) {
        const tag = 'Ticket #' + ref + (j.status ? ' · ' + j.status : '')
        const det = String(j.legacy_detail || '')
        const email = (det.match(/Email\s*:\s*([^\s<]+@[^\s<]+)/i) || [])[1]
        const phone = (det.match(/(?:T[ée]l[ée]phone|Tel)\s*:\s*([\d().+\-\s]{7,})/i) || [])[1]
        const nom = (det.match(/Nom\s*:\s*(.+?)\s+(?:ID|Email|Adresse|T[ée]l)\s*:/i) || [])[1]
        const adr = (det.match(/Adresse\s*:\s*(.+?)\s+(?:T[ée]l[ée]phone|Tel|Installa|Service)/i) || [])[1] || j.address
        const ids = ((det.match(/\bID\s*:\s*([\d\s]+)/i) || [])[1] || '').trim().split(/\s+/).filter(x => x.length >= 3)
        // Identité du ticket (utile même si AUCUNE fiche ERPNext ne correspond — fréquent : client non encore synchronisé)
        if (!ticket) ticket = { ref, name: (nom || '').trim(), email: email || '', phone: (phone || '').replace(/\s+/g, ' ').trim(), address: (adr || '').trim(), subject: j.subject || '', status: j.status || '' }
        // Résolution de la FICHE : client lié au job → courriel → téléphone → n° de compte → adresse (service location)
        if (j.customer) { let cn = j.customer; try { const c = (await erp.list('Customer', { filters: [['name', '=', j.customer]], fields: ['name', 'customer_name'], limit: 1 }))[0]; cn = (c && c.customer_name) || j.customer } catch (e) { /* */ } push({ name: j.customer, customer_name: cn }, tag, j.subject); continue }
        let got = 0
        if (email) { for (const m of await lookupCustomersByEmail(email, 5)) { push(m, tag + ' · courriel', j.subject); got++ } }
        if (!got && phone) { for (const m of await lookupCustomersByPhone(phone, 5)) { push(m, tag + ' · téléphone', j.subject); got++ } }
        if (!got && ids.length) { for (const id of ids.slice(0, 4)) { const cs = await erp.list('Customer', { filters: [['legacy_account_id', '=', id]], fields: ['name', 'customer_name'], limit: 2 }); for (const c of (cs || [])) { push({ name: c.name, customer_name: c.customer_name }, tag + ' · n° de compte', j.subject); got++ } } }
        if (!got && j.service_location) { try { const sl = (await erp.list('Service Location', { filters: [['name', '=', j.service_location]], fields: ['customer'], limit: 1 }))[0]; if (sl && sl.customer) { const c = (await erp.list('Customer', { filters: [['name', '=', sl.customer]], fields: ['name', 'customer_name'], limit: 1 }))[0]; push({ name: sl.customer, customer_name: (c && c.customer_name) || sl.customer }, tag + ' · adresse', j.subject) } } catch (e) { /* */ } }
      }
    } catch (e) { log('resolve-ref error: ' + e.message) }
    return json(res, 200, { ok: true, ref, matches: out, ticket })
  }

  if (p === '/conversations/archive' && method === 'POST') {
    const { tokens } = await parseBody(req)
    if (!tokens || !tokens.length) return json(res, 400, { error: 'Missing tokens array' })
    try {
      const result = await archiveDiscussion(tokens)
      if (!result) return json(res, 404, { error: 'No messages found' })
      sse.broadcast('conversations', 'conv-deleted', { archived: true, issue: result.issue })
      return json(res, 200, { ok: true, ...result })
    } catch (e) { return json(res, 500, { error: 'Archive failed: ' + e.message }) }
  }

  if (p === '/conversations/bulk' && method === 'DELETE') {
    const { discussions: discs } = await parseBody(req)
    if (!discs || !discs.length) return json(res, 400, { error: 'Missing discussions array' })
    let totalDeleted = 0
    for (const d of discs) {
      totalDeleted += d.tokens ? deleteDiscussionByTokens(d.tokens) : (d.phone && d.date ? deleteDiscussion(d.phone, d.date) : 0)
    }
    sse.broadcast('conversations', 'conv-deleted', { bulk: true, count: totalDeleted })
    return json(res, 200, { ok: true, deleted: totalDeleted })
  }

  if (p === '/conversations/discussion' && method === 'DELETE') {
    const { phone, date } = await parseBody(req)
    if (!phone || !date) return json(res, 400, { error: 'Missing phone or date' })
    const count = deleteDiscussion(phone, date)
    log(`Deleted discussion: ${phone} on ${date} (${count} conversations)`)
    sse.broadcast('conversations', 'conv-deleted', { phone, date, count })
    return json(res, 200, { ok: true, deleted: count })
  }

  // Liste des agents assignables (roster DÉCOUPLÉ des notifs de file). AVANT le match TK (« agents » ≈ un token).
  // Source : /app/data/agents.json (roster canonique) ; repli = union des membres de file. Découplé car les listes
  // de notif peuvent être vidées (anti-spam) sans retirer l'agent du choix d'assignation.
  if (p === '/conversations/agents' && method === 'GET') {
    let list = readJsonFile('/app/data/agents.json', null)
    if (!Array.isArray(list) || !list.length) { const mem = loadQueueMembers(); const set = new Set(); for (const k of Object.keys(mem)) for (const e of (mem[k] || [])) set.add(e); list = [...set] }
    const out = [...new Set((list || []).map(e => String(e).toLowerCase()).filter(e => /.+@.+\..+/.test(e)))].sort()
    // Noms RÉELS (tabUser) → afficher/chercher « Michel Blais » et pas seulement le préfixe courriel « michel » dans l'assignation/@mention. Best-effort.
    const names = {}
    try {
      let pool = null; try { pool = require('./address-db').pool() } catch (e) { /* address-db indispo */ }
      if (pool && out.length) { const r = await pool.query('SELECT name, full_name FROM "tabUser" WHERE lower(name) = ANY($1)', [out]); for (const row of r.rows) if (row.full_name) names[String(row.name).toLowerCase()] = row.full_name }
    } catch (e) { /* noms best-effort — l'assignation marche sans */ }
    return json(res, 200, { agents: out, names })
  }

  let m
  if ((m = p.match(TK)) && method === 'GET') {
    const conv = getConversation(m[1])
    if (!conv) return json(res, 404, { error: 'Conversation not found or expired' })
    return json(res, 200, {
      token: conv.token, customerName: conv.customerName, subject: conv.subject,
      status: conv.status, messages: conv.messages, createdAt: conv.createdAt,
      customer: conv.customer || null, phone: conv.phone || null,
      channel: conv.channel || 'sms', email: conv.email || null,
      queue: conv.queue || '', assignee: conv.assignee || null, assigneeAt: conv.assigneeAt || null, priority: conv.priority || '',
      assistants: conv.assistants || [], ticketState: conv.ticketState || (conv.status === 'closed' ? 'closed' : 'open'), pendingUntil: conv.pendingUntil || null,
      notes: conv.notes || [], linkedTickets: conv.linkedTickets || [], links: conv.links || [], suggestedTicket: conv.suggestedTicket || null,
      pipeline: conv.pipeline || null, pipelineStages: PIPELINE_STAGES,
      triage: conv.triage || null, gmailReplyUrl: gmailReplyUrl(conv), queues: QUEUES,
      readBy: conv.readBy || {}, draft: conv.draft || null,
      vapidPublicKey: cfg.VAPID_PUBLIC_KEY || null,
    })
  }

  if ((m = p.match(TK)) && method === 'DELETE') {
    const existed = deleteConversation(m[1], { trashGmail: true }) // suppression explicite → courriels liés à la corbeille Gmail (réversible 30 j)
    if (!existed) return json(res, 404, { error: 'Conversation not found' })
    log(`Deleted conversation: ${m[1]}`)
    sse.broadcast('conversations', 'conv-deleted', { token: m[1] })
    return json(res, 200, { ok: true })
  }

  if ((m = p.match(TK_SUB))) {
    const sub = p.slice(p.indexOf(m[1]) + m[1].length + 1)
    const token = m[1]

    // Créer un TICKET (ERPNext Issue OUVERT) depuis la conversation — la conversation RESTE (≠ archive).
    if (sub === 'ticket' && method === 'POST') {
      const b = await parseBody(req)
      const r = await createTicketFromConversation(token, b)
      return json(res, r.ok ? 200 : 500, r)
    }

    // SPLIT — détecte les sujets distincts (aperçu, ne crée rien) puis crée 1 ticket par sujet sur confirmation.
    if (sub === 'split-preview' && method === 'GET') {
      const r = await detectSplitTopics(token)
      return json(res, r.ok ? 200 : 400, r)
    }
    if (sub === 'split' && method === 'POST') {
      const b = await parseBody(req)
      const r = await splitConversation(token, { topics: b.topics })
      return json(res, r.ok ? 200 : 400, r)
    }
    // MERGE — fusionne une conversation SOURCE (body.from) dans CELLE-CI (cible = token de l'URL).
    if (sub === 'merge' && method === 'POST') {
      const b = await parseBody(req)
      const r = mergeConversations(token, String(b.from || b.source || ''))
      return json(res, r.ok ? 200 : 400, r)
    }
    // RE-SYNCHRONISER ce fil depuis Gmail — récupère les réponses envoyées hors hub + les courriels manqués (panne / sortis de la boîte).
    if (sub === 'resync' && method === 'POST') {
      const r = await resyncThread(token)
      return json(res, r.ok ? 200 : 400, r)
    }
    // PREUVE DE PAIEMENT — OCR d'une image (capture client) → affiche montant/date/réf (sans écrire en facturation).
    if (sub === 'payment-proof' && method === 'POST') {
      const b = await parseBody(req)
      const r = await analyzePaymentProof(token, b)
      return json(res, r.ok ? 200 : 400, r)
    }
    // Pièces jointes (PDF/JPG) du fil — pour le trombone. Lues à la demande via Gmail puis mises en cache sur le message.
    if (sub === 'attachments' && method === 'GET') {
      const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
      const map = {}; let fetched = false
      for (const m of (conv.messages || [])) {
        if (!m.gmail_id) continue
        if (m._att === undefined) {
          try { const gm = await gmail.getMessage(m.gmail_id); m._att = (gm.attachments || []).map(a => ({ filename: a.filename, mimeType: a.mimeType, attachmentId: a.attachmentId, size: a.size })); fetched = true } catch (e) { m._att = [] }
        }
        if (m._att && m._att.length) map[m.id] = m._att.map(a => ({ ...a, gmail_id: m.gmail_id }))
      }
      if (fetched) saveToDisk()
      return json(res, 200, { attachments: map })
    }
    // Sert une pièce jointe en ligne (clic trombone / source pour la Vision). mime/name fournis par le client (issus de la liste).
    if (sub === 'attachment' && method === 'GET') {
      const gid = url.searchParams.get('gmail_id') || ''; const att = url.searchParams.get('att') || ''
      const mime = url.searchParams.get('mime') || 'application/octet-stream'
      const fname = (url.searchParams.get('name') || 'document').replace(/[^\w.\- ]/g, '_')
      if (!gid || !att) return json(res, 400, { error: 'gmail_id + att requis' })
      try {
        const buf = Buffer.from(String(await gmail.getAttachment(gid, att) || '').replace(/-/g, '+').replace(/_/g, '/'), 'base64')
        res.writeHead(200, { 'Content-Type': mime, 'Content-Disposition': 'inline; filename="' + fname + '"', 'Cache-Control': 'private, max-age=300' })
        return res.end(buf)
      } catch (e) { return json(res, 502, { error: 'Gmail: ' + e.message }) }
    }
    // RÉSUMÉ IA du fil (Gemini) — « Summarise this email » de Gmail, pour l'agent.
    if (sub === 'summarize' && (method === 'POST' || method === 'GET')) {
      const r = await summarizeConversation(token)
      return json(res, r.ok ? 200 : 400, r)
    }
    // #2 DISPONIBILITÉ : adresse (IA) → fibre (RQA) → brouillon de réponse de vente (l'agent valide avant envoi).
    if (sub === 'serviceability' && method === 'POST') {
      const b = await parseBody(req)
      const r = await serviceabilityDraft(token, b)
      return json(res, r.ok ? 200 : 400, r)
    }

    // ── Coordination shared-inbox : claim / release / file / note interne ──
    if (sub === 'claim' && method === 'POST') {
      const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
      const agent = req.headers['x-authentik-email'] || ((await parseBody(req)).agent) || 'agent'
      conv.assignee = agent; conv.assigneeAt = new Date().toISOString(); conv.lastHumanDate = todayET(); saveToDisk()
      sse.broadcast('conversations', 'conv-claimed', { token, assignee: agent }) // collision : les autres voient « pris par X » en direct
      return json(res, 200, { ok: true, assignee: agent })
    }
    if (sub === 'release' && method === 'POST') {
      const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
      conv.assignee = null; conv.assigneeAt = null; saveToDisk()
      sse.broadcast('conversations', 'conv-claimed', { token, assignee: null })
      return json(res, 200, { ok: true })
    }
    // Assignation DIRECTE (agent précis) + ASSISTANTS (liste d'emails, affichés en chips) — modèle assign_to + participants de F.
    if (sub === 'assign' && method === 'POST') {
      const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
      const b = await parseBody(req)
      if (b.assignee !== undefined) { conv.assignee = b.assignee ? String(b.assignee).toLowerCase() : null; conv.assigneeAt = conv.assignee ? new Date().toISOString() : null }
      if (Array.isArray(b.assistants)) conv.assistants = [...new Set(b.assistants.map(e => String(e).toLowerCase()).filter(e => /.+@.+\..+/.test(e) && e !== conv.assignee))]
      conv.lastHumanDate = todayET(); saveToDisk()
      sse.broadcast('conversations', 'conv-claimed', { token, assignee: conv.assignee, assistants: conv.assistants || [] })
      return json(res, 200, { ok: true, assignee: conv.assignee, assistants: conv.assistants || [] })
    }
    // ÉTAT « ticket » : open | pending (avec date d'attente) | closed. Reflété sur le ticket lié (Open/On Hold/Closed + rappel) s'il existe.
    if (sub === 'state' && method === 'POST') {
      const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
      const b = await parseBody(req)
      const st = ['open', 'pending', 'closed'].includes(b.state) ? b.state : 'open'
      conv.ticketState = st
      conv.pendingUntil = (st === 'pending' && b.pendingUntil) ? String(b.pendingUntil).slice(0, 10) : null
      conv.status = (st === 'closed') ? 'closed' : 'active' // fermé = quitte la file active ; ouvert/attente = actif
      conv.lastHumanDate = todayET(); saveToDisk()
      if (conv.linkedTickets && conv.linkedTickets.length) { // reflète sur le ticket ERPNext lié
        const issue = conv.linkedTickets[0].name
        const data = { status: st === 'closed' ? 'Closed' : st === 'pending' ? 'On Hold' : 'Open' }
        if (st === 'pending' && conv.pendingUntil) data.custom_reminder_date = conv.pendingUntil
        try { await require('./erp').update('Issue', issue, data) } catch (e) { log('state→ticket ' + issue + ': ' + e.message) }
      }
      sse.broadcast('conversations', 'conv-state', { token, ticketState: st, pendingUntil: conv.pendingUntil, status: conv.status })
      return json(res, 200, { ok: true, ticketState: st, pendingUntil: conv.pendingUntil, status: conv.status })
    }
    // SPAM → marque le(s) courriel(s) SPAM côté Gmail (Gmail apprend + sort de la boîte) puis retire la conversation de l'Inbox.
    if (sub === 'spam' && method === 'POST') {
      const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
      let marked = 0
      if (conv.channel === 'email') {
        const gmail = require('./gmail')
        const ids = (conv._gmailIds && conv._gmailIds.length) ? conv._gmailIds : (conv.lastGmailId ? [conv.lastGmailId] : [])
        for (const id of ids) { try { await gmail.markSpam(id); marked++ } catch (e) { log('markSpam ' + id + ': ' + e.message) } }
      }
      conversations.delete(token); store.remove(token).catch(() => {}); saveToDisk() // SPAM côté Gmail = le dossier Spam EST l'archive ; on ne met PAS à la corbeille
      sse.broadcast('conversations', 'conv-deleted', { token, spam: true })
      log(`Conv ${token} → SPAM (${marked} courriel(s) marqués) + retirée de l'Inbox`)
      return json(res, 200, { ok: true, spammed: marked })
    }
    if (sub === 'queue' && method === 'POST') {
      const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
      const b = await parseBody(req); conv.queue = String(b.queue || ''); saveToDisk()
      sse.broadcast('conversations', 'conv-queue', { token, queue: conv.queue })
      if (conv.queue && !conv.assignee) notifyQueue(conv).catch(() => {}) // routage manuel → pousse aux membres de la file
      return json(res, 200, { ok: true, queue: conv.queue })
    }
    if (sub === 'note' && method === 'POST') {
      const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
      const b = await parseBody(req); const text = String(b.text || '').trim(); if (!text) return json(res, 400, { error: 'text requis' })
      const agent = req.headers['x-authentik-email'] || 'agent'
      conv.notes = conv.notes || []; const note = { id: crypto.randomUUID(), agent, text, ts: new Date().toISOString() }
      conv.notes.push(note); conv.lastHumanDate = todayET(); saveToDisk()
      sse.broadcast('conversations', 'conv-note', { token, note }) // note INTERNE (jamais envoyée au client) → remplace le reply-all « je m'en occupe »
      return json(res, 200, { ok: true, note })
    }
    if (sub === 'note' && method === 'PATCH') { // corriger une note (faute de frappe…)
      const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
      const b = await parseBody(req); const id = String(b.id || ''); const text = String(b.text || '').trim()
      if (!id || !text) return json(res, 400, { error: 'id + text requis' })
      const note = (conv.notes || []).find(n => n.id === id); if (!note) return json(res, 404, { error: 'Note introuvable' })
      note.text = text; note.edited = new Date().toISOString(); saveToDisk()
      sse.broadcast('conversations', 'conv-note-upd', { token, note })
      return json(res, 200, { ok: true, note })
    }
    if (sub === 'note' && method === 'DELETE') { // supprimer une note (erreur)
      const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
      const b = await parseBody(req); const id = String(b.id || ''); if (!id) return json(res, 400, { error: 'id requis' })
      const before = (conv.notes || []).length
      conv.notes = (conv.notes || []).filter(n => n.id !== id)
      if (conv.notes.length === before) return json(res, 404, { error: 'Note introuvable' })
      saveToDisk()
      sse.broadcast('conversations', 'conv-note-del', { token, id })
      return json(res, 200, { ok: true, id })
    }
    if (sub === 'nl' && method === 'POST') { // commande en langage naturel (copilote)
      const b = await parseBody(req)
      const r = await nlCommand(token, b.command || '', req.headers['x-authentik-email'] || '')
      return json(res, r.ok ? 200 : 400, r)
    }
    if (sub === 'pipeline' && method === 'POST') { // entrer/déplacer/retirer un lead dans le pipeline
      const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
      const b = await parseBody(req)
      conv.pipeline = conv.pipeline || {}
      if (b.stage != null) conv.pipeline.stage = String(b.stage)
      if (b.value != null) conv.pipeline.value = Number(b.value) || 0
      conv.pipeline.updatedAt = new Date().toISOString()
      if (!conv.pipeline.stage) conv.pipeline = null // étape vide = retiré du pipeline
      saveToDisk()
      sse.broadcast('conversations', 'conv-pipeline', { token, pipeline: conv.pipeline })
      return json(res, 200, { ok: true, pipeline: conv.pipeline })
    }

    // Rattacher la conversation à une fiche client choisie (résout le cas multi-fiches).
    if (sub === 'link' && method === 'POST') {
      const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
      const b = await parseBody(req)
      if (b.unlink || b.customer === '') { // délier la fiche → réaffiche « Lier une fiche »
        conv.customer = ''; conv.customerName = ''; conv.candidates = null; saveToDisk()
        sse.broadcast('conversations', 'conv-update', { token: conv.token, customer: '', customerName: '' })
        return json(res, 200, { ok: true, customer: '', customerName: '' })
      }
      if (!b.customer) return json(res, 400, { error: 'customer requis' })
      conv.customer = String(b.customer); conv.customerName = b.customerName || String(b.customer); conv.candidates = null
      // Compte lié → on suggère de PROMOUVOIR le fil en ticket (le bouton apparaît dans l'Inbox ; clic = import du fil + réponses [Ticket #N]).
      if (!conv.suggestedTicket && !(conv.linkedTickets && conv.linkedTickets.length)) {
        const lastCust = [...(conv.messages || [])].reverse().find(m => m.from === 'customer')
        conv.suggestedTicket = { title: conv.subject || (lastCust && String(lastCust.text || '').replace(/^✉️[^\n]*\n+/, '').slice(0, 90)) || 'Demande client', category: 'Support', priority: 'Medium', reason: 'Compte client lié — promouvoir le fil en ticket', ts: new Date().toISOString() }
      }
      saveToDisk()
      sse.broadcast('conversations', 'conv-update', { token: conv.token, customer: conv.customer, customerName: conv.customerName })
      return json(res, 200, { ok: true, token: conv.token, customer: conv.customer, customerName: conv.customerName })
    }

    // LIER (interdépendances omnichannel, style ClickUp) : relie CE fil à un autre fil / une fiche client / un ticket.
    // Stocké sur `conv.links` (persisté via PG comme le reste). Lien conv↔conv = bidirectionnel. `remove:true` retire.
    if (sub === 'relate' && method === 'POST') {
      const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
      const b = await parseBody(req)
      const type = String(b.targetType || '').trim() // 'conversation' | 'customer' | 'ticket'
      const id = String(b.targetId || '').trim()
      if (!type || !id) return json(res, 400, { error: 'targetType + targetId requis' })
      const by = req.headers['x-authentik-email'] || null
      const rel = String(b.rel || 'related')
      const sameKey = (l) => l.type === type && String(l.id) === id
      conv.links = Array.isArray(conv.links) ? conv.links : []
      if (b.remove) {
        conv.links = conv.links.filter(l => !sameKey(l))
        if (type === 'conversation') { const o = getConversation(id); if (o && Array.isArray(o.links)) o.links = o.links.filter(l => !(l.type === 'conversation' && String(l.id) === token)) }
      } else {
        if (!conv.links.some(sameKey)) conv.links.push({ type, id, label: String(b.targetLabel || '').slice(0, 120), rel, by, at: new Date().toISOString() })
        if (type === 'conversation') { // miroir bidirectionnel : l'autre fil pointe aussi vers celui-ci
          const o = getConversation(id)
          if (o) { o.links = Array.isArray(o.links) ? o.links : []; if (!o.links.some(l => l.type === 'conversation' && String(l.id) === token)) o.links.push({ type: 'conversation', id: token, label: (conv.customerName || conv.email || 'Conversation'), rel, by, at: new Date().toISOString() }) }
        }
      }
      saveToDisk()
      sse.broadcast('conv:' + conv.token, 'conv-update', { token: conv.token })
      sse.broadcast('conversations', 'conv-update', { token: conv.token })
      return json(res, 200, { ok: true, links: conv.links })
    }

    // PRIORITÉ (style ClickUp) : urgent | high | normal | low | '' (aucune). Sur conv.priority (persiste via PG meta).
    if (sub === 'priority' && method === 'POST') {
      const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
      const b = await parseBody(req)
      const p = ['high', 'medium', 'low', 'urgent', 'normal', ''].includes(b.priority) ? b.priority : '' // 3 niveaux (high/medium/low) ; urgent/normal gardés pour rétro-compat
      conv.priority = p; saveToDisk()
      const payload = { token: conv.token, priority: p }
      sse.broadcast('conv:' + conv.token, 'conv-update', payload)
      sse.broadcast('conversations', 'conv-update', payload)
      return json(res, 200, { ok: true, priority: p })
    }

    // Présence agent : signale que CET agent (Authentik) est en train d'écrire → visible par les AUTRES agents OPS.
    if (sub === 'typing' && method === 'POST') {
      const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
      const agentEmail = req.headers['x-authentik-email'] || null
      const sid = (url && url.searchParams.get('sid')) || null // identifiant d'onglet (filtre côté client)
      if (agentEmail) {
        const payload = { token: conv.token, agent: agentEmail, sid, customer: conv.customer, customerName: conv.customerName }
        sse.broadcast('conv:' + conv.token, 'conv-agent-typing', payload)
        sse.broadcast('conversations', 'conv-agent-typing', payload)
      }
      return json(res, 200, { ok: true })
    }

    // « Vu par » : marque que CET agent a ouvert/lu la conversation → avatars chez les autres agents.
    if (sub === 'read' && method === 'POST') {
      const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
      const agentEmail = req.headers['x-authentik-email'] || null
      if (agentEmail) {
        const lastTs = conv.messages.length ? conv.messages[conv.messages.length - 1].ts : null
        conv.readBy = conv.readBy || {}
        conv.readBy[agentEmail] = { readAt: new Date().toISOString(), lastTs }
        saveToDisk()
        const payload = { token: conv.token, agent: agentEmail, readAt: conv.readBy[agentEmail].readAt, lastTs }
        sse.broadcast('conv:' + conv.token, 'conv-read', payload)
        sse.broadcast('conversations', 'conv-read', payload)
      }
      return json(res, 200, { ok: true, readBy: conv.readBy || {} })
    }

    // Brouillon partagé (miroir temps réel) : un agent rédige, les autres voient le texte en direct.
    // Dernier qui écrit gagne (pas de co-édition simultanée). Vidé quand html/text sont vides (envoi/annulation).
    if (sub === 'draft' && method === 'POST') {
      const conv = getConversation(token); if (!conv) return json(res, 404, { error: 'Conversation not found' })
      const agentEmail = req.headers['x-authentik-email'] || null
      const sid = (url && url.searchParams.get('sid')) || null
      const body = await parseBody(req)
      const html = String(body.html || '')
      const text = String(body.text || '')
      const replyTo = body.replyTo ? String(body.replyTo) : null // id du message PRÉCIS auquel le brouillon répond → ré-ancrage dans le fil
      conv.draft = (html.trim() || text.trim()) ? { agent: agentEmail, sid, html, text, replyTo, ts: new Date().toISOString() } : null
      saveToDisk()
      const payload = { token: conv.token, agent: agentEmail, sid, draft: conv.draft }
      sse.broadcast('conv:' + conv.token, 'conv-draft', payload)
      sse.broadcast('conversations', 'conv-draft', payload)
      return json(res, 200, { ok: true })
    }

    if (sub === 'messages' && method === 'POST') {
      const conv = getConversation(token)
      if (!conv) return json(res, 404, { error: 'Conversation not found or expired' })
      const body = await parseBody(req)
      const text = (body.text || '').trim()
      const media = body.media || ''
      const html = body.html || '' // réponse WYSIWYG (canal email)
      const attachments = Array.isArray(body.attachments) ? body.attachments : []
      if (!text && !media && !html && !attachments.length) return json(res, 400, { error: 'Missing text or media' })
      const cc = String(body.cc || '').trim()              // Cc éditable sur la réponse (« répondre à tous »)
      const toOverride = String(body.to || '').trim()      // À éditable (défaut = client si vide)
      const agentEmail = req.headers['x-authentik-email']
      const from = agentEmail ? 'agent' : 'customer'
      const isEmail = conv.channel === 'email'
      // Nom complet canonique de l'agent (Authentik) → le fil montre QUEL collègue a répondu/transféré.
      const agentName = from === 'agent' ? await require('./auth').getDisplayNameByEmail(agentEmail).catch(() => '') : ''
      if (from === 'agent') conv.lastHumanDate = todayET() // humain (agent OPS) actif aujourd'hui → l'IA se tait ce jour (posé AVANT addMessage pour être persisté par son saveToDisk)
      const msg = addMessage(conv, { from, text, via: isEmail ? 'email' : 'web', type: media ? 'image' : 'text', media, html, agent: agentEmail || '', agentName, attachments, sendAs: body.sendAs || '', toRaw: (from === 'agent' && isEmail) ? (toOverride || conv.email || '') : '', ccRaw: cc })
      if (from === 'agent') { msg.cc = cc; if (toOverride) msg.replyToOverride = toOverride; msg.notifiedVia = await notifyCustomer(conv, msg) } // email → envoi HTML dans le fil (To/Cc inclus) ; sinon push/SMS
      if (from === 'customer' && conv.customer && !isEmail) triggerAgent(conv) // pas d'auto-réponse IA sur courriel
      if (isEmail && conv.linkedTickets && conv.linkedTickets.length) logToLinkedTicket(conv, msg, conv.lastSubject).catch(() => {}) // chaîne : alimente le ticket lié (Issue ERPNext)
      if (from === 'agent' && conv.linkedTickets && conv.linkedTickets.some(t => t && t.legacy_ticket_id)) mirrorAgentReplyToLegacyTicket(conv, msg, agentEmail).catch(() => {}) // P2 : miroir réponse → fil osTicket legacy (F sync)
      if (conv.customer) {
        createCommunication({
          communication_type: 'Communication', communication_medium: isEmail ? 'Email' : 'Chat',
          sent_or_received: from === 'agent' ? 'Sent' : 'Received',
          sender: from === 'agent' ? (agentEmail || (isEmail ? (cfg.MAIL_FROM || 'cc@targointernet.com') : 'sms@gigafibre.ca')) : (isEmail ? (conv.email || '') : conv.phone),
          sender_full_name: from === 'agent' ? 'Targo Ops' : conv.customerName,
          phone_no: conv.phone, content: (html || text || '') + (media ? '\n[image jointe]' : ''), subject: isEmail ? (conv.lastSubject || conv.subject) : `Chat: ${conv.subject}`,
          reference_doctype: 'Customer', reference_name: conv.customer, status: 'Linked',
        }).catch(() => {})
      }
      return json(res, 201, { ok: true, message: msg })
    }

    if (sub === 'push' && method === 'POST') {
      const body = await parseBody(req)
      if (!body.endpoint || !body.keys) return json(res, 400, { error: 'Invalid push subscription' })
      const ok = registerPush(token, body)
      return json(res, ok ? 200 : 404, ok ? { ok: true } : { error: 'Conversation not found' })
    }

    if (sub === 'close' && method === 'POST') {
      const conv = getConversation(token)
      if (!conv) return json(res, 404, { error: 'Conversation not found' })
      conv.status = 'closed'
      addMessage(conv, { from: 'system', text: 'Conversation terminée', via: 'web' })
      saveToDisk()
      sse.broadcast('conversations', 'conv-closed', { token: conv.token })
      return json(res, 200, { ok: true })
    }

    if (sub === 'sse' && method === 'GET') {
      const conv = getConversation(token)
      if (!conv) return json(res, 404, { error: 'Conversation not found' })
      res.writeHead(200, {
        'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache',
        Connection: 'keep-alive', 'X-Accel-Buffering': 'no',
      })
      res.write(': connected\n\n')
      sse.addClient(['conv-client:' + conv.token], res, 'customer:' + conv.phone)
      const keepalive = setInterval(() => { try { res.write(': ping\n\n') } catch { clearInterval(keepalive) } }, 25000)
      res.on('close', () => clearInterval(keepalive))
      return
    }
  }

  return json(res, 404, { error: 'Not found' })
}

module.exports = {
  handle,
  createTicketFromConversation,
  ingestEmail,
  channelIngest,
  sendNewEmail,
  sendSms,
  nlCommand,
  detectSplitTopics,
  splitConversation,
  mergeConversations,
  analyzePaymentProof,
  loadCanned,
  saveCanned,
  listConversations,
  createConversation,
  createDraftConversation,
  getOrCreateConvForLegacyTicket,
  getOrCreateConvForJob,
  findConvByJob,
  getConversation,
  addMessage,
  findConversationByPhone,
  autoLinkIncomingSms,
  triggerAgent,
  notifyCustomer,
  deleteConversation,
  deleteDiscussion,
  archiveDiscussion,
}
