'use strict'
/**
 * Événements clients + inscription (RSVP).
 *
 *  - Page PUBLIQUE tokenisable : GET /rsvp/<eventId>[?t=<jwt>&lang=fr|en]
 *      Champs : nom, courriel, téléphone, n° client (optionnel), combien, allergies.
 *      ?t=<jwt magic-link client> → pré-remplit nom/courriel + rattache d'office.
 *  - POST /rsvp/<eventId>/submit → rattachement + validation croisée AVEUGLE (voir matchAndValidate),
 *      clé par client → re-soumission = mise à jour (pas de doublon). Jamais bloquant.
 *  - Endpoints STAFF (gatés) : GET /events, POST /events (créer), PUT/DELETE /events/<id>,
 *      GET /events/<id>/rsvps (+ décompte + confiance), DELETE /events/<id>/rsvps/<key>,
 *      POST /events/<id>/send (mode test), POST /events/<id>/audience (aperçu),
 *      POST /events/<id>/attachments (téléverser une pièce jointe image), DELETE /events/<id>/attachments/<nom>.
 *
 * MODÈLE : chaque événement réutilise la mise en page festive (barre confetti, pastille badge,
 * pastilles de programme). Le CONTENU (nom, date, textes, programme, badge, capacité, pièces jointes)
 * est ÉDITABLE et persisté par fichier ; les LIBELLÉS d'interface (champs, erreurs, remerciements)
 * sont communs à tous les événements (const LABELS). L'événement « Fête 20 ans » est un cas semé.
 *
 * CAPACITÉ : `capacity` (0 = illimité) = plafond de PERSONNES (somme des party_size). À l'atteinte,
 * le formulaire public est remplacé par un message « inscriptions complètes » (enforcement serveur
 * ET client) ; un inscrit existant (jeton) peut toujours modifier sa réponse.
 *
 * PIÈCES JOINTES : images/fichiers joints au COURRIEL d'invitation (masse + test) uniquement — pas
 * affichées sur la page RSVP. Stockées sur disque (DATA_DIR/attachments/<id>/), métadonnées dans la config.
 *
 * IMPORTANT (demande user) : AUCUN autosuggest/auto-remplissage à partir des données client
 * (fuite de PII). Le client saisit ce qu'il connaît ; on valide côté serveur, à la soumission,
 * sans jamais RENVOYER de donnée stockée. « confirmed » = ≥2 infos concordent sur le même compte.
 *
 * Réutilise : magic-link.generateCustomerToken/verifyJwt · portal-auth.lookupCustomer ·
 *             helpers.lookupCustomersByEmail/lookupCustomersByPhone · email.sendEmail / gmail.sendMessage.
 */
const fs = require('fs')
const path = require('path')
const cfg = require('./config')
const { log, json, parseBody, readJsonFile, writeJsonFile, lookupCustomersByEmail, lookupCustomersByPhone } = require('./helpers')
const { generateCustomerToken, verifyJwt } = require('./magic-link')

const DATA_DIR = process.env.EVENTS_DATA_DIR || '/app/data/events'
const CONFIG_DIR = path.join(DATA_DIR, 'config') // configs d'événements créés/édités (shadow le SEED)
const ATT_DIR = path.join(DATA_DIR, 'attachments') // pièces jointes des courriels d'invitation
const AUDIENCE_DIR = path.join(DATA_DIR, 'audience') // liste principale d'audience par événement (additive)
try { fs.mkdirSync(CONFIG_DIR, { recursive: true }) } catch { /* best-effort */ }
try { fs.mkdirSync(ATT_DIR, { recursive: true }) } catch { /* best-effort */ }
try { fs.mkdirSync(AUDIENCE_DIR, { recursive: true }) } catch { /* best-effort */ }
const pub = () => cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca'
const LOGO = 'https://msg.gigafibre.ca/campaigns/assets/6a2bcb6057d9881c08304a5d2fea8723e2a15b05061fbbe3590bd4a0ee714477.png'
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]))
const normLang = (l) => /^en/i.test(String(l || '')) ? 'en' : 'fr'
const firstName = (n) => String(n || '').trim().split(/\s+/)[0] || ''

// ── Libellés d'interface COMMUNS (indépendants de l'événement) ─────────────
// Séparés du contenu éditable : mêmes champs/erreurs/remerciements pour tous les événements.
const LABELS = {
  fr: {
    f_name: 'Votre nom',
    f_email: 'Votre adresse courriel',
    f_phone: 'Votre téléphone',
    f_number: 'Votre numéro client',
    f_party: 'Combien serez-vous ?',
    f_allerg: 'Allergies ou restrictions alimentaires',
    opt: '(optionnel)',
    f_party_ph: 'Nombre de personnes',
    submit: 'Confirmer ma présence',
    greet: (n) => n ? `Bonjour ${n} !` : '',
    thanks_title: 'Merci, votre présence est confirmée ! 🎉',
    thanks_body: "On a hâte de vous voir. Vous recevrez votre coupon repas à l'accueil.",
    thanks_recognized: (n) => `✓ Nous vous avons reconnu${n} — inscription reliée à votre compte.`,
    thanks_tocheck: 'Nous validerons votre inscription sous peu.',
    thanks_update: 'Vous pouvez modifier votre réponse en tout temps depuis ce lien.',
    err_name: 'Veuillez entrer votre nom.',
    err_party: 'Veuillez indiquer combien de personnes seront présentes.',
    err_email: 'Veuillez entrer une adresse courriel valide.',
    err_generic: "Une erreur s'est produite. Réessayez ou écrivez-nous.",
    foot: 'TARGO — votre fournisseur Internet local',
    full_title: 'Inscriptions complètes',
    full_msg: "Malheureusement, les inscriptions à l'événement sont complètes.",
  },
  en: {
    f_name: 'Your name',
    f_email: 'Your email address',
    f_phone: 'Your phone',
    f_number: 'Your customer number',
    f_party: 'How many will attend?',
    f_allerg: 'Allergies or dietary restrictions',
    opt: '(optional)',
    f_party_ph: 'Number of people',
    submit: 'Confirm my attendance',
    greet: (n) => n ? `Hi ${n}!` : '',
    thanks_title: "Thank you, you're confirmed! 🎉",
    thanks_body: 'We look forward to seeing you. Pick up your meal coupon at the welcome desk.',
    thanks_recognized: (n) => `✓ We found your account${n} — your RSVP is linked.`,
    thanks_tocheck: "We'll confirm your registration shortly.",
    thanks_update: 'You can change your answer anytime from this link.',
    err_name: 'Please enter your name.',
    err_party: 'Please tell us how many people will attend.',
    err_email: 'Please enter a valid email address.',
    err_generic: 'Something went wrong. Please try again or contact us.',
    foot: 'TARGO — your local Internet provider',
    full_title: 'Registration full',
    full_msg: 'Unfortunately, the event booking is full.',
  },
}

// ── Contenu éditable des événements (semé : Fête Targo 20 ans) ─────────────
// Forme brute persistée : { id, active, date_iso, badge_top, badge_bottom, capacity, attachments[],
//                           fr:{ name, tagline, when, body, program:[{icon,label,sub}], program_line, limited, closing, notes[] }, en:{...} }
const SEED_CONTENT = {
  'fete-20-ans': {
    id: 'fete-20-ans',
    active: true,
    date_iso: '2026-08-01',
    badge_top: '20',
    badge_bottom: 'ANS',
    capacity: 0,
    attachments: [],
    fr: {
      name: 'Targo fête ses 20 ans',
      tagline: "Toute l'équipe de Targo est heureuse de vous inviter à notre grande fête d'anniversaire !",
      when: '1ᵉʳ août, de 10 h à 15 h',
      address: 'À nos bureaux : 1867 chemin de la rivière, Ste-Clotilde',
      body: "Joignez-vous à nous pour une journée de plaisir, de rencontres et de célébration en compagnie des employés de Targo et de leurs familles.",
      program: [
        { icon: '🚚', label: 'Food truck', sub: 'repas inclus' },
        { icon: '🎧', label: 'DJ', sub: '' },
        { icon: '🎪', label: 'Jeux gonflables', sub: '' },
        { icon: '🎨', label: 'Maquillage', sub: '' },
        { icon: '🎁', label: 'Et des surprises', sub: '' },
      ],
      program_line: "Le tout dans une ambiance familiale et conviviale.",
      limited: "Les places étant limitées, nous vous invitons à confirmer votre présence dès que possible en remplissant le formulaire ci-dessous.",
      closing: "Nous avons hâte de vous retrouver pour célébrer ensemble !",
      notes: [
        'Si vous avez des allergies, le menu pourrait ne pas vous convenir.',
        "Vous recevrez un coupon repas sur place (à l'accueil).",
      ],
    },
    en: {
      name: 'Targo turns 20!',
      tagline: 'The whole Targo team is happy to invite you to our big anniversary celebration!',
      when: 'August 1st, 10 a.m. to 3 p.m.',
      address: 'At our offices: 1867 chemin de la rivière, Ste-Clotilde',
      body: 'Join us for a day of fun, meeting people and celebrating alongside the Targo team and their families.',
      program: [
        { icon: '🚚', label: 'Food truck', sub: 'meal included' },
        { icon: '🎧', label: 'DJ', sub: '' },
        { icon: '🎪', label: 'Bouncy castles', sub: '' },
        { icon: '🎨', label: 'Face painting', sub: '' },
        { icon: '🎁', label: 'And surprises', sub: '' },
      ],
      program_line: 'All in a warm, family-friendly atmosphere.',
      limited: 'Space is limited, so please confirm your attendance as soon as possible using the form below.',
      closing: "We can't wait to celebrate together!",
      notes: [
        'If you have allergies, the menu may not suit you.',
        'You will receive a meal coupon on site (at the welcome desk).',
      ],
    },
  },
}

// ── Persistance des configs (fichier shadow le SEED ; édition/attachments écrivent ici) ──
function configFile (id) { return path.join(CONFIG_DIR, `${id}.json`) }
function rawConfig (id) {
  const stored = readJsonFile(configFile(id), null)
  if (stored && stored.id) return stored
  if (SEED_CONTENT[id]) return JSON.parse(JSON.stringify(SEED_CONTENT[id]))
  return null
}
function saveConfig (id, obj) { obj.id = id; writeJsonFile(configFile(id), obj) }

// Construit l'objet événement d'exécution : contenu + LIBELLÉS communs (EN retombe sur FR si vide).
function buildEvent (base) {
  if (!base) return null
  const content = (lang) => {
    const c = base[lang] && Object.keys(base[lang]).length ? base[lang] : (base.fr || {})
    return { ...LABELS[lang], ...c, program: Array.isArray(c.program) ? c.program : [], notes: Array.isArray(c.notes) ? c.notes : [] }
  }
  return {
    id: base.id,
    active: base.active !== false,
    date_iso: base.date_iso || '',
    badge_top: String(base.badge_top || ''),
    badge_bottom: String(base.badge_bottom || ''),
    capacity: Math.max(0, parseInt(base.capacity, 10) || 0),
    email_template: String(base.email_template || ''), // '' = gabarit festif intégré ; sinon nom d'un template éditable (Unlayer)
    attachments: Array.isArray(base.attachments) ? base.attachments : [],
    fr: content('fr'),
    en: content('en'),
  }
}
function getEvent (eventId) { return buildEvent(rawConfig(eventId)) }
function listEventIds () {
  const ids = new Set(Object.keys(SEED_CONTENT))
  try { for (const f of fs.readdirSync(CONFIG_DIR)) if (f.endsWith('.json')) ids.add(f.slice(0, -5)) } catch { /* best-effort */ }
  return [...ids]
}
function listEvents () {
  return listEventIds().map(getEvent).filter(Boolean)
    .map(e => ({ id: e.id, active: e.active, date_iso: e.date_iso, name: e.fr.name, capacity: e.capacity, attachments: (e.attachments || []).length }))
    .sort((a, b) => (b.date_iso || '').localeCompare(a.date_iso || ''))
}

// ── Slug / validation à la création ────────────────────────────────────────
function slugify (s) {
  return String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
    .replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60)
}
function uniqueId (base) {
  let id = base || 'evenement'; let n = 2
  const taken = new Set(listEventIds())
  while (taken.has(id)) { id = `${base}-${n++}` }
  return id
}
function sanitizeProgram (arr) {
  return (Array.isArray(arr) ? arr : []).slice(0, 8).map(p => ({
    icon: String(p.icon || '').slice(0, 8),
    label: String(p.label || '').slice(0, 60),
    sub: String(p.sub || '').slice(0, 60),
  })).filter(p => p.label || p.icon)
}
function sanitizeLangContent (c) {
  c = c || {}
  return {
    name: String(c.name || '').slice(0, 160),
    tagline: String(c.tagline || '').slice(0, 300),
    when: String(c.when || '').slice(0, 120),
    address: String(c.address || '').slice(0, 200),
    body: String(c.body || '').slice(0, 1200),
    program: sanitizeProgram(c.program),
    program_line: String(c.program_line || '').slice(0, 200),
    limited: String(c.limited || '').slice(0, 400),
    closing: String(c.closing || '').slice(0, 300),
    notes: (Array.isArray(c.notes) ? c.notes : []).slice(0, 6).map(n => String(n || '').slice(0, 300)).filter(Boolean),
  }
}
// Normalise le corps de création/édition en une config brute persistable.
function normalizeConfig (b, existing = null) {
  const fr = sanitizeLangContent(b.fr)
  const enRaw = b.en && Object.values(b.en).some(v => (Array.isArray(v) ? v.length : String(v || '').trim())) ? sanitizeLangContent(b.en) : null
  return {
    id: existing ? existing.id : undefined,
    active: b.active !== false,
    date_iso: /^\d{4}-\d{2}-\d{2}$/.test(String(b.date_iso || '')) ? b.date_iso : (existing ? existing.date_iso : ''),
    badge_top: String(b.badge_top || '').slice(0, 6),
    badge_bottom: String(b.badge_bottom || '').slice(0, 8),
    capacity: Math.max(0, parseInt(b.capacity, 10) || 0),
    email_template: /^[a-z0-9-]+$/.test(String(b.email_template || '')) ? String(b.email_template) : '',
    attachments: existing && Array.isArray(existing.attachments) ? existing.attachments : [],
    fr,
    ...(enRaw ? { en: enRaw } : {}),
  }
}

// ── Stockage des réponses (une map par événement, clé = client) ────────────
function respFile (eventId) { return path.join(DATA_DIR, `${eventId}.json`) }
function loadResp (eventId) { const d = readJsonFile(respFile(eventId), {}); return (d && typeof d === 'object' && !Array.isArray(d)) ? d : {} }
function saveResp (eventId, m) { writeJsonFile(respFile(eventId), m) }
function headcountOf (m) { return Object.values(m).reduce((s, r) => s + (parseInt(r.party_size, 10) || 0), 0) }

const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/
function clampParty (v) { const n = parseInt(v, 10); if (!Number.isFinite(n) || n < 1) return null; return Math.min(30, n) }

// ── Comparaison de noms (signal CORROBORANT, aveugle) ──────────────────────
function normName (s) { return String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^a-z\s]/g, ' ').split(/\s+/).filter(t => t.length >= 2) }
function nameMatches (entered, candidate) {
  const A = new Set(normName(entered)); const B = normName(candidate)
  if (!A.size || !B.length) return false
  const shared = B.filter(t => A.has(t)).length
  return shared >= 2 || (shared >= 1 && (A.size <= 1 || B.length <= 1))
}

// ── Rattachement + validation croisée (AVEUGLE : ne révèle/renvoie JAMAIS de PII) ──
// Jeton = preuve forte. Sinon on cherche des candidats par courriel/téléphone/numéro (en parallèle) ;
// le NOM saisi corrobore. « confirmed » = ≥2 signaux concordent sur le MÊME compte ; « probable » = 1 ;
// « none » = aucun (accepté quand même). Aucun autosuggest, aucun retour de donnée client.
async function matchAndValidate ({ token = '', number = '', email = '', phone = '', name = '' } = {}) {
  if (token) {
    const p = verifyJwt(token)
    if (p && p.scope === 'customer' && p.sub) return { customer_id: p.sub, customer_name: p.name || name || '', confidence: 'confirmed', signals: ['lien'] }
  }
  const votes = {}; const nameById = {}
  const add = (cid, cname, sig) => { if (!cid) return; (votes[cid] = votes[cid] || new Set()).add(sig); if (cname && !nameById[cid]) nameById[cid] = cname }
  const tasks = []
  if (email) tasks.push(lookupCustomersByEmail(email, 5).then(r => (r || []).forEach(c => add(c.name, c.customer_name, 'courriel'))).catch(() => {}))
  if (phone) tasks.push(lookupCustomersByPhone(phone, 5).then(r => (r || []).forEach(c => add(c.name, c.customer_name, 'téléphone'))).catch(() => {}))
  if (number) tasks.push(require('./portal-auth').lookupCustomer(number).then(c => { if (c && c.name) add(c.name, c.customer_name, 'numéro') }).catch(() => {}))
  await Promise.all(tasks)
  if (name) for (const cid of Object.keys(votes)) if (nameMatches(name, nameById[cid])) votes[cid].add('nom')
  let best = null; let bestN = 0
  for (const [cid, set] of Object.entries(votes)) if (set.size > bestN) { best = cid; bestN = set.size }
  if (!best) return { customer_id: null, customer_name: '', confidence: 'none', signals: [] }
  const signals = [...votes[best]]
  return { customer_id: best, customer_name: nameById[best] || '', confidence: signals.length >= 2 ? 'confirmed' : 'probable', signals }
}

function keyFor (resolved, form) {
  if (resolved.customer_id) return 'c:' + resolved.customer_id
  const id = (form.number || form.email || form.phone || '').trim().toLowerCase()
  return 'x:' + id
}

// ── Lien perso + courriel d'invitation ─────────────────────────────────────
// `lang` (optionnel) → ajouté en ?lang= EXPLICITE sur l'URL : la page RSVP priorise TOUJOURS ce
// paramètre de requête sur la langue du jeton (cf handle() : `if (!url.searchParams.get('lang') && p.lang)`).
// Sans ça, la version EN du courriel pointait vers la même URL que la FR (défaut FR). Nécessaire
// même si le modèle éditable (Unlayer) ne permet pas de fixer un href différent par langue.
function rsvpLink (eventId, customerId, name, email, ttlHours = 60 * 24, lang = '') {
  const tok = generateCustomerToken(customerId, name, email, ttlHours)
  const l = lang ? `&lang=${encodeURIComponent(normLang(lang))}` : ''
  return `${pub()}/rsvp/${encodeURIComponent(eventId)}?t=${encodeURIComponent(tok)}${l}`
}
function inviteSubject (eventId, lang) {
  const e = getEvent(eventId); if (!e) return ''
  return normLang(lang) === 'en' ? `You're invited — ${e.en.name} 🎉` : `Vous êtes invité — ${e.fr.name} 🎉`
}
// Variables Mustache offertes aux templates éditables d'invitation d'événement.
function inviteTemplateVars (e, lang, name, rsvpUrl) {
  const t = e[lang]
  return {
    firstname: firstName(name), name: name || '', rsvp_url: rsvpUrl, gift_url: rsvpUrl, // gift_url = alias (templates cadeau réutilisables)
    event_name: t.name, tagline: t.tagline, when: t.when, address: t.address, body: t.body, closing: t.closing,
    year: String(new Date().getFullYear()), lang,
  }
}
// Invitation : modèle éditable (Unlayer) si configuré, sinon gabarit festif intégré.
function inviteEmail (eventId, lang, name, rsvpUrl) {
  const e = getEvent(eventId); if (!e) return ''
  lang = normLang(lang)
  if (e.email_template) {
    const html = require('./campaigns').renderNamedTemplate(e.email_template, inviteTemplateVars(e, lang, name, rsvpUrl))
    if (html) return html // template introuvable → repli festif
  }
  return inviteEmailFestive(e, lang, name, rsvpUrl)
}
// Gabarit festif intégré (charte TARGO garantie) — utilisé par défaut et en repli.
function inviteEmailFestive (e, lang, name, rsvpUrl) {
  lang = normLang(lang); const t = e[lang]
  const merci = lang === 'en' ? 'Thank you for your trust over the years! 💚' : 'Merci de votre confiance au fil des années ! 💚'
  const badge = [e.badge_top, e.badge_bottom].filter(Boolean).join(' ')
  const CIRC = ['#e8f5ec', '#e6f2f9', '#f1f5f9', '#e8f5ec', '#e6f2f9'] // vert / bleu / gris (palette TARGO)
  const LBL = ['#1e8e3e', '#1a6f9e', '#4b5563', '#1e8e3e', '#1a6f9e']
  const prog = (t.program || []).map((p, i) =>
    `<td align="center" valign="top" style="padding:6px 3px">`
    + `<div style="width:50px;height:50px;line-height:50px;border-radius:25px;background:${CIRC[i % 5]};font-size:25px;margin:0 auto">${esc(p.icon)}</div>`
    + `<div style="margin-top:6px;font-size:10px;font-weight:700;color:${LBL[i % 5]};line-height:1.2">${esc(p.label)}</div></td>`).join('')
  return `<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>`
    + `<body style="margin:0;background:#eef4f8;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1e293b">`
    + `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#eef4f8"><tr><td align="center" style="padding:30px 14px">`
    + `<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;background:#fff;border-radius:18px;overflow:hidden;border:1px solid #e2e8f0">`
    + `<tr><td style="height:10px;background:#fff;font-size:0;line-height:0">&nbsp;</td></tr>`
    + `<tr><td align="center" style="padding:22px 30px 0">`
    + `<div style="font-size:24px;letter-spacing:6px;line-height:1">🎈🎉🎈</div>`
    + (badge ? `<div style="display:inline-block;background:#21a34a;color:#fff;font-weight:800;font-size:12px;letter-spacing:1.5px;padding:6px 16px;border-radius:999px;margin:10px 0 8px">🎉 ${esc(badge)} 🎉</div><br>` : '<div style="height:10px"></div>')
    + `<img src="${LOGO}" alt="TARGO" width="150" style="width:150px;max-width:150px;height:auto;display:inline-block;border:0;margin:2px 0 22px"></td></tr>`
    + `<tr><td align="center" style="padding:8px 26px 0"><h1 style="margin:6px 0 4px;font-size:27px;font-weight:800;color:#1f2937;line-height:1.15">${esc(t.name)} 🎉</h1>`
    + `<p style="margin:0;font-size:15px;font-weight:700;color:#21a34a;line-height:1.4">${esc(t.tagline)}</p></td></tr>`
    + `<tr><td style="padding:16px 34px 4px"><p style="margin:0 0 10px;font-size:15px;line-height:1.6;color:#475569">${esc(t.greet(firstName(name)) || '')}</p>`
    + `<p style="margin:0;font-size:15px;line-height:1.6;color:#475569">${esc(t.body)}</p></td></tr>`
    + (t.when ? `<tr><td align="center" style="padding:16px 30px 6px"><table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="background:#1f2937;border-radius:12px;padding:13px 24px;color:#fff;font-size:18px;font-weight:800">📅 ${esc(t.when)}</td></tr></table></td></tr>` : '')
    + (t.address ? `<tr><td align="center" style="padding:2px 30px 6px"><p style="margin:0;font-size:14px;font-weight:600;color:#374151">📍 ${esc(t.address)}</p></td></tr>` : '')
    + ((t.program || []).length ? `<tr><td style="padding:14px 18px 4px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>${prog}</tr></table></td></tr>` : '')
    + (t.program_line ? `<tr><td align="center" style="padding:2px 30px 8px"><p style="margin:0;font-size:13px;font-style:italic;color:#94a3b8">${esc(t.program_line)}</p></td></tr>` : '')
    + (t.limited ? `<tr><td style="padding:6px 30px 4px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6;border:1px solid #e5e7eb;border-radius:12px"><tr><td style="padding:12px 16px;font-size:14px;line-height:1.6;color:#374151">⏳ ${esc(t.limited)}</td></tr></table></td></tr>` : '')
    + `<tr><td align="center" style="padding:20px 30px 6px"><table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="background:#21a34a;border-radius:14px;box-shadow:0 6px 16px rgba(33,163,74,.3)"><a href="${esc(rsvpUrl)}" target="_blank" style="display:inline-block;padding:16px 40px;color:#fff;font-size:17px;font-weight:800;text-decoration:none">${esc(t.submit)} →</a></td></tr></table></td></tr>`
    + `<tr><td align="center" style="padding:8px 30px 20px"><p style="margin:0;font-size:15px;font-weight:700;color:#21a34a">${esc(merci)}</p>`
    + `<p style="margin:6px 0 0;font-size:13px;color:#94a3b8">${esc(t.closing)}</p></td></tr>`
    + `<tr><td style="background:#f0f7f2;padding:16px 30px;text-align:center;font-size:12px;line-height:1.6;color:#94a3b8;border-top:1px solid #e2e8f0">${(t.notes || []).map(esc).join('<br>')}`
    + `<div style="margin-top:12px;padding-top:12px;border-top:1px solid #dce8e0"><a href="https://targo.ca" style="color:#21a34a;font-weight:700;text-decoration:none">🌐 targo.ca</a> &nbsp;·&nbsp; ${esc(t.foot)}</div></td></tr>`
    + `</table></td></tr></table></body></html>`
}

// ── Pièces jointes du courriel d'invitation ────────────────────────────────
const ATT_MAX_BYTES = 4 * 1024 * 1024 // 4 Mo par fichier
const ATT_MAX_COUNT = 6
const ATT_MIME_OK = /^(image\/(png|jpe?g|gif|webp)|application\/pdf)$/i
function eventAttDir (eventId) { const d = path.join(ATT_DIR, eventId); try { fs.mkdirSync(d, { recursive: true }) } catch { /* */ } return d }
function safeName (name) { return String(name || 'fichier').replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 80) || 'fichier' }
// Langue d'une pièce jointe : 'fr' | 'en' | 'both' (both = envoyée quelle que soit la langue du client).
function normAttLang (l) { l = String(l || '').toLowerCase(); return (l === 'fr' || l === 'en') ? l : 'both' }
// Détection best-effort de la langue d'une image (affiche/flyer) via Gemini Vision. 'both' si échec/ambigu.
const LANG_SCHEMA = { type: 'object', properties: { lang: { type: 'string', enum: ['fr', 'en', 'both'] } }, required: ['lang'] }
async function detectAttachmentLang (base64, mime) {
  try {
    if (!/^image\//i.test(String(mime || ''))) return 'both' // PDF & co : pas d'auto-détection
    const r = await require('./vision').geminiVision(base64,
      'This is a marketing/event flyer or poster image. Detect the PRIMARY written language of its text. Answer "fr" (French), "en" (English), or "both" if bilingual or if there is no meaningful text.',
      LANG_SCHEMA, String(mime))
    return normAttLang(r && r.lang)
  } catch (e) { log('detectAttachmentLang: ' + e.message); return 'both' }
}
// Ajoute une pièce jointe (data base64) à un événement → persiste le fichier + métadonnée (dont langue).
// lang: 'fr'|'en'|'both' ; lang='auto' → détection Gemini best-effort (images seulement).
async function addAttachment (eventId, { filename, mime, data, lang }) {
  const base = rawConfig(eventId); if (!base) return { error: 'event_not_found' }
  if (!ATT_MIME_OK.test(String(mime || ''))) return { error: 'bad_type' }
  const raw = String(data || '').replace(/^data:[^,]*,/, '')
  const buf = Buffer.from(raw, 'base64')
  if (!buf.length) return { error: 'empty' }
  if (buf.length > ATT_MAX_BYTES) return { error: 'too_large' }
  base.attachments = Array.isArray(base.attachments) ? base.attachments : []
  if (base.attachments.length >= ATT_MAX_COUNT) return { error: 'too_many' }
  const alang = String(lang || '').toLowerCase() === 'auto' ? await detectAttachmentLang(raw, mime) : normAttLang(lang)
  const stored = Date.now().toString(36) + '-' + safeName(filename)
  fs.writeFileSync(path.join(eventAttDir(eventId), stored), buf)
  const meta = { filename: safeName(filename), stored, mime: String(mime), size: buf.length, lang: alang }
  base.attachments.push(meta)
  saveConfig(eventId, base)
  return { attachment: meta, attachments: base.attachments }
}
function removeAttachment (eventId, stored) {
  const base = rawConfig(eventId); if (!base) return { error: 'event_not_found' }
  base.attachments = (base.attachments || []).filter(a => a.stored !== stored)
  try { fs.unlinkSync(path.join(eventAttDir(eventId), stored)) } catch { /* déjà absent */ }
  saveConfig(eventId, base)
  return { attachments: base.attachments }
}
// Lit les pièces jointes en mémoire pour l'envoi (buffers), FILTRÉES par langue du destinataire.
// On joint les fichiers de SA langue + les fichiers 'both' (neutres). Ignore un fichier manquant.
function loadSendAttachments (event, lang) {
  const want = normLang(lang) // 'fr' ou 'en'
  const out = []
  for (const a of (event.attachments || [])) {
    const al = a.lang || 'both'
    if (al !== 'both' && al !== want) continue
    try { out.push({ filename: a.filename, mime: a.mime, buffer: fs.readFileSync(path.join(eventAttDir(event.id), a.stored)) }) } catch { /* fichier absent */ }
  }
  return out
}

// Normalise les pièces jointes (buffers) au format attendu par le canal.
function attachmentPayload (channel, atts) {
  return channel === 'gmail'
    ? atts.map(a => ({ filename: a.filename, mime: a.mime, content: a.buffer.toString('base64') }))
    : atts.map(a => ({ filename: a.filename, contentType: a.mime, content: a.buffer }))
}
// Envoi bas niveau d'un courriel d'invitation. customId (Mailjet) = suivi ouvertures/clics.
async function sendInviteMessage ({ channel, to, subject, html, from, attachments, customId }) {
  if (channel === 'gmail') { const r = await require('./gmail').sendMessage({ to, subject, html, from: from || undefined, attachments }); return { ok: !!r, id: r && r.id } }
  const headers = customId ? { 'X-MJ-CustomID': customId } : undefined
  const info = await require('./email').sendEmail({ to, subject, html, from: from || undefined, attachments, headers })
  return { ok: !!info, id: info && info.messageId, error: info ? null : ((require('./email').getLastError() || {}).message || 'send_failed') }
}

// ── Envoi TEST (déclenché par le staff depuis l'admin ; jamais automatique) ──
async function sendTest ({ eventId, emails, channel = 'mailjet', from = '', lang = 'fr', name = '' }) {
  const event = getEvent(eventId)
  const atts = attachmentPayload(channel, loadSendAttachments(event, lang)) // pièces jointes de la langue testée (+ 'both')
  const results = []
  for (const em of emails) {
    const link = rsvpLink(eventId, 'TEST-' + em, name || 'Test', em, 24, lang)
    const html = inviteEmail(eventId, lang, name, link)
    const subject = '[TEST] ' + inviteSubject(eventId, lang)
    try { const r = await sendInviteMessage({ channel, to: em, subject, html, from, attachments: atts }); results.push({ email: em, ok: r.ok, error: r.error }) }
    catch (e) { results.push({ email: em, ok: false, error: e.message }) }
  }
  return results
}

// ── Envoi de MASSE (blast d'invitations) ───────────────────────────────────
// Crée un enregistrement CAMPAGNE (suivi Mailjet via X-MJ-CustomID `<id>:<i>` → webhook campagne existant,
// report.csv, /campaigns list/détail, SSE) MAIS events fait l'envoi lui-même : lien rsvp_url PERSONNEL par
// destinataire + pièces jointes par langue + gabarit festif/Unlayer. Le worker campagne ne gère ni rsvp_url
// ni pièces jointes ni HTML festif → on ne le réutilise pas, seulement son format d'enregistrement.
function massSend (eventId, { channel = 'mailjet', from = '' } = {}) {
  const event = getEvent(eventId); if (!event) return { error: 'event_not_found' }
  const aud = loadAudienceList(eventId)
  const recipients = (aud.recipients || []).filter(r => EMAIL_RE.test(r.email || ''))
  if (!recipients.length) return { error: 'empty_list' }
  const campaigns = require('./campaigns')
  const id = campaigns.newCampaignId()
  const ch = channel === 'gmail' ? 'gmail' : 'mailjet'
  const campaign = {
    id,
    name: `Invitation — ${event.fr.name}`,
    created_at: new Date().toISOString(),
    status: 'sending',
    params: { channel: ch, from: from || '', type: 'event', event_id: eventId, template: event.email_template || 'festive' },
    recipients: recipients.map(r => ({ email: r.email, firstname: r.firstname || '', lastname: r.lastname || '', language: normLang(r.language), customer_id: r.customer_id || '', source: r.source || '', status: 'pending' })),
  }
  campaigns.saveCampaign(campaign)
  setImmediate(() => sendEventCampaignAsync(id, eventId, ch, from).catch(e => log('event blast async: ' + e.message)))
  return { campaign_id: id, count: campaign.recipients.length }
}

async function sendEventCampaignAsync (id, eventId, channel, from) {
  const campaigns = require('./campaigns')
  const c = campaigns.loadCampaign(id); if (!c) return
  const event = getEvent(eventId); if (!event) return
  let sse = null; try { sse = require('./sse') } catch { /* SSE optionnel */ }
  const topic = 'campaign:' + id
  const bcast = (ev, data) => { try { sse && sse.broadcast(topic, ev, data) } catch { /* */ } }
  const payloadByLang = { fr: attachmentPayload(channel, loadSendAttachments(event, 'fr')), en: attachmentPayload(channel, loadSendAttachments(event, 'en')) }
  const sleep = (ms) => new Promise(r => setTimeout(r, ms))
  bcast('campaign-status', { id, status: 'sending' })
  for (let i = 0; i < c.recipients.length; i++) {
    const r = c.recipients[i]
    if (r.status !== 'pending') continue
    const lang = normLang(r.language)
    const name = ((r.firstname || '') + ' ' + (r.lastname || '')).trim()
    const rsvpUrl = rsvpLink(eventId, r.customer_id || ('x-' + r.email), name, r.email, 60 * 24, lang)
    r.rsvp_url = rsvpUrl
    const html = inviteEmail(eventId, lang, name, rsvpUrl)
    const subject = inviteSubject(eventId, lang)
    const customId = id + ':' + i
    try {
      const res = await sendInviteMessage({ channel, to: r.email, subject, html, from, attachments: payloadByLang[lang] || [], customId })
      if (res.ok) { r.status = 'sent'; r.sent_at = new Date().toISOString(); if (channel !== 'gmail') { r.mailjet_custom_id = customId; if (res.id) r.mailjet_uuid = res.id } }
      else { r.status = 'failed'; r.error = res.error || 'send_failed' }
    } catch (e) { r.status = 'failed'; r.error = e.message }
    campaigns.saveCampaign(c)
    bcast('recipient-update', { i, recipient: r })
    if (i < c.recipients.length - 1) await sleep(600) // throttle (comme le worker campagne)
  }
  c.status = 'completed'; c.send_completed_at = new Date().toISOString()
  campaigns.saveCampaign(c)
  const sent = c.recipients.filter(x => x.status === 'sent').length
  log(`Event blast ${eventId} → campaign ${id} : ${sent}/${c.recipients.length} envoyés (${channel})`)
  bcast('campaign-done', { id, counters: c.counters })
}

// ── Audience de l'envoi de masse : liste clients filtrée OU import CSV ──────
function splitName (full) {
  const parts = String(full || '').trim().split(/\s+/).filter(Boolean)
  if (parts.length <= 1) return { firstname: parts[0] || '', lastname: '' }
  return { firstname: parts[0], lastname: parts.slice(1).join(' ') }
}
const firstEmail = (s) => String(s || '').split(/[;,]/)[0].trim()

// Comptes F RÉSILIÉS (status 3/4/5) — à EXCLURE de l'audience (bug sync : services restent status=1 après
// résiliation du compte → abonnements 'Actif' fantômes ; cf feedback_ghost_active_subscriptions). Caché 30 min.
let _resilCache = null; let _resilTs = 0
async function resiliatedSet () {
  if (_resilCache && (Date.now() - _resilTs < 30 * 60 * 1000)) return _resilCache
  try { const ids = await require('./legacy-sync').fResiliatedAccountIds(); _resilCache = new Set((ids || []).map(Number)); _resilTs = Date.now() } catch (e) { log('events resiliatedSet: ' + e.message); if (!_resilCache) _resilCache = new Set() }
  return _resilCache
}

// CSV : SEULE colonne obligatoire = email ; alias FR/EN acceptés ; en-tête requise.
function parseAudienceCsv (text) {
  const lines = String(text || '').split(/\r?\n/).filter(l => l.trim())
  if (!lines.length) return { error: 'empty' }
  const split = (l) => l.split(',').map(c => c.trim().replace(/^"|"$/g, ''))
  const header = split(lines[0]).map(h => h.toLowerCase())
  const idx = (al) => header.findIndex(h => al.includes(h))
  const iEmail = idx(['email', 'courriel', 'e-mail', 'mail'])
  if (iEmail < 0) return { error: 'no_email_column' }
  const iFirst = idx(['firstname', 'first_name', 'prénom', 'prenom'])
  const iLast = idx(['lastname', 'last_name', 'name', 'nom'])
  const iLang = idx(['language', 'langue', 'lang'])
  const iCust = idx(['customer_id', 'customer', 'client', 'no_client', 'numéro', 'numero'])
  const iPhone = idx(['phone', 'téléphone', 'telephone', 'tel'])
  const rows = []
  for (let k = 1; k < lines.length; k++) {
    const c = split(lines[k]); const email = firstEmail(c[iEmail] || '')
    rows.push({ email, firstname: iFirst >= 0 ? c[iFirst] || '' : '', lastname: iLast >= 0 ? c[iLast] || '' : '', language: normLang(iLang >= 0 ? c[iLang] : 'fr'), customer_id: iCust >= 0 ? c[iCust] || '' : '', phone: iPhone >= 0 ? c[iPhone] || '' : '' })
  }
  return { rows }
}

// Résout l'audience → destinataires [{email,firstname,lastname,language,customer_id}] (dédupliqués, courriel requis).
// mode : 'filter' (liste filtrée), 'csv' (import), 'manual' (pool de clients ajoutés un à un — filters.customer_ids[]).
const AUD_CFIELDS = ['name', 'customer_name', 'email_id', 'language', 'customer_type', 'legacy_account_id']
function mapCustomerRow (c) { const n = splitName(c.customer_name); return { email: firstEmail(c.email_id), firstname: n.firstname, lastname: n.lastname, language: normLang(c.language), customer_id: c.name, customer_type: c.customer_type || '', monthly: Math.round(c._monthly || 0) } }

// Clients ayant une Service Location dans une ville. FUZZY : accent/casse-insensible (unaccent+lower)
// via le pool Postgres ERPNext (comme la recherche floue app-wide) ; repli REST `like` si pool indispo.
async function cityCustomerIds (city) {
  const term = String(city || '').trim()
  const ids = new Set()
  if (!term) return ids
  let pool = null
  try { pool = require('./address-db').pool() } catch { pool = null }
  if (pool) {
    try {
      const r = await pool.query(`SELECT DISTINCT customer FROM "tabService Location" WHERE customer IS NOT NULL AND unaccent(lower(coalesce(city,''))) LIKE unaccent(lower($1))`, ['%' + term + '%'])
      r.rows.forEach(x => { if (x.customer) ids.add(x.customer) })
      return ids // PG autoritatif (même si 0)
    } catch (e) { log('cityCustomerIds pg: ' + e.message) /* → repli REST */ }
  }
  const erp = require('./erp'); let s = 0
  for (let p = 0; p < 80; p++) { const b = await erp.list('Service Location', { filters: [['city', 'like', '%' + term + '%']], fields: ['customer'], limit: 500, start: s }); b.forEach(x => { if (x.customer) ids.add(x.customer) }); if (b.length < 500) break; s += 500 }
  return ids
}

// Filtre « nom » de l'audience — FUZZY comme la recherche app-wide : accent/casse-insensible ET multi-champs
// (customer_name + ID de compte `name` + mandataire + contact_name_legacy). Ex. « lpb » → C-LPB4 (Louis-Paul
// Bourdon, matché par ID/mandataire, pas par customer_name). Via PG ; repli REST (customer_name, sensible à la casse).
async function nameLikeCustomerIds (term) {
  const t = String(term || '').trim(); const ids = new Set()
  if (!t) return ids
  let pool = null
  try { pool = require('./address-db').pool() } catch { pool = null }
  if (pool) {
    const like = '%' + t + '%'
    try {
      const r = await pool.query("SELECT name FROM \"tabCustomer\" WHERE unaccent(lower(coalesce(customer_name,''))) LIKE unaccent(lower($1)) OR lower(coalesce(name,'')) LIKE lower($1)", [like])
      r.rows.forEach(x => { if (x.name) ids.add(x.name) })
    } catch (e) { log('nameLikeCustomerIds pg: ' + e.message) }
    // Champs legacy (colonnes custom possibles) — try/catch séparé pour ne pas casser si absentes.
    try {
      const r = await pool.query("SELECT name FROM \"tabCustomer\" WHERE unaccent(lower(coalesce(mandataire,''))) LIKE unaccent(lower($1)) OR unaccent(lower(coalesce(contact_name_legacy,''))) LIKE unaccent(lower($1))", [like])
      r.rows.forEach(x => { if (x.name) ids.add(x.name) })
    } catch { /* colonnes absentes → ignore */ }
    return ids
  }
  const erp = require('./erp'); let s = 0
  for (let p = 0; p < 80; p++) { const b = await erp.list('Customer', { filters: [['customer_name', 'like', '%' + t + '%']], fields: ['name'], limit: 500, start: s }); b.forEach(x => { if (x.name) ids.add(x.name) }); if (b.length < 500) break; s += 500 }
  return ids
}
async function resolveAudience ({ mode = 'filter', filters = {}, csv = '' } = {}) {
  let rows = []
  let resiliatedExcluded = 0
  if (mode === 'csv') {
    const p = parseAudienceCsv(csv)
    if (p.error) return { error: p.error }
    rows = p.rows
  } else if (mode === 'manual') {
    // Pool manuel : le staff a ajouté des clients un à un ; on re-résout les IDs → destinataires autoritatifs
    // (courriel/langue frais depuis ERPNext). Choix EXPLICITE → pas d'exclusion des résiliés.
    const erp = require('./erp')
    const ids = [...new Set((filters.customer_ids || []).map(v => String(v).trim()).filter(Boolean))].slice(0, 5000)
    if (!ids.length) return { error: 'empty_pool' }
    const customers = []
    for (let i = 0; i < ids.length; i += 100) { const b = await erp.list('Customer', { filters: [['name', 'in', ids.slice(i, i + 100)]], fields: AUD_CFIELDS, limit: 500 }); customers.push(...b) }
    rows = customers.map(mapCustomerRow)
  } else {
    const erp = require('./erp')
    const f = [] // filtres au niveau Customer
    if (filters.statut === 'active') f.push(['disabled', '=', 0])
    else if (filters.statut === 'disabled') f.push(['disabled', '=', 1])
    if (filters.customer_type) f.push(['customer_type', '=', filters.customer_type]) // Individual = résidentiel · Company = commercial
    const minMonthly = Number(filters.min_monthly) || 0

    // Ensembles d'IDs à INTERSECTER (nom fuzzy, ville via Service Location, abonnements via Service Subscription).
    let restrict = null // null = pas de restriction ; sinon Set d'IDs Customer
    const intersect = (set) => { restrict = restrict ? new Set([...restrict].filter(x => set.has(x))) : set }

    // NOM (fuzzy, multi-champs, insensible casse/accents) — ex. « lpb » → C-LPB4.
    if (filters.name_like && String(filters.name_like).trim()) intersect(await nameLikeCustomerIds(String(filters.name_like)))
    // VILLE → Service Location.customer (champ `city` peuplé, contrairement à `territory`).
    if (filters.city && String(filters.city).trim()) intersect(await cityCustomerIds(String(filters.city)))

    // ABONNEMENTS ACTIFS (somme mensuelle) → restriction + montant par client.
    let byCust = null
    if (filters.active_sub || minMonthly > 0) {
      byCust = {}; let s = 0
      for (let p = 0; p < 40; p++) { const b = await erp.list('Service Subscription', { filters: [['status', '=', 'Actif']], fields: ['customer', 'monthly_price'], limit: 500, start: s }); b.forEach(x => { if (x.customer) byCust[x.customer] = (byCust[x.customer] || 0) + (Number(x.monthly_price) || 0) }); if (b.length < 500) break; s += 500 }
      let subIds = Object.keys(byCust)
      if (minMonthly > 0) subIds = subIds.filter(c => byCust[c] >= minMonthly)
      intersect(new Set(subIds))
    }

    let customers = []
    if (restrict) {
      const ids = [...restrict]
      for (let i = 0; i < ids.length; i += 100) { const b = await erp.list('Customer', { filters: [...f, ['name', 'in', ids.slice(i, i + 100)]], fields: AUD_CFIELDS, limit: 500 }); customers.push(...b) }
    } else {
      let s = 0
      for (let p = 0; p < 60; p++) { const b = await erp.list('Customer', { filters: f, fields: AUD_CFIELDS, limit: 500, start: s }); customers.push(...b); if (b.length < 500) break; s += 500 }
    }
    if (byCust) customers.forEach(c => { c._monthly = byCust[c.name] || 0 })
    // GARDE-FOU par défaut : exclure les comptes F résiliés (abos fantômes, cf feedback_ghost_active_subscriptions).
    // Sauf si `include_resiliated` (choisi via « Tous ») → on autorise les ex-clients (relance / win-back).
    if (!filters.include_resiliated) {
      const resil = await resiliatedSet()
      const before = customers.length
      customers = customers.filter(c => !resil.has(Number(c.legacy_account_id)))
      resiliatedExcluded = before - customers.length
    }
    rows = customers.map(mapCustomerRow)
  }
  const seen = new Set(); const recipients = []; const noEmail = []; let dropped = 0
  for (const r of rows) {
    if (!EMAIL_RE.test(r.email || '')) {
      dropped++
      if (r.customer_id) noEmail.push({ customer_id: r.customer_id, name: ((r.firstname || '') + ' ' + (r.lastname || '')).trim() || r.customer_id }) // rattachable → lien fiche pour valider
      continue
    }
    const k = r.email.toLowerCase(); if (seen.has(k)) continue; seen.add(k)
    recipients.push(r)
  }
  return { recipients, count: recipients.length, dropped_no_email: dropped, no_email: noEmail, resiliated_excluded: resiliatedExcluded }
}

// ── Liste principale d'audience (ADDITIVE, persistée par événement) ─────────
// Le staff construit UNE liste en additionnant plusieurs lots (filtre ville A, puis filtre ville B,
// puis sélections manuelles, puis CSV…). Dédup par courriel (insensible à la casse). Chaque
// destinataire porte son `source` (libellé du lot) pour la ventilation. C'est CETTE liste qu'un
// futur envoi de masse consommera.
function audienceFile (id) { return path.join(AUDIENCE_DIR, `${id}.json`) }
function loadAudienceList (id) { const d = readJsonFile(audienceFile(id), null); return (d && Array.isArray(d.recipients)) ? d : { recipients: [], updated: '' } }
function saveAudienceList (id, a) { writeJsonFile(audienceFile(id), a) }
function bySource (a) { const m = {}; for (const r of a.recipients) { const s = r.source || '—'; m[s] = (m[s] || 0) + 1 } return m }
function audienceSummary (a) { return { count: a.recipients.length, by_source: bySource(a), updated: a.updated || '', sample: a.recipients.slice(0, 200).map(r => ({ email: r.email, name: ((r.firstname || '') + ' ' + (r.lastname || '')).trim() || r.email, customer_id: r.customer_id || '', language: r.language || 'fr', source: r.source || '' })) } }

// Résout un lot (filtre/manuel/CSV) et l'AJOUTE à la liste principale (dédup courriel).
async function audienceListAdd (id, { mode, filters, csv, source_label }) {
  const r = await resolveAudience({ mode, filters, csv })
  if (r.error) return { error: r.error }
  const a = loadAudienceList(id)
  const seen = new Set(a.recipients.map(x => String(x.email || '').toLowerCase()))
  const label = String(source_label || '').slice(0, 80) || (mode === 'csv' ? 'CSV' : mode === 'manual' ? 'Sélection manuelle' : 'Filtre')
  let added = 0
  for (const rec of r.recipients) {
    const k = String(rec.email || '').toLowerCase()
    if (!k || seen.has(k)) continue
    seen.add(k)
    a.recipients.push({ email: rec.email, firstname: rec.firstname || '', lastname: rec.lastname || '', language: rec.language || 'fr', customer_id: rec.customer_id || '', customer_type: rec.customer_type || '', monthly: rec.monthly || 0, source: label })
    added++
  }
  a.updated = new Date().toISOString()
  saveAudienceList(id, a)
  return { added, duplicates: r.count - added, dropped_no_email: r.dropped_no_email || 0, resiliated_excluded: r.resiliated_excluded || 0, ...audienceSummary(a) }
}
function audienceListRemove (id, email) {
  const a = loadAudienceList(id)
  const before = a.recipients.length
  a.recipients = a.recipients.filter(r => String(r.email || '').toLowerCase() !== String(email || '').toLowerCase())
  a.updated = new Date().toISOString()
  saveAudienceList(id, a)
  return { removed: before - a.recipients.length, ...audienceSummary(a) }
}
function audienceListClear (id) { saveAudienceList(id, { recipients: [], updated: new Date().toISOString() }); return { count: 0, by_source: {}, sample: [] } }

// ── Page publique (festive, mobile-first, bilingue) ────────────────────────
function field (id, nameAttr, labelHtml, inputHtml, errId, errMsg) {
  return `<label for="${id}">${labelHtml}</label>${inputHtml}${errId ? `<div class="err" id="${errId}">${esc(errMsg)}</div>` : ''}`
}
function page (event, lang, prefill = {}, opts = {}) {
  lang = normLang(lang)
  const t = event[lang]
  const other = lang === 'fr' ? 'en' : 'fr'
  const full = !!opts.full
  const greet = t.greet(firstName(prefill.name))
  const program = (t.program || []).map(p =>
    `<div class="prog"><div class="prog-ic">${esc(p.icon)}</div><div class="prog-lb">${esc(p.label)}${p.sub ? `<span>${esc(p.sub)}</span>` : ''}</div></div>`
  ).join('')
  const notes = (t.notes || []).map((n, i) => `<div class="note">${'*'.repeat(i + 1)} ${esc(n)}</div>`).join('')
  const optlbl = (s) => `${esc(s)} <span class="opt">${esc(t.opt)}</span>`
  const badgeHtml = event.badge_top || event.badge_bottom
    ? `<div class="badge"><b>${esc(event.badge_top || '🎉')}</b>${event.badge_bottom ? `<span>${esc(event.badge_bottom)}</span>` : ''}</div>`
    : `<div class="badge emoji">🎉</div>`
  return `<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(t.name)}</title>
<style>
:root{--g:#21a34a;--blue:#1a86c7;--ink:#1f2937;--muted:#6b7280;--line:#e5e7eb}
*{box-sizing:border-box}
body{margin:0;font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:var(--ink);
  background:radial-gradient(1200px 400px at 50% -120px,#eafbf0 0%,transparent 70%),linear-gradient(180deg,#f7fbff 0%,#ffffff 60%);min-height:100vh}
.confetti{height:10px;background:var(--blue)}
.wrap{max-width:600px;margin:0 auto;padding:26px 18px 46px}
.lang{text-align:right;margin-bottom:6px}
.lang a{color:var(--muted);text-decoration:none;font-size:.82rem;border:1px solid var(--line);padding:4px 10px;border-radius:999px}
.head{text-align:center;margin:6px 0 18px}
.badge{display:inline-flex;flex-direction:column;align-items:center;justify-content:center;width:64px;height:64px;border-radius:50%;
  background:var(--g);color:#fff;font-weight:800;line-height:1;box-shadow:0 8px 20px rgba(33,163,74,.3);margin-bottom:12px}
.badge b{font-size:1.35rem}.badge span{font-size:.6rem;letter-spacing:1px}
.badge.emoji{font-size:2rem}
.logo{height:34px;width:auto;display:block;margin:0 auto 34px}
h1{font-size:1.7rem;font-weight:800;letter-spacing:-.5px;margin:0 0 6px;color:var(--ink)}
.tag{color:var(--g);font-weight:700;font-size:1.02rem;margin:0 0 4px}
.card{background:#fff;border:1px solid #eef2f0;border-radius:20px;box-shadow:0 16px 44px rgba(23,58,94,.09);padding:26px 22px;margin-bottom:16px}
p{line-height:1.6;font-size:1rem;margin:0 0 12px;color:#334155}
.when{display:flex;align-items:center;justify-content:center;gap:10px;background:var(--ink);color:#fff;font-weight:700;
  font-size:1.12rem;border-radius:14px;padding:13px 18px;margin:4px 0 16px}
.when .cal{font-size:1.3rem}
.addr{display:flex;align-items:center;justify-content:center;gap:6px;color:#374151;font-weight:600;font-size:.92rem;margin:-8px 0 16px;text-align:center}
.prog-wrap{display:flex;flex-wrap:wrap;gap:10px;justify-content:center;margin:6px 0 4px}
.prog{flex:1 1 90px;max-width:110px;text-align:center;background:#f8fafc;border:1px solid #eef2f6;border-radius:14px;padding:12px 6px}
.prog-ic{font-size:1.7rem;line-height:1}
.prog-lb{margin-top:6px;font-size:.78rem;font-weight:700;color:var(--ink)}
.prog-lb span{display:block;font-weight:500;color:var(--muted);font-size:.7rem}
.limited{display:flex;gap:10px;align-items:flex-start;background:#f3f4f6;border:1px solid var(--line);border-radius:12px;padding:12px 14px;margin:14px 0 2px}
.limited .i{font-size:1.1rem}
.limited p{margin:0;font-size:.92rem;color:#374151}
.greet{font-weight:700;color:var(--ink);font-size:1.05rem;margin:0 0 10px}
label{display:block;font-weight:600;font-size:.92rem;color:var(--ink);margin:14px 0 6px}
label .opt{font-weight:400;color:var(--muted);font-size:.82rem}
input,textarea{width:100%;border:1.5px solid var(--line);border-radius:12px;padding:13px 15px;font:inherit;font-size:1rem;background:#fcfdfc;transition:border-color .15s,box-shadow .15s}
input:focus,textarea:focus{outline:none;border-color:var(--g);box-shadow:0 0 0 4px rgba(33,163,74,.14)}
input[readonly]{background:#f1f5f9;color:var(--muted)}
textarea{min-height:74px;resize:vertical}
.err{color:#dc2626;font-size:.85rem;margin-top:5px;display:none}
button{margin-top:20px;width:100%;background:var(--g);color:#fff;border:0;border-radius:14px;padding:15px;font:inherit;font-weight:800;
  font-size:1.05rem;cursor:pointer;box-shadow:0 10px 24px rgba(33,163,74,.3);transition:filter .15s,transform .08s}
button:hover{filter:brightness(1.05)}button:active{transform:translateY(1px)}button:disabled{opacity:.6;cursor:default}
.notes{margin-top:16px}.note{color:var(--muted);font-size:.8rem;line-height:1.5;margin-top:3px}
.foot{text-align:center;color:#9aa7a0;font-size:.8rem;margin-top:20px}
.thanks{text-align:center;padding:14px 4px}
.thanks .big{font-size:3rem;line-height:1;margin-bottom:10px}
.thanks h2{color:var(--g);font-size:1.4rem;margin:0 0 10px}
.recog{font-weight:600;color:var(--g);margin:0 0 10px}
.full{text-align:center;padding:16px 6px}
.full .big{font-size:2.6rem;line-height:1;margin-bottom:12px}
.full h2{color:var(--ink);font-size:1.35rem;margin:0 0 10px}
.full p{color:var(--muted)}
.hidden{display:none}
</style></head>
<body><div class="confetti"></div><div class="wrap">
<div class="lang"><a href="?lang=${other}">${other === 'en' ? 'English' : 'Français'}</a></div>
<div class="head">
  ${badgeHtml}
  <img class="logo" src="${LOGO}" alt="TARGO">
  <h1>${esc(t.name)}</h1>
  <div class="tag">${esc(t.tagline)}</div>
</div>

<div id="invite" class="card">
  ${t.when ? `<div class="when"><span class="cal">📅</span><span>${esc(t.when)}</span></div>` : ''}
  ${t.address ? `<div class="addr">📍 ${esc(t.address)}</div>` : ''}
  <p>${esc(t.body)}</p>
  ${program ? `<div class="prog-wrap">${program}</div>` : ''}
  ${t.program_line ? `<p style="text-align:center;font-style:italic;color:var(--muted);margin-top:12px">${esc(t.program_line)}</p>` : ''}
  ${t.limited ? `<div class="limited"><span class="i">⏳</span><p>${esc(t.limited)}</p></div>` : ''}
</div>

<div id="fullCard" class="card${full ? '' : ' hidden'}">
  <div class="full">
    <div class="big">🎪</div>
    <h2>${esc(t.full_title)}</h2>
    <p>${esc(t.full_msg)}</p>
  </div>
</div>

<div id="formCard" class="card${full ? ' hidden' : ''}">
  ${greet ? `<div class="greet">${esc(greet)}</div>` : ''}
  <form id="rsvp" onsubmit="return submitRsvp(event)" autocomplete="on">
    ${field('nm', 'name', esc(t.f_name), `<input id="nm" name="name" value="${esc(prefill.name || '')}" autocomplete="name" placeholder="Jean Tremblay">`, 'e_nm', t.err_name)}
    ${field('em', 'email', esc(t.f_email), `<input id="em" name="email" type="email" value="${esc(prefill.email || '')}" autocomplete="email" placeholder="vous@exemple.com">`, 'e_em', t.err_email)}
    ${field('ph', 'phone', optlbl(t.f_phone), `<input id="ph" name="phone" type="tel" autocomplete="tel" placeholder="819 555-1234">`)}
    ${field('ps', 'party_size', esc(t.f_party), `<input id="ps" name="party_size" type="number" min="1" max="30" inputmode="numeric" placeholder="${esc(t.f_party_ph)}">`, 'e_ps', t.err_party)}
    ${field('al', 'allergies', optlbl(t.f_allerg), `<textarea id="al" name="allergies" maxlength="500"></textarea>`)}
    <button type="submit" id="sub">${esc(t.submit)}</button>
    <div class="err" id="e_gen" style="text-align:center">${esc(t.err_generic)}</div>
  </form>
</div>

<div id="thanks" class="card hidden">
  <div class="thanks">
    <div class="big">🎉</div>
    <h2>${esc(t.thanks_title)}</h2>
    <p class="recog" id="recog"></p>
    <p>${esc(t.thanks_body)}</p>
    <p style="color:var(--muted);font-size:.9rem">${esc(t.thanks_update)}</p>
  </div>
</div>

<div class="notes">${notes}</div>
<div class="foot">${esc(t.foot)} · targo.ca</div>
</div>
<script>
var TOKEN=${JSON.stringify(prefill.token || '')};
var EVENT=${JSON.stringify(event.id)};
var LANG=${JSON.stringify(lang)};
var RECOG_TPL=${JSON.stringify(t.thanks_recognized('###'))};
var MSG_CHECK=${JSON.stringify(t.thanks_tocheck)};
function show(id){document.getElementById(id).classList.remove('hidden')}
function hide(id){document.getElementById(id).classList.add('hidden')}
function submitRsvp(e){
  e.preventDefault();
  var f=e.target;
  ['e_nm','e_em','e_ps','e_gen'].forEach(function(id){document.getElementById(id).style.display='none'});
  var nm=(f.name.value||'').trim();
  var em=(f.email.value||'').trim();
  var ps=parseInt(f.party_size.value,10);
  var ok=true;
  if(!nm){document.getElementById('e_nm').style.display='block';ok=false}
  if(!em||!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(em)){document.getElementById('e_em').style.display='block';ok=false}
  if(!ps||ps<1){document.getElementById('e_ps').style.display='block';ok=false}
  if(!ok)return false;
  var b=document.getElementById('sub');b.disabled=true;
  fetch('/rsvp/'+encodeURIComponent(EVENT)+'/submit',{method:'POST',headers:{'Content-Type':'application/json'},
    body:JSON.stringify({token:TOKEN,name:nm,email:em,phone:(f.phone.value||'').trim(),party_size:ps,allergies:(f.allergies.value||'').trim(),lang:LANG})})
   .then(function(r){return r.json()})
   .then(function(d){
     if(d&&d.ok){
       var rec=document.getElementById('recog');
       if(d.confidence==='confirmed'){rec.textContent=RECOG_TPL.replace('###',d.name?(', '+d.name):'')}
       else{rec.textContent=MSG_CHECK;rec.style.color='#64748b'}
       hide('formCard');show('thanks');window.scrollTo({top:0,behavior:'smooth'})
     }else if(d&&d.full){hide('formCard');show('fullCard');window.scrollTo({top:0,behavior:'smooth'})
     }else{b.disabled=false;document.getElementById('e_gen').style.display='block'}
   })
   .catch(function(){b.disabled=false;document.getElementById('e_gen').style.display='block'});
  return false;
}
</script>
</body></html>`
}

// ── Vue staff : liste + décompte + confiance ───────────────────────────────
function listRsvps (eventId) {
  const m = loadResp(eventId)
  const responses = Object.entries(m).map(([key, r]) => ({ key, ...r }))
    .sort((a, b) => String(b.updated || b.created || '').localeCompare(String(a.updated || a.created || '')))
  const headcount = responses.reduce((s, r) => s + (parseInt(r.party_size, 10) || 0), 0)
  const confirmed = responses.filter(r => r.confidence === 'confirmed').length
  const probable = responses.filter(r => r.confidence === 'probable').length
  const unverified = responses.filter(r => !r.confidence || r.confidence === 'none').length
  return { responses, count: responses.length, headcount, confirmed, probable, unverified, matched: confirmed + probable, unmatched: unverified }
}
function deleteRsvp (eventId, key) { const m = loadResp(eventId); if (m[key]) { delete m[key]; saveResp(eventId, m); return true } return false }

// ── Routage HTTP ───────────────────────────────────────────────────────────
async function handle (req, res, method, path, url) {
  try {
    // Page publique : GET /rsvp/<eventId>
    let mm = path.match(/^\/rsvp\/([A-Za-z0-9_-]+)$/)
    if (mm && method === 'GET') {
      const event = getEvent(mm[1])
      if (!event || !event.active) { res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end('<h1>Événement introuvable</h1>') }
      const token = url.searchParams.get('t') || ''
      let lang = url.searchParams.get('lang') || 'fr'
      const prefill = { token }
      if (token) {
        const p = verifyJwt(token)
        if (p && p.scope === 'customer') { prefill.customer_id = p.sub; prefill.customer_number = p.sub; prefill.name = p.name || ''; prefill.email = p.email || ''; if (!url.searchParams.get('lang') && p.lang) lang = p.lang }
      }
      // Capacité (personnes) : plein → message « complet », SAUF si cet inscrit existe déjà (peut modifier).
      let full = false
      if (event.capacity > 0) {
        const m = loadResp(event.id)
        const existing = prefill.customer_id ? m['c:' + prefill.customer_id] : null
        full = headcountOf(m) >= event.capacity && !existing
      }
      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
      return res.end(page(event, lang, prefill, { full }))
    }

    // Soumission publique : POST /rsvp/<eventId>/submit
    mm = path.match(/^\/rsvp\/([A-Za-z0-9_-]+)\/submit$/)
    if (mm && method === 'POST') {
      const eventId = mm[1]
      const event = getEvent(eventId)
      if (!event || !event.active) return json(res, 404, { ok: false, error: 'event_not_found' })
      const b = await parseBody(req)
      const party = clampParty(b.party_size)
      const email = String(b.email || '').trim()
      const name = String(b.name || '').trim().slice(0, 120)
      const phone = String(b.phone || '').trim().slice(0, 30)
      const number = String(b.customer_number || '').trim().slice(0, 40)
      if (!party) return json(res, 400, { ok: false, error: 'party_size' })
      if (!EMAIL_RE.test(email)) return json(res, 400, { ok: false, error: 'email' })
      const r = await matchAndValidate({ token: b.token, number, email, phone, name })
      const key = keyFor(r, { number, email, phone })
      const now = new Date().toISOString()
      const ip = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim() || (req.socket && req.socket.remoteAddress) || ''
      const m = loadResp(eventId)
      const prev = m[key] || {}
      // Enforcement CAPACITÉ (personnes) : bloque une NOUVELLE inscription quand le plafond est atteint.
      // Un inscrit existant peut toujours mettre à jour sa réponse (déjà compté).
      if (event.capacity > 0 && !m[key] && headcountOf(m) >= event.capacity) {
        return json(res, 200, { ok: false, full: true, error: 'event_full' })
      }
      m[key] = {
        event_id: eventId,
        ip,
        customer_id: r.customer_id,
        customer_number: number || (r.customer_id || ''),
        customer_name: name || r.customer_name || prev.customer_name || '',
        account_name: r.customer_name || '',
        party_size: party,
        email,
        phone,
        allergies: String(b.allergies || '').trim().slice(0, 500),
        lang: normLang(b.lang),
        confidence: r.confidence,
        matched: r.confidence !== 'none',
        signals: r.signals,
        created: prev.created || now,
        updated: now,
      }
      saveResp(eventId, m)
      log(`RSVP ${eventId} — ${key} · ${party}p · ${r.confidence} [${r.signals.join(',')}]`)
      return json(res, 200, { ok: true, confidence: r.confidence, name: firstName(name || r.customer_name) })
    }

    // Staff : GET /events
    if (path === '/events' && method === 'GET') return json(res, 200, { events: listEvents() })

    // Staff : POST /events  (créer un événement)
    if (path === '/events' && method === 'POST') {
      const b = await parseBody(req)
      const fr = (b.fr || {})
      if (!String(fr.name || '').trim()) return json(res, 400, { error: 'name_required' })
      const id = uniqueId(slugify(b.id || fr.name))
      if (!id) return json(res, 400, { error: 'bad_id' })
      const conf = normalizeConfig(b)
      conf.id = id
      saveConfig(id, conf)
      log(`Event created — ${id}`)
      const e = getEvent(id)
      return json(res, 200, { ok: true, event: { id: e.id, name: e.fr.name, date_iso: e.date_iso, capacity: e.capacity, public_url: `${pub()}/rsvp/${e.id}` } })
    }

    // Staff : GET /events/<id>  (config brute éditable — pour le formulaire d'édition)
    mm = path.match(/^\/events\/([A-Za-z0-9_-]+)$/)
    if (mm && method === 'GET') {
      const base = rawConfig(mm[1]); if (!base) return json(res, 404, { error: 'event_not_found' })
      return json(res, 200, { event: { ...base, public_url: `${pub()}/rsvp/${mm[1]}` } })
    }
    // Staff : PUT /events/<id>  (éditer)  ·  DELETE /events/<id>
    if (mm && method === 'PUT') {
      const existing = rawConfig(mm[1]); if (!existing) return json(res, 404, { error: 'event_not_found' })
      const b = await parseBody(req)
      const fr = (b.fr || {})
      if (!String(fr.name || '').trim()) return json(res, 400, { error: 'name_required' })
      const conf = normalizeConfig(b, existing)
      conf.id = mm[1]
      saveConfig(mm[1], conf)
      log(`Event updated — ${mm[1]}`)
      return json(res, 200, { ok: true })
    }
    if (mm && method === 'DELETE') {
      // Ne supprime que le fichier config (les événements semés reviennent à leur défaut).
      try { fs.unlinkSync(configFile(mm[1])) } catch { /* absent */ }
      log(`Event config deleted — ${mm[1]}`)
      return json(res, 200, { ok: true, reverted_to_seed: !!SEED_CONTENT[mm[1]] })
    }

    // Staff : GET /events/<id>/invite-preview?lang=fr|en[&template=<name>] → HTML rendu (aperçu inbox)
    mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/invite-preview$/)
    if (mm && method === 'GET') {
      const event = getEvent(mm[1]); if (!event) return json(res, 404, { error: 'event_not_found' })
      const lang = normLang(url.searchParams.get('lang') || 'fr')
      const sampleName = lang === 'en' ? 'Jean Smith' : 'Jean Tremblay'
      const sampleUrl = `${pub()}/rsvp/${event.id}`
      // ?template absent → modèle configuré de l'événement · ='' → forcer le festif · =<nom> → ce template (aperçu avant enregistrement)
      const override = url.searchParams.get('template')
      let html
      if (override === null) html = inviteEmail(event.id, lang, sampleName, sampleUrl)
      else if (override === '') html = inviteEmailFestive(event, lang, sampleName, sampleUrl)
      else {
        html = require('./campaigns').renderNamedTemplate(override, inviteTemplateVars(event, lang, sampleName, sampleUrl))
        if (!html) return json(res, 400, { error: 'template_not_found' })
      }
      return json(res, 200, { html })
    }

    // Staff : GET /events/<id>/rsvps
    mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/rsvps$/)
    if (mm && method === 'GET') {
      const event = getEvent(mm[1]); if (!event) return json(res, 404, { error: 'event_not_found' })
      const data = listRsvps(mm[1])
      return json(res, 200, { event: { id: event.id, name: event.fr.name, date_iso: event.date_iso, when: event.fr.when, capacity: event.capacity, email_template: event.email_template || '', attachments: event.attachments || [], public_url: `${pub()}/rsvp/${event.id}` }, ...data })
    }
    // Staff : DELETE /events/<id>/rsvps/<key>
    mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/rsvps\/(.+)$/)
    if (mm && method === 'DELETE') {
      const ok = deleteRsvp(mm[1], decodeURIComponent(mm[2]))
      return json(res, ok ? 200 : 404, { ok })
    }
    // Staff : POST /events/<id>/attachments {filename, mime, data(base64)}  ·  DELETE /events/<id>/attachments/<stored>
    mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/attachments$/)
    if (mm && method === 'POST') {
      if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' })
      const b = await parseBody(req)
      const r = await addAttachment(mm[1], { filename: b.filename, mime: b.mime, data: b.data, lang: b.lang })
      if (r.error) return json(res, 400, { error: r.error })
      return json(res, 200, { ok: true, attachment: r.attachment, attachments: r.attachments })
    }
    mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/attachments\/(.+)$/)
    if (mm && method === 'DELETE') {
      if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' })
      const r = removeAttachment(mm[1], decodeURIComponent(mm[2]))
      return json(res, 200, { ok: true, attachments: r.attachments || [] })
    }
    // Staff : POST /events/<id>/send  (mode TEST uniquement ; masse = pas encore activé)
    mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/send$/)
    if (mm && method === 'POST') {
      const eventId = mm[1]; const event = getEvent(eventId)
      if (!event) return json(res, 404, { error: 'event_not_found' })
      const b = await parseBody(req)
      const channel = b.channel === 'gmail' ? 'gmail' : 'mailjet'
      if (b.test) {
        const emails = [...new Set((b.test_emails || []).map(e => String(e).trim()).filter(e => EMAIL_RE.test(e)))].slice(0, 10)
        if (!emails.length) return json(res, 400, { error: 'no_test_emails' })
        const results = await sendTest({ eventId, emails, channel, from: b.from, lang: b.lang || 'fr', name: b.name })
        log(`Event invite TEST — ${eventId} · ${channel} · ${results.filter(r => r.ok).length}/${results.length} ok`)
        return json(res, 200, { ok: results.every(r => r.ok), test: true, results })
      }
      // Envoi de MASSE réel (gaté côté UI par une confirmation ; ici on exige mass:true).
      if (b.mass) {
        const r = massSend(eventId, { channel, from: b.from })
        if (r.error) return json(res, 400, { error: r.error })
        log(`Event invite MASS — ${eventId} · ${channel} · ${r.count} destinataire(s) → campagne ${r.campaign_id}`)
        return json(res, 202, { ok: true, campaign_id: r.campaign_id, count: r.count })
      }
      return json(res, 400, { error: 'bad_request' })
    }
    // Staff : POST /events/<id>/audience {mode:'filter'|'csv'|'manual', filters, csv} → APERÇU (ne persiste rien)
    mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/audience$/)
    if (mm && method === 'POST') {
      if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' })
      const b = await parseBody(req)
      const r = await resolveAudience({ mode: b.mode, filters: b.filters || {}, csv: b.csv || '' })
      if (r.error) return json(res, 400, { error: r.error })
      return json(res, 200, { count: r.count, dropped_no_email: r.dropped_no_email, resiliated_excluded: r.resiliated_excluded || 0, sample: r.recipients.slice(0, 50), no_email: (r.no_email || []).slice(0, 300) })
    }

    // Staff : LISTE PRINCIPALE d'audience (additive, persistée)
    //   GET    /events/<id>/audience-list            → { count, by_source, sample, updated }
    //   POST   /events/<id>/audience-list/add {mode,filters,csv,source_label} → résout un lot + l'ajoute (dédup)
    //   POST   /events/<id>/audience-list/remove {email}  → retire un destinataire
    //   DELETE /events/<id>/audience-list            → vide la liste
    mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/audience-list$/)
    if (mm && method === 'GET') {
      if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' })
      return json(res, 200, audienceSummary(loadAudienceList(mm[1])))
    }
    if (mm && method === 'DELETE') {
      if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' })
      return json(res, 200, { ok: true, ...audienceListClear(mm[1]) })
    }
    mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/audience-list\/add$/)
    if (mm && method === 'POST') {
      if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' })
      const b = await parseBody(req)
      const r = await audienceListAdd(mm[1], { mode: b.mode, filters: b.filters || {}, csv: b.csv || '', source_label: b.source_label })
      if (r.error) return json(res, 400, { error: r.error })
      return json(res, 200, { ok: true, ...r })
    }
    mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/audience-list\/remove$/)
    if (mm && method === 'POST') {
      if (!getEvent(mm[1])) return json(res, 404, { error: 'event_not_found' })
      const b = await parseBody(req)
      return json(res, 200, { ok: true, ...audienceListRemove(mm[1], b.email) })
    }

    return json(res, 404, { error: 'not found' })
  } catch (e) { log('events handle: ' + e.message); return json(res, 500, { error: e.message }) }
}

module.exports = { handle, getEvent, listEvents, listRsvps, deleteRsvp, rsvpLink, inviteEmail, inviteSubject, sendTest, massSend, sendEventCampaignAsync, matchAndValidate, resolveAudience, parseAudienceCsv, page, normLang, addAttachment, removeAttachment, loadSendAttachments, headcountOf, audienceListAdd, audienceListRemove, audienceListClear, loadAudienceList, audienceSummary }
