'use strict'
// ── Collaboration légère sur un document ERPNext (ticket Issue, etc.) ───────
// Objectif : intervenir VITE (indice, note, mention d'un collègue) SANS toucher l'assignation (_assign intact).
// Stocké comme Comment ERPNext (visible aussi dans Desk) ; mention = notification courriel au collègue. UI = wizard épuré côté OPS.
const cfg = require('./config')
const { log, json, parseBody, lookupCustomersByPhone, lookupCustomersByEmail, readJsonFile } = require('./helpers')
const erp = require('./erp')

// Mots « problème » (jamais un nom → toujours retirés) vs mots « rue » (retirés SEULEMENT en contexte adresse,
// pour ne pas amputer un nom comme « Saint-Pierre »).
const PROBLEM = /\b(probl[eè]me|panne|wi-?fi|internet|t[ée]l[ée]vision|t[ée]l[ée]phone|t[ée]l[ée]|lent[e]?|ralenti|coup[ée]+|d[ée]connect|bug|app|appartement)\b/gi
const STREET = /\b(rue|av|ave|avenue|boul|boulevard|ch|chemin|st|ste|saint|sainte|qc|no)\b/gi
// Normalise pour le fuzzy : minuscules + sans accents + sans séparateurs (« Louis-Paul » = « Louis-paul » = « LouisPaul »).
const normName = (s) => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]/g, '')

// Recherche client UNIFIÉE depuis un texte libre : téléphone (10 ch.), no civique (→ adresse via Service Location),
// et/ou nom (→ Customer). Insensible à la casse/accents (unaccent). Renvoie des candidats classés pour l'autosuggest.
async function searchCustomers (q) {
  const s = String(q || '').trim()
  if (s.length < 2) return []
  const nums = s.match(/\d+/g) || []
  const phone = nums.find(n => n.length >= 10)
  const civic = nums.find(n => n.length >= 1 && n.length <= 6 && n !== phone)
  let text = s.replace(/\d+/g, ' ').replace(PROBLEM, ' ')
  if (civic) text = text.replace(STREET, ' ') // contexte adresse → on retire « rue/st/… » pour isoler le nom ; sinon on garde (« Saint-Pierre »)
  text = text.replace(/[^\p{L}\s'-]/gu, ' ').replace(/\s+/g, ' ').trim()
  const found = new Map() // name -> { matched, address? }
  // 1) Téléphone (REST multi-champs)
  if (phone) { try { for (const c of await lookupCustomersByPhone(phone, 6)) if (!found.has(c.name)) found.set(c.name, { matched: 'téléphone' }) } catch (e) { /* */ } }
  // 1b) Courriel — la requête ressemble à une adresse e-mail
  if (/.+@.+/.test(s)) { try { for (const c of await lookupCustomersByEmail(s, 6)) if (!found.has(c.name)) found.set(c.name, { matched: 'courriel' }) } catch (e) { /* */ } }
  let p = null
  try { p = require('./address-db').pool() } catch (e) { /* address-db indispo */ }
  let addrResolved = false // adresse civique résolue → on NE mélange PAS les homonymes (fuzzy nom) : « 2338 rue X » = LE résident, pas les noms qui se ressemblent.
  // 2) No civique → adresse (Service Location → client). Typo de dictée toléré : si rien, RQA (trigram) canonicalise la rue puis on ré-essaie.
  if (p && civic && found.size < 12) {
    const slByAddr = async (toks) => {
      const cond = toks.length ? ' AND unaccent(lower(coalesce(sl.address_line,\'\'))) LIKE unaccent(lower($2))' : ''
      const r = await p.query(
        `SELECT DISTINCT sl.name, sl.customer, sl.address_line, sl.city FROM "tabService Location" sl
         WHERE sl.customer IS NOT NULL AND sl.address_line ILIKE $1 ${cond} ORDER BY sl.address_line LIMIT 8`,
        toks.length ? ['%' + civic + '%', '%' + toks.join('%') + '%'] : ['%' + civic + '%'])
      return r.rows
    }
    try {
      let street = text.split(/[\s-]+/).filter(w => w.length >= 3).slice(0, 2)
      let rows = await slByAddr(street)
      // Rue mal orthographiée / dictée (« Vinette » vs « Vinet ») → RQA fuzzy trouve la vraie rue → on ré-essaie. Seulement si la requête ressemble à une adresse (mot « rue/rang/… »).
      if (!rows.length && /\b(rue|av|ave|avenue|boul|boulevard|ch|chemin|rang|mont|place|impasse|croissant|terrasse|c[oô]te)\b/i.test(s)) {
        try { const canon = (await require('./address-db').searchRaw(s, 1))[0]; const rue = canon && (canon.rue || canon.odonyme_recompose_normal); if (rue) { const t = String(rue).split(/[\s-]+/).filter(w => w.length >= 3).slice(0, 2); if (t.length) rows = await slByAddr(t) } } catch (e) { /* RQA indispo */ }
      }
      for (const row of rows) if (row.customer && !found.has(row.customer)) found.set(row.customer, { matched: 'adresse', address: [row.address_line, row.city].filter(Boolean).join(', '), service_location: row.name }) // lie la BONNE adresse de service (pas la facturation)
      if (rows.length) addrResolved = true // → on saute la recherche par NOM (homonymes) plus bas
    } catch (e) { log('searchCustomers addr: ' + e.message) }
  }
  // 3) Nom (Customer) — FUZZY : normalisé (sans séparateurs/accents/casse) + similarité trigramme (score façon Google, tolère typos).
  if (p && found.size < 12 && !addrResolved) {
    const qn = normName(text)
    if (qn.length >= 3) {
      const NORM = "regexp_replace(unaccent(lower(customer_name)), '[^a-z0-9]', '', 'g')"
      let rows = []
      try {
        // word_similarity($q, nom) = meilleure sous-chaîne → « bourdn » matche « andrebourdon ». Seuil explicite (pas de GUC).
        // On INCLUT les clients désactivés (anciens / réactivation) mais on classe les ACTIFS d'abord (+ badge « inactif » côté UI).
        rows = (await p.query(
          `SELECT name, customer_name, word_similarity($1, ${NORM}) AS score FROM "tabCustomer"
           WHERE (${NORM} LIKE '%'||$1||'%' OR word_similarity($1, ${NORM}) > 0.4)
           ORDER BY (${NORM} LIKE '%'||$1||'%') DESC, coalesce(disabled,0) ASC, score DESC NULLS LAST LIMIT 12`, [qn])).rows
      } catch (e) {
        // Repli si pg_trgm indisponible : sous-chaîne normalisée seule.
        try { rows = (await p.query(`SELECT name, customer_name FROM "tabCustomer" WHERE ${NORM} LIKE '%'||$1||'%' ORDER BY coalesce(disabled,0) ASC, customer_name LIMIT 8`, [qn])).rows } catch (e2) { log('searchCustomers name: ' + e2.message) }
      }
      for (const row of rows) if (!found.has(row.name)) found.set(row.name, { matched: 'nom', score: row.score != null ? Math.round(row.score * 100) / 100 : null })
    }
  }
  // 3b) Mandataire / contact détaillé (titulaire ≠ demandeur, ex. représentant qui paie/écrit). FUZZY sur les champs legacy.
  //     try/catch : si les colonnes custom n'existent pas encore, on ignore silencieusement (pas de régression).
  if (p && found.size < 12 && !addrResolved) {
    const qn = normName(text)
    if (qn.length >= 3) {
      const NORMM = "regexp_replace(unaccent(lower(coalesce(mandataire,'') || ' ' || coalesce(contact_name_legacy,''))), '[^a-z0-9]', '', 'g')"
      try {
        const rows = (await p.query(
          `SELECT name, customer_name, word_similarity($1, ${NORMM}) AS score FROM "tabCustomer"
           WHERE (${NORMM} LIKE '%'||$1||'%' OR word_similarity($1, ${NORMM}) > 0.45)
           ORDER BY (${NORMM} LIKE '%'||$1||'%') DESC, coalesce(disabled,0) ASC, score DESC NULLS LAST LIMIT 6`, [qn])).rows
        for (const row of rows) if (!found.has(row.name)) found.set(row.name, { matched: 'mandataire', score: row.score != null ? Math.round(row.score * 100) / 100 : null })
      } catch (e) { log('searchCustomers mandataire: ' + e.message) }
    }
  }
  if (!found.size) return []
  // Enrichissement contacts (1 requête) : courriel + téléphones → résumé + « SMS dispo ? » (mobile présent).
  const names = [...found.keys()].slice(0, 12)
  let rows = []
  if (p) { try { rows = (await p.query('SELECT name, customer_name, email_id, mobile_no, cell_phone, tel_home, tel_office, territory, disabled FROM "tabCustomer" WHERE name = ANY($1)', [names])).rows } catch (e) { log('searchCustomers enrich: ' + e.message) } }
  return names.map(n => {
    const r = rows.find(x => x.name === n) || {}; const f = found.get(n)
    const mobile = r.mobile_no || r.cell_phone || ''
    return {
      name: n, customer_name: r.customer_name || n, email: r.email_id || '',
      mobile, phone: mobile || r.tel_home || r.tel_office || '', can_sms: !!mobile,
      territory: r.territory || '', address: f.address || '', matched: f.matched, inactive: !!r.disabled,
      service_location: f.service_location || null,
    }
  })
}

// Recherche MEMBRES DE L'ÉQUIPE (System Users ERPNext sur nos domaines @targo.ca / @targointernet.com) — pour choisir
// VITE un collègue comme destinataire To/Cc, le taguer ou l'assigner. Renvoyés AVANT les clients : un employé cherche
// d'abord un collègue (« michel » → Michel Blais, sans devoir préciser « b »). FUZZY (typo-tolérant) comme les clients.
// On scope STRICTEMENT à user_type='System User' : tabUser contient AUSSI tous les clients (Website User) → sinon « michel » noyé.
async function searchTeam (q) {
  const s = String(q || '').trim(); if (s.length < 2) return []
  const qn = normName(s); if (qn.length < 2) return []
  let p = null; try { p = require('./address-db').pool() } catch (e) { return [] }
  if (!p) return []
  const NORM = "regexp_replace(unaccent(lower(coalesce(full_name,'') || ' ' || name)), '[^a-z0-9]', '', 'g')"
  let rows = []
  try {
    rows = (await p.query(
      `SELECT name, full_name, word_similarity($1, ${NORM}) AS score FROM "tabUser"
       WHERE enabled = 1 AND user_type = 'System User'
         AND (lower(name) LIKE '%@targo.ca' OR lower(name) LIKE '%@targointernet.com')
         AND (${NORM} LIKE '%'||$1||'%' OR word_similarity($1, ${NORM}) > 0.35)
       ORDER BY (${NORM} LIKE '%'||$1||'%') DESC, score DESC NULLS LAST LIMIT 8`, [qn])).rows
  } catch (e) {
    try { rows = (await p.query(`SELECT name, full_name FROM "tabUser" WHERE enabled=1 AND user_type='System User' AND (lower(name) LIKE '%@targo.ca' OR lower(name) LIKE '%@targointernet.com') AND ${NORM} LIKE '%'||$1||'%' ORDER BY full_name LIMIT 8`, [qn])).rows } catch (e2) { log('searchTeam: ' + e2.message) }
  }
  return rows.map(r => ({ name: r.name, customer_name: r.full_name || r.name, email: r.name, matched: 'équipe', kind: 'équipe', can_sms: false }))
}

// Recherche TECHNICIEN par nom (fuzzy, comme les clients) → pour texter un tech par la voix (« texto au technicien Marc »).
async function searchTechnicians (q) {
  let text = String(q || '').replace(/\b(technicien|technicienne|tech)\b/gi, ' ').replace(/\s+/g, ' ').trim()
  const qn = normName(text); if (qn.length < 2) return []
  let p = null; try { p = require('./address-db').pool() } catch (e) { return [] }
  if (!p) return []
  const NORM = "regexp_replace(unaccent(lower(full_name)), '[^a-z0-9]', '', 'g')"
  let rows = []
  try {
    rows = (await p.query(`SELECT name, full_name, phone, word_similarity($1, ${NORM}) AS score FROM "tabDispatch Technician"
       WHERE (${NORM} LIKE '%'||$1||'%' OR word_similarity($1, ${NORM}) > 0.4)
       ORDER BY (${NORM} LIKE '%'||$1||'%') DESC, score DESC NULLS LAST LIMIT 6`, [qn])).rows
  } catch (e) { try { rows = (await p.query(`SELECT name, full_name, phone FROM "tabDispatch Technician" WHERE ${NORM} LIKE '%'||$1||'%' LIMIT 6`, [qn])).rows } catch (e2) { log('searchTechnicians: ' + e2.message) } }
  return rows.map(r => { const ph = String(r.phone || '').replace(/\D/g, ''); return { name: r.name, customer_name: r.full_name, phone: ph, can_sms: ph.length >= 10, kind: 'technicien' } })
}

// Destinataire = clients ∪ techniciens (pour les SMS/courriels orchestrés). Techs d'abord (contexte « texto au tech »).
async function searchRecipients (q) {
  const [techs, custs] = await Promise.all([searchTechnicians(q), searchCustomers(q)])
  const out = []
  for (const t of techs) out.push(t)
  for (const c of custs) out.push({ ...c, kind: c.kind || 'client' })
  return out.slice(0, 6)
}

// Liste les adresses de service d'un compte (un compte entreprise en a plusieurs) → pour choisir la bonne.
// Adresses de service d'un compte, TRIÉES : celles avec un service ACTIF d'abord, puis par
// service actif le plus RÉCENT (start_date), puis création. → la 1re ligne = l'adresse à pré-sélectionner.
async function serviceLocations (customer) {
  if (!customer) return []
  let p = null; try { p = require('./address-db').pool() } catch (e) { return [] }
  if (!p) return []
  try {
    const r = await p.query(`
      SELECT sl.name, sl.address_line, sl.city, sl.status,
             MAX(CASE WHEN ss.status='Actif' THEN 1 ELSE 0 END) AS has_active,
             MAX(ss.start_date) FILTER (WHERE ss.status='Actif') AS last_active
      FROM "tabService Location" sl
      LEFT JOIN "tabService Subscription" ss ON ss.service_location = sl.name AND ss.customer = sl.customer
      WHERE sl.customer = $1
      GROUP BY sl.name, sl.address_line, sl.city, sl.status, sl.creation
      ORDER BY has_active DESC, last_active DESC NULLS LAST, sl.creation DESC
      LIMIT 50`, [customer])
    return r.rows.map(x => ({ name: x.name, address: [x.address_line, x.city].filter(Boolean).join(', '), status: x.status || '', active: !!Number(x.has_active) }))
  } catch (e) { log('serviceLocations: ' + e.message); return [] }
}

// Adresse de service par DÉFAUT = la plus récente ACTIVE (1re ligne de serviceLocations).
async function defaultServiceLocation (customer) {
  const locs = await serviceLocations(customer)
  return locs.length ? locs[0].name : null
}

// ── STATUT DU SERVICE (lecture seule, pendant « lecture » du Do Stuff de F) ─────────────
// Qualité du signal optique GPON (Rx power, dBm) — mêmes seuils que useDeviceStatus.signalQuality.
function signalFrom (rx) {
  if (rx == null || isNaN(rx)) return null
  if (rx > -8) return 'excellent'; if (rx > -20) return 'bon'; if (rx > -25) return 'faible'; return 'critique'
}
// Combine TR-069 (canal de gestion) + OLT SNMP (fibre, AUTORITAIRE) → statut définitif.
// Miroir serveur de useDeviceStatus.combinedStatus : l'OLT prime, TR-069 = repli si pas de donnée OLT.
function combineDeviceStatus (summary, onu) {
  const li = summary && summary.lastInform ? new Date(summary.lastInform).getTime() : null
  const tr069 = li ? (Date.now() - li) < 15 * 60 * 1000 : null
  const oltOnline = onu ? (onu.status === 'online') : null
  let online, source, detail
  if (oltOnline === true) { online = true; source = tr069 === true ? 'both' : 'olt'; detail = tr069 === true ? 'TR-069 + Fibre OK' : 'Fibre OK · TR-069 inactif' }
  else if (oltOnline === false) { online = false; source = 'olt'; detail = tr069 === true ? 'Fibre coupée · TR-069 résiduel' : 'Fibre hors ligne' }
  else if (tr069 === true) { online = true; source = 'tr069'; detail = 'TR-069 actif · fibre non vérifiée' }
  else if (tr069 === false) { online = false; source = 'tr069'; detail = 'TR-069 inactif · fibre non vérifiée' }
  else { online = null; source = 'unknown'; detail = 'Aucune donnée' }
  const rx = onu && onu.rxPower != null ? Number(onu.rxPower) : (summary && summary.rxPower != null ? Number(summary.rxPower) : null)
  return {
    online, source, detail,
    label: online === true ? 'En ligne' : online === false ? 'Hors ligne' : 'Inconnu',
    rxPower: rx != null && !isNaN(rx) ? rx : null, signal: signalFrom(rx),
    lastInform: (summary && summary.lastInform) || null,
    minutesAgo: li ? Math.floor((Date.now() - li) / 60000) : null,
    wifiClients: summary && summary.wifi ? (summary.wifi.totalClients || 0) : null,
    hostsCount: summary && summary.hostsCount != null ? summary.hostsCount : null,
    uptime: (summary && summary.uptime) || null,
    ip: (summary && summary.ip) || null,
    model: summary ? [summary.manufacturer, summary.model].filter(Boolean).join(' ').trim() : '',
    offlineCause: (onu && onu.lastOfflineCause) || null,
    oltName: (onu && onu.oltName) || null, oltPort: onu && onu.port != null ? onu.port : null,
  }
}
// Statut « service + modem » par IDENTIFIANT LIBRE (adresse | nom | téléphone | courriel).
// Même paradigme que la création de ticket en langage naturel, mais en LECTURE : résout le compte →
// adresse de service active la plus récente → équipement(s) → statut TR-069 + OLT (fibre). Aucune dépendance à F.
async function serviceStatus ({ q, customer, location } = {}) {
  const devices = require('./devices')
  let olt = null; try { olt = require('./olt-snmp') } catch (e) { /* SNMP indispo */ }
  // 1) Résolution du compte (courriel → lookupCustomersByEmail ; sinon nom/téléphone/adresse → searchCustomers)
  let cust = customer || null, matches = []
  if (!cust) {
    const s = String(q || '').trim()
    if (!s) return { ok: false, error: 'identifiant requis (adresse, nom, téléphone ou courriel)' }
    if (/.+@.+\..+/.test(s)) {
      try { matches = (await lookupCustomersByEmail(s, 6)).map(m => ({ name: m.name, customer_name: m.customer_name || m.name, email: m.email || s, phone: m.phone || '', matched: 'courriel' })) } catch (e) { matches = [] }
    } else {
      try { matches = await searchCustomers(s) } catch (e) { matches = [] }
    }
    matches = (matches || []).filter(m => m && m.name)
    if (!matches.length) return { ok: true, found: false, matches: [], query: s }
    if (matches.length > 1) return { ok: true, found: false, ambiguous: true, matches: matches.slice(0, 6), query: s }
    cust = matches[0].name
  }
  // En-tête client (depuis le match, sinon fiche ERPNext si le compte est passé explicitement)
  let head = matches[0] || null
  if (!head && cust) { try { const c = await erp.get('Customer', cust); if (c) head = { name: cust, customer_name: c.customer_name || cust, email: c.email_id || '', phone: c.mobile_no || c.cell_phone || '' } } catch (e) { /* */ } }
  // 2) Adresse de service (fournie, sinon active la plus récente)
  const locs = await serviceLocations(cust)
  const loc = location || (locs.length ? locs[0].name : null)
  const locInfo = locs.find(l => l.name === loc) || (loc ? { name: loc, address: loc } : null)
  // 3) Équipement(s) à cette adresse (repli : tout l'équipement du compte si rien à l'adresse)
  const EQF = ['name', 'serial_number', 'mac_address', 'ip_address', 'brand', 'model', 'status', 'service_location', 'olt_ip']
  let equip = []
  try {
    const f = [['customer', '=', cust]]; if (loc) f.push(['service_location', '=', loc])
    equip = await erp.list('Service Equipment', { filters: f, fields: EQF, limit: 6 }) || []
    if (!equip.length && loc) equip = await erp.list('Service Equipment', { filters: [['customer', '=', cust]], fields: EQF, limit: 6 }) || []
  } catch (e) { log('serviceStatus equip ' + cust + ': ' + e.message) }
  // 4) Statut par équipement (TR-069 via cache/ACS + OLT SNMP autoritaire)
  const devs = []
  for (const eq of equip.slice(0, 4)) {
    const serial = eq.serial_number || null
    let summary = null
    if (serial) {
      try { const c = devices.getCached(serial); summary = (c && c.summary && c.summary.lastInform) ? c.summary : await devices.fetchDeviceDetails(serial) } catch (e) { /* */ }
    }
    let onu = null; if (serial && olt) { try { onu = olt.getOnuBySerial(serial) } catch (e) { /* */ } }
    devs.push({ serial, mac: eq.mac_address || null, olt: eq.olt_ip || null, brand: eq.brand || '', equipmentStatus: eq.status || '', service_location: eq.service_location || null, ...combineDeviceStatus(summary, onu), resolved: !!(summary || onu) })
  }
  // 5) Abonnement(s) service à l'adresse (moitié « service » : actif/suspendu + forfait)
  let subs = []
  try {
    const f = [['customer', '=', cust]]; if (loc) f.push(['service_location', '=', loc])
    subs = await erp.list('Service Subscription', { filters: f, fields: ['name', 'plan_name', 'status', 'start_date'], orderBy: 'start_date desc', limit: 8 }) || []
  } catch (e) { /* doctype/champ absent → on ignore */ }
  // Résumé global = le « meilleur » équipement (en ligne d'abord)
  const best = devs.slice().sort((a, b) => (b.online === true ? 1 : 0) - (a.online === true ? 1 : 0))[0] || null
  return {
    ok: true, found: true,
    customer: { name: cust, customer_name: (head && head.customer_name) || cust, email: (head && head.email) || '', phone: (head && head.phone) || '' },
    location: locInfo, locations: locs,
    subscriptions: subs.map(s => ({ name: s.name, plan: s.plan_name || '', status: s.status || '', start: s.start_date || null })),
    devices: devs,
    summary: best
      ? { online: best.online, label: best.label, detail: best.detail, source: best.source, signal: best.signal, rxPower: best.rxPower }
      : { online: null, label: 'Aucun équipement', detail: equip.length ? 'équipement sans numéro de série' : 'aucun équipement lié à ce compte', source: 'none' },
  }
}

const OPS_URL = () => (cfg.OPS_PUBLIC_URL || 'https://erp.gigafibre.ca/ops').replace(/\/$/, '')
const ERP_URL = () => (cfg.ERP_PUBLIC_URL || 'https://erp.gigafibre.ca').replace(/\/$/, '')
const shortName = (e) => String(e || '').split('@')[0]

// Activité récente (commentaires/notes) d'un document — pour afficher le fil dans le wizard.
async function activity (doctype, name) {
  const doc = await erp.get(doctype, name)
  let comments = []
  try {
    comments = await erp.list('Comment', {
      filters: [['reference_doctype', '=', doctype], ['reference_name', '=', name], ['comment_type', 'in', ['Comment', 'Info']]],
      fields: ['content', 'comment_email', 'comment_by', 'creation', 'comment_type'], orderBy: 'creation desc', limit: 25,
    })
  } catch (e) { log('collab activity ' + doctype + '/' + name + ': ' + e.message) }
  return { doc: doc || null, comments }
}

// Ajoute un commentaire (note/indice) + notifie les collègues mentionnés. NE TOUCHE PAS _assign / l'assignation.
async function addComment (doctype, name, { text, mentions = [], agent } = {}) {
  const content = String(text || '').trim()
  if (!content) return { ok: false, error: 'texte requis' }
  const ment = (Array.isArray(mentions) ? mentions : []).filter(e => /.+@.+\..+/.test(e))
  const tag = ment.length ? ('<div data-mentions="' + ment.join(',') + '">↪ ' + ment.map(shortName).join(', ') + '</div>') : ''
  const r = await erp.create('Comment', {
    comment_type: 'Comment', reference_doctype: doctype, reference_name: name,
    content: tag + '<div>' + content.replace(/</g, '&lt;').replace(/\n/g, '<br>') + '</div>' + (agent ? '<div style="color:#94a3b8">— ' + shortName(agent) + ' (OPS)</div>' : ''),
    comment_email: agent || 'ops@targo.ca', comment_by: agent || 'OPS',
  })
  if (!r.ok) return { ok: false, error: r.error }
  // Notifier les mentionnés (courriel) — sans rien changer à l'assignation.
  if (ment.length) {
    const gmail = require('./gmail')
    const subj = (doctype === 'Issue' ? 'Ticket ' : doctype + ' ') + name
    // Deep link OPS (détail natif) — plus JAMAIS le desk ERPNext pour les tickets. Autres doctypes : desk en repli
    // (rare ; à migrer quand un deep link générique existera).
    const link = doctype === 'Issue'
      ? OPS_URL() + '/#/tickets?open=' + encodeURIComponent(name)
      : ERP_URL() + '/app/' + doctype.toLowerCase().replace(/ /g, '-') + '/' + encodeURIComponent(name)
    const body = `${shortName(agent) || 'Un collègue'} t'a mentionné sur ${subj} :\n\n${content}\n\nOuvrir : ${link}\n(OPS — collaboration, ceci ne change pas l'assignation du ticket.)`
    for (const to of ment) { try { await gmail.sendMessage({ to, subject: `[OPS] mention — ${subj}`, body }) } catch (e) { log('collab notify ' + to + ': ' + e.message) } }
  }
  return { ok: true, mentioned: ment }
}

// Crée un ticket (ERPNext Issue) autonome — depuis le FAB / composer NL. customer optionnel ; queue = notifie l'équipe.
async function createTicket ({ title, category, priority, description, customer, customer_name, queue, status, due_date, service_location, agent } = {}) {
  const t = String(title || '').trim()
  if (!t) return { ok: false, error: 'titre requis' }
  const cat = String(category || '').trim()
  const st = (status === 'On Hold' || status === 'pending' || status === 'En attente') ? 'On Hold' : 'Open' // « pending » = suspendu jusqu'à la date de rappel
  const data = {
    subject: (cat ? '[' + cat + '] ' : '') + t.slice(0, 130), status: st, priority: priority || 'Medium',
    description: (description ? String(description) + '\n\n' : '') + (agent ? '— créé par ' + shortName(agent) + ' (OPS)' : ''),
  }
  if (customer) data.customer = customer
  // Adresse de service : celle fournie, SINON l'adresse active la plus récente du compte (auto).
  if (!service_location && customer) { try { service_location = await defaultServiceLocation(customer) } catch (e) {} }
  if (service_location) data.custom_service_location = service_location // lie le ticket à la BONNE adresse de service
  if (due_date && /^\d{4}-\d{2}-\d{2}/.test(due_date)) data.custom_reminder_date = due_date.slice(0, 10) // rappel / échéance
  const r = await erp.create('Issue', data)
  if (!r.ok) return { ok: false, error: r.error }
  // Router vers l'équipe : notifier les membres de la file (ex. Supports) — comme un courriel entrant.
  if (queue) {
    const members = (readJsonFile('/app/data/queue_members.json', {})[queue] || []).filter(e => /.+@.+\..+/.test(e))
    if (members.length) {
      const link = OPS_URL() + '/#/tickets?open=' + encodeURIComponent(r.name) // détail natif OPS (fini le desk ERPNext)
      const body = `Nouveau ticket pour l'équipe « ${queue} » :\n\n${data.subject}\n${customer_name ? 'Client : ' + customer_name + '\n' : ''}${description ? '\n' + description + '\n' : ''}\nOuvrir : ${link}\n— TARGO OPS`
      try { await require('./outbox').enqueue({ to: members.join(','), subject: `[Ticket · ${queue}] ${data.subject}`.slice(0, 90), body }, { kind: 'ticket-notif', label: queue }) } catch (e) { log('createTicket notify ' + queue + ': ' + e.message) }
    }
  }
  return { ok: true, name: r.name, subject: data.subject }
}

// ── ORCHESTRATEUR EN LANGAGE NATUREL / DICTÉE ──────────────────────────────
// « Crée un ticket de facture non payée pour Louispaul, envoie un texto à ce tech concernant telle adresse. »
// L'IA décompose en ACTIONS (sans inventer d'entités) ; le code RÉSOUT les clients (searchCustomers) ; on renvoie un
// PLAN à confirmer (les actions sortantes = SMS/ticket ne s'exécutent qu'après validation humaine). Puis /run exécute.
const ORCH_SYS = `Tu transformes la commande d'un agent (fournisseur Internet TARGO) en JSON d'ACTIONS. Réponds UNIQUEMENT par {"actions":[...]} (aucun texte autour).
Types autorisés :
- {"type":"create_ticket","customer_query":"<nom/adresse/téléphone du client tel que dicté, ou null>","subject":"<sujet court>","category":"Support|Facturation|Installation|Télévision|Téléphonie|Commercial|Autre","priority":"Low|Medium|High|Urgent","status":"Open|On Hold","due_date":"YYYY-MM-DD ou null"}
- {"type":"send_sms","to_query":"<nom/adresse/téléphone du destinataire tel que dicté>","message":"<le texto>"}
- {"type":"send_email","to_query":"<...>","subject":"<...>","body":"<...>"}
- {"type":"note","text":"<note interne>"}
- {"type":"diagnose","customer_query":"<nom/adresse/téléphone du client tel que dicté>"}  // QUESTION ou VÉRIFICATION d'état (lecture) : connexion, service, internet, modem, signal, « est-il en ligne ? », « a-t-il une panne ? »
Règles : status="On Hold" si on ATTEND quelque chose (paiement, pièce…). Ne résous PAS les entités, garde le terme dicté dans *_query. N'invente jamais de numéro, montant ni adresse. Déduis la catégorie par le sens (« facture »→Facturation, « wifi/panne »→Support, « installation »→Installation).
IMPORTANT — si la commande est une QUESTION ou une demande de VÉRIFICATION d'état (pas une action à exécuter), p.ex. « vérifie/vérifier la connexion|le service|internet de X », « est-ce que X est en ligne », « X a-t-il une panne », « statut de X » → utilise UNIQUEMENT {"type":"diagnose"} (JAMAIS create_ticket). Une vérification ne crée rien.`

async function orchestratePlan (text) {
  if (!String(text || '').trim()) return { ok: false, error: 'commande vide' }
  let out
  try { out = await require('./ai').chat({ task: 'nl', maxTokens: 800, temperature: 0.1, messages: [{ role: 'system', content: ORCH_SYS }, { role: 'user', content: String(text) }] }) } 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 actions = Array.isArray(parsed.actions) ? parsed.actions.slice(0, 8) : []
  for (const a of actions) {
    // Intent LECTURE : on exécute le diagnostic tout de suite (sûr) — serviceStatus gère seul
    // 1 client → diagnostic ; plusieurs → {ambiguous, matches} (l'agent précise l'adresse) ; aucun → introuvable.
    if (a.type === 'diagnose') {
      try { a.diagnostic = await serviceStatus({ q: a.customer_query || '' }) }
      catch (e) { a.diagnostic = { ok: false, error: e.message } }
      continue
    }
    const q = a.customer_query || a.to_query
    if (!q) continue
    if (/^\+?\d[\d\s().-]{6,}$/.test(String(q))) { a.phone = String(q).replace(/\D/g, ''); continue } // numéro brut dicté
    try {
      // ticket = pour un CLIENT ; sms/courriel = destinataire CLIENT ∪ TECHNICIEN.
      const m = (a.type === 'create_ticket') ? await searchCustomers(q) : await searchRecipients(q)
      a.candidates = m.slice(0, 4); a.resolved = m[0] || null
    } catch (e) { a.candidates = []; a.resolved = null }
  }
  return { ok: true, actions, transcript: String(text) }
}

async function orchestrateRun (actions, agent) {
  const conv = require('./conversation')
  const results = []
  for (const a of (Array.isArray(actions) ? actions : [])) {
    try {
      if (a.type === 'create_ticket') {
        const r = await createTicket({ title: a.subject || 'Demande', category: a.category, priority: a.priority, status: a.status, due_date: a.due_date, customer: a.resolved && a.resolved.name, customer_name: a.resolved && a.resolved.customer_name, service_location: a.service_location || (a.resolved && a.resolved.service_location), queue: a.queue, agent })
        results.push({ type: 'create_ticket', ok: r.ok, name: r.name, label: r.subject, error: r.error })
      } else if (a.type === 'send_sms') {
        const phone = a.phone || (a.resolved && a.resolved.phone)
        if (!phone) { results.push({ type: 'send_sms', ok: false, error: 'destinataire sans numéro' }); continue }
        const r = await conv.sendSms({ phone, customer: a.resolved && a.resolved.name, customerName: a.resolved && a.resolved.customer_name, message: a.message })
        results.push({ type: 'send_sms', ok: r.ok, to: (a.resolved && a.resolved.customer_name) || phone, via: r.via, error: r.error })
      } else if (a.type === 'send_email') {
        const to = (a.resolved && a.resolved.email) || a.to
        if (!to) { results.push({ type: 'send_email', ok: false, error: 'destinataire sans courriel' }); continue }
        const r = await conv.sendNewEmail({ to, subject: a.subject, body: a.body, customer: a.resolved && a.resolved.name, customerName: a.resolved && a.resolved.customer_name })
        results.push({ type: 'send_email', ok: r.ok, to, error: r.error })
      } else if (a.type === 'note') {
        results.push({ type: 'note', ok: true, text: a.text })
      }
    } catch (e) { results.push({ type: a.type, ok: false, error: e.message }) }
  }
  return { ok: true, results }
}

// ── Rapport : comptes avec TOTAL RÉCURRENT MENSUEL NÉGATIF ──────────────────
// Un abonnement récurrent ne peut pas être négatif (rabais > frais). F est autoritaire et
// correct ; ce rapport débusque la DÉRIVE côté ERPNext/OPS (rabais ajoutés à la main / artefacts
// d'import). Scan des Service Subscription ACTIF mensuelles, agrégées par (client, adresse).
// Calcul en arrière-plan + cache 1 h (la requête HTTP ne bloque pas ; la page sonde jusqu'à prêt).
let _negBill = { ts: 0, data: null, computing: false, error: null }
async function computeNegativeBilling () {
  const groups = new Map() // clé client|adresse → { customer, service_location, total, charges, rebate, rebates[] }
  const custTotals = new Map() // client → total mensuel ACTIF toutes adresses (pour distinguer un vrai négatif d'un artefact de regroupement par adresse)
  // « Actif/Inactif » du client = statut de compte GLOBAL F (account.status), récupéré après le scan (cf. fAccountStatuses) — autorité.
  const TODAY = new Date().toISOString().slice(0, 10)
  let start = 0, scanned = 0; const PAGE = 2000
  for (let i = 0; i < 60; i++) { // borne dure 120k
    const rows = await erp.list('Service Subscription', {
      filters: [['status', '=', 'Actif'], ['billing_cycle', '!=', 'Annuel']],
      fields: ['name', 'customer', 'service_location', 'monthly_price', 'plan_name', 'product_sku', 'legacy_service_id', 'end_date'],
      limit: PAGE, start,
    }) || []
    if (!rows.length) break
    for (const r of rows) {
      const price = Number(r.monthly_price || 0)
      const cust = r.customer || '?'
      const key = cust + '|' + (r.service_location || '')
      const legacy = Number(r.legacy_service_id || 0)
      let g = groups.get(key)
      if (!g) { g = { customer: cust, service_location: r.service_location || '', total: 0, charges: 0, rebate: 0, rebates: [], manualRebates: 0, expiredActive: 0 }; groups.set(key, g) }
      g.total += price
      custTotals.set(cust, (custTotals.get(cust) || 0) + price)
      // signal de fin de service non respectée (date de fin passée mais encore Actif)
      if (r.end_date && r.end_date < TODAY) g.expiredActive++
      if (price < 0) {
        g.rebate += price
        if (legacy <= 0) g.manualRebates++ // rabais SANS lien F = ajouté dans ERPNext
        g.rebates.push({ name: r.name, sku: r.product_sku || '', plan: r.plan_name || '', price, legacy, source: legacy > 0 ? ('F #' + legacy) : 'ajouté OPS', end_date: r.end_date || null })
      } else g.charges += price
    }
    scanned += rows.length
    if (rows.length < PAGE) break
    start += PAGE
  }
  const neg = [...groups.values()].filter(g => g.total < -0.005).sort((a, b) => a.total - b.total)
  // Pour chaque compte négatif : examiner TOUS les abos (actif+inactif) → une CHARGE suspendue/annulée
  // qui rendrait le total positif = désync de statut F→ERPNext (cause réelle vue sur Schink : FTTH500 Suspendu
  // dans ERPNext mais actif dans F). Peu de comptes (~quelques dizaines) → fetch par compte économique.
  const allByCust = new Map()
  for (const g of neg) {
    if (!allByCust.has(g.customer)) {
      try { allByCust.set(g.customer, await erp.list('Service Subscription', { filters: [['customer', '=', g.customer]], fields: ['name', 'status', 'monthly_price', 'plan_name', 'service_location', 'legacy_service_id', 'billing_cycle'], limit: 100 }) || []) } catch (e) { allByCust.set(g.customer, []) }
    }
    const inact = (allByCust.get(g.customer) || []).filter(s => (s.service_location || '') === g.service_location && s.billing_cycle !== 'Annuel' && Number(s.monthly_price || 0) > 0 && s.status !== 'Actif')
    g.inactiveChargeSum = inact.reduce((s, x) => s + Number(x.monthly_price || 0), 0)
    g.inactiveCharges = inact.map(s => ({ plan: s.plan_name || '', price: Number(s.monthly_price || 0), status: s.status || '', legacy: Number(s.legacy_service_id || 0) }))
  }
  const causeOf = (g, ct) => {
    if (ct >= -0.005) return 'artifact'                                   // rabais/frais sur adresses ≠ → compte OK
    if (g.manualRebates > 0) return 'manual'                              // rabais ajouté hors F
    if (g.total + (g.inactiveChargeSum || 0) >= -0.005) return 'suspended_charge' // une charge suspendue/annulée explique le négatif
    if (g.expiredActive > 0) return 'expired'
    return 'stacked_rebates'                                             // vrais rabais F qui dépassent (à vérifier vs F)
  }
  // Noms clients + ID de compte F (batch 'in' par 100)
  const ids = [...new Set(neg.map(g => g.customer).filter(Boolean))]
  const names = {}, acctIdOf = {}
  for (let i = 0; i < ids.length; i += 100) {
    try { const cs = await erp.list('Customer', { filters: [['name', 'in', ids.slice(i, i + 100)]], fields: ['name', 'customer_name', 'legacy_account_id'], limit: 100 }) || []; for (const c of cs) { names[c.name] = c.customer_name; acctIdOf[c.name] = Number(c.legacy_account_id || 0) } } catch (e) { /* nom optionnel */ }
  }
  // Statut de compte GLOBAL F (account.status : 1=Actif, 2=Suspendu, 3/4/5=Résilié) — l'autorité (≠ heuristique forfait, ≠ flag disabled).
  let acctStatus = {}; const ls = require('./legacy-sync')
  try { acctStatus = await ls.fAccountStatuses(Object.values(acctIdOf)) } catch (e) { log('negative-billing fAccountStatuses: ' + e.message) }
  const LABEL = ls.F_ACCT_STATUS_LABEL || {}
  const round = n => Math.round(n * 100) / 100
  return {
    count: neg.length, scanned, generated: Date.now(),
    rows: neg.slice(0, 2000).map(g => { const ct = round(custTotals.get(g.customer) || 0); const fst = acctStatus[acctIdOf[g.customer]]; return ({ customer: g.customer, customer_name: names[g.customer] || g.customer, service_location: g.service_location, total: round(g.total), charges: round(g.charges), rebate: round(g.rebate), customer_total: ct, account_status: fst != null ? fst : null, account_status_label: LABEL[fst] || (fst != null ? 'Inconnu' : '—'), customer_active: fst === 1, manualRebates: g.manualRebates, expiredActive: g.expiredActive, inactiveChargeSum: round(g.inactiveChargeSum || 0), inactiveCharges: g.inactiveCharges || [], cause: causeOf(g, ct), rebates: g.rebates }) }),
  }
}
async function negativeBilling (refresh) {
  const fresh = (Date.now() - _negBill.ts) < 3600000
  if (!refresh && _negBill.data && fresh) return { ready: true, cached: true, ..._negBill.data }
  if (_negBill.computing) return { ready: false, computing: true }
  if (!refresh && _negBill.error && fresh) return { ready: false, error: _negBill.error }
  _negBill.computing = true
  computeNegativeBilling()
    .then(d => { _negBill = { ts: Date.now(), data: d, computing: false, error: null }; log(`negative-billing: ${d.count} comptes négatifs / ${d.scanned} abos scannés`) })
    .catch(e => { _negBill = { ts: Date.now(), data: null, computing: false, error: e.message }; log('negative-billing ERROR: ' + e.message) })
  return { ready: false, computing: true, started: true }
}

// ── Rapport : COMPTES RÉSILIÉS qui gardent des SERVICES ACTIFS (à nettoyer au niveau service) ──
// F ne désactive pas les services récurrents à la résiliation → charges + crédits restent status=1.
// Liste de ménage (les employés à internet fourni sont des exceptions à garder). Calcul en arrière-plan + cache 1 h.
let _termActive = { ts: 0, data: null, computing: false, error: null }
async function computeTerminatedActive () {
  const ls = require('./legacy-sync')
  const resilieIds = await ls.fResiliatedAccountIds()
  if (!resilieIds.length) return { count: 0, services: 0, generated: Date.now(), rows: [] }
  const pgp = require('./address-db').pool()
  const custAcct = new Map() // customer ERPNext → legacy_account_id (résilié)
  if (pgp) {
    for (let i = 0; i < resilieIds.length; i += 5000) {
      try { const r = await pgp.query('SELECT name, legacy_account_id FROM "tabCustomer" WHERE legacy_account_id = ANY($1)', [resilieIds.slice(i, i + 5000)]); for (const x of r.rows) custAcct.set(x.name, Number(x.legacy_account_id)) } catch (e) { log('terminated-active PG: ' + e.message) }
    }
  }
  if (!custAcct.size) return { count: 0, services: 0, generated: Date.now(), rows: [] }
  const byCust = new Map()
  let start = 0, scanned = 0; const PAGE = 2000
  for (let i = 0; i < 60; i++) {
    const rows = await erp.list('Service Subscription', { filters: [['status', '=', 'Actif'], ['billing_cycle', '!=', 'Annuel']], fields: ['name', 'customer', 'service_location', 'monthly_price', 'plan_name', 'product_sku', 'legacy_service_id'], limit: PAGE, start }) || []
    if (!rows.length) break
    for (const r of rows) {
      if (!custAcct.has(r.customer)) continue
      let g = byCust.get(r.customer); if (!g) { g = { customer: r.customer, total: 0, services: [] }; byCust.set(r.customer, g) }
      const price = Number(r.monthly_price || 0)
      g.total += price
      g.services.push({ legacy: Number(r.legacy_service_id || 0), sku: r.product_sku || '', plan: r.plan_name || '', price, location: r.service_location || '' })
    }
    scanned += rows.length
    if (rows.length < PAGE) break
    start += PAGE
  }
  const ids = [...byCust.keys()]; const names = {}
  for (let i = 0; i < ids.length; i += 100) { try { const cs = await erp.list('Customer', { filters: [['name', 'in', ids.slice(i, i + 100)]], fields: ['name', 'customer_name'], limit: 100 }) || []; for (const c of cs) names[c.name] = c.customer_name } catch (e) { /* */ } }
  const round = n => Math.round(n * 100) / 100
  const rows = [...byCust.values()].map(g => ({
    customer: g.customer, customer_name: names[g.customer] || g.customer, account_id: custAcct.get(g.customer),
    active_services: g.services.length,
    charges: round(g.services.filter(s => s.price > 0).reduce((a, s) => a + s.price, 0)),
    credits: round(g.services.filter(s => s.price < 0).reduce((a, s) => a + s.price, 0)),
    total: round(g.total),
    services: g.services.map(s => ({ ...s, price: round(s.price) })),
  })).sort((a, b) => b.active_services - a.active_services || b.total - a.total)
  return { count: rows.length, services: rows.reduce((a, r) => a + r.active_services, 0), scanned, generated: Date.now(), rows: rows.slice(0, 3000) }
}
async function terminatedActive (refresh) {
  const fresh = (Date.now() - _termActive.ts) < 3600000
  if (!refresh && _termActive.data && fresh) return { ready: true, cached: true, ..._termActive.data }
  if (_termActive.computing) return { ready: false, computing: true }
  if (!refresh && _termActive.error && fresh) return { ready: false, error: _termActive.error }
  _termActive.computing = true
  computeTerminatedActive()
    .then(d => { _termActive = { ts: Date.now(), data: d, computing: false, error: null }; log(`terminated-active: ${d.count} comptes résiliés / ${d.services} services actifs`) })
    .catch(e => { _termActive = { ts: Date.now(), data: null, computing: false, error: e.message }; log('terminated-active ERROR: ' + e.message) })
  return { ready: false, computing: true, started: true }
}

async function handle (req, res, method, p, url) {
  if (!_macSyncStarted) { _macSyncStarted = true; try { startWirelessMacSync() } catch (e) { /* */ } } // planificateur démarré au 1er requête (pas au require → pas de timer en test)
  // POST /collab/orchestrate {text} → PLAN d'actions (ne s'exécute pas). POST /collab/orchestrate/run {actions} → exécute (confirmé).
  if (p === '/collab/orchestrate' && method === 'POST') {
    const b = await parseBody(req); return json(res, 200, await orchestratePlan(b.text || ''))
  }
  if (p === '/collab/orchestrate/run' && method === 'POST') {
    const b = await parseBody(req); return json(res, 200, await orchestrateRun(b.actions, req.headers['x-authentik-email'] || b.agent || ''))
  }
  // GET /collab/customer-search?q=&team=1 — autosuggest contact par texte libre (nom / courriel / téléphone / adresse).
  // team=1 → inclut les MEMBRES DE L'ÉQUIPE en PREMIER (destinataires To/Cc, tag collègue) ; sinon clients seuls (recherche globale → fiche).
  if (p === '/collab/customer-search' && method === 'GET') {
    const q = url.searchParams.get('q') || ''
    const wantTeam = url.searchParams.get('team') === '1'
    const [team, custs] = await Promise.all([wantTeam ? searchTeam(q) : Promise.resolve([]), searchCustomers(q)])
    const seen = new Set(team.map(t => (t.email || '').toLowerCase()))
    const matches = [...team, ...custs.filter(c => !(c.email && seen.has(c.email.toLowerCase())))]
    return json(res, 200, { matches })
  }
  // GET /collab/ratings — évaluations clients (basses prioritaires) pour la vue Ops « satisfaction »
  if (p === '/collab/ratings' && method === 'GET') {
    return json(res, 200, require('./rating').listRatings({ limit: Number(url.searchParams.get('limit')) || 150, customer: url.searchParams.get('customer') || '' }))
  }
  // DELETE /collab/ratings/<token> — supprimer une évaluation (avis de test)
  const mRatingDel = p.match(/^\/collab\/ratings\/([\w-]+)$/)
  if (mRatingDel && method === 'DELETE') {
    return json(res, 200, { ok: require('./rating').deleteRating(mRatingDel[1]) })
  }
  // GET /collab/rating-invite-draft?customer=&channel=&kind=invite|feedback — BROUILLON pré-rempli (coords + message, langue du client) pour la fenêtre Compose.
  //   kind=invite (défaut) : invitation à évaluer (courriel = {{rating}} ; SMS = lien /rate/start tokenisé).  kind=feedback : « écrire au client » (suite à un avis, ex. mécontent ; aucun lien).
  if (p === '/collab/rating-invite-draft' && method === 'GET') {
    const customer = url.searchParams.get('customer') || ''
    const channel = url.searchParams.get('channel') === 'sms' ? 'sms' : 'email'
    const kind = url.searchParams.get('kind') === 'feedback' ? 'feedback' : 'invite'
    let email = url.searchParams.get('email') || ''; let phone = url.searchParams.get('phone') || ''; let name = url.searchParams.get('name') || ''; let lang = url.searchParams.get('lang') || ''
    if (customer) { try { const c = await erp.get('Customer', customer); if (c) { email = email || c.email_id || c.email_billing || ''; phone = phone || c.cell_phone || c.mobile_no || ''; lang = lang || c.language || ''; name = name || c.customer_name || '' } } catch (e) { /* */ } }
    if (!customer && !email && !phone) return json(res, 400, { error: 'customer ou coordonnées requis' })
    const rating = require('./rating')
    if (channel === 'sms') {
      if (!phone) return json(res, 200, { ok: false, error: 'Aucun numéro mobile pour ce client' })
      const text = kind === 'feedback' ? rating.feedbackSms(name, lang) : rating.inviteSmsText(rating.newRatingToken({ customer, email, name, lang }), lang)
      return json(res, 200, { ok: true, prefill: { channel: 'sms', phone, customer, customerName: name, text } })
    }
    if (!email) return json(res, 200, { ok: false, error: 'Aucun courriel pour ce client' })
    const subject = kind === 'feedback' ? rating.feedbackSubject(lang) : rating.inviteSubject(lang)
    const prefill = { channel: 'email', to: email, customer, customerName: name, subject }
    if (kind === 'feedback') prefill.html = rating.feedbackHtml(name, lang)            // texte simple → éditable dans l'éditeur
    else prefill.advHtml = rating.inviteEmailMarkerHtml(lang, name)                     // courriel MIS EN PAGE → envoyé tel quel (composeAdvHtml)
    return json(res, 200, { ok: true, prefill })
  }
  // POST /collab/send-rating-invite {customer|email|phone, channel} — renvoie l'invitation d'évaluation (courriel/SMS) DANS LA LANGUE DU CLIENT.
  if (p === '/collab/send-rating-invite' && method === 'POST') {
    const b = await parseBody(req)
    const channel = b.channel === 'sms' ? 'sms' : 'email'
    let email = String(b.email || ''); let phone = String(b.phone || ''); let name = String(b.name || ''); let lang = String(b.lang || '')
    const customer = String(b.customer || ''); const conv = String(b.conv || '')
    if (customer) { // résout courriel / mobile / langue depuis la fiche
      try { const c = await erp.get('Customer', customer); if (c) { email = email || c.email_id || c.email_billing || ''; phone = phone || c.cell_phone || c.mobile_no || ''; lang = lang || c.language || ''; name = name || c.customer_name || '' } } catch (e) { /* */ }
    }
    const rating = require('./rating')
    const token = rating.newRatingToken({ customer, conv, email, name, lang })
    if (channel === 'sms') {
      if (!phone) return json(res, 400, { error: 'Aucun numéro mobile pour ce client' })
      try { const { sendSmsInternal } = require('./twilio'); const ok = await sendSmsInternal(phone, rating.inviteSmsText(token, lang), customer || null); return json(res, ok ? 200 : 502, { ok: !!ok, channel: 'sms', to: phone }) } catch (e) { return json(res, 500, { error: e.message }) }
    }
    if (!email) return json(res, 400, { error: 'Aucun courriel pour ce client' })
    try { const r = await require('./conversation').sendNewEmail({ to: email, subject: rating.inviteSubject(lang), html: rating.inviteEmailHtml(token, lang, name), customer, customerName: name, agentEmail: req.headers['x-authentik-email'] || '' }); return json(res, r && r.ok ? 200 : 400, { ok: !!(r && r.ok), channel: 'email', to: email }) } catch (e) { return json(res, 500, { error: e.message }) }
  }
  // POST /collab/suggest-account — croise les signaux d'un courriel pour proposer le BON compte même quand
  // l'expéditeur ≠ le titulaire (ex. mandataire). IA extrait adresse/nom/tél du CORPS, puis recoupe + classe.
  if (p === '/collab/suggest-account' && method === 'POST') {
    const b = await parseBody(req)
    const senderEmail = String(b.email || '').trim()
    const senderName = String(b.name || '').trim()
    const text = String(b.text || '').slice(0, 4000)
    let ex = {}
    if (text) {
      try {
        const sys = 'Tu extrais les identifiants de compte qu\'un client mentionne dans un courriel à un fournisseur Internet. Le TITULAIRE du compte peut différer de l\'expéditeur (ex. un mandataire écrit pour le titulaire). Renvoie UNIQUEMENT du JSON : {"address":"adresse civique complète si mentionnée, sinon vide","names":["noms complets de personnes mentionnés"],"invoice_number":"","phone":""}. N\'invente rien ; vide si absent.'
        const out = await require('./ai').chat({ task: 'default', maxTokens: 300, temperature: 0, reasoningEffort: 'none', messages: [{ role: 'system', content: sys }, { role: 'user', content: text }] })
        ex = JSON.parse((String(out || '').match(/\{[\s\S]*\}/) || ['{}'])[0]) || {}
      } catch (e) { ex = {} }
    }
    // Signaux pondérés : l'adresse du corps est la plus discriminante ; corroboration par un nom = forte confiance.
    const queries = []
    if (ex.address) queries.push({ q: ex.address, signal: 'adresse du courriel', w: 5 })
    for (const n of (Array.isArray(ex.names) ? ex.names : [])) if (n && String(n).trim().length > 2) queries.push({ q: String(n), signal: 'nom mentionné', w: 3 })
    if (ex.phone) queries.push({ q: ex.phone, signal: 'téléphone du courriel', w: 3 })
    if (senderName) queries.push({ q: senderName, signal: 'expéditeur', w: 2 })
    if (senderEmail) queries.push({ q: senderEmail, signal: 'courriel expéditeur', w: 2 })
    const acc = new Map()
    for (const { q, signal, w } of queries) {
      let matches = []
      try { matches = await searchCustomers(q) } catch (e) { matches = [] }
      for (const m of matches.slice(0, 5)) {
        const cur = acc.get(m.name) || { ...m, score: 0, signals: [] }
        cur.score += w
        if (!cur.signals.includes(signal)) cur.signals.push(signal)
        acc.set(m.name, cur)
      }
    }
    const suggestions = [...acc.values()].sort((a, b) => b.score - a.score).slice(0, 6)
    return json(res, 200, { suggestions, extracted: ex })
  }
  // GET /collab/service-locations?customer= — adresses de service d'un compte (multi-adresses)
  if (p === '/collab/service-locations' && method === 'GET') {
    return json(res, 200, { locations: await serviceLocations(url.searchParams.get('customer') || '') })
  }
  // GET /collab/service-status?q=<adresse|nom|téléphone|courriel>[&customer=&location=] — statut service+modem (lecture)
  if (p === '/collab/service-status' && method === 'GET') {
    return json(res, 200, await serviceStatus({ q: url.searchParams.get('q') || '', customer: url.searchParams.get('customer') || '', location: url.searchParams.get('location') || '' }))
  }
  // GET /collab/dostuff?serial=<sn>&olt=<olt_ip> — statut LIVE du modem via le webhook n8n que F utilise (LENT ~25-30 s ; tech=3)
  if (p === '/collab/dostuff' && method === 'GET') {
    return json(res, 200, await dostuffStatus({ sn: url.searchParams.get('serial') || url.searchParams.get('sn') || '', olt: url.searchParams.get('olt') || '' }))
  }
  // GET /collab/airos-signal?serial=<sn>[&ip=&port=&user=] — signal LIVE d'un CPE SANS-FIL (airOS/Cambium) via webhook n8n
  // (le hub ne joint PAS les CPE directement → pont n8n, comme la fibre TP-Link). 404 = webhook pas encore créé.
  if (p === '/collab/airos-signal' && method === 'GET') {
    return json(res, 200, await airosSignal({ sn: url.searchParams.get('serial') || url.searchParams.get('sn') || '', ip: url.searchParams.get('ip') || '', port: url.searchParams.get('port') || '', user: url.searchParams.get('user') || '' }))
  }
  // GET /collab/airos-status?serial=|ip= — état RAPIDE (RADIUS seul, pas de status.cgi) pour le chargement de page.
  if (p === '/collab/airos-status' && method === 'GET') {
    return json(res, 200, await airosStatus({ sn: url.searchParams.get('serial') || url.searchParams.get('sn') || '', ip: url.searchParams.get('ip') || '' }))
  }
  // POST /collab/airos-mac-sync — réconciliation MAC park-wide à la demande (le planificateur 03h l'appelle aussi).
  if (p === '/collab/airos-mac-sync' && method === 'POST') {
    return json(res, 200, await syncWirelessMacs())
  }
  // GET /collab/negative-billing[?refresh=1] — comptes dont le total récurrent mensuel ACTIF est < 0 (rabais > frais)
  if (p === '/collab/negative-billing' && method === 'GET') {
    return json(res, 200, await negativeBilling(url.searchParams.get('refresh') === '1'))
  }
  // GET /collab/terminated-active[?refresh=1] — comptes RÉSILIÉS (F status 3/4/5) gardant des services ACTIFS (ménage)
  if (p === '/collab/terminated-active' && method === 'GET') {
    return json(res, 200, await terminatedActive(url.searchParams.get('refresh') === '1'))
  }
  // GET /collab/wan-rate?serial=<sn>[&olt=&slot=&port=&ontid=] — compteurs d'octets WAN de l'ONU (Raisecom/tech-2 seulement),
  // tels que F les lit dans raisecom_rcmg_bandwith.php. Le CLIENT échantillonne 2× et calcule le débit (delta/sec*8).
  if (p === '/collab/wan-rate' && method === 'GET') {
    let olt = null; try { olt = require('./olt-snmp') } catch (e) { return json(res, 200, { available: false, reason: 'snmp_unavailable' }) }
    const serial = url.searchParams.get('serial') || url.searchParams.get('sn') || ''
    const opts = {}
    if (url.searchParams.get('olt')) opts.oltIp = url.searchParams.get('olt')
    if (url.searchParams.get('slot') != null && url.searchParams.get('slot') !== '') opts.slot = url.searchParams.get('slot')
    if (url.searchParams.get('port') != null && url.searchParams.get('port') !== '') opts.port = url.searchParams.get('port')
    if (url.searchParams.get('ontid') != null && url.searchParams.get('ontid') !== '') opts.ontId = url.searchParams.get('ontid')
    if (url.searchParams.get('type')) opts.type = url.searchParams.get('type')
    if (!serial && !opts.oltIp) return json(res, 400, { error: 'serial ou olt requis' })
    try { return json(res, 200, await olt.getOnuBandwidth(serial, opts)) }
    catch (e) { return json(res, 200, { available: false, reason: 'error', error: e.message }) }
  }
  // GET /collab/diag-link?customer=C-XXX[&name=] — lien de diagnostic libre-service (token signé 7 j) à envoyer au client
  if (p === '/collab/diag-link' && method === 'GET') {
    const customer = url.searchParams.get('customer') || ''
    if (!customer) return json(res, 400, { error: 'customer requis' })
    let name = url.searchParams.get('name') || ''
    if (!name) { try { const c = await erp.get('Customer', customer); name = (c && c.customer_name) || '' } catch (e) { /* */ } }
    const token = require('./magic-link').generateCustomerToken(customer, name, '', 24 * 7)
    const base = (cfg.CLIENT_PUBLIC_URL || 'https://app.gigafibre.ca').replace(/\/$/, '')
    return json(res, 200, { ok: true, link: base + '/diag/' + token, customer, name })
  }
  // ── Bibliothèque d'images de pièces jointes (équipements fréquents, réutilisables — mêmes images qu'une FAQ) ──
  // Manifeste léger /app/data/attach_presets.json ; les octets vivent dans le store d'actifs (content-hash, partagé).
  if (p === '/collab/attach-presets' && method === 'GET') {
    const list = (readJsonFile('/app/data/attach_presets.json') || []).map(x => ({ ...x, url: require('./campaigns').uploadUrl(x.asset, req) }))
    return json(res, 200, { presets: list })
  }
  if (p === '/collab/attach-presets' && method === 'POST') {
    const b = await parseBody(req)
    const campaigns = require('./campaigns')
    const dec = campaigns.decodeDataUrl(b && b.data)
    if (!dec || dec.error) return json(res, 400, { error: dec && dec.error === 'too_large' ? 'image trop volumineuse' : 'image invalide' })
    const asset = campaigns.persistUpload(dec.buffer, dec.ext)
    const id = 'ap_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7)
    const fs = require('fs'); const PRESETS = '/app/data/attach_presets.json'
    const list = readJsonFile(PRESETS) || []
    const preset = { id, name: String((b && b.name) || 'Image').slice(0, 80), category: String((b && b.category) || 'Équipement').slice(0, 40), asset, mime: dec.mime }
    list.push(preset)
    try { fs.writeFileSync(PRESETS, JSON.stringify(list, null, 2)) } catch (e) { return json(res, 500, { error: 'écriture manifeste' }) }
    return json(res, 200, { ok: true, preset: { ...preset, url: campaigns.uploadUrl(asset, req) } })
  }
  const mPreset = p.match(/^\/collab\/attach-presets\/([\w-]+)$/)
  if (mPreset && method === 'DELETE') {
    const fs = require('fs'); const PRESETS = '/app/data/attach_presets.json'
    const list = (readJsonFile(PRESETS) || []).filter(x => x.id !== mPreset[1]) // retiré du manifeste ; l'actif content-hash reste (potentiellement partagé)
    try { fs.writeFileSync(PRESETS, JSON.stringify(list, null, 2)) } catch (e) { return json(res, 500, { error: 'écriture manifeste' }) }
    return json(res, 200, { ok: true })
  }
  // POST /collab/ticket {title, category, priority, description, customer, customer_name, queue} — créer un ticket autonome
  if (p === '/collab/ticket' && method === 'POST') {
    const b = await parseBody(req)
    const r = await createTicket({ ...b, agent: req.headers['x-authentik-email'] || b.agent || '' })
    return json(res, r.ok ? 200 : 400, r)
  }
  // GET /collab/activity?doctype=Issue&name=ISS-0001
  if (p === '/collab/activity' && method === 'GET') {
    const doctype = url.searchParams.get('doctype'); const name = url.searchParams.get('name')
    if (!doctype || !name) return json(res, 400, { error: 'doctype + name requis' })
    return json(res, 200, await activity(doctype, name))
  }
  // POST /collab/comment {doctype, name, text, mentions}
  if (p === '/collab/comment' && method === 'POST') {
    const b = await parseBody(req)
    if (!b.doctype || !b.name) return json(res, 400, { error: 'doctype + name requis' })
    const agent = req.headers['x-authentik-email'] || b.agent || ''
    const r = await addComment(b.doctype, b.name, { text: b.text, mentions: b.mentions, agent })
    return json(res, r.ok ? 200 : 400, r)
  }
  return json(res, 404, { error: 'not found' })
}

// ── DoStuff (n8n) : statut LIVE d'un modem via le MÊME webhook que F (GET {sn, olt}) ──────────────────
// F appelle n8napi.targo.ca/webhook/dostuff en GET avec un body JSON → on réplique via le module https (fetch
// refuse un body en GET). LENT (~25-30 s : n8n interroge l'OLT en direct) → action À LA DEMANDE seulement.
// Réservé tech=3 (OLT reconnus par n8n) ; tech=2 (poller OLT standard) → n8n renvoie « OLT non reconnue » → repli sur notre statut.
function dostuffStatus ({ sn, olt } = {}) {
  return new Promise((resolve) => {
    if (!sn || !olt) return resolve({ ok: false, error: 'sn + olt requis' })
    let https; try { https = require('https') } catch (e) { return resolve({ ok: false, error: 'https indisponible' }) }
    const body = JSON.stringify({ sn: String(sn), olt: String(olt) })
    const req = https.request('https://n8napi.targo.ca/webhook/dostuff', { method: 'GET', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 40000 }, (res) => {
      let d = ''; res.on('data', c => { d += c }); res.on('end', () => {
        let arr; try { arr = JSON.parse(d) } catch (e) { return resolve({ ok: false, error: 'réponse n8n illisible' }) }
        const r = Array.isArray(arr) ? arr[0] : arr
        if (!r || r.ErrorCode) return resolve({ ok: false, recognized: false, error: (r && r.ErrorCode) || 'réponse vide' }) // OLT non reconnue (tech=2) → repli
        const clean = (v) => String(v == null ? '' : v).replace(/^"+|"+$/g, '').trim()
        const num = (v) => { const n = parseFloat(clean(v)); return isNaN(n) ? null : n }
        const online = clean(r.Status) === '1' || clean(r.InternetOn) === '1'
        resolve({
          ok: true, source: 'dostuff', online, label: online ? 'En ligne' : 'Hors ligne',
          rxPower: num(r.RxSignal), txPower: num(r.TxSignal), signal: signalFrom(num(r.RxSignal)), distance: num(r.Distance),
          uptime: clean(r.Uptime) || null, lastDown: clean(r.LastDown) || null, firmware: clean(r.Firmware) || null,
          managementIp: clean(r.Management) || null, wanIp: clean(r.Internet) || null, voip: clean(r.VoIP) || null, iptv: clean(r.IPTV) || null,
          profileId: clean(r.ProfileID) || null, slot: clean(r.Slot) || null, port: clean(r.Port) || null, onuId: clean(r.OnuID) || null,
        })
      })
    })
    req.on('error', e => resolve({ ok: false, error: e.message }))
    req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'délai dépassé (~40 s) — OLT lent ou modem hors ligne' }) })
    req.write(body); req.end()
  })
}

// ── airOS/Cambium (sans-fil fixe) : signal LIVE d'un CPE. Le hub NE JOINT PAS les CPE (réseau terrain non routable —
// vérifié : timeouts). PRIMAIRE = pont F `ops_airos.php` sur facturation (F joint les CPE + a le code airOS + les
// creds ; même auth X-Ops-Token que ops_reassign.php). REPLI = webhook n8n `get_signal_airos` (chemin post-migration
// F ; 404 tant qu'il n'existe pas). Miroir de F `device_ajax/airos_ac_ajax.php` (status.cgi).
function opsBridgeToken () { try { return (process.env.OPS_LEGACY_TOKEN || require('fs').readFileSync('/app/data/ops_legacy.token', 'utf8') || '').trim() } catch (e) { return (process.env.OPS_LEGACY_TOKEN || '').trim() } }

function normalizeAiros (r) {
  const clean = (v) => { const s = String(v == null ? '' : v).replace(/^"+|"+$/g, '').trim(); return s || null }
  const num = (v) => { const n = parseFloat(clean(v)); return isNaN(n) ? null : n }
  return {
    ok: true, source: r.source || 'airos',
    model: clean(r.device_model || r.model), name: clean(r.device_name || r.name), version: clean(r.version),
    ssid: clean(r.ssid), frequency: clean(r.frequency), txPower: clean(r.tx_power || r.txPower), apMac: clean(r.mac_ap || r.apMac), apIp: clean(r.ap_ip || r.apIp),
    signal: num(r.signal), signalAp: num(r.signal_ap || r.signalAp), ccq: num(r.ccq),
    txRate: clean(r.tx_rate || r.txRate), rxRate: clean(r.rx_rate || r.rxRate),
    capUp: num(r.airmax_capacity_uplink || r.capUp), capDown: num(r.airmax_capacity_downlink || r.capDown),
    uptime: clean(r.connection_time || r.uptime), lan0: clean(r.if_speed_lan0 || r.lan0), lan1: clean(r.if_speed_lan1 || r.lan1),
    // RADIUS (WPA2) : MAC courante autoritaire (auto-MàJ au remplacement) + IP AP (nasipaddress). Débit eth0 : compteurs + ts.
    radiusMac: clean(r.radius_mac || r.radiusMac), radiusUser: clean(r.radius_user || r.radiusUser),
    ethRx: (r.eth_rx_bytes != null ? Number(r.eth_rx_bytes) : null), ethTx: (r.eth_tx_bytes != null ? Number(r.eth_tx_bytes) : null),
    ts: (r.ts != null ? Number(r.ts) : null),
  }
}

function httpsGetJson (targetUrl, headers, timeoutMs) {
  return new Promise((resolve) => {
    let https; try { https = require('https') } catch (e) { return resolve({ _err: 'https indisponible' }) }
    const req = https.request(targetUrl, { method: 'GET', headers: headers || {}, timeout: timeoutMs || 20000 }, (res) => {
      let d = ''; res.on('data', c => { d += c }); res.on('end', () => {
        let j; try { j = JSON.parse(d) } catch (e) { return resolve({ _err: 'réponse illisible', _status: res.statusCode }) }
        if (j && typeof j === 'object') j._status = res.statusCode
        resolve(j)
      })
    })
    req.on('error', e => resolve({ _err: e.message }))
    req.on('timeout', () => { req.destroy(); resolve({ _err: 'délai dépassé' }) })
    req.end()
  })
}

const macNorm = (m) => String(m || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase()
// AUTO-SYNC : RADIUS = MAC autoritaire. Si elle diffère de l'enregistrée → MàJ Service Equipment (idempotent).
async function autoSyncMac (out, seName, seMac) {
  if (!(out && out.ok && out.radiusMac && seName)) return
  if (!macNorm(out.radiusMac) || macNorm(out.radiusMac) === macNorm(seMac)) return
  const res = await erp.update('Service Equipment', seName, { mac_address: out.radiusMac }).catch(e => ({ ok: false, error: String(e.message || e) }))
  if (res && res.ok) { out.macSynced = true; out.macWas = seMac || null; log('[airos] MAC auto-sync ' + seName + ' ' + (seMac || '∅') + ' → ' + out.radiusMac + ' (RADIUS)') }
  else { out.macSyncError = String((res && res.error) || 'échec').slice(0, 120) } // ex. lien service_location cassé → non bloquant
}
async function airosSignal ({ sn, ip, port, user, mac } = {}) {
  // Résout le Service Equipment (nom + IP + MAC enregistrée) — sert à l'AUTO-SYNC de la MAC depuis RADIUS.
  let seName = null, seMac = ''
  try {
    let rows = []
    if (sn) rows = await erp.list('Service Equipment', { filters: [['serial_number', '=', String(sn)]], fields: ['name', 'ip_address', 'mac_address'], limit: 1 })
    if ((!rows || !rows.length) && ip) rows = await erp.list('Service Equipment', { filters: [['ip_address', '=', String(ip)]], fields: ['name', 'ip_address', 'mac_address'], limit: 1 })
    const e = rows && rows[0]
    if (e) { seName = e.name; seMac = e.mac_address || ''; if (!ip) ip = e.ip_address || ''; if (!mac) mac = e.mac_address || '' }
  } catch (e) { /* best-effort */ }
  if (!sn && !ip && !mac) return { ok: false, error: 'serial, ip ou mac requis' }
  // PRIMAIRE : pont F ops_airos.php (même hôte + token que ops_reassign.php).
  const token = opsBridgeToken()
  const base = (process.env.OPS_LEGACY_URL || 'https://facturation.targo.ca/ops_reassign.php').replace(/ops_reassign\.php.*$/, 'ops_airos.php')
  let out = null
  if (token && base) {
    const q = new URLSearchParams()
    if (sn) q.set('serial', sn); if (ip) q.set('ip', ip); if (mac) q.set('mac', mac); if (port) q.set('port', String(port))
    const r = await httpsGetJson(base + '?' + q.toString(), { 'X-Ops-Token': token }, 25000)
    if (r && r.ok === true) out = normalizeAiros(r)
    else if (r && r.ok === false && r.error && !r._err) return { ok: false, error: r.error } // réponse F propre (device introuvable / CPE injoignable)
    // transport KO (r._err) → tenter n8n en repli
  }
  // REPLI : webhook n8n get_signal_airos (post-migration F ; 404 = pas encore créé).
  if (!out) out = await airosViaN8n({ sn, ip, port, user })
  // AUTO-SYNC MAC : RADIUS (WPA2) = autoritaire, s'auto-MàJ au remplacement → on aligne Service Equipment (serveur).
  await autoSyncMac(out, seName, seMac)
  return out
}

// STATUS RAPIDE (chargement de page) : état via RADIUS seul (aucun status.cgi ~5 s). En ligne / depuis / MAC / IPs.
async function airosStatus ({ sn, ip } = {}) {
  let seName = null, seMac = ''
  try {
    let rows = []
    if (sn) rows = await erp.list('Service Equipment', { filters: [['serial_number', '=', String(sn)]], fields: ['name', 'ip_address', 'mac_address'], limit: 1 })
    if ((!rows || !rows.length) && ip) rows = await erp.list('Service Equipment', { filters: [['ip_address', '=', String(ip)]], fields: ['name', 'ip_address', 'mac_address'], limit: 1 })
    const e = rows && rows[0]; if (e) { seName = e.name; seMac = e.mac_address || ''; if (!ip) ip = e.ip_address || '' }
  } catch (e) { /* best-effort */ }
  if (!sn && !ip) return { ok: false, error: 'serial ou ip requis' }
  const token = opsBridgeToken()
  const base = (process.env.OPS_LEGACY_URL || 'https://facturation.targo.ca/ops_reassign.php').replace(/ops_reassign\.php.*$/, 'ops_airos.php')
  if (!token || !base) return { ok: false, error: 'pont F non configuré' }
  const q = new URLSearchParams({ mode: 'status' })
  if (sn) q.set('serial', sn); if (ip) q.set('ip', ip)
  const r = await httpsGetJson(base + '?' + q.toString(), { 'X-Ops-Token': token }, 15000)
  if (!r || r._err) return { ok: false, error: (r && r._err) || 'pont F injoignable' }
  if (r.ok === false) return { ok: false, error: r.error }
  const out = { ok: true, kind: 'sans-fil', online: (r.online === true || r.online === false) ? r.online : null, since: r.since || null, model: r.model || null, radiusMac: r.radius_mac || null, framedIp: r.framed_ip || null, apIp: r.ap_ip || null }
  await autoSyncMac(out, seName, seMac) // le status porte aussi la MAC RADIUS → auto-sync au chargement de page
  return out
}

// Réconciliation MAC park-wide (job quotidien 03 h) : 1 appel bulk F (radacct courant) → MàJ Service Equipment changées.
async function syncWirelessMacs () {
  const token = opsBridgeToken()
  const base = (process.env.OPS_LEGACY_URL || 'https://facturation.targo.ca/ops_reassign.php').replace(/ops_reassign\.php.*$/, 'ops_airos.php')
  if (!token || !base) return { ok: false, error: 'pont F non configuré' }
  const r = await httpsGetJson(base + '?bulk=1', { 'X-Ops-Token': token }, 60000)
  if (!r || r._err || !Array.isArray(r.devices)) return { ok: false, error: (r && r._err) || 'bulk KO' }
  // Index des Service Equipment sans-fil par IP de gestion (clé stable même quand la MAC change).
  const existing = await erp.list('Service Equipment', { filters: [['ip_address', '!=', '']], fields: ['name', 'ip_address', 'mac_address'], limit: 8000 }).catch(() => [])
  const byIp = {}; for (const e of existing) if (e.ip_address) byIp[String(e.ip_address).trim()] = e
  let checked = 0, updated = 0, errs = 0, brokenLoc = 0
  for (const d of r.devices) {
    if (!d.radius_mac || !d.manage) continue
    const se = byIp[String(d.manage).trim()]; if (!se) continue
    checked++
    if (macNorm(se.mac_address) !== macNorm(d.radius_mac)) {
      const res = await erp.update('Service Equipment', se.name, { mac_address: d.radius_mac }).catch(e => ({ ok: false, error: String(e.message || e) }))
      if (res && res.ok) updated++
      else { errs++; if (/LinkValidation|Lieu de service/i.test(String(res && res.error))) brokenLoc++ }
    }
  }
  log('[airos] daily MAC sync — bulk ' + r.devices.length + ' · checked ' + checked + ' · updated ' + updated + ' · errs ' + errs + (brokenLoc ? ' (dont ' + brokenLoc + ' lien service_location cassé)' : ''))
  return { ok: true, bulk: r.devices.length, checked, updated, errs, broken_location: brokenLoc }
}

// Planificateur quotidien 03 h ET (tick /30 min ; s'exécute 1×/jour dans la fenêtre 03h). Désactivable WIRELESS_MAC_SYNC=off.
let _lastMacSyncDay = null; let _macSyncStarted = false
function startWirelessMacSync () {
  if (process.env.WIRELESS_MAC_SYNC === 'off') return
  const tick = async () => {
    try {
      const now = new Date()
      const day = now.toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
      const hour = parseInt(new Intl.DateTimeFormat('en-US', { timeZone: 'America/Toronto', hour: 'numeric', hour12: false }).format(now), 10)
      if (hour === 3 && _lastMacSyncDay !== day) { _lastMacSyncDay = day; await syncWirelessMacs() }
    } catch (e) { log('[airos] mac-sync tick: ' + e.message) }
  }
  setInterval(tick, 30 * 60 * 1000)
  log('[airos] wireless MAC daily reconcile scheduled (03 h ET)')
}

function airosViaN8n ({ sn, ip, port, user } = {}) {
  return new Promise((resolve) => {
    let https; try { https = require('https') } catch (e) { return resolve({ ok: false, error: 'https indisponible' }) }
    const body = JSON.stringify({ sn: String(sn || ''), ip: String(ip || ''), port: Number(port) || 2196, user: String(user || 'admin') })
    const req = https.request('https://n8napi.targo.ca/webhook/get_signal_airos', { method: 'GET', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 25000 }, (res) => {
      let d = ''; res.on('data', c => { d += c }); res.on('end', () => {
        if (res.statusCode === 404) return resolve({ ok: false, registered: false, error: 'Signal sans-fil indisponible (pont F injoignable + webhook n8n « get_signal_airos » absent)' })
        let arr; try { arr = JSON.parse(d) } catch (e) { return resolve({ ok: false, error: 'réponse n8n illisible' }) }
        const r = Array.isArray(arr) ? arr[0] : arr
        if (!r || r.ErrorCode || r.error) return resolve({ ok: false, error: (r && (r.ErrorCode || r.error)) || 'réponse vide (CPE injoignable ?)' })
        resolve(normalizeAiros(r))
      })
    })
    req.on('error', e => resolve({ ok: false, error: e.message }))
    req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'délai dépassé (~25 s) — CPE lent ou hors ligne' }) })
    req.write(body); req.end()
  })
}

module.exports = { handle, addComment, activity, createTicket, serviceStatus, dostuffStatus, airosSignal, airosStatus, syncWirelessMacs, startWirelessMacSync }
