'use strict'
// ─────────────────────────────────────────────────────────────────────────────
// Gift campaigns — backend for the ops UI.
//
// One campaign = one Mailjet blast of personalized French emails containing a
// Giftbit shortlink. Two CSVs come in (raw Map export + Giftbit shortlinks),
// we match each contact to an ERPNext Customer (email / phone / civic address),
// store the send list as JSON, and run the send in the background with live
// progress over SSE.
//
// Storage layout:
//   data/campaigns/<campaign-id>.json
//     {
//       id, name, created_at, status: draft|sending|completed|failed,
//       params: { amount, expiry, commitment_months, subject, from, template,
//                 throttle_ms, smtp_host, smtp_port },
//       counters: { total, sent, failed, queued, opened, clicked, bounced },
//       recipients: [{
//         firstname, lastname, email, phone, civic_address, postal_code,
//         gift_url, giftbit_uuid, gift_value_cents,
//         customer_id,           # null if unmatched
//         customer_name,         # display name from ERPNext
//         match_method,          # 'email' | 'phone' | 'civic' | null
//         match_confidence,      # 1.0 (exact) | 0.8 (postal+civic) | 0 (none)
//         status,                # pending | queued | sent | failed | clicked | bounced
//         mailjet_uuid,          # set when sent (used by webhook to update)
//         error, sent_at, opened_at, clicked_at,
//         excluded               # true = skip on send
//       }]
//     }
//
// SSE topic: 'campaign:<id>' — emits 'recipient-update' on every status change
// and 'campaign-done' when send finishes.
// ─────────────────────────────────────────────────────────────────────────────

const fs    = require('fs')
const path  = require('path')
const crypto = require('crypto')
const cfg   = require('./config')
const { log, json, parseBody, readJsonFile, writeJsonFile, erpFetch, createCommunication } = require('./helpers')
const erp   = require('./erp')
const email = require('./email')
const gmail = require('./gmail')
const rating = require('./rating')
const sse   = require('./sse')
// MJML v5 is async — compile MJML source to email-safe HTML server-side at
// save time so the send-worker only ever reads pre-compiled HTML.
const mjml2html = require('mjml')

// AI service (Gemini Flash) — used by the template translation endpoint to
// convert email content between FR ↔ EN while preserving HTML structure.
// Lazy require because lib/ai.js requires AI_API_KEY which may not be set
// in dev environments — we only fail when the endpoint is actually called.
let aiLib = null
function getAi () {
  if (!aiLib) aiLib = require('./ai')
  return aiLib
}

const DATA_DIR = path.join(__dirname, '..', 'data', 'campaigns')
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true })

// User-uploaded image assets (logos, photos, illustrations). Self-hosted so
// we're not dependent on Mailjet's CDN for new images added post-template-
// creation. Existing Mailjet-hosted brand logos in gift-email-fr.html stay
// where they are — this is for ADDITIONAL assets the user wants to drop in
// via the GrapesJS editor.
//
// Naming: <sha256-of-content>.<ext>. Content-addressable = automatic dedup
// (same image uploaded twice = same URL, no duplicate storage) + immutable
// cache (URL never changes content, safe to Cache-Control: immutable).
const UPLOADS_DIR = path.join(__dirname, '..', 'uploads')
if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true })

const ALLOWED_IMAGE_TYPES = {
  'image/png':     'png',
  'image/jpeg':    'jpg',
  'image/gif':     'gif',
  'image/webp':    'webp',
  'image/svg+xml': 'svg',
}
const MAX_UPLOAD_BYTES = 5 * 1024 * 1024  // 5 MB raw image

// Decode a `data:image/png;base64,iVBOR...` URL into { mime, ext, buffer }.
// Returns null on invalid input.
function decodeDataUrl (dataUrl) {
  if (typeof dataUrl !== 'string') return null
  const m = dataUrl.match(/^data:([^;,]+)(;base64)?,(.*)$/)
  if (!m) return null
  const mime = m[1].toLowerCase()
  const isBase64 = !!m[2]
  if (!ALLOWED_IMAGE_TYPES[mime]) return null
  let buffer
  try {
    buffer = isBase64
      ? Buffer.from(m[3], 'base64')
      : Buffer.from(decodeURIComponent(m[3]), 'binary')
  } catch { return null }
  if (buffer.length === 0) return null
  if (buffer.length > MAX_UPLOAD_BYTES) return { error: 'too_large', size: buffer.length }
  return { mime, ext: ALLOWED_IMAGE_TYPES[mime], buffer }
}

function uploadPath (filename) {
  // Only accept hash-named files: 64 hex chars + .ext (no path separator,
  // no '..', no leading dot). Anything else = rejected (path traversal).
  if (!/^[a-f0-9]{64}\.(png|jpg|gif|webp|svg)$/.test(filename)) {
    throw new Error('invalid asset filename')
  }
  return path.join(UPLOADS_DIR, filename)
}

// Write an uploaded buffer to disk (dedup: skip if same content already
// stored). Returns the persisted filename. Module-level (not inside handle)
// because `path` inside handle() is shadowed by the URL parameter.
function persistUpload (buffer, ext) {
  const hash = crypto.createHash('sha256').update(buffer).digest('hex')
  const filename = `${hash}.${ext}`
  const target = path.join(UPLOADS_DIR, filename)
  if (!fs.existsSync(target)) fs.writeFileSync(target, buffer)
  return filename
}

function readUpload (filename) {
  if (!/^[a-f0-9]{64}\.(png|jpg|gif|webp|svg)$/.test(filename)) return null
  const p = path.join(UPLOADS_DIR, filename)
  if (!fs.existsSync(p)) return null
  const ext = path.extname(p).slice(1)
  const mime = Object.entries(ALLOWED_IMAGE_TYPES).find(([, e]) => e === ext)?.[0] || 'application/octet-stream'
  return { buffer: fs.readFileSync(p), mime }
}

function deleteUpload (filename) {
  if (!/^[a-f0-9]{64}\.(png|jpg|gif|webp|svg)$/.test(filename)) return false
  const p = path.join(UPLOADS_DIR, filename)
  if (!fs.existsSync(p)) return false
  fs.unlinkSync(p)
  return true
}

function uploadUrl (filename, req) {
  // Build the absolute URL the browser will use. Falls back to a relative
  // path if Host header is missing (shouldn't happen on prod via Traefik).
  const proto = (req.headers['x-forwarded-proto'] || 'https')
  const host  = req.headers['host'] || 'msg.gigafibre.ca'
  return `${proto}://${host}/campaigns/assets/${filename}`
}

function listUploads (req) {
  if (!fs.existsSync(UPLOADS_DIR)) return []
  return fs.readdirSync(UPLOADS_DIR)
    .filter(f => /^[a-f0-9]{64}\.(png|jpg|gif|webp|svg)$/.test(f))
    .map(f => {
      const st = fs.statSync(path.join(UPLOADS_DIR, f))
      return {
        filename: f,
        url: uploadUrl(f, req),
        size: st.size,
        modified: st.mtime.toISOString(),
        content_type: Object.entries(ALLOWED_IMAGE_TYPES).find(([, e]) => f.endsWith('.' + e))?.[0] || 'application/octet-stream',
      }
    })
    .sort((a, b) => (b.modified || '').localeCompare(a.modified || ''))
}

// ── CSV utilities ────────────────────────────────────────────────────────────
// Same RFC-4180-ish parser as the CLI scripts. Handles quoted fields with
// embedded delimiters and escaped double-quotes. Delimiter auto-detect
// (comma / tab / pipe) based on the first line.
function parseCsv (text, opts = {}) {
  const skipPreamble = !!opts.skipPreamble
  let work = text.replace(/^﻿/, '')               // strip BOM
  if (skipPreamble) work = work.replace(/^[^\n]*\n/, '') // drop title line

  const sample = work.split(/\r?\n/, 1)[0] || ''
  const delim = sample.includes('|') ? '|'
              : sample.includes('\t') ? '\t'
              : ','
  const rows = []; let row = [], field = '', inQ = false
  for (let i = 0; i < work.length; i++) {
    const c = work[i]
    if (inQ) {
      if (c === '"' && work[i + 1] === '"') { field += '"'; i++ }
      else if (c === '"') inQ = false
      else field += c
    } else {
      if (c === '"') inQ = true
      else if (c === delim) { row.push(field); field = '' }
      else if (c === '\n' || c === '\r') {
        if (field !== '' || row.length) { row.push(field); rows.push(row); row = []; field = '' }
        if (c === '\r' && work[i + 1] === '\n') i++
      } else field += c
    }
  }
  if (field !== '' || row.length) { row.push(field); rows.push(row) }
  if (!rows.length) return []
  const header = rows[0].map(h => h.trim())
  return rows.slice(1).filter(r => r.some(c => c !== '')).map(r => {
    const o = {}; header.forEach((h, i) => { o[h] = (r[i] || '').trim() }); return o
  })
}

// ── Address / name normalization (mirrors contacts_from_legacy.py) ──────────
const LOWER_WORDS = new Set(['de','du','des','la','le','les','au','aux','à',
                             'et','sur','en'])
const EMAIL_SPLIT = /\s*[;,]\s*/

// ── French Québécois first-name accent restoration ──────────────────────────
// Top ~100 names from MIQ Service Canada Québec baby names + manual additions
// for older generations (legacy customer base skews 40+). Lookup is on the
// LOWERCASE no-accent form → returns the properly accented version.
// Missing from this list: rare names, English names that don't need fixing,
// already-correctly-accented names (handled by Title Case alone).
const FR_NAME_FIXES = {
  // É (acute)
  'andre': 'André', 'andree': 'Andrée', 'aimee': 'Aimée',
  'amelie': 'Amélie', 'angele': 'Angèle', 'audrey': 'Audrey',
  'beatrice': 'Béatrice', 'benedict': 'Bénédict',
  'cecile': 'Cécile', 'cedric': 'Cédric', 'celine': 'Céline', 'chloe': 'Chloé',
  'clemence': 'Clémence', 'clement': 'Clément',
  'desire': 'Désiré', 'desiree': 'Désirée',
  'edith': 'Édith', 'edouard': 'Édouard', 'eliane': 'Éliane',
  'elie': 'Élie', 'elisa': 'Élisa', 'elise': 'Élise',
  'eloi': 'Éloi', 'emile': 'Émile', 'emilie': 'Émilie',
  'emmanuel': 'Emmanuel', 'eric': 'Éric', 'ethan': 'Éthan', 'eugene': 'Eugène',
  'felix': 'Félix', 'fleur': 'Fleur', 'francois': 'François', 'francoise': 'Françoise',
  'frederic': 'Frédéric', 'frederique': 'Frédérique',
  'gabrielle': 'Gabrielle', 'gaetan': 'Gaétan', 'gaetane': 'Gaétane',
  'genevieve': 'Geneviève', 'gerald': 'Gérald', 'gerard': 'Gérard',
  'helena': 'Helena', 'helene': 'Hélène', 'herve': 'Hervé',
  'irene': 'Irène', 'jeremie': 'Jérémie', 'jeremy': 'Jérémie',
  'jerome': 'Jérôme', 'jose': 'José', 'josee': 'Josée',
  'leandre': 'Léandre', 'leon': 'Léon', 'leo': 'Léo', 'leonard': 'Léonard',
  'magali': 'Magali', 'medard': 'Médard',
  'melanie': 'Mélanie', 'michele': 'Michèle',
  'noemie': 'Noémie', 'noel': 'Noël', 'noella': 'Noëlla',
  'pamela': 'Paméla', 'pierre': 'Pierre',
  'raphael': 'Raphaël', 'raphaele': 'Raphaëlle', 'regina': 'Régina',
  'regine': 'Régine', 'rejean': 'Réjean', 'rejeanne': 'Réjeanne',
  'remi': 'Rémi', 'rene': 'René', 'renee': 'Renée',
  'salome': 'Salomé', 'sebastien': 'Sébastien', 'severin': 'Séverin',
  'severine': 'Séverine', 'solange': 'Solange',
  'stephane': 'Stéphane', 'stephanie': 'Stéphanie',
  'theo': 'Théo', 'theodore': 'Théodore', 'therese': 'Thérèse',
  'valerie': 'Valérie', 'veronique': 'Véronique', 'zoe': 'Zoé',
}

// Parts known to form compound first names in QC (Marie-André, Jean-Philippe,
// etc.). When two parts appear concatenated with no separator (e.g. "Marcandre"
// or "Mariejose"), we split + hyphenate them. Ordered roughly by frequency so
// the longest match wins on lookups.
const COMPOUND_PARTS = [
  'jean', 'marie', 'anne', 'pierre', 'paul', 'louis', 'claude', 'marc',
  'andre', 'philippe', 'francois', 'francoise', 'jose', 'josee',
  'luc', 'olivier', 'antoine', 'sebastien', 'michel', 'francois',
  'christian', 'henri', 'denis', 'rene', 'roger',
]

function splitCompoundName (lower) {
  for (const a of COMPOUND_PARTS) {
    if (!lower.startsWith(a)) continue
    const rest = lower.slice(a.length)
    if (!rest) continue
    if (COMPOUND_PARTS.includes(rest)) {
      return [a, rest]
    }
  }
  return null
}

// Title-case a single token. Handles apostrophes (O'Brien, L'Heureux) and
// hyphens (Marie-André). Returns input unchanged if empty.
function titleCaseToken (tok) {
  if (!tok) return tok
  return tok.replace(/(^|[\s\-'])([\p{L}])/gu, (_, sep, ch) => sep + ch.toUpperCase())
            .replace(/(?<=[\p{L}])([\p{L}]+)/gu, m => m.toLowerCase())
}

// Clean a person name: trim, Title Case, accent-restore from dictionary,
// split known compound concatenations. Returns the cleaned name.
function cleanName (raw) {
  if (!raw) return ''
  let s = raw.trim()
  if (!s) return ''
  // First pass: Title Case (so 'MARC' → 'Marc', 'marc' → 'Marc')
  s = s.split(/\s+/).map(titleCaseToken).join(' ')

  // Apply dictionary fixes to each space-separated word
  s = s.split(' ').map(word => {
    // For hyphenated compounds, try each part separately
    if (word.includes('-')) {
      return word.split('-').map(p => {
        const fix = FR_NAME_FIXES[p.toLowerCase()]
        return fix || titleCaseToken(p)
      }).join('-')
    }
    const lower = word.toLowerCase()
    if (FR_NAME_FIXES[lower]) return FR_NAME_FIXES[lower]
    // Try compound split: "Marcandre" → "Marc-Andre" → "Marc-André"
    const parts = splitCompoundName(lower)
    if (parts) {
      return parts.map(p => FR_NAME_FIXES[p] || titleCaseToken(p)).join('-')
    }
    return word
  }).join(' ')

  return s
}

// Heuristics to flag names the cleaner may not have caught — surface to the
// user in the preview UI so they can manually verify / edit before sending.
// Returns null if the name looks fine, or a short FR string explaining why.
function nameWarning (name) {
  if (!name) return null
  const trimmed = name.trim()
  if (!trimmed) return null
  if (/\d/.test(trimmed)) return 'contient un chiffre'
  if (trimmed.length === 1) return 'une seule lettre'
  if (trimmed.length > 25) return 'trop long, peut-être plusieurs noms collés'
  // After cleanName, common-name lookups should have hit. If we still see a
  // name in the unaccented form, it's likely an uncommon name OR a typo.
  // We can't tell which, so just inform the user.
  const SUSPECT_PATTERNS = [
    // Common unaccented forms that the dict should have caught — if they
    // survive, the name is unusual and worth a glance
    /^(ana|ari|cleo|elo|jul|leo|maw|oce|pen|sof|the|val)/i,
  ]
  // Likely two names mashed (no separator, length 13-25, lowercase or with
  // unexpected capitalisation pattern like 'Marcandre')
  if (/^[\p{Lu}][\p{Ll}]+[\p{Ll}]{8,}$/u.test(trimmed) && !/[\s\-']/.test(trimmed)) {
    return 'peut-être deux prénoms collés (vérifier)'
  }
  return null
}

function titleAddress (addr) {
  if (!addr) return ''
  return addr.split(/\s+/).map((word, i) => {
    const lw = word.toLowerCase()
    if (i > 0 && LOWER_WORDS.has(lw)) return lw
    if (word.includes('-')) {
      return word.split('-').map(c => {
        const cl = c.toLowerCase()
        return LOWER_WORDS.has(cl) ? cl : (c[0] || '').toUpperCase() + c.slice(1).toLowerCase()
      }).join('-')
    }
    return (word[0] || '').toUpperCase() + word.slice(1).toLowerCase()
  }).join(' ')
}

function normalizeEmail (raw) {
  return (raw || '').trim().toLowerCase()
}

// Strip non-digit chars; treat as Canadian 10-digit if longer than 10
function normalizePhone (raw) {
  const digits = (raw || '').replace(/\D/g, '')
  if (digits.length === 11 && digits.startsWith('1')) return digits.slice(1)
  if (digits.length === 10) return digits
  return digits || null
}

// Canadian postal H1A 1B1 — uppercase, no internal space
function normalizePostal (raw) {
  return (raw || '').replace(/\s+/g, '').toUpperCase().replace(/^([A-Z]\d[A-Z])(\d[A-Z]\d)$/, '$1 $2')
}

// Civic = numeric prefix + first non-empty street word. We compare prefix +
// first 5 chars of street to avoid false-positives from "Rue" vs "Route".
function normalizeCivic (addr) {
  if (!addr) return null
  const m = addr.trim().match(/^(\d+[A-Za-z]?)\s+(.+)$/)
  if (!m) return null
  const num = m[1].toUpperCase()
  // Strip "Rue", "Route", "Boulevard" etc. — they're noise for matching
  const street = m[2].replace(/^(rue|route|boulevard|boul\.?|avenue|av\.?|chemin|ch\.?|place|pl\.?)\s+/i, '').trim()
  return num + '|' + street.toLowerCase().slice(0, 12)
}

// ── Map CSV parsing — port of contacts_from_legacy.py ────────────────────────
// The legacy export has a 1-line title preamble + pipe-delimited columns.
// Columns we care about:
//   "nom au compte"           — billing contact name (preferred)
//   "nom à l'adresse"         — service-address name (fallback)
//   "email au compte"         — billing email (preferred)
//   "email à l'adresse"       — service-address email (fallback)
//   "telephone au compte" or "telephone à l'adresse" — phone for matching
//   "adresse dans F"          — street address
//   "code postal au compte" or "code postal à l'adresse" — postal
//   "id emplacement"          — legacy_delivery_id (note: only 25% resolves)
function parseMapCsv (text, multi = 'first') {
  const rows = parseCsv(text, { skipPreamble: true })
  const contacts = []
  const seen = new Set()
  let skippedNoEmail = 0, skippedNoName = 0, skippedDuplicate = 0, skippedMultiSkip = 0
  // Capture the actual rows that were dropped so the wizard can show
  // operators exactly WHO got lost, not just a count. Each entry includes
  // the reason and just enough source-CSV columns to identify the human.
  const skippedRows = []
  const snapshot = (row, reason, idx) => ({
    source_row:   idx + 2,   // +1 for 0-index, +1 for header line — easier to cross-reference in Excel
    reason,
    raw_full_name: (row['nom au compte'] || row["nom à l'adresse"] || '').trim(),
    raw_email:     (row['email au compte'] || row["email à l'adresse"] || '').trim(),
    raw_phone:     (row['telephone au compte'] || row["telephone à l'adresse"] || '').trim(),
    raw_address:   (row['adresse dans F'] || '').trim(),
    raw_postal:    (row['code postal au compte'] || row["code postal à l'adresse"] || '').trim(),
  })

  for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) {
    const r = rows[rowIdx]
    // Pull emails from either source column
    let rawEmails = (r['email au compte'] || r["email à l'adresse"] || '').trim()
    if (!rawEmails) { skippedNoEmail++; skippedRows.push(snapshot(r, 'no_email', rowIdx)); continue }
    const emails = rawEmails.split(EMAIL_SPLIT)
                            .map(e => normalizeEmail(e))
                            .filter(e => e.includes('@') && e.split('@')[1]?.includes('.'))
    if (!emails.length) { skippedNoEmail++; skippedRows.push(snapshot(r, 'no_email', rowIdx)); continue }

    const sendEmails = multi === 'split' ? emails
                     : multi === 'skip'  ? (emails.length > 1 ? [] : emails)
                     : emails.slice(0, 1)
    if (!sendEmails.length) { skippedMultiSkip++; skippedRows.push(snapshot(r, 'multi_skip', rowIdx)); continue }

    // We used to skip rows without a name here. That dropped the contact AND
    // wasted its paired Giftbit shortlink — the worker already defaults
    // firstname to "cher client" / "dear customer" when missing, so we now
    // keep the row and just flag it with a name warning.
    const full = (r['nom au compte'] || r["nom à l'adresse"] || '').trim()
    const hasName = !!full
    if (!hasName) skippedNoName++   // informational counter, NOT a continue
    const parts = full.split(/\s+/, 2)
    const firstnameRaw = parts[0] || ''
    const lastnameRaw  = parts.length > 1 ? full.slice(parts[0].length).trim() : ''

    // Auto-clean: Title Case, accent restoration from QC dictionary, compound
    // name detection. The user sees these cleaned versions in Step 2 and can
    // edit inline. Original raw values are kept on the recipient (firstname_raw
    // + lastname_raw) so we know whether the auto-cleaner touched anything.
    const firstname = cleanName(firstnameRaw)
    const lastname  = cleanName(lastnameRaw)
    const name_warnings = {
      firstname: !hasName ? 'pas de nom dans le CSV — utilisera "cher client" à l\'envoi' : nameWarning(firstname),
      lastname:  nameWarning(lastname),
    }
    const cleaned_changed = (firstname !== firstnameRaw || lastname !== lastnameRaw)

    const civic_address = titleAddress(r['adresse dans F'] || '')
    const postal_code   = normalizePostal(r['code postal au compte'] || r["code postal à l'adresse"] || '')
    const phone         = normalizePhone(r['telephone au compte'] || r["telephone à l'adresse"] || '')

    for (const em of sendEmails) {
      if (seen.has(em)) {
        skippedDuplicate++
        // Capture the duplicate row with its specific email — useful when one
        // raw row had multiple emails and only some were dupes (multi='split').
        skippedRows.push({ ...snapshot(r, 'duplicate', rowIdx), raw_email: em })
        continue
      }
      seen.add(em)
      contacts.push({
        firstname, lastname,
        firstname_raw: firstnameRaw, lastname_raw: lastnameRaw,
        cleaned_changed,
        name_warnings,
        email: em,
        phone,
        civic_address,
        postal_code,
      })
    }
  }
  return {
    contacts,
    skipped: {
      no_email:   skippedNoEmail,
      no_name:    skippedNoName,         // informational only — these rows are KEPT now
      duplicate:  skippedDuplicate,
      multi_skip: skippedMultiSkip,
      total_rows: rows.length,
    },
    // Cap at 200 to keep response size reasonable on very dirty exports;
    // the count in `skipped` always reflects the true total.
    skipped_rows: skippedRows.slice(0, 200),
  }
}

// ── Giftbit CSV parsing ──────────────────────────────────────────────────────
// Two supported formats:
//
// 1. "Link Order" export (headerless, one URL per line):
//      http://gft.link/4kpZMApLK4B
//      http://gft.link/Dn2cb27xYJ8
//
//    This is what Giftbit ships when you pre-buy N gift links upfront without
//    targeting specific recipients. Each line = one redeemable shortlink. The
//    recipient mapping is done entirely on our side (Map CSV ↔ link by row).
//
// 2. "Campaign" export (with header row, multiple columns):
//      firstname,lastname,email,gift_url,giftbit_uuid,gift_value_cents,internal_id
//      Alice,Tremblay,alice@x.com,https://...,uuid-001,5000,ACC-1
//
//    This is what create_giftbit_campaign.js produces (and what Giftbit
//    sends when you use their Campaign API with delivery_type=SHORTLINK).
function parseGiftbitCsv (text) {
  const cleaned = text.replace(/^﻿/, '').trim()
  if (!cleaned) return []

  // Detect headerless one-URL-per-line format: first non-empty line starts
  // with http:// or https:// and has no comma/pipe/tab separators.
  const firstLine = cleaned.split(/\r?\n/, 1)[0].trim()
  if (/^https?:\/\/\S+$/.test(firstLine) && !firstLine.includes(',') && !firstLine.includes('\t') && !firstLine.includes('|')) {
    return cleaned.split(/\r?\n/)
      .map(l => l.trim())
      .filter(l => /^https?:\/\//.test(l))
      .map(url => ({
        gift_url:         url,
        giftbit_uuid:     '',
        gift_value_cents: 0,
        email:            '',
        firstname:        '',
        lastname:         '',
        internal_id:      '',
      }))
  }

  // Otherwise treat as CSV with header row
  const rows = parseCsv(text)
  if (!rows.length) return []
  const keys = Object.keys(rows[0])
  const urlCandidates = ['gift_url','gift link','gift_link','url','link','shortlink',
                         'redemption_url','gift','giftbit_link','campaign_link']
  let urlCol = null
  for (const c of urlCandidates) {
    const hit = keys.find(k => k.toLowerCase().replace(/\s+/g, '_') === c)
    if (hit) { urlCol = hit; break }
  }
  if (!urlCol) {
    for (const k of keys) {
      if ((rows[0][k] || '').match(/^https?:\/\//)) { urlCol = k; break }
    }
  }
  if (!urlCol) return []

  return rows.map(r => ({
    gift_url:          r[urlCol],
    giftbit_uuid:      r.giftbit_uuid || r.uuid || r.gift_id || '',
    gift_value_cents:  parseInt(r.gift_value_cents || r.value_cents || r.amount || '0', 10) || 0,
    email:             normalizeEmail(r.email || ''),
    firstname:         r.firstname || '',
    lastname:          r.lastname || '',
    internal_id:       r.internal_id || '',
  }))
}

// ── Customer matching against ERPNext ────────────────────────────────────────
// Strategy: email → phone → civic+postal. First hit wins. Confidence:
//   1.0 = exact email match
//   0.9 = exact phone match
//   0.8 = civic + postal_code on a Service Location (then customer link)
//   0   = unmatched
async function matchCustomer (recipient) {
  // 1) Email match on Customer.email_id (most reliable)
  if (recipient.email) {
    try {
      // ERPNext's Customer.email_id can hold multiple addresses joined by
      // ';' or ',' (211 records as of 2026-05 — legacy migration artifact).
      // An exact-equality filter misses those, so we LIKE-search and then
      // validate locally by splitting on the common delimiters.
      const needle = recipient.email.toLowerCase()
      const r = await erp.list('Customer', {
        fields: ['name', 'customer_name', 'email_id', 'mobile_no', 'language'],
        filters: [['email_id', 'like', '%' + needle + '%']],
        limit: 5,
      })
      const hit = (r || []).find(c => {
        const list = (c.email_id || '').toLowerCase().split(/[;,\s]+/).filter(Boolean)
        return list.includes(needle)
      })
      if (hit) {
        return { customer_id: hit.name, customer_name: hit.customer_name,
                 language: hit.language || 'fr',
                 match_method: 'email', match_confidence: 1.0 }
      }
    } catch (e) { log('match email error:', e.message) }
  }

  // 2) Phone match
  if (recipient.phone) {
    try {
      const r = await erp.list('Customer', {
        fields: ['name', 'customer_name', 'mobile_no', 'language'],
        filters: [['mobile_no', 'like', '%' + recipient.phone.slice(-7) + '%']],
        limit: 5,
      })
      const hit = (r || []).find(c => normalizePhone(c.mobile_no) === recipient.phone)
      if (hit) {
        return { customer_id: hit.name, customer_name: hit.customer_name,
                 language: hit.language || 'fr',
                 match_method: 'phone', match_confidence: 0.9 }
      }
    } catch (e) { log('match phone error:', e.message) }
  }

  // 3) Civic + postal on Service Location → Customer (+ fetch Customer.language separately)
  if (recipient.civic_address && recipient.postal_code) {
    try {
      const civic = normalizeCivic(recipient.civic_address)
      if (civic) {
        const [num, streetPrefix] = civic.split('|')
        // Add a street-word filter so the 10-row default limit doesn't
        // miss the right SL when many addresses share the same street
        // number in the postal code. Same approach as name+civic below.
        const streetWord = (streetPrefix.split(/\s+/).find(w => w.length >= 4) || streetPrefix).slice(0, 8)
        const r = await erp.list('Service Location', {
          fields: ['name', 'address_line', 'postal_code', 'customer', 'customer_name'],
          filters: [
            ['postal_code', '=', recipient.postal_code],
            ['address_line', 'like', num + '%'],
            ['address_line', 'like', '%' + streetWord + '%'],
          ],
          limit: 50,
        })
        // Address-shape filter — same logic as before
        const candidates = (r || []).filter(sl => {
          const slCivic = normalizeCivic(sl.address_line)
          return slCivic && slCivic.startsWith(num + '|') &&
                 slCivic.split('|')[1]?.startsWith(streetPrefix.slice(0, 5))
        })
        // When recipient has a name AND we have multiple candidates,
        // disambiguate by name. Falls back to the first candidate if
        // no name is provided or no name match found (legacy behaviour).
        const tgtFirst = (recipient.firstname || '').toLowerCase().trim()
        const tgtLast  = (recipient.lastname  || '').toLowerCase().trim()
        const looksLikeMatch = async (sl) => {
          if (!tgtFirst && !tgtLast) return true  // no name → trust civic
          let custName = sl.customer_name || ''
          if (!custName && sl.customer) {
            try {
              const cc = await erp.list('Customer', {
                fields: ['customer_name'], filters: [['name', '=', sl.customer]], limit: 1,
              })
              if (cc?.[0]) { custName = cc[0].customer_name; sl.customer_name = custName }
            } catch {}
          }
          const n = (custName || '').toLowerCase()
          if (!n) return false
          if (tgtFirst && tgtLast && n.includes(tgtFirst) && n.includes(tgtLast)) return true
          if (tgtLast.length >= 4 && n.includes(tgtLast)) return true
          return false
        }
        let hit = null
        for (const sl of candidates) {
          if (await looksLikeMatch(sl)) { hit = sl; break }
        }
        // If name disambiguation found nothing AND no name was provided,
        // accept the first civic-shape candidate (legacy single-pass behaviour).
        if (!hit && !tgtFirst && !tgtLast) hit = candidates[0]
        if (hit && hit.customer) {
          let language = 'fr'
          try {
            const c = await erp.list('Customer', {
              fields: ['language'], filters: [['name', '=', hit.customer]], limit: 1,
            })
            if (c?.[0]?.language) language = c[0].language
          } catch {}
          return { customer_id: hit.customer, customer_name: hit.customer_name || hit.customer,
                   language,
                   match_method: 'civic', match_confidence: 0.85 }
        }
      }
    } catch (e) { log('match civic error:', e.message) }
  }

  // 4) Name + civic_address fallback — for CSV rows without postal_code,
  // when the Map export gave a partner's email that doesn't match ERPNext.
  // We scan Service Locations whose civic_address starts with the same
  // street number+street prefix, fetch each linked Customer's actual name,
  // and accept the hit only if the names plausibly match. This refuses to
  // link when the address is shared with a different household (e.g.
  // previous occupant) — better unmatched than wrongly matched.
  if (recipient.civic_address && (recipient.firstname || recipient.lastname)) {
    try {
      const civic = normalizeCivic(recipient.civic_address)
      if (civic) {
        const [num, streetPrefix] = civic.split('|')
        // Narrow the SQL search with one street-name word from the
        // normalized prefix (skip "des"/"de"/"la" style stopwords by
        // picking the first ≥ 4-char word). Postgres LIKE is case-
        // sensitive but Service Location address_line is typically
        // stored in mixed/title case, so we cast both ends to lowercase
        // by using the normalized prefix's lowercase output literally.
        // limit bumped from 20 → 100 to cover edge cases on common
        // streets like "des Merles" that have hundreds of addresses
        // starting with the same digit.
        const streetWord = (streetPrefix.split(/\s+/).find(w => w.length >= 4) || streetPrefix).slice(0, 8)
        const r = await erp.list('Service Location', {
          fields: ['name', 'address_line', 'postal_code', 'customer', 'customer_name'],
          // Two filters AND'd: street number prefix + a contains-word
          // narrowing. The lowercased streetWord works because Frappe
          // wraps LIKE with case-insensitive collation on postgres.
          filters: [
            ['address_line', 'like', num + '%'],
            ['address_line', 'like', '%' + streetWord + '%'],
          ],
          limit: 100,
        })
        // Pre-filter by civic shape before doing per-row Customer lookups
        // (which are network round-trips — keep their count small).
        const candidates = (r || []).filter(sl => {
          const slCivic = normalizeCivic(sl.address_line)
          if (!slCivic || !slCivic.startsWith(num + '|')) return false
          return slCivic.split('|')[1]?.startsWith(streetPrefix.slice(0, 5))
        })
        const tgtFirst = (recipient.firstname || '').toLowerCase().trim()
        const tgtLast  = (recipient.lastname  || '').toLowerCase().trim()
        const nameMatches = (custName) => {
          const n = (custName || '').toLowerCase()
          if (!n) return false
          if (tgtFirst && tgtLast && n.includes(tgtFirst) && n.includes(tgtLast)) return true
          if (tgtLast.length >= 4 && n.includes(tgtLast)) return true
          return false
        }
        for (const sl of candidates) {
          if (!sl.customer) continue
          // First try the SL's denormalized customer_name (cheap), fall
          // back to a Customer lookup if empty (common — denorm is often
          // missing post-import).
          let custName = sl.customer_name || ''
          let language = 'fr'
          if (!custName) {
            try {
              const c = await erp.list('Customer', {
                fields: ['customer_name', 'language'],
                filters: [['name', '=', sl.customer]], limit: 1,
              })
              if (c?.[0]) { custName = c[0].customer_name; language = c[0].language || 'fr' }
            } catch {}
          }
          if (nameMatches(custName)) {
            // We don't have language yet if denorm hit — fetch it once.
            if (sl.customer_name) {
              try {
                const c = await erp.list('Customer', {
                  fields: ['language'], filters: [['name', '=', sl.customer]], limit: 1,
                })
                if (c?.[0]?.language) language = c[0].language
              } catch {}
            }
            return { customer_id: sl.customer, customer_name: custName,
                     language,
                     match_method: 'name+civic', match_confidence: 0.65 }
          }
        }
      }
    } catch (e) { log('match name+civic error:', e.message) }
  }

  // Unmatched: default to French (93% of customer base)
  return { customer_id: null, customer_name: null, language: 'fr',
           match_method: null, match_confidence: 0 }
}

// ── Storage helpers ──────────────────────────────────────────────────────────
function campaignPath (id) {
  // Defensive: prevent path traversal — IDs are uuid-like; reject anything else
  if (!/^[a-zA-Z0-9_-]+$/.test(id)) throw new Error('invalid campaign id')
  return path.join(DATA_DIR, id + '.json')
}

function listCampaigns () {
  if (!fs.existsSync(DATA_DIR)) return []
  return fs.readdirSync(DATA_DIR)
    .filter(f => f.endsWith('.json'))
    .map(f => {
      try {
        const c = JSON.parse(fs.readFileSync(path.join(DATA_DIR, f), 'utf8'))
        return { id: c.id, name: c.name, created_at: c.created_at,
                 status: c.status, counters: c.counters,
                 total: (c.recipients || []).length }
      } catch { return null }
    })
    .filter(Boolean)
    .sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''))
}

function loadCampaign (id) {
  const p = campaignPath(id)
  if (!fs.existsSync(p)) return null
  return JSON.parse(fs.readFileSync(p, 'utf8'))
}

function saveCampaign (campaign) {
  // Recompute counters from recipients every save — single source of truth.
  // gift_clicked is the high-value engagement signal (recipient clicked the
  // CTA button leading to the Giftbit shortlink), tracked separately from
  // the generic 'clicked' status which fires on any link in the email.
  const c = { ...campaign }
  c.counters = (c.recipients || []).reduce((acc, r) => {
    acc[r.status] = (acc[r.status] || 0) + 1
    if (r.gift_link_clicked) acc.gift_clicked++
    return acc
  }, { total: (c.recipients || []).length, gift_clicked: 0 })
  fs.writeFileSync(campaignPath(c.id), JSON.stringify(c, null, 2))
  return c
}

function newCampaignId () {
  const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, '')
  const slug = crypto.randomBytes(3).toString('hex')
  return `cmp-${stamp}-${slug}`
}

// ── Template rendering (mirrors send_gift_campaign.js) ───────────────────────
function renderTemplate (tpl, vars) {
  // Section blocks first: {{#var}}...{{/var}} kept if var truthy
  tpl = tpl.replace(/\{\{\s*#\s*(\w+)\s*\}\}([\s\S]*?)\{\{\s*\/\s*\1\s*\}\}/g,
                    (_, k, body) => (vars[k] ? body : ''))
  return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, k) => {
    const v = vars[k]
    return v == null ? '' : String(v)
  })
}

// Templates are bundled inside the hub at services/targo-hub/templates/.
// Keep them in sync with scripts/campaigns/templates/ for the CLI use case.
const TEMPLATES_DIR  = path.resolve(__dirname, '..', 'templates')

// Rend un template éditable (Unlayer/HTML) par NOM avec des variables Mustache.
// Réutilisé par lib/events.js (invitation d'événement). Renvoie null si nom invalide/absent
// (l'appelant retombe sur son gabarit intégré). Anti-LFI : nom validé par l'allow-list.
function renderNamedTemplate (name, vars = {}) {
  if (!isValidTemplateName(name)) return null
  const file = path.join(TEMPLATES_DIR, name + '.html')
  if (!fs.existsSync(file)) return null
  try { return renderTemplate(fs.readFileSync(file, 'utf8'), vars) } catch { return null }
}
const DEFAULT_TEMPLATE = path.join(TEMPLATES_DIR, 'gift-email-fr.html')

// Allow-list templates that can be edited via the API. Naming convention:
// gift-email-<lang>[-variant] — the recipient's language drives which one
// gets used at send time. `-simple` variants are flat-structured copies
// designed to parse cleanly in GrapesJS visual editor (no nested tables).
// Adding a new lang or variant = drop a new .html here + add to this list.
// Templates allow-list — moved from a hardcoded array to a regex-validated
// + disk-scan approach so the user can create new templates from the editor
// UI without us re-deploying the hub. The prefix list bounds what names are
// allowed (prevents arbitrary file writes outside the campaign domain).
const EDITABLE_TEMPLATE_PREFIXES = ['gift-email-', 'newsletter-', 'transactional-', 'event-']
const TEMPLATE_NAME_RE = /^[a-z0-9-]+$/   // lowercase, digits, dashes only

function isValidTemplateName (name) {
  if (typeof name !== 'string' || !TEMPLATE_NAME_RE.test(name)) return false
  return EDITABLE_TEMPLATE_PREFIXES.some(p => name.startsWith(p))
}

// Scan the templates dir and return every file matching our allow-list
// prefixes (.html OR .mjml — either format makes it editable). The .json
// design file alone doesn't count (it's a companion).
function scanEditableTemplates () {
  if (!fs.existsSync(TEMPLATES_DIR)) return []
  const seen = new Set()
  for (const f of fs.readdirSync(TEMPLATES_DIR)) {
    const m = f.match(/^([a-z0-9-]+)\.(html|mjml)$/)
    if (!m) continue
    if (m[1].includes('.bak-') || m[1].includes('.legacy-')) continue  // skip backups
    if (isValidTemplateName(m[1])) seen.add(m[1])
  }
  return [...seen].sort()
}

// Resolve the template path for a given recipient language. Falls back to
// the French version (most of the customer base) if the language-specific
// file doesn't exist. Per-campaign override via params.template_path skips
// language routing entirely.
function templateForLanguage (lang) {
  const safe = (lang || 'fr').toLowerCase().split('-')[0]   // 'fr-CA' → 'fr'
  const candidate = path.join(TEMPLATES_DIR, `gift-email-${safe}.html`)
  if (fs.existsSync(candidate)) return candidate
  return DEFAULT_TEMPLATE
}

// Per-campaign per-language template override. Used when the wizard lets
// the operator pick a non-default variant (e.g. seasonal: gift-email-2026-summer-fr).
// The override is stored on params as a bare template name (no extension,
// no path) — we resolve to an absolute path here with a defensive name
// regex to block path traversal.
function resolveTemplatePath (p, lang) {
  const safe = (lang || 'fr').toLowerCase().split('-')[0]
  const tplName = safe === 'en' ? p?.template_en : p?.template_fr
  if (tplName && /^[a-z0-9-]+$/i.test(tplName)) {
    const candidate = path.join(TEMPLATES_DIR, tplName + '.html')
    if (fs.existsSync(candidate)) return candidate
  }
  // Legacy single-template override — CONFINÉ à TEMPLATES_DIR (anti-LFI : `params`
  // vient de la requête ; sans ça, /view lirait n'importe quel fichier du serveur).
  if (p?.template_path) {
    const base = path.resolve(TEMPLATES_DIR)
    const resolved = path.resolve(base, p.template_path)
    if ((resolved === base || resolved.startsWith(base + path.sep)) && fs.existsSync(resolved)) return resolved
  }
  return templateForLanguage(lang)
}

// ── Gift redirect wrapper ───────────────────────────────────────────────────
// We don't put the raw Giftbit shortlink in outgoing emails any more. Instead
// each recipient gets a short opaque token, and the email contains
//   https://msg.gigafibre.ca/g/<token>
// which 302-redirects to the underlying Giftbit URL — but ONLY if our own
// expiry hasn't passed and we haven't revoked it. This gives us:
//   1. End-date control independent of Giftbit's (e.g. expire after 30d
//      even if Giftbit's underlying gift is valid for 12 months)
//   2. Reuse of unredeemed gifts: if recipient A doesn't click, we generate
//      a NEW token for recipient B pointing to the same Giftbit URL. A's
//      old wrapper URL stops working (expired/revoked) while B's new one
//      directs to the same Giftbit gift.
//
// In-memory index `tokenIndex` is the fast lookup; the source of truth lives
// inside each campaign's recipient row (gift_token, gift_url, gift_expires_at,
// gift_revoked, gift_redirected_count, gift_first_redirected_at). The index
// is rebuilt at startup by scanning all campaign JSONs.

const tokenIndex = new Map()  // token → { campaign_id, row }

function generateGiftToken () {
  // 10 base64url chars ≈ 60 bits entropy — collision-safe for our volumes
  // (millions of tokens × billion years before a clash). Avoids ambiguity
  // characters (0/O, 1/l/I) accidentally — base64url already does.
  return crypto.randomBytes(8).toString('base64url').slice(0, 10)
}

function rebuildTokenIndex () {
  tokenIndex.clear()
  let n = 0
  for (const meta of listCampaigns()) {
    const c = loadCampaign(meta.id)
    if (!c?.recipients) continue
    for (let i = 0; i < c.recipients.length; i++) {
      const tok = c.recipients[i].gift_token
      if (tok) { tokenIndex.set(tok, { campaign_id: c.id, row: i }); n++ }
    }
  }
  log(`gift token index rebuilt: ${n} tokens across ${listCampaigns().length} campaigns`)
}

function lookupGiftToken (token) {
  const ref = tokenIndex.get(token)
  if (!ref) return null
  const c = loadCampaign(ref.campaign_id)
  if (!c?.recipients?.[ref.row]) return null
  return { campaign: c, row: ref.row, recipient: c.recipients[ref.row] }
}

// Renders the wrapper URL for a recipient. Falls back to the raw gift_url
// when no token is set (backwards-compat with campaigns sent before this
// feature shipped — those emails already left and contain the raw URL).
function wrapperUrl (token) {
  return `${cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca'}/g/${encodeURIComponent(token)}`
}

// Static HTML returned when a token is expired/revoked/unknown. Branded but
// minimal so a 60s loading time from a corporate proxy doesn't reveal
// internal structure.
function giftExpiredPage (reason) {
  const messages = {
    expired:  { fr: 'Ce cadeau a expiré.', en: 'This gift has expired.' },
    revoked:  { fr: 'Ce lien a été désactivé.', en: 'This link has been revoked.' },
    notfound: { fr: 'Lien introuvable.', en: 'Link not found.' },
    blocked:  { fr: 'Ce lien doit être ouvert depuis votre appareil personnel (téléphone ou connexion résidentielle).', en: 'Please open this link from your personal device (phone or home connection).' },
  }
  const m = messages[reason] || messages.notfound
  return `<!doctype html><html lang="fr"><head><meta charset="utf-8">
<title>TARGO — ${m.fr}</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<style>
body{margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#F5FAF7;color:#1B2E24;min-height:100vh;display:flex;align-items:center;justify-content:center}
.card{background:#fff;max-width:480px;margin:24px;padding:48px 32px;border-radius:16px;border:1px solid #e5e7eb;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,.04)}
.logo{font-size:28px;font-weight:800;color:#00C853;margin-bottom:24px;letter-spacing:-.5px}
.icon{font-size:48px;margin-bottom:16px}
h1{font-size:22px;margin:0 0 8px;font-weight:700}
p{color:#64748B;margin:0;line-height:1.5}
.en{margin-top:16px;font-size:14px;color:#94a3b8}
a{color:#00C853;text-decoration:none;font-weight:600}
</style></head><body>
<div class="card">
  <div class="logo">TARGO</div>
  <div class="icon">${reason === 'blocked' ? '🔒' : '🎁'}</div>
  <h1>${m.fr}</h1>
  <p>Si tu penses qu'il s'agit d'une erreur, écris-nous à <a href="mailto:support@targo.ca">support@targo.ca</a>.</p>
  <p class="en">${m.en}</p>
</div></body></html>`
}

function templatePath (name) {
  if (!isValidTemplateName(name)) {
    throw new Error(`template name invalid or not allowed: ${name}`)
  }
  return path.join(TEMPLATES_DIR, name + '.html')
}

// Companion .mjml path for a template name. If this file exists, the template
// is in MJML mode — the editor loads the .mjml source and on save we
// recompile to .html. If only the .html exists, classic HTML mode.
function templateMjmlPath (name) {
  if (!isValidTemplateName(name)) {
    throw new Error(`template name invalid or not allowed: ${name}`)
  }
  return path.join(TEMPLATES_DIR, name + '.mjml')
}

// Companion .json — visual editor's design tree (now Unlayer's design JSON,
// previously was easy-email's tree — same file, different content). When
// present, the editor uses it as the source of truth on load. Generated
// client-side by the editor on save; we just persist it as-is.
function templateJsonPath (name) {
  if (!isValidTemplateName(name)) {
    throw new Error(`template name invalid or not allowed: ${name}`)
  }
  return path.join(TEMPLATES_DIR, name + '.json')
}

// Return 'mjml' if the template has a .mjml companion file on disk (source
// of truth = MJML, .html is the auto-compiled output). Otherwise 'html'.
function templateFormat (name) {
  try { return fs.existsSync(templateMjmlPath(name)) ? 'mjml' : 'html' }
  catch { return 'html' }
}

function listEditableTemplates () {
  return scanEditableTemplates().map(name => {
    const format = templateFormat(name)
    const p = format === 'mjml' ? templateMjmlPath(name) : path.join(TEMPLATES_DIR, name + '.html')
    let size = 0, modified = null
    try {
      const stat = fs.statSync(p)
      size = stat.size
      modified = stat.mtime.toISOString()
    } catch {}
    return { name, format, size, modified }
  })
}

function readTemplate (tplPath) {
  const p = tplPath || DEFAULT_TEMPLATE
  if (!fs.existsSync(p)) {
    throw new Error(`Template not found: ${p}`)
  }
  return fs.readFileSync(p, 'utf8')
}

// ── Send-async worker ────────────────────────────────────────────────────────
// Iterates recipients (not excluded, status=pending), sends each via the
// existing email lib, broadcasts each status change over SSE. Throttle is
// configurable. Failures stop only that recipient — the loop continues.
//
// Runs in the background — caller fires this and returns immediately.
const activeWorkers = new Set()

async function sendCampaignAsync (id) {
  if (activeWorkers.has(id)) {
    log(`campaign ${id} already sending`)
    return
  }
  activeWorkers.add(id)
  const topic = `campaign:${id}`

  try {
    let campaign = loadCampaign(id)
    if (!campaign) throw new Error(`campaign ${id} not found`)
    campaign.status = 'sending'
    campaign.send_started_at = new Date().toISOString()
    campaign = saveCampaign(campaign)
    sse.broadcast(topic, 'campaign-status', { id, status: 'sending' })

    const p = campaign.params || {}
    const throttle = parseInt(p.throttle_ms || 600, 10)
    // Pre-load templates by language to avoid re-reading from disk on every
    // recipient. Cache map keyed by resolved template path.
    const tplCache = new Map()
    const getTpl = (lang) => {
      const tplPath = resolveTemplatePath(p, lang)
      if (!tplCache.has(tplPath)) {
        tplCache.set(tplPath, fs.readFileSync(tplPath, 'utf8'))
      }
      return tplCache.get(tplPath)
    }

    for (let i = 0; i < campaign.recipients.length; i++) {
      const r = campaign.recipients[i]
      if (r.excluded || r.status !== 'pending') continue

      // Mark queued so the UI shows movement immediately
      r.status = 'queued'
      saveCampaign(campaign)
      sse.broadcast(topic, 'recipient-update', { i, recipient: r })

      const lang = (r.language || 'fr').toLowerCase().split('-')[0]
      const tplText = getTpl(lang)
      // Web fallback ("View in browser") so recipients with rendering
      // issues (image-blocking, antique Outlook, third-party mail apps)
      // can open the campaign in any modern browser. The URL hits the
      // /recipients/:row_index/view endpoint defined further down which
      // re-renders the same template with this recipient's variables.
      const viewUrl = `${cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca'}/campaigns/${encodeURIComponent(id)}/recipients/${i}/view`
      // Per-recipient amount override. Precedence:
      //   1. r.amount       — explicit override typed in the manual-add dialog
      //   2. r.gift_value_cents → "$X" formatted (when CSV import set this)
      //   3. p.amount       — campaign default ("60 $" etc.)
      //   4. "50 $" hard fallback
      // This matters for manual recipients where a user pastes a $50 Giftbit
      // link into a campaign whose params.amount defaults to "60 $".
      let displayAmount = p.amount || '50 $'
      if (r.amount) {
        displayAmount = r.amount
      } else if (r.gift_value_cents) {
        const cents = Number(r.gift_value_cents) || 0
        // Cents are exact dollars when divisible, else 2-decimal
        displayAmount = cents % 100 === 0
          ? `${cents / 100} $`
          : `${(cents / 100).toFixed(2)} $`
      }
      // Generate the wrapper token if absent (idempotent across retries).
      // Computed BEFORE the gift_url var so the email gets the wrapped URL.
      if (!r.gift_token && r.gift_url) {
        r.gift_token = generateGiftToken()
        // Expiry resolution order:
        //   1. params.gift_expires_at (explicit ISO date set in the wizard
        //      via the date picker) — all recipients of THIS campaign get
        //      the same hard cutoff, regardless of when the worker fires.
        //   2. Fallback: now() + gift_expiry_days (relative deadline,
        //      shifts forward by the queue lag).
        if (p.gift_expires_at) {
          r.gift_expires_at = new Date(p.gift_expires_at).toISOString()
        } else {
          const expiryDays = parseInt(p.gift_expiry_days || 90, 10)
          r.gift_expires_at = new Date(Date.now() + expiryDays * 86400 * 1000).toISOString()
        }
        r.gift_revoked = false
        r.gift_redirected_count = 0
        tokenIndex.set(r.gift_token, { campaign_id: id, row: i })
      }
      // Format the wrapper's own expiry date for display in the email body.
      // Locale matches the recipient's language so "May 31, 2026" / "31 mai 2026"
      // appear naturally. expires_in_days is a friendlier shorter format for
      // tight deadlines (≤ 60 days). The two are intentionally redundant
      // so the template author can pick the one that fits their layout.
      const expiryLocale = lang === 'en' ? 'en-CA' : 'fr-CA'
      let expiresAtDate = ''
      let expiresInDays = ''
      if (r.gift_expires_at) {
        const exp = new Date(r.gift_expires_at)
        expiresAtDate = exp.toLocaleDateString(expiryLocale, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'America/Montreal' })
        const days = Math.max(0, Math.ceil((exp - new Date()) / 86400000))
        expiresInDays = String(days)
      }
      const vars = {
        firstname:         r.firstname || (lang === 'en' ? 'dear customer' : 'cher client'),
        lastname:          r.lastname  || '',
        email:             r.email,
        description:       r.civic_address || '',
        // Wrapper URL when we have a token; fall back to the raw Giftbit URL
        // (backwards-compat for recipients sent before this feature shipped).
        gift_url:          r.gift_token ? wrapperUrl(r.gift_token) : r.gift_url,
        amount:            displayAmount,
        expiry:            p.expiry || '',
        // Auto-derived from the wrapper's gift_expires_at — distinct from
        // the manual {{expiry}} field above which is for promotion-end dates
        // unrelated to the gift link wrapper.
        expires_at_date:   expiresAtDate,
        expires_in_days:   expiresInDays,
        commitment_months: p.commitment_months || '3',
        year:              new Date().getFullYear(),
        view_url:          viewUrl + (r.gift_token ? ('?t=' + r.gift_token) : ''),
      }
      const html = renderTemplate(tplText, vars)
      const toName = `${r.firstname || ''} ${r.lastname || ''}`.trim()
      const to = toName ? `"${toName}" <${r.email}>` : r.email

      // Set Mailjet CustomID = campaign_id:recipient_index so the Event API
      // webhook events can be matched back to a specific recipient row.
      // Mailjet echoes this value in every event under the CustomID field;
      // SMTP messageId alone is not a reliable join key (different ID space).
      const customId = `${id}:${i}`
      r.mailjet_custom_id = customId

      // Auto-retry: most SMTP failures in this campaign (we observed
      // "Unexpected socket close" once per ~200 sends) are transient
      // Mailjet connection hiccups. Retry up to 2 times with backoff
      // before marking the recipient as failed. The retry doesn't
      // require any operator action and adds at most ~10s to the run.
      const RETRY_BACKOFF_MS = [2000, 5000]
      let sendRes = null
      let lastErrMessage = null
      const attempts = 1 + RETRY_BACKOFF_MS.length
      // Sujet PAR LANGUE du destinataire (subject_fr / subject_en) ; repli sur le sujet unique p.subject pour les campagnes legacy.
      const subject = (lang === 'en' ? p.subject_en : p.subject_fr) || p.subject || 'Un cadeau pour toi, de la part de TARGO'
      const fromAddr = p.from || cfg.MAIL_FROM
      for (let attempt = 1; attempt <= attempts; attempt++) {
        try {
          if (p.channel === 'gmail') {
            // Envoi DIRECT depuis la boîte Workspace impersonnée (alias « send-as »
            // vérifié). Le courriel part du vrai domaine → AUCUN « via mailjet »
            // chez le client, et les réponses retombent dans l'Inbox. Compromis :
            // pas de suivi ouvertures/clics (pas d'Event API Mailjet).
            const gr = await gmail.sendMessage({ to, subject, html, from: fromAddr })
            sendRes = { messageId: gr.id || null }
          } else {
            sendRes = await email.sendEmail({
              to, subject, html, from: fromAddr,
              headers: { 'X-MJ-CustomID': customId },
            })
          }
        } catch (e) {
          sendRes = false
          lastErrMessage = String(e.message || e)
        }
        if (sendRes && sendRes.messageId !== undefined) break  // success
        // Falsy return — pick up the real reason from email.js side-channel
        const le = email.getLastError && email.getLastError()
        if (le) lastErrMessage = String(le.message || le).slice(0, 500)
        if (attempt < attempts) {
          log(`campaign ${id} recipient ${i} attempt ${attempt} failed (${lastErrMessage || 'unknown'}); retry in ${RETRY_BACKOFF_MS[attempt - 1]}ms`)
          await new Promise(rs => setTimeout(rs, RETRY_BACKOFF_MS[attempt - 1]))
        }
      }
      if (sendRes && sendRes.messageId !== undefined) {
        r.mailjet_uuid = sendRes.messageId || null   // SMTP Message-ID for reference
        r.status = 'sent'
        r.sent_at = new Date().toISOString()
        r.error = null
        r.retry_count = attempts - 1  // 0 means first attempt succeeded
      } else {
        r.status = 'failed'
        r.error = lastErrMessage || 'SMTP send failed (no detail available)'
        r.retry_count = RETRY_BACKOFF_MS.length
        log(`campaign ${id} recipient ${i} failed after ${attempts} attempts:`, r.error)
      }

      saveCampaign(campaign)
      sse.broadcast(topic, 'recipient-update', { i, recipient: r })

      if (throttle > 0) await new Promise(rs => setTimeout(rs, throttle))
    }

    campaign = loadCampaign(id)
    campaign.status = 'completed'
    campaign.send_completed_at = new Date().toISOString()
    campaign = saveCampaign(campaign)
    sse.broadcast(topic, 'campaign-done', { id, counters: campaign.counters })
    log(`campaign ${id} done — ${campaign.counters.sent || 0} sent, ${campaign.counters.failed || 0} failed`)
  } catch (e) {
    log(`campaign ${id} worker failed:`, e.message)
    try {
      const c = loadCampaign(id)
      if (c) {
        c.status = 'failed'
        c.error = String(e.message || e)
        saveCampaign(c)
        sse.broadcast(topic, 'campaign-done', { id, error: c.error })
      }
    } catch {}
  } finally {
    activeWorkers.delete(id)
  }
}

// ── Mailjet webhook receiver ─────────────────────────────────────────────────
// Mailjet's Event API posts a JSON array of events:
//   [{"event": "sent"|"open"|"click"|"bounce"|"blocked"|"spam"|"unsub",
//     "MessageID": 1234, "CustomID": "<our reference>",
//     "time": 1234567890, "email": "...", "Payload": "..."}, ...]
// We match by mailjet_uuid (== MessageID) and update status across all
// campaigns. We don't know which campaign a given event belongs to without
// scanning, but for the volumes we're dealing with (a few campaigns × ~200
// recipients each) scanning all open JSON files per webhook is fine.
function mailjetEventToStatus (event) {
  switch ((event || '').toLowerCase()) {
    case 'sent':    return null   // already 'sent' from SMTP — informational
    case 'open':    return 'opened'
    case 'click':   return 'clicked'
    case 'bounce':
    case 'hardbounce':
    case 'softbounce': return 'bounced'
    case 'blocked':
    case 'spam':
    case 'unsub':   return 'failed'
    default:        return null
  }
}

// Reminder campaigns are deep-copies of their parent recipients with new
// gift_tokens. A click on a reminder updates the CHILD's gift_link_clicked
// but the parent campaign would still show "non-cliqué" for that recipient.
// This helper mirrors the click flag back to the parent so the parent's
// detail page and counters reflect cumulative engagement across the
// reminder chain. Idempotent — if the parent is already flagged, no-op.
function cascadeClickToParent (childCampaign, childRow) {
  if (!childRow.parent_campaign_id || childRow.parent_row_index == null) return false
  let parent
  try { parent = loadCampaign(childRow.parent_campaign_id) }
  catch { return false }
  if (!parent?.recipients?.[childRow.parent_row_index]) return false
  const parentR = parent.recipients[childRow.parent_row_index]
  if (parentR.gift_link_clicked) return false   // already counted
  parentR.gift_link_clicked = true
  parentR.gift_clicked_at = childRow.gift_clicked_at || new Date().toISOString()
  // Breadcrumb so the parent UI can show "cliqué via la relance XXX"
  parentR.gift_clicked_via_reminder = childCampaign.id
  saveCampaign(parent)
  sse.broadcast(`campaign:${parent.id}`, 'recipient-update',
                { i: childRow.parent_row_index, recipient: parentR })
  log(`cascade: ${childCampaign.id} row ${childRow.parent_row_index} click → parent ${parent.id}`)
  return true
}

function applyWebhookEvent (ev) {
  const newStatus = mailjetEventToStatus(ev.event)
  if (!newStatus) return false
  // Primary join key: CustomID = "<campaign-id>:<recipient-index>" which we
  // injected on send via X-MJ-CustomID. Falling back to MessageID for events
  // that might predate the CustomID rollout.
  const customId = String(ev.CustomID || ev.custom_id || '')
  const msgId    = String(ev.MessageID || ev.message_id || '')

  // Mailjet click events include the original URL the recipient clicked. We
  // distinguish "clicked the gift CTA" (the actionable engagement signal)
  // from "clicked the support email / footer link" (low-signal). Match the
  // recipient's stored gift_url first (exact), with a host-level fallback
  // for shortlink redirects (gft.link / giftbit.com).
  function isGiftClick (r, ev) {
    const clickedUrl = String(ev.url || ev.URL || '')
    if (!clickedUrl) return false
    // Wrapper URL match (current behaviour — emails sent after the gift
    // redirect rollout contain our /g/<token> link, not the raw Giftbit one).
    if (r.gift_token && clickedUrl.includes(`/g/${r.gift_token}`)) return true
    // Legacy emails sent before the wrapper shipped still have the raw URL.
    if (r.gift_url && clickedUrl.startsWith(r.gift_url)) return true
    return /(?:^|\/\/)(?:[\w-]+\.)?(?:gft\.link|giftbit\.com)/i.test(clickedUrl)
  }

  // Fast path: parse CustomID to skip the campaign scan entirely
  if (customId && customId.includes(':')) {
    const [campId, idxStr] = customId.split(':')
    const idx = parseInt(idxStr, 10)
    const c = loadCampaign(campId)
    if (c && c.recipients && c.recipients[idx]) {
      const r = c.recipients[idx]
      r.status = newStatus
      if (newStatus === 'opened')  r.opened_at  = new Date((ev.time || 0) * 1000).toISOString()
      if (newStatus === 'clicked') {
        r.clicked_at = new Date((ev.time || 0) * 1000).toISOString()
        // Flag the high-value signal — recipient engaged with the offer.
        // Once true, stays true (later non-gift clicks don't unset it).
        if (isGiftClick(r, ev) && !r.gift_link_clicked) {
          r.gift_link_clicked = true
          r.gift_clicked_at = r.clicked_at
        }
      }
      if (newStatus === 'bounced' || newStatus === 'failed') {
        r.error = ev.error || ev.error_related_to || ev.event
      }
      saveCampaign(c)
      sse.broadcast(`campaign:${c.id}`, 'recipient-update', { i: idx, recipient: r })
      // Reminder click → mirror to parent so cumulative engagement is visible.
      if (r.gift_link_clicked) cascadeClickToParent(c, r)
      return true
    }
  }

  // Fallback: scan all campaigns for a recipient matching msgId (slower)
  if (!msgId) return false
  for (const meta of listCampaigns()) {
    const c = loadCampaign(meta.id)
    if (!c) continue
    for (let i = 0; i < (c.recipients || []).length; i++) {
      const r = c.recipients[i]
      if (String(r.mailjet_uuid) !== msgId) continue
      r.status = newStatus
      if (newStatus === 'opened')  r.opened_at  = new Date((ev.time || 0) * 1000).toISOString()
      if (newStatus === 'clicked') {
        r.clicked_at = new Date((ev.time || 0) * 1000).toISOString()
        if (isGiftClick(r, ev) && !r.gift_link_clicked) {
          r.gift_link_clicked = true
          r.gift_clicked_at = r.clicked_at
        }
      }
      if (newStatus === 'bounced' || newStatus === 'failed') {
        r.error = ev.error || ev.error_related_to || ev.event
      }
      saveCampaign(c)
      sse.broadcast(`campaign:${c.id}`, 'recipient-update', { i, recipient: r })
      if (r.gift_link_clicked) cascadeClickToParent(c, r)
      return true
    }
  }
  return false
}

// ── HTTP routing ─────────────────────────────────────────────────────────────
// ── Pool de réallocation des liens-cadeaux NON CLIQUÉS ──────────────────────
// Un lien jamais cliqué = le destinataire n'a JAMAIS atteint le vrai lien Giftbit
// → 100 % sûr à réallouer (zéro risque de double-rédemption). On le sort dans un
// pool réutilisable et on tue le lien intermédiaire (revoke) s'il est encore actif.
const POOL_FILE = path.join(__dirname, '..', 'data', 'giftbit-pool.json')
function loadPool () { return readJsonFile(POOL_FILE, []) || [] }
function savePool (arr) { return writeJsonFile(POOL_FILE, arr) }

// Sécurité globale : un lien cliqué dans N'IMPORTE QUELLE campagne (parent OU rappel)
// ne doit JAMAIS être poolé (un rappel ne voit pas les clics du parent → on cumule).
function reachedUrlsGlobal () {
  const set = new Set()
  for (const meta of listCampaigns()) {
    const c = loadCampaign(meta.id)
    for (const r of (c && c.recipients) || []) {
      if (r.gift_url && (r.gift_link_clicked || (r.gift_redirected_count || 0) > 0)) set.add(r.gift_url)
    }
  }
  return set
}

function reclaimCampaign (id, { dryRun = true, revokeLive = true } = {}) {
  const c = loadCampaign(id)
  if (!c) return { error: 'campaign not found', id }
  const now = Date.now()
  const pool = loadPool()
  const have = new Set(pool.map(p => p.gift_url))
  const reachedGlobal = reachedUrlsGlobal() // « atteint » = cliqué OU redirigé (cette campagne OU un rappel)
  let reclaimable = 0; let clickedSkipped = 0; let alreadyPooled = 0; let noUrl = 0; let added = 0; let revoked = 0
  const preview = []
  for (let i = 0; i < (c.recipients || []).length; i++) {
    const r = c.recipients[i]
    if (!r.gift_url) { noUrl++; continue }
    if (r.gift_link_clicked || (r.gift_redirected_count || 0) > 0 || reachedGlobal.has(r.gift_url)) { clickedSkipped++; continue } // JAMAIS réallouer un lien atteint
    if (r.gift_pooled) { alreadyPooled++; continue }
    reclaimable++
    if (preview.length < 5) preview.push({ row: i, firstname: r.firstname, email: r.email })
    if (dryRun) continue
    const expired = r.gift_expires_at && new Date(r.gift_expires_at).getTime() < now
    if (revokeLive && !expired && !r.gift_revoked) { r.gift_revoked = true; r.gift_revoked_at = new Date().toISOString(); revoked++ }
    if (!have.has(r.gift_url)) {
      // Réallouable par conception : non cliqué = jamais redirigé = le client n'a jamais vu le lien
      // Giftbit (notre /g/ seul) ; on révoque l'intermédiaire ci-dessus → un seul wrapper vivant à la fois.
      pool.push({ gift_url: r.gift_url, giftbit_uuid: r.giftbit_uuid || '', gift_value_cents: r.gift_value_cents || 0, source_campaign: c.id, source_row: i, reclaimed_at: new Date().toISOString(), used: false, reallocatable: true })
      have.add(r.gift_url); added++
    }
    r.gift_pooled = true
    r.gift_pooled_at = new Date().toISOString()
  }
  if (!dryRun) { saveCampaign(c); savePool(pool) }
  return { id, dryRun, recipients: (c.recipients || []).length, reclaimable, clickedSkipped, alreadyPooled, noUrl, added, revoked, pool_total: pool.length, pool_available: pool.filter(p => !p.used).length, preview }
}

// ── Auto-reclaim : pool les liens DÉJÀ EXPIRÉS non cliqués (toutes campagnes) ──
// Différent du reclaim manuel : ne révoque PAS les liens vivants (on attend l'expiration
// naturelle) → on ne ramasse que ce qui est déjà mort + jamais atteint. Sûr, idempotent.
function reclaimExpiredAll () {
  const now = Date.now()
  const pool = loadPool()
  const have = new Set(pool.map(p => p.gift_url))
  const reached = reachedUrlsGlobal()
  let scanned = 0; let added = 0
  for (const meta of listCampaigns()) {
    if (meta.id === 'rewards-adhoc') continue // ne pas re-pooler les récompenses déjà envoyées
    const c = loadCampaign(meta.id)
    if (!c || !c.recipients) continue
    let changed = false
    for (let i = 0; i < c.recipients.length; i++) {
      const r = c.recipients[i]
      scanned++
      if (!r.gift_url || r.gift_pooled) continue
      if (r.gift_link_clicked || (r.gift_redirected_count || 0) > 0 || reached.has(r.gift_url)) continue
      const expired = r.gift_expires_at && new Date(r.gift_expires_at).getTime() < now
      if (!expired) continue // AUTO = uniquement les liens déjà expirés
      if (!have.has(r.gift_url)) {
        pool.push({ gift_url: r.gift_url, giftbit_uuid: r.giftbit_uuid || '', gift_value_cents: r.gift_value_cents || 0, source_campaign: c.id, source_row: i, reclaimed_at: new Date().toISOString(), used: false, reallocatable: true, auto: true })
        have.add(r.gift_url); added++
      }
      r.gift_pooled = true; r.gift_pooled_at = new Date().toISOString()
      changed = true
    }
    if (changed) saveCampaign(c)
  }
  if (added) { savePool(pool); log('gift auto-reclaim: ' + added + ' lien(s) expiré(s) non cliqué(s) → pool') }
  return { scanned, added, pool_available: pool.filter(p => !p.used).length }
}

function startGiftReclaimCron () {
  if (process.env.GIFT_RECLAIM_CRON !== 'on') return
  log('gift auto-reclaim cron activé (toutes les 12 h)')
  setTimeout(() => { try { reclaimExpiredAll() } catch (e) { log('gift auto-reclaim: ' + (e && e.message)) } }, 60000)
  setInterval(() => { try { reclaimExpiredAll() } catch (e) { log('gift auto-reclaim: ' + (e && e.message)) } }, 12 * 3600 * 1000)
}

// Révoque une récompense DÉJÀ ENVOYÉE (par erreur / mauvais client / non utilisée) et remet le
// vrai lien Giftbit au pool disponible. SÛR seulement si jamais atteint (ni cliqué ni redirigé).
function revokeReward (giftUrl) {
  const pool = loadPool()
  const item = pool.find(p => p.gift_url === giftUrl)
  if (!item) return { error: 'lien introuvable dans le pool' }
  const camp = loadCampaign('rewards-adhoc')
  const matches = ((camp && camp.recipients) || []).filter(r => r.gift_url === giftUrl && !r.gift_revoked)
  if (matches.some(r => r.gift_link_clicked || (r.gift_redirected_count || 0) > 0)) {
    return { error: 'BLOQUÉ — le client a déjà ouvert le lien (cliqué/redirigé). Non récupérable (risque « carte déjà utilisée »).' }
  }
  let revoked = 0
  for (const r of matches) { r.gift_revoked = true; r.gift_revoked_at = new Date().toISOString(); r.status = 'revoked'; revoked++ }
  if (camp && revoked) saveCampaign(camp)
  item.used = false
  for (const k of ['used_at', 'used_by', 'used_row', 'draft']) delete item[k]
  item.revoked_reallocated_at = new Date().toISOString()
  savePool(pool)
  return { revoked, gift_url: giftUrl, available: pool.filter(p => !p.used).length }
}

// ── Envoi d'une récompense unitaire (depuis le pool) à un client ────────────
// Réutilise toute la machinerie campagne : une campagne permanente « rewards-adhoc »
// accumule les envois ; sendCampaignAsync n'envoie QUE les destinataires 'pending'.
function ensureRewardsCampaign () {
  let c = loadCampaign('rewards-adhoc')
  if (!c) {
    c = {
      id: 'rewards-adhoc',
      name: 'Récompenses ponctuelles',
      status: 'idle',
      created_at: new Date().toISOString(),
      params: { from: 'Targo <support@targo.ca>', subject: 'Une récompense de Targo 🎁', subject_fr: 'Une récompense de Targo 🎁', subject_en: 'A reward from Targo 🎁', template_fr: 'gift-email-fr', template_en: 'gift-email-en', channel: 'gmail', gift_expiry_days: 60, amount: '60 $', throttle_ms: 300 },
      recipients: []
    }
    saveCampaign(c)
  }
  return c
}

async function rewardSend (body) {
  const channel = (body.channel || 'email').toLowerCase()
  let firstname = body.firstname || ''
  const lastname = body.lastname || ''
  let email = String(body.email || '').trim()
  let phone = String(body.phone || '').trim()
  let language = body.language || 'fr'
  const customer_id = body.customer_id || ''
  let customer_name = body.customer_name || ''
  if (customer_id) {
    try {
      const r = await erpFetch('/api/resource/Customer/' + encodeURIComponent(customer_id) + '?fields=' + encodeURIComponent(JSON.stringify(['name', 'customer_name', 'email_id', 'mobile_no', 'cell_phone', 'language'])))
      const d = r && r.data && r.data.data
      if (d) {
        customer_name = d.customer_name || customer_name
        email = email || d.email_id || ''
        phone = phone || d.mobile_no || d.cell_phone || ''
        language = (d.language || language || 'fr').toLowerCase().split('-')[0]
      }
    } catch (e) { /* best-effort */ }
  }
  if (!firstname && customer_name) firstname = customer_name.trim().split(/\s+/)[0]
  if (channel === 'sms') return { error: "SMS bientôt (v1.1) — utilise courriel pour l'instant" }
  if (!email) return { error: 'courriel requis (client sans email_id ?)' }

  const pool = loadPool()
  // Sûr par conception : un lien NON cliqué = le client n'a JAMAIS vu le lien Giftbit (il ne voit
  // que notre wrapper /g/, et l'intermédiaire d'origine a été révoqué au reclaim) → réallouable.
  const idx = pool.findIndex(p => !p.used && (!body.gift_value_cents || p.gift_value_cents === body.gift_value_cents))
  if (idx < 0) return { error: 'pool vide — aucun lien disponible' }
  const link = pool[idx]

  if (body.dry_run) {
    // Aperçu : on REND le vrai courriel pour PROUVER que seul NOTRE lien /g/ y figure
    // (le lien Giftbit brut n'est JAMAIS envoyé au client).
    const cc = ensureRewardsCampaign()
    const params = cc.params || {}
    const token = generateGiftToken()
    const clientUrl = wrapperUrl(token)
    let rawInEmail = null
    try {
      const lang = String(language || 'fr').toLowerCase().split('-')[0]
      const tplText = fs.readFileSync(resolveTemplatePath(params, lang), 'utf8')
      const html = renderTemplate(tplText, { firstname: firstname || 'cher client', lastname, email, gift_url: clientUrl, amount: params.amount || '', year: new Date().getFullYear() })
      rawInEmail = html.includes(link.gift_url) // doit être false
    } catch (e) { /* rendu best-effort */ }
    return { dry_run: true, would_send: { firstname, email, channel, customer_id, customer_name }, client_link: clientUrl, client_link_note: 'notre redirection HUB → Giftbit ; le lien Giftbit brut n\'est PAS dans le courriel', raw_link_in_email: rawInEmail, pool_link_internal: link.gift_url, gift_value_cents: link.gift_value_cents || 0, pool_available: pool.filter(pp => !pp.used).length }
  }

  // Mode LIEN SEUL : réserve un lien du pool + renvoie le wrapper /g/, SANS envoyer ni créer de conversation.
  // Sert au bouton « Copier le lien » et à « Personnaliser » (qui ouvre ensuite le composeur normal pré-rempli).
  if (body.mode === 'link') {
    const camp = ensureRewardsCampaign()
    const params = camp.params || {}
    const token = generateGiftToken()
    const clientUrl = wrapperUrl(token)
    const row = camp.recipients.length
    camp.recipients.push({ firstname, lastname, email, phone, language, gift_url: link.gift_url, giftbit_uuid: link.giftbit_uuid || '', gift_value_cents: link.gift_value_cents || 0, customer_id, customer_name, gift_token: token, gift_expires_at: new Date(Date.now() + parseInt(params.gift_expiry_days || 60, 10) * 86400000).toISOString(), gift_revoked: false, gift_redirected_count: 0, status: 'link', reward_adhoc: true, queued_at: new Date().toISOString() })
    saveCampaign(camp)
    try { tokenIndex.set(token, { campaign_id: 'rewards-adhoc', row }) } catch (e) { /* reconstruit au boot */ }
    link.used = true; link.used_at = new Date().toISOString(); link.used_by = customer_id || email; link.used_row = row
    savePool(pool)
    return { mode: 'link', client_link: clientUrl, gift_token: token, gift_value_cents: link.gift_value_cents || 0, pool_available: pool.filter(pp => !pp.used).length }
  }

  // Mode BROUILLON : prépare un courriel (avec NOTRE lien /g/) dans la messagerie centralisée à éditer/envoyer.
  if (body.mode === 'draft') {
    const camp = ensureRewardsCampaign()
    const params = camp.params || {}
    const lang = String(language || 'fr').toLowerCase().split('-')[0]
    const token = generateGiftToken()
    const clientUrl = wrapperUrl(token)
    let html = ''
    try { html = renderTemplate(fs.readFileSync(resolveTemplatePath(params, lang), 'utf8'), { firstname: firstname || 'cher client', lastname, email, gift_url: clientUrl, amount: params.amount || '', year: new Date().getFullYear() }) } catch (e) { /* rendu best-effort */ }
    const text = `Bonjour ${firstname || ''},\n\nNous tenons à vous offrir une récompense. Cliquez ici pour la récupérer :\n${clientUrl}\n\nMerci de votre confiance,\nL'équipe Targo`
    // Design Unlayer résolu → ouvre le brouillon en ÉDITION dans l'éditeur visuel (merge /g/ + prénom + 60$).
    let design = null
    try {
      const tplName = params['template_' + lang] || params.template_fr || 'gift-email-fr'
      let ds = fs.readFileSync(path.join(TEMPLATES_DIR, tplName + '.json'), 'utf8')
      const esc = v => JSON.stringify(String(v == null ? '' : v)).slice(1, -1)
      const expDays = parseInt(params.gift_expiry_days || 60, 10)
      let expDate = ''
      try { expDate = new Date(Date.now() + expDays * 86400000).toLocaleDateString(lang === 'en' ? 'en-CA' : 'fr-CA', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'America/Montreal' }) } catch (e) { /* */ }
      const repl = { firstname: firstname || 'cher client', lastname: lastname || '', gift_url: clientUrl, amount: params.amount || '60 $', year: String(new Date().getFullYear()), email, description: '', commitment_months: String(params.commitment_months || '3'), view_url: '', expires_at_date: expDate, expires_in_days: String(expDays) }
      for (const k in repl) ds = ds.split('{{' + k + '}}').join(esc(repl[k]))
      design = JSON.parse(ds)
    } catch (e) { log('reward draft design: ' + (e && e.message)) }
    const row = camp.recipients.length
    camp.recipients.push({ firstname, lastname, email, phone, language: lang, gift_url: link.gift_url, giftbit_uuid: link.giftbit_uuid || '', gift_value_cents: link.gift_value_cents || 0, customer_id, customer_name, gift_token: token, gift_expires_at: new Date(Date.now() + parseInt(params.gift_expiry_days || 60, 10) * 86400000).toISOString(), gift_revoked: false, gift_redirected_count: 0, status: 'draft', reward_adhoc: true, draft: true, queued_at: new Date().toISOString() })
    saveCampaign(camp)
    try { tokenIndex.set(token, { campaign_id: 'rewards-adhoc', row }) } catch (e) { /* l'index est aussi reconstruit au boot depuis les recipients */ }
    link.used = true; link.used_at = new Date().toISOString(); link.used_by = customer_id || email; link.used_row = row; link.draft = true
    savePool(pool)
    let convToken = null
    try {
      const conv = require('./conversation').createDraftConversation({ customer: customer_id || null, customerName: customer_name || firstname, email, subject: params.subject_fr || 'Votre récompense Targo 🎁', html, text })
      convToken = conv && conv.token
    } catch (e) { log('reward draft conv: ' + (e && e.message)) }
    if (convToken) { try { camp.recipients[row].conversation_token = convToken; saveCampaign(camp) } catch (e) { /* */ } } // permet de ré-ouvrir/éditer ce brouillon depuis la fiche
    return { mode: 'draft', conversation_token: convToken, client_link: clientUrl, body_text: text, design, pool_available: pool.filter(pp => !pp.used).length }
  }

  const c = ensureRewardsCampaign()
  c.recipients.push({ firstname, lastname, email, phone, language, gift_url: link.gift_url, giftbit_uuid: link.giftbit_uuid || '', gift_value_cents: link.gift_value_cents || 0, customer_id, customer_name, status: 'pending', reward_adhoc: true, queued_at: new Date().toISOString() })
  const row = c.recipients.length - 1
  saveCampaign(c)

  // Marque le lien utilisé AVANT l'envoi (anti double-allocation si envoi concurrent)
  link.used = true; link.used_at = new Date().toISOString(); link.used_by = customer_id || email; link.used_row = row
  savePool(pool)

  try { await sendCampaignAsync('rewards-adhoc') } catch (e) { log('rewardSend send: ' + e.message) }

  const after = loadCampaign('rewards-adhoc')
  const sent = (after && after.recipients[row]) || {}
  const wrap = sent.gift_token ? wrapperUrl(sent.gift_token) : null

  if (customer_id) {
    try {
      await createCommunication({ communication_type: 'Communication', communication_medium: 'Email', subject: 'Récompense envoyée — ' + (customer_name || firstname), content: 'Carte-cadeau (' + ((link.gift_value_cents || 0) / 100).toFixed(2) + ' $) envoyée à ' + email + '. Lien suivi : ' + (wrap || link.gift_url), sent_or_received: 'Sent', reference_doctype: 'Customer', reference_name: customer_id, status: 'Linked' })
    } catch (e) { /* timeline best-effort */ }
  }
  return { sent: sent.status === 'sent', status: sent.status || 'unknown', gift_token: sent.gift_token || null, wrapper_url: wrap, gift_value_cents: link.gift_value_cents, campaign_id: 'rewards-adhoc', row, error: sent.error || null, pool_available: pool.filter(p => !p.used).length }
}

async function handle (req, res, method, path) {
  // NOTE: auth is enforced centrally in server.js (hub auth gate): /campaigns
  // is ALWAYS_ENFORCE (Bearer HUB_SERVICE_TOKEN) except the public
  // POST /campaigns/webhook. No per-module gate needed here.
  // POST /campaigns/parse — preview matched send list (no save)
  // Returns:
  //   - recipients[]: contacts paired with a gift_url (ready to send)
  //   - unpaired_contacts[]: contacts with no matching gift (won't be sent —
  //     these are surfaced so the user can decide whether to acquire more
  //     gift links and re-upload, or proceed anyway)
  //   - unused_gifts[]: gift URLs with no matching contact (lost capacity —
  //     surfaced so the user knows the imbalance)
  if (path === '/campaigns/parse' && method === 'POST') {
    const body = await parseBody(req)
    const { map_csv, giftbit_csv, multi } = body || {}
    if (!map_csv || !giftbit_csv) {
      return json(res, 400, { error: 'map_csv and giftbit_csv required' })
    }
    const { contacts, skipped, skipped_rows } = parseMapCsv(map_csv, multi || 'first')
    const gifts = parseGiftbitCsv(giftbit_csv)

    // Match contacts ↔ gifts (by row order, fallback to email when Giftbit
    // echoed back our recipient email in their export)
    const recipients = []
    const n = Math.min(contacts.length, gifts.length)
    for (let i = 0; i < n; i++) {
      const c = contacts[i]; const g = gifts[i]
      const giftByEmail = gifts.find(gg => normalizeEmail(gg.email) === c.email)
      const gift = giftByEmail || g
      const match = await matchCustomer(c)
      recipients.push({
        row_index:         i + 1,
        ...c,
        gift_url:          gift.gift_url,
        giftbit_uuid:      gift.giftbit_uuid,
        gift_value_cents:  gift.gift_value_cents,
        ...match,
        status: 'pending',
        excluded: false,
      })
    }

    // Capture the imbalance: leftover contacts have no gift, leftover gifts
    // have no contact. Returning them as arrays (not just counts) lets the
    // UI render them so the user makes an informed decision.
    const unpaired_contacts = contacts.slice(n).map((c, idx) => ({
      row_index: n + idx + 1,
      ...c,
    }))
    const unused_gifts = gifts.slice(n).map((g, idx) => ({
      row_index: n + idx + 1,
      ...g,
    }))

    return json(res, 200, {
      recipients,
      unpaired_contacts,
      unused_gifts,
      // Kept for backward compat with any older callers
      leftover_gifts:    unused_gifts.length,
      leftover_contacts: unpaired_contacts.length,
      skipped,
      skipped_rows,
    })
  }

  // POST /campaigns — create from a parsed send list
  if (path === '/campaigns' && method === 'POST') {
    const body = await parseBody(req)
    const { name, params, recipients } = body || {}
    if (!Array.isArray(recipients) || !recipients.length) {
      return json(res, 400, { error: 'recipients[] required' })
    }
    const id = newCampaignId()
    const campaign = {
      id,
      name: name || `Campagne ${id}`,
      created_at: new Date().toISOString(),
      status: 'draft',
      params: params || {},
      recipients: recipients.map(r => ({ ...r, status: r.status || 'pending' })),
    }
    const saved = saveCampaign(campaign)
    return json(res, 200, saved)
  }

  // GET /campaigns/gifts — flattened inventory of every gift across every
  // campaign so operators can see which Giftbit shortlinks are still
  // unredeemed and reassignable. Cross-campaign view; lighter than the
  // full campaign JSONs (no template params, no recipient PII beyond name+email).
  // ORDER MATTERS: this route must come BEFORE the /campaigns/:id wildcard.
  if (path === '/campaigns/gifts' && method === 'GET') {
    const now = Date.now()
    const rows = []
    for (const meta of listCampaigns()) {
      const c = loadCampaign(meta.id)
      if (!c?.recipients) continue
      for (let i = 0; i < c.recipients.length; i++) {
        const r = c.recipients[i]
        if (!r.gift_url) continue
        const expired = r.gift_expires_at && new Date(r.gift_expires_at).getTime() < now
        // Status taxonomy for the inventory UI:
        //   redeemed — recipient already clicked through to Giftbit (we
        //              don't know if they actually redeemed; would need
        //              the Giftbit /gifts/{uuid} poll, task #25)
        //   revoked  — manually killed by an operator
        //   expired  — our own gift_expires_at has passed; reassignable
        //   active   — still live, may be clicked any moment
        //   pending  — not yet sent (no gift_token generated yet)
        let status = 'pending'
        if (r.gift_token) {
          status = 'active'
          if (r.gift_revoked) status = 'revoked'
          else if (expired) status = 'expired'
          if (r.gift_link_clicked) status = 'redeemed'
        }
        rows.push({
          campaign_id: c.id,
          campaign_name: c.name,
          row_index: i,
          firstname: r.firstname, lastname: r.lastname, email: r.email,
          gift_token: r.gift_token || null,
          gift_url: r.gift_url,
          giftbit_uuid: r.giftbit_uuid,
          gift_expires_at: r.gift_expires_at || null,
          gift_revoked: !!r.gift_revoked,
          gift_redirected_count: r.gift_redirected_count || 0,
          gift_first_redirected_at: r.gift_first_redirected_at || null,
          gift_link_clicked: !!r.gift_link_clicked,
          gift_clicked_at: r.gift_clicked_at || null,
          status,
        })
      }
    }
    return json(res, 200, { gifts: rows })
  }

  // GET /campaigns/gifts/pool — liens récupérés (jamais cliqués) disponibles à la réallocation
  if (path === '/campaigns/gifts/pool' && method === 'GET') {
    const pool = loadPool()
    return json(res, 200, { total: pool.length, available: pool.filter(p => !p.used).length, items: pool })
  }
  // POST /campaigns/gifts/reclaim {campaign_id, dry_run?, revoke_live?} — pool les liens NON cliqués (dry_run par défaut)
  if (path === '/campaigns/gifts/reclaim' && method === 'POST') {
    const b = await parseBody(req)
    if (!b.campaign_id) return json(res, 400, { error: 'campaign_id requis' })
    return json(res, 200, reclaimCampaign(b.campaign_id, { dryRun: b.dry_run !== false, revokeLive: b.revoke_live !== false }))
  }
  // POST /campaigns/reward-send {customer_id|email, channel, gift_value_cents?, dry_run?} — envoi unitaire depuis le pool
  if (path === '/campaigns/reward-send' && method === 'POST') {
    const b = await parseBody(req)
    return json(res, 200, await rewardSend(b))
  }
  // GET /campaigns/reward/by-customer?customer_id=&email= — récompenses déjà préparées/envoyées à un client (pour la fiche : voir / copier le lien, éviter le double-don)
  if (path === '/campaigns/reward/by-customer' && method === 'GET') {
    const u = new URL(req.url, 'http://localhost')
    const cid = (u.searchParams.get('customer_id') || '').trim()
    const email = (u.searchParams.get('email') || '').trim().toLowerCase()
    if (!cid && !email) return json(res, 400, { error: 'customer_id ou email requis' })
    const c = loadCampaign('rewards-adhoc')
    const rows = (c && c.recipients ? c.recipients : [])
      .filter(r => (cid && r.customer_id === cid) || (email && String(r.email || '').toLowerCase() === email))
      .filter(r => !r.gift_revoked && r.gift_token)
      .map(r => ({ gift_token: r.gift_token, link: wrapperUrl(r.gift_token), value_cents: r.gift_value_cents || 0, status: r.status || 'pending', email: r.email || '', queued_at: r.queued_at || null, clicks: r.gift_redirected_count || 0, conversation_token: r.conversation_token || null, is_draft: r.status === 'draft' || !!r.draft, clicked: !!r.gift_link_clicked || (r.gift_redirected_count || 0) > 0 }))
    return json(res, 200, { rewards: rows })
  }
  // POST /campaigns/reward/revoke {gift_token} — supprime un brouillon / révoque une récompense non cliquée et REMET le lien au pool (bloqué si déjà ouvert)
  if (path === '/campaigns/reward/revoke' && method === 'POST') {
    const b = await parseBody(req)
    if (!b || !b.gift_token) return json(res, 400, { error: 'gift_token requis' })
    const camp = loadCampaign('rewards-adhoc')
    const r = ((camp && camp.recipients) || []).find(x => x.gift_token === b.gift_token && !x.gift_revoked)
    if (!r) return json(res, 404, { error: 'récompense introuvable' })
    const convTok = r.conversation_token; const isDraft = r.status === 'draft' || !!r.draft
    let result
    if (r.gift_url) { result = revokeReward(r.gift_url) } // gère pool + blocage si déjà cliqué
    else { r.gift_revoked = true; r.gift_revoked_at = new Date().toISOString(); r.status = 'revoked'; if (camp) saveCampaign(camp); result = { revoked: 1, available: null } }
    // Brouillon supprimé avec succès → retire aussi sa conversation orpheline (jamais envoyée, rien à corbeiller côté Gmail).
    if (result && !result.error && isDraft && convTok) { try { require('./conversation').deleteConversation(convTok) } catch (e) { /* */ } }
    return json(res, 200, result)
  }
  // POST /campaigns/gifts/reclaim-expired — pool tous les liens EXPIRÉS non cliqués (manuel ; aussi en cron)
  if (path === '/campaigns/gifts/reclaim-expired' && method === 'POST') {
    return json(res, 200, reclaimExpiredAll())
  }
  // POST /campaigns/gifts/revoke-reward {gift_url} — révoque une récompense envoyée + remet le lien au pool
  if (path === '/campaigns/gifts/revoke-reward' && method === 'POST') {
    const b = await parseBody(req)
    if (!b.gift_url) return json(res, 400, { error: 'gift_url requis' })
    return json(res, 200, revokeReward(b.gift_url))
  }

  // POST /campaigns/:id/recipients/:row/revoke — kill switch for a single
  // wrapper token. Sets gift_revoked=true so /g/<token> returns the
  // "désactivé" page. Used when an operator wants to free a Giftbit URL
  // for reassignment before its natural expiry.
  const revokeMatch = path.match(/^\/campaigns\/([^/]+)\/recipients\/(\d+)\/revoke$/)
  if (revokeMatch && method === 'POST') {
    const c = loadCampaign(revokeMatch[1])
    if (!c) return json(res, 404, { error: 'not found' })
    const i = parseInt(revokeMatch[2], 10)
    const r = (c.recipients || [])[i]
    if (!r) return json(res, 404, { error: 'recipient not found' })
    if (!r.gift_token) return json(res, 400, { error: 'no token to revoke' })
    r.gift_revoked = true
    r.gift_revoked_at = new Date().toISOString()
    saveCampaign(c)
    log(`gift token ${r.gift_token} revoked (campaign ${c.id} row ${i})`)
    sse.broadcast(`campaign:${c.id}`, 'recipient-update', { i, recipient: r })
    return json(res, 200, { revoked: true, gift_token: r.gift_token })
  }

  // GET /campaigns — list summaries
  if (path === '/campaigns' && method === 'GET') {
    return json(res, 200, { campaigns: listCampaigns() })
  }

  // ── Template CRUD (for the GrapesJS editor in the ops UI) ─────────────────
  // ORDER MATTERS: these template routes must be BEFORE the /campaigns/:id
  // wildcard below, otherwise paths like /campaigns/templates get matched
  // by the wildcard as if "templates" were a campaign ID.

  // GET /campaigns/templates — list editable templates with metadata
  if (path === '/campaigns/templates' && method === 'GET') {
    return json(res, 200, { templates: listEditableTemplates() })
  }

  // GET /campaigns/templates/:name — return template content (HTML or MJML
  // source depending on what's stored). The `format` field tells the editor
  // which mode to load (grapesjs-mjml for mjml, preset-newsletter for html).
  const tplGet = path.match(/^\/campaigns\/templates\/([a-zA-Z0-9_-]+)$/)
  if (tplGet && method === 'GET') {
    const name = tplGet[1]
    try {
      const format = templateFormat(name)
      // Always return: name, format, html (canonical, what gets sent), and
      // design (Unlayer/visual-editor JSON tree if present, for re-edit).
      // For legacy MJML templates the .mjml is also returned for reference.
      const html = fs.readFileSync(templatePath(name), 'utf8')
      let design = null
      try {
        const jsonStr = fs.readFileSync(templateJsonPath(name), 'utf8')
        design = JSON.parse(jsonStr)
      } catch {}
      const out = { name, format, html, design }
      if (format === 'mjml') {
        try { out.mjml = fs.readFileSync(templateMjmlPath(name), 'utf8') } catch {}
      }
      return json(res, 200, out)
    } catch (e) {
      return json(res, 404, { error: 'template not found', detail: e.message })
    }
  }

  // PUT /campaigns/templates/:name — save updated template content.
  // Accepts EITHER:
  //   { mjml: "<mjml>...</mjml>" } — compile to HTML server-side, save both
  //   { html: "<html>..." }        — save HTML directly (legacy path)
  //
  // Keeps the previous version as <name>.bak-<ts>.<ext> so a bad edit can
  // be rolled back without git access.
  if (tplGet && method === 'PUT') {
    const body = await parseBody(req)
    const name = tplGet[1]
    if (!body || (typeof body.html !== 'string' && typeof body.mjml !== 'string')) {
      return json(res, 400, { error: 'html OR mjml string required' })
    }
    try {
      // ── MJML save path ──
      if (typeof body.mjml === 'string' && body.mjml) {
        const mjmlPath = templateMjmlPath(name)
        const htmlPath = templatePath(name)
        const jsonPath = templateJsonPath(name)
        // Compile MJML → HTML (async in mjml v5)
        const r = await mjml2html(body.mjml, { validationLevel: 'soft' })
        if (r.errors?.length) {
          return json(res, 400, {
            error: 'MJML validation failed',
            details: r.errors.map(e => e.formattedMessage || e.message),
          })
        }
        // Backup all 3 companion files before overwriting
        const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
        try {
          if (fs.existsSync(mjmlPath)) fs.copyFileSync(mjmlPath, mjmlPath.replace(/\.mjml$/, `.bak-${ts}.mjml`))
          if (fs.existsSync(htmlPath)) fs.copyFileSync(htmlPath, htmlPath.replace(/\.html$/, `.bak-${ts}.html`))
          if (fs.existsSync(jsonPath)) fs.copyFileSync(jsonPath, jsonPath.replace(/\.json$/, `.bak-${ts}.json`))
        } catch (e) { log('template backup failed:', e.message) }
        fs.writeFileSync(mjmlPath, body.mjml, 'utf8')
        fs.writeFileSync(htmlPath, r.html, 'utf8')
        // Optional editor-state snapshot — only written when client sends it
        // (the React editor does, but the legacy HTML editor doesn't).
        let json_size = 0
        if (body.json) {
          const jsonStr = typeof body.json === 'string' ? body.json : JSON.stringify(body.json)
          fs.writeFileSync(jsonPath, jsonStr, 'utf8')
          json_size = jsonStr.length
        }
        log(`template ${name} updated (mjml: ${body.mjml.length}b → html: ${r.html.length}b${json_size ? ', json: ' + json_size + 'b' : ''})`)
        return json(res, 200, {
          name, format: 'mjml', saved: true,
          mjml_size: body.mjml.length, html_size: r.html.length, json_size,
        })
      }
      // ── HTML save path (now the PRIMARY flow — Unlayer outputs HTML directly) ──
      const p = templatePath(name)
      const jsonPath = templateJsonPath(name)
      try {
        const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
        if (fs.existsSync(p)) fs.copyFileSync(p, p.replace(/\.html$/, `.bak-${ts}.html`))
        if (fs.existsSync(jsonPath)) fs.copyFileSync(jsonPath, jsonPath.replace(/\.json$/, `.bak-${ts}.json`))
      } catch (e) { log('template backup failed:', e.message) }
      fs.writeFileSync(p, body.html, 'utf8')
      // Persist editor design JSON alongside (Unlayer's loadDesign() input)
      let design_size = 0
      if (body.design) {
        const designStr = typeof body.design === 'string' ? body.design : JSON.stringify(body.design)
        fs.writeFileSync(jsonPath, designStr, 'utf8')
        design_size = designStr.length
      }
      log(`template ${name} updated (html: ${body.html.length}b${design_size ? ', design: ' + design_size + 'b' : ''})`)
      return json(res, 200, {
        name, format: 'html', saved: true,
        size: body.html.length, design_size,
      })
    } catch (e) {
      return json(res, 400, { error: e.message })
    }
  }

  // POST /campaigns/templates/:name/translate-to/:targetName — translate the
  // source template's HTML content via Gemini Flash and save as targetName.
  // Preserves HTML structure, Mustache {{vars}}, URLs, brand names, emojis.
  // Body (optional): { override: true } to overwrite an existing target.
  const tplTranslate = path.match(/^\/campaigns\/templates\/([a-zA-Z0-9_-]+)\/translate-to\/([a-zA-Z0-9_-]+)$/)
  if (tplTranslate && method === 'POST') {
    const srcName    = tplTranslate[1]
    const targetName = tplTranslate[2]
    if (!isValidTemplateName(srcName) || !isValidTemplateName(targetName)) {
      return json(res, 400, { error: 'invalid source or target template name' })
    }
    if (srcName === targetName) {
      return json(res, 400, { error: 'source and target must differ' })
    }
    // Detect direction from name suffix (-fr → translate to en, -en → fr)
    // Falls back to the explicit target language inferred from target name.
    const srcLang = srcName.match(/-([a-z]{2})(-|$)/)?.[1] || 'fr'
    const tgtLang = targetName.match(/-([a-z]{2})(-|$)/)?.[1] || (srcLang === 'fr' ? 'en' : 'fr')
    let srcHtml
    try { srcHtml = fs.readFileSync(templatePath(srcName), 'utf8') }
    catch { return json(res, 404, { error: 'source template not found' }) }

    const body = await parseBody(req).catch(() => ({}))
    const targetHtmlPath = templatePath(targetName)
    if (fs.existsSync(targetHtmlPath) && !body?.override) {
      return json(res, 409, {
        error: 'target template already exists',
        hint: 'POST again with { "override": true } to overwrite',
      })
    }

    const langNames = { fr: 'French Canadian (Quebec)', en: 'English (North American)' }
    // System prompt: emphasize MEANING + TONE + IDIOMATIC REPHRASING over
    // literal word-by-word translation. The previous version produced robotic
    // output because it forced byte-for-byte preservation, which suppressed
    // Gemini's natural rephrasing capability. New approach: rephrase freely
    // within the constraint of preserving HTML structure + technical content.
    const systemPrompt = [
      `You are a senior MARKETING COPYWRITER, not a literal translator. You`,
      `craft email copy for TARGO, a regional Quebec-based fiber Internet ISP`,
      `that prides itself on local presence, warmth, and trust. You're`,
      `localizing email content between Quebec French and North American English.`,
      ``,
      `══ YOUR MISSION ══`,
      `Recreate the MEANING and EMOTIONAL IMPACT of the source in the target`,
      `language. You are NOT translating words — you are rewriting marketing`,
      `copy that lands the same way for a different audience. Feel free to:`,
      `  • Rephrase sentences entirely`,
      `  • Use idioms native to the target audience`,
      `  • Shorten or split sentences for clarity`,
      `  • Change metaphors if the original doesn't translate culturally`,
      `  • Switch passive↔active voice freely`,
      ``,
      `══ TONE (HARD CONSTRAINT) ══`,
      `Warm, conversational, slightly playful. Like a neighbor explaining`,
      `something — never corporate, never stiff. Informal "tu" in French and`,
      `informal "you" in English. Use contractions ("we're", "you'll", "on est").`,
      ``,
      `══ STRUCTURAL PRESERVATION (HARD CONSTRAINT) ══`,
      `Keep IDENTICAL byte-for-byte:`,
      `  1. ALL HTML tags, attributes, classes, inline styles, Outlook MSO`,
      `     conditional comments`,
      `  2. Mustache placeholders: {{firstname}}, {{amount}}, {{gift_url}},`,
      `     {{commitment_months}}, {{description}}, {{expiry}}, {{year}}`,
      `  3. URLs, email addresses, phone numbers, hex colors, CSS, font names`,
      `  4. Brand names: TARGO, Gigafibre, Giftbit, Mailjet, Amazon, IGA,`,
      `     Tim Hortons, Pizza Pizza, Home Depot, Best Buy, Walmart,`,
      `     Petro-Canada, Esso, Home Hardware, Sobeys`,
      `  5. ALL emojis: 🎁 ⚡ 🤝 🪂 ✅ ⏭️ ⏰`,
      `  6. Technical values like "3.5 Gbit/s", "7j/7" → "7 days/week"`,
      ``,
      `══ FEW-SHOT EXAMPLES (THIS IS THE STYLE I WANT) ══`,
      ``,
      `FR: "On veut te remercier pour ta loyauté envers l'achat local."`,
      `EN: "Thanks for keeping it local — it means more than you think."`,
      `   (NOT: "We want to thank you for your loyalty to local shopping.")`,
      ``,
      `FR: "Comme toi, on aime les connexions stables et les relations durables."`,
      `EN: "Like you, we believe in steady connections — both the fiber kind and the human kind."`,
      `   (NOT: "Like you, we love stable connections and lasting relationships.")`,
      ``,
      `FR: "On est juste à côté et on aime aider."`,
      `EN: "We're right next door — and we genuinely love lending a hand."`,
      `   (NOT: "We are next to you and we love to help.")`,
      ``,
      `FR: "Avec l'arrivée de l'été, voici un cadeau pour toi, disponible pour un temps limité."`,
      `EN: "Summer's here, and we've got something for you — but only for a little while."`,
      ``,
      `══ OUTPUT FORMAT ══`,
      `Output the COMPLETE translated HTML from <!doctype html> to </html>.`,
      `NO commentary, NO markdown fences, NO preamble. Just the HTML.`,
    ].join('\n')

    const userPrompt = `Localize this ${langNames[srcLang]} marketing email into ${langNames[tgtLang]}. ` +
      `Apply the COPYWRITER mindset from the system prompt — rephrase for natural flow, ` +
      `keep the warm tone. Preserve every HTML tag and Mustache placeholder exactly.\n\n${srcHtml}`

    let translated
    try {
      translated = await getAi().aiCall(systemPrompt, userPrompt, {
        jsonMode: false,
        maxTokens: 32768,
        temperature: 0.7,   // bumped from 0.2 — let the AI rephrase creatively
      })
    } catch (e) {
      return json(res, 502, { error: 'AI translation failed', detail: e.message })
    }

    // Sanity checks — refuse to save garbled output
    if (typeof translated !== 'string' || translated.length < srcHtml.length * 0.5) {
      return json(res, 502, {
        error: 'AI returned truncated or invalid output',
        src_bytes: srcHtml.length,
        out_bytes: typeof translated === 'string' ? translated.length : 0,
      })
    }
    // Strip optional markdown code fences if Gemini still added them despite the prompt
    translated = translated.replace(/^```(?:html)?\s*\n?/i, '').replace(/\n?```\s*$/i, '').trim()

    // Backup existing target before overwriting
    try {
      if (fs.existsSync(targetHtmlPath)) {
        const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
        fs.copyFileSync(targetHtmlPath, targetHtmlPath.replace(/\.html$/, `.bak-${ts}.html`))
        const jsonPath = templateJsonPath(targetName)
        if (fs.existsSync(jsonPath)) fs.copyFileSync(jsonPath, jsonPath.replace(/\.json$/, `.bak-${ts}.json`))
      }
    } catch (e) { log('translate backup failed:', e.message) }

    // Write translated HTML
    fs.writeFileSync(targetHtmlPath, translated, 'utf8')

    // Regenerate the Unlayer design JSON via the converter so the editor
    // can reload the translated template visually. Inline equivalent of
    // running scripts/convert-html-to-unlayer.js for this target.
    const bodyMatch = translated.match(/<body[^>]*>([\s\S]*?)<\/body>/i)
    const innerHtml = bodyMatch ? bodyMatch[1].trim() : translated
    const preheaderMatch = innerHtml.match(/<div[^>]*display:\s*none[^>]*>\s*([^<]+?)\s*<\/div>/i)
    const preheader = preheaderMatch ? preheaderMatch[1].trim() : ''
    const design = {
      counters: { u_row: 1, u_column: 1, u_content_html: 1 },
      body: {
        id: 'BODY-1',
        rows: [{
          id: 'ROW-1', cells: [1],
          columns: [{
            id: 'COL-1',
            contents: [{
              id: 'HTML-1', type: 'html',
              values: {
                html: innerHtml, hideDesktop: false, displayCondition: null,
                containerPadding: '0px',
                _meta: { htmlID: 'u_content_html_1', htmlClassNames: 'u_content_html' },
                selectable: true, draggable: true, duplicatable: true, deletable: true, hideable: true,
              },
            }],
            values: { _meta: { htmlID: 'u_column_1', htmlClassNames: 'u_column' } },
          }],
          values: {
            displayCondition: null, columns: false, backgroundColor: '', padding: '0px',
            _meta: { htmlID: 'u_row_1', htmlClassNames: 'u_row' },
            selectable: true, draggable: true, duplicatable: true, deletable: true, hideable: true,
          },
        }],
        values: {
          contentWidth: '600px',
          fontFamily: { label: 'Plus Jakarta Sans', value: "'Plus Jakarta Sans', sans-serif" },
          textColor: '#1B2E24',
          backgroundColor: '#F5FAF7',
          preheaderText: preheader,
          linkStyle: { body: true, linkColor: '#00C853', linkUnderline: true },
          _meta: { htmlID: 'u_body', htmlClassNames: 'u_body' },
        },
      },
      schemaVersion: 12,
    }
    fs.writeFileSync(templateJsonPath(targetName), JSON.stringify(design, null, 2), 'utf8')

    log(`translate: ${srcName} (${srcHtml.length}b ${srcLang}) → ${targetName} (${translated.length}b ${tgtLang})`)
    return json(res, 200, {
      source: srcName,
      target: targetName,
      from_lang: srcLang,
      to_lang: tgtLang,
      src_bytes: srcHtml.length,
      out_bytes: translated.length,
      saved: true,
    })
  }

  // POST /campaigns/templates/:name/test-send — send ONE rendered email to a
  // specific address for visual QA. Uses the campaign Mailjet sender +
  // throttle. NOT linked to any campaign — purely for template testing.
  // Body: { to: 'louis@targo.ca', vars: {...}, from?: 'TARGO <support@targointernet.com>' }
  const tplTestSend = path.match(/^\/campaigns\/templates\/([a-zA-Z0-9_-]+)\/test-send$/)
  if (tplTestSend && method === 'POST') {
    const body = await parseBody(req)
    const to = (body.to || '').trim()
    if (!to || !to.includes('@')) {
      return json(res, 400, { error: 'to (recipient email) required' })
    }
    const name = tplTestSend[1]
    let html
    try {
      // Always read the COMPILED .html — what the recipient actually receives.
      // MJML source is for the editor; the send-worker reads compiled HTML.
      html = fs.readFileSync(templatePath(name), 'utf8')
    } catch (e) {
      return json(res, 404, { error: 'template not found', detail: e.message })
    }
    // Sample wrapper expiry — 30 days out, locale-formatted FR. This is
    // critical for the reminder template which uses {{expires_at_date}}
    // as its main urgency line; without it the test email shows an
    // empty space where the date should be.
    const sampleExpAt = new Date(Date.now() + 30 * 86400 * 1000)
      .toLocaleDateString('fr-CA', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'America/Montreal' })
    const vars = {
      firstname:         'Louis',
      lastname:          'Test',
      email:             to,
      description:       '123 Rue de Test, Ste-Clotilde',
      gift_url:          'https://gft.link/TEST123',
      amount:            '60 $',
      expiry:            '31 décembre 2026',
      expires_at_date:   sampleExpAt,
      expires_in_days:   '30',
      commitment_months: '3',
      year:              new Date().getFullYear(),
      // view_url left empty so the {{#view_url}} section collapses —
      // test emails go to internal addresses and don't need the web
      // fallback link.
      view_url:          '',
      ...(body.vars || {}),
    }
    // Étoiles d'évaluation : {{rating}} n'est PAS une variable de campagne → renderTemplate
    // le viderait (encart vide, cf. courriel test sans étoiles). On l'expanse ici en un vrai
    // bloc tokenisé (cliquable) pour que le test montre les étoiles. No-op si pas de marqueur.
    html = await rating.expandRatingMarker(html, { email: to, customerName: vars.firstname })
    const rendered = renderTemplate(html, vars)
    const subject = body.subject || `[TEST] Template ${name}`
    const via = String(body.via || '').toLowerCase()
    const fromAddr = body.from || cfg.MAIL_FROM || 'TARGO <support@targointernet.com>'
    try {
      let messageId = null; let usedFrom = fromAddr; let channel = 'mailjet'
      if (via === 'gmail') {
        // Envoi via le compte Gmail surveillé = MÊME canal que les vraies invitations
        // (rating.js → conversation → gmail.sendMessage). From = compte Gmail par défaut
        // (alias vérifié) si body.from absent.
        channel = 'gmail'
        const g = await gmail.sendMessage({ to, subject, body: '', html: rendered, from: body.from || undefined })
        messageId = (g && g.id) || null
        usedFrom = body.from || (typeof gmail.mailbox === 'function' ? gmail.mailbox() : 'compte Gmail')
      } else {
        const info = await email.sendEmail({
          to, from: fromAddr, subject, html: rendered,
          headers: { 'X-MJ-CustomID': `test-send:${name}:${Date.now()}` },
        })
        if (!info) return json(res, 500, { error: 'SMTP send returned false (see hub logs)' })
        messageId = info.messageId || null
      }
      log(`test-send: ${name} → ${to} via ${channel} (${rendered.length} bytes)`)
      return json(res, 200, { sent: true, to, from: usedFrom, channel, message_id: messageId, bytes: rendered.length })
    } catch (e) {
      return json(res, 500, { error: e.message })
    }
  }

  // POST /campaigns/templates/:name/preview — render with sample data
  // Useful for the editor's "Preview" pane to see what {{vars}} resolve to.
  const tplPreview = path.match(/^\/campaigns\/templates\/([a-zA-Z0-9_-]+)\/preview$/)
  if (tplPreview && method === 'POST') {
    const body = await parseBody(req)
    let html = body.html || fs.readFileSync(templatePath(tplPreview[1]), 'utf8')
    // Aperçu : montrer les étoiles {{rating}} avec un bloc d'exemple NON persistant (token
    // factice « apercu » — pas de pollution de ratings.json ; l'envoi réel/test crée un vrai token).
    html = html.replace(/\{\{\s*(rating|etoiles|évaluation|evaluation)\s*\}\}/gi, rating.ratingBlock('apercu'))
    const sampleExpAt = new Date(Date.now() + 30 * 86400 * 1000)
      .toLocaleDateString('fr-CA', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'America/Montreal' })
    const vars = {
      firstname: 'Louis', lastname: 'Paul', email: 'louis@targo.ca',
      description: '123 Rue de Test', gift_url: 'http://gtbt.co/PREVIEW',
      amount: '60 $', expiry: '31 décembre 2026', commitment_months: '3',
      expires_at_date: sampleExpAt, expires_in_days: '30',
      year: new Date().getFullYear(),
      view_url: '',
      ...(body.vars || {}),
    }
    return json(res, 200, { rendered: renderTemplate(html, vars) })
  }

  // ── Image asset upload (self-hosted, for new images added via the editor) ──
  // Existing Mailjet-hosted brand logos in the templates are NOT affected —
  // those stay on xqy3m.mjt.lu/img2/... This is for additional images the
  // user uploads through the GrapesJS asset manager.

  // POST /campaigns/assets/upload — accepts JSON { name, data } where data
  // is a data:image/...;base64,... URL. Returns { url, filename, size }.
  // Hash-based filename = automatic dedup + immutable URLs.
  if (path === '/campaigns/assets/upload' && method === 'POST') {
    const body = await parseBody(req)
    if (!body || !body.data) return json(res, 400, { error: 'data field required (data URL)' })
    const decoded = decodeDataUrl(body.data)
    if (!decoded) return json(res, 415, { error: 'unsupported or invalid image data URL' })
    if (decoded.error === 'too_large') {
      return json(res, 413, { error: `image too large (${decoded.size}b, max ${MAX_UPLOAD_BYTES}b)` })
    }
    const filename = persistUpload(decoded.buffer, decoded.ext)
    log(`asset upload: ${filename} (${decoded.buffer.length}b, name: ${body.name || 'n/a'})`)
    const url = uploadUrl(filename, req)
    return json(res, 200, {
      filename, url, size: decoded.buffer.length, content_type: decoded.mime,
      // GrapesJS asset manager expects this shape on upload success
      data: [{ src: url, type: 'image' }],
    })
  }

  // GET /campaigns/assets — list all uploaded assets with metadata
  if (path === '/campaigns/assets' && method === 'GET') {
    return json(res, 200, { assets: listUploads(req) })
  }

  // GET /campaigns/assets/:filename — serve image bytes
  const assetGet = path.match(/^\/campaigns\/assets\/([a-f0-9]{64}\.(?:png|jpg|gif|webp|svg))$/)
  if (assetGet && method === 'GET') {
    const r = readUpload(assetGet[1])
    if (!r) return json(res, 404, { error: 'asset not found' })
    res.writeHead(200, {
      'Content-Type':   r.mime,
      'Content-Length': r.buffer.length,
      // Immutable cache: filename is content-hash, the bytes for a given URL
      // never change. 1y cache aligns with how email image proxies work.
      'Cache-Control':  'public, max-age=31536000, immutable',
      'Access-Control-Allow-Origin': '*',
    })
    return res.end(r.buffer)
  }

  // DELETE /campaigns/assets/:filename — remove from disk
  if (assetGet && method === 'DELETE') {
    if (deleteUpload(assetGet[1])) {
      log(`asset deleted: ${assetGet[1]}`)
      return json(res, 200, { deleted: assetGet[1] })
    }
    return json(res, 404, { error: 'asset not found' })
  }

  // POST /campaigns/webhook — Mailjet Event API receiver
  // Mailjet sends an array of events; we process all of them.
  if (path === '/campaigns/webhook' && method === 'POST') {
    const body = await parseBody(req)
    const events = Array.isArray(body) ? body : [body]
    let applied = 0
    for (const ev of events) {
      if (applyWebhookEvent(ev)) applied++
    }
    log(`mailjet webhook: ${events.length} events, ${applied} applied`)
    return json(res, 200, { received: events.length, applied })
  }

  // ── Per-campaign wildcard routes (MUST stay below the /templates and
  //    /webhook fixed paths above, otherwise the wildcard captures them) ─────

  // GET /campaigns/:id/recipients/:i/view — re-render the email for ONE
  // recipient using the same variables the worker used at send time. Linked
  // from the "Voir dans le navigateur / View in browser" line at the top of
  // every campaign email so recipients with rendering issues (image-blocking
  // clients, antique Outlook, niche third-party mail apps) can fall back to
  // a fresh browser render. No auth needed — the campaign-id is 21-char
  // nanoid (≈10²¹ space) and row_index alone is enough to reconcile.
  const viewMatch = path.match(/^\/campaigns\/([^/]+)\/recipients\/(\d+)\/view$/)
  if (viewMatch && method === 'GET') {
    const c = loadCampaign(viewMatch[1])
    if (!c) return json(res, 404, { error: 'not found' })
    const i = parseInt(viewMatch[2], 10)
    const r = (c.recipients || [])[i]
    if (!r) return json(res, 404, { error: 'recipient not found' })
    // Courriel-cadeau : exige ?t=<gift_token> (la route est publique mais non énumérable pour les liens /g/).
    if (r.gift_token) {
      let t = ''
      try { t = new URL(req.url, 'http://h').searchParams.get('t') || '' } catch (e) { /* */ }
      if (t !== r.gift_token) return json(res, 403, { error: 'forbidden' })
    }
    const p = c.params || {}
    const lang = (r.language || 'fr').toLowerCase().split('-')[0]
    const tplPath = resolveTemplatePath(p, lang)
    let tplText
    try { tplText = fs.readFileSync(tplPath, 'utf8') }
    catch { return json(res, 500, { error: 'template missing' }) }
    let displayAmount = p.amount || '50 $'
    if (r.amount) {
      displayAmount = r.amount
    } else if (r.gift_value_cents) {
      const cents = Number(r.gift_value_cents) || 0
      displayAmount = cents % 100 === 0
        ? `${cents / 100} $`
        : `${(cents / 100).toFixed(2)} $`
    }
    // Locale-formatted wrapper expiry — same logic as the worker so the
    // /view fallback renders the exact same date the recipient saw at first.
    const expiryLocale = lang === 'en' ? 'en-CA' : 'fr-CA'
    let expiresAtDate = ''
    let expiresInDays = ''
    if (r.gift_expires_at) {
      const exp = new Date(r.gift_expires_at)
      expiresAtDate = exp.toLocaleDateString(expiryLocale, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'America/Montreal' })
      const days = Math.max(0, Math.ceil((exp - new Date()) / 86400000))
      expiresInDays = String(days)
    }
    const vars = {
      firstname:         r.firstname || (lang === 'en' ? 'dear customer' : 'cher client'),
      lastname:          r.lastname  || '',
      email:             r.email,
      description:       r.civic_address || '',
      // Same wrapper logic as the worker so the in-browser fallback view
      // links to the same URL the email did. Clicking from /view goes
      // through our redirect, lets us honour expiry/revoke consistently.
      gift_url:          r.gift_token ? wrapperUrl(r.gift_token) : r.gift_url,
      amount:            displayAmount,
      expiry:            p.expiry || '',
      expires_at_date:   expiresAtDate,
      expires_in_days:   expiresInDays,
      commitment_months: p.commitment_months || '3',
      year:              new Date().getFullYear(),
      // Empty so the {{#view_url}} section block in the template collapses
      // — we don't want the "view in browser" line to show up when the
      // user is ALREADY viewing it in a browser.
      view_url:          '',
    }
    const html = renderTemplate(tplText, vars)
    res.writeHead(200, {
      'Content-Type':  'text/html; charset=utf-8',
      'Cache-Control': 'no-store',
      // Disallow indexing — these URLs aren't meant for search engines
      'X-Robots-Tag':  'noindex, nofollow',
    })
    return res.end(html)
  }

  // GET /campaigns/:id/report.csv — per-recipient report download
  // Columns chosen for operational follow-up (resend, refund, support):
  // row, firstname, lastname, email, phone, language, customer_id, civic_address,
  // postal_code, gift_value_cents, gift_url, giftbit_uuid, status, excluded,
  // sent_at, opened_at, clicked_at, mailjet_uuid, error
  // RFC 4180: comma-separated, CRLF endings, fields with comma/quote/newline get
  // wrapped in double quotes with embedded " doubled up. We prepend a UTF-8 BOM
  // so Excel auto-detects encoding and renders accents correctly.
  const reportMatch = path.match(/^\/campaigns\/([^/]+)\/report\.csv$/)
  if (reportMatch && method === 'GET') {
    const c = loadCampaign(reportMatch[1])
    if (!c) return json(res, 404, { error: 'not found' })
    const headers = [
      'row', 'firstname', 'lastname', 'email', 'phone', 'language', 'customer_id',
      'civic_address', 'city', 'postal_code', 'gift_value_cents',
      'gift_url', 'giftbit_uuid', 'giftbit_admin_url',
      'gift_token', 'gift_expires_at', 'gift_revoked', 'gift_redirected_count', 'gift_first_redirected_at',
      'status', 'excluded', 'sent_at', 'opened_at', 'clicked_at',
      'gift_link_clicked', 'gift_clicked_at',
      'mailjet_uuid', 'error',
    ]
    const esc = (v) => {
      if (v == null) return ''
      const s = String(v)
      return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s
    }
    const lines = [headers.join(',')]
    for (const r of (c.recipients || [])) {
      // Build Giftbit admin search URL for manual redemption check
      // (until /gifts/{uuid} API polling lands — see task #25).
      let giftbitAdminUrl = ''
      if (r.gift_url) {
        const code = String(r.gift_url).replace(/\/+$/, '').split('/').pop()
        if (code && code.length >= 4) {
          giftbitAdminUrl = `https://app.giftbit.com/app/rewards?search=${encodeURIComponent(code)}`
        }
      }
      lines.push([
        r.row_index, r.firstname, r.lastname, r.email, r.phone, r.language, r.customer_id,
        r.civic_address, r.city, r.postal_code, r.gift_value_cents,
        r.gift_url, r.giftbit_uuid, giftbitAdminUrl,
        r.gift_token, r.gift_expires_at, r.gift_revoked ? 'true' : 'false',
        r.gift_redirected_count || 0, r.gift_first_redirected_at,
        r.status, r.excluded ? 'true' : 'false', r.sent_at, r.opened_at, r.clicked_at,
        r.gift_link_clicked ? 'true' : 'false', r.gift_clicked_at,
        r.mailjet_uuid, r.error,
      ].map(esc).join(','))
    }
    const csv = '﻿' + lines.join('\r\n') + '\r\n'
    const safeName = (c.name || c.id).replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 80)
    res.writeHead(200, {
      'Content-Type':        'text/csv; charset=utf-8',
      'Content-Disposition': `attachment; filename="campaign-${safeName}.csv"`,
      'Cache-Control':       'no-store',
    })
    return res.end(csv)
  }

  // GET /campaigns/:id — full detail (+ reminder children if any)
  const detailMatch = path.match(/^\/campaigns\/([^/]+)$/)
  if (detailMatch && method === 'GET') {
    const c = loadCampaign(detailMatch[1])
    if (!c) return json(res, 404, { error: 'not found' })
    // Attach summaries of reminder children pointing at this campaign so
    // the UI can render a "famille de campagnes" banner. Children fully
    // load their recipients but we only return aggregate counters here
    // to keep the response light.
    const reminders = []
    for (const meta of listCampaigns()) {
      if (meta.id === c.id) continue
      const child = loadCampaign(meta.id)
      if (child?.reminder_of === c.id || child?.parent_campaign_id === c.id) {
        reminders.push({
          id:           child.id,
          name:         child.name,
          status:       child.status,
          created_at:   child.created_at,
          counters:     child.counters,
          total:        (child.recipients || []).length,
          gift_clicked: child.counters?.gift_clicked || 0,
        })
      }
    }
    return json(res, 200, { ...c, reminders })
  }

  // PATCH /campaigns/:id — update recipients (e.g. exclude rows, edit email)
  if (detailMatch && method === 'PATCH') {
    const body = await parseBody(req)
    const c = loadCampaign(detailMatch[1])
    if (!c) return json(res, 404, { error: 'not found' })
    if (body.name) c.name = body.name
    if (body.params) c.params = { ...c.params, ...body.params }
    if (Array.isArray(body.recipients)) c.recipients = body.recipients
    return json(res, 200, saveCampaign(c))
  }

  // POST /campaigns/:id/send — fire background worker
  const sendMatch = path.match(/^\/campaigns\/([^/]+)\/send$/)
  if (sendMatch && method === 'POST') {
    const id = sendMatch[1]
    const c = loadCampaign(id)
    if (!c) return json(res, 404, { error: 'not found' })
    if (activeWorkers.has(id)) return json(res, 409, { error: 'already sending' })
    // Fire and forget
    setImmediate(() => sendCampaignAsync(id))
    return json(res, 202, { id, status: 'sending' })
  }

  // POST /campaigns/:id/clone — duplicate a campaign as a fresh DRAFT.
  // Copies the params (subject(s), From, templates, amount…) and the recipient
  // list, but resets every per-recipient send-lifecycle field back to pending
  // so the clone can be reviewed and re-sent from scratch. Used to re-run a
  // completed campaign (e.g. with a corrected sender) without touching the
  // original's history/metrics. The clone is a draft, so the From picker and
  // the pre-send review are fully editable on it.
  const cloneMatch = path.match(/^\/campaigns\/([^/]+)\/clone$/)
  if (cloneMatch && method === 'POST') {
    const src = loadCampaign(cloneMatch[1])
    if (!src) return json(res, 404, { error: 'not found' })
    // Pick a non-colliding "_N" id suffix (strip any existing one first), and
    // a matching " (N)" on the display name.
    const baseId = src.id.replace(/_\d+$/, '')
    let n = 2
    while (fs.existsSync(campaignPath(`${baseId}_${n}`))) n++
    const newId = `${baseId}_${n}`
    const baseName = (src.name || 'Campagne').replace(/\s*\(\d+\)\s*$/, '')
    const recipients = (src.recipients || []).map((r, i) => ({
      ...r,
      row_index: i + 1,
      status: 'pending',          // re-send everyone (excluded flag preserved)
      error: null,
      retry_count: 0,
      sent_at: null,
      opened_at: null,
      clicked_at: null,
      mailjet_uuid: null,
      gift_token: null,           // worker regenerates → clean click tracking
      gift_link_clicked: false,
      // Drop stale reminder lineage if the source was itself a reminder.
      parent_campaign_id: undefined,
      parent_row_index: undefined,
    }))
    const clone = {
      id:         newId,
      name:       `${baseName} (${n})`,
      created_at: new Date().toISOString(),
      status:     'draft',
      cloned_from: src.id,
      params:     { ...(src.params || {}) },
      recipients,
    }
    const saved = saveCampaign(clone)
    log(`campaign cloned: ${src.id} → ${newId} (${recipients.length} recipients, draft)`)
    return json(res, 201, saved)
  }

  // POST /campaigns/:id/reminder — clone the campaign for the non-clickers.
  // Creates a brand-new draft campaign rooted at the same Giftbit shortlinks
  // but with the reminder templates (gift-email-reminder-*) and an urgency
  // subject. Fresh gift_tokens get generated when the operator launches the
  // send so each campaign owns its own click-tracking metrics.
  const reminderMatch = path.match(/^\/campaigns\/([^/]+)\/reminder$/)
  if (reminderMatch && method === 'POST') {
    const parent = loadCampaign(reminderMatch[1])
    if (!parent) return json(res, 404, { error: 'not found' })
    const now = Date.now()
    // Filter rules: only people who actually got the email (status sent /
    // opened — NOT clicked), aren't excluded, aren't expired or revoked
    // on the wrapper side, and still have a gift_url to redirect to.
    const candidates = (parent.recipients || []).filter(r => {
      if (r.excluded) return false
      if (r.gift_link_clicked) return false           // already engaged
      if (r.gift_revoked) return false
      if (r.gift_expires_at && new Date(r.gift_expires_at).getTime() < now) return false
      if (!r.gift_url) return false                    // nothing to redirect to
      // Only worth reminding people we actually sent to (skip pending /
      // failed permanently). Opened counts as sent + we want to remind.
      return ['sent', 'opened'].includes(r.status)
    })
    if (!candidates.length) {
      return json(res, 400, { error: 'no non-clicked recipients eligible for a reminder' })
    }
    // Build the reminder recipients. Fresh tokens & status (worker will
    // generate gift_token at send time). Keep firstname/lastname/email/
    // gift_url/language/amount-override so we hit the same person at the
    // same gift, just via a new wrapper URL with new expiry.
    const reminderRecipients = candidates.map((r, i) => ({
      row_index:        i + 1,
      firstname:        r.firstname,
      lastname:         r.lastname,
      email:            r.email,
      phone:            r.phone || '',
      civic_address:    r.civic_address || '',
      city:             r.city || '',
      postal_code:      r.postal_code || '',
      language:         r.language || 'fr',
      gift_url:         r.gift_url,              // same Giftbit shortlink
      giftbit_uuid:     r.giftbit_uuid || null,
      gift_value_cents: r.gift_value_cents || null,
      amount:           r.amount || null,
      customer_id:      r.customer_id || null,
      customer_name:    r.customer_name || null,
      match_method:     r.match_method || null,
      // Tracing links back to the parent (which recipient row triggered
      // this reminder) — handy for the CSV report and debugging.
      parent_campaign_id: parent.id,
      parent_row_index:   parent.recipients.indexOf(r),
      status:           'pending',
      excluded:         false,
    }))
    const parentParams = parent.params || {}
    const reminderId = newCampaignId() + '-rem'
    const reminderCampaign = {
      id:         reminderId,
      name:       (parent.name || 'Campagne') + ' — Relance',
      created_at: new Date().toISOString(),
      status:     'draft',
      parent_campaign_id: parent.id,
      reminder_of: parent.id,                  // for the UI "this is a reminder of X" hint
      params: {
        ...parentParams,
        subject:     parentParams.subject_reminder
                      || '⏰ Dernière chance — ton cadeau TARGO expire bientôt',
        template_fr: 'gift-email-reminder-fr',
        template_en: 'gift-email-reminder-en',
      },
      recipients: reminderRecipients,
    }
    const saved = saveCampaign(reminderCampaign)
    log(`reminder campaign created: ${reminderId} (${candidates.length} recipients from ${parent.id})`)
    return json(res, 201, saved)
  }

  // POST /campaigns/:id/recipients/:row/retry — reset a single failed
  // recipient back to "pending" and re-fire the worker so it picks the
  // row up on the next pass. Used by the "Renvoyer" button in the UI
  // for one-off transient failures that didn't recover via auto-retry.
  const retryMatch = path.match(/^\/campaigns\/([^/]+)\/recipients\/(\d+)\/retry$/)
  if (retryMatch && method === 'POST') {
    const id = retryMatch[1]
    const c = loadCampaign(id)
    if (!c) return json(res, 404, { error: 'not found' })
    const i = parseInt(retryMatch[2], 10)
    const r = (c.recipients || [])[i]
    if (!r) return json(res, 404, { error: 'recipient not found' })
    if (r.status !== 'failed') return json(res, 400, { error: `recipient status is "${r.status}", only "failed" can be retried` })
    if (activeWorkers.has(id)) return json(res, 409, { error: 'campaign worker already running' })
    r.status = 'pending'
    r.error = null
    // Clear the previous retry counter so the new attempt gets its own 3
    // retries inside the worker. Keep mailjet_uuid in case it WAS partially
    // accepted by Mailjet — we'll overwrite on a successful resend.
    r.retry_count = 0
    // Also force the global campaign status back to 'sending' so the UI
    // counter strip refreshes.
    if (c.status === 'completed' || c.status === 'failed') c.status = 'sending'
    saveCampaign(c)
    sse.broadcast(`campaign:${id}`, 'recipient-update', { i, recipient: r })
    setImmediate(() => sendCampaignAsync(id))
    return json(res, 202, { id, row: i, status: 'pending' })
  }

  // DELETE /campaigns/:id — remove the campaign JSON from disk. Mostly for
  // cleaning up test/draft runs; the gifts themselves live on Giftbit and
  // are unaffected. Refuses to delete while the send worker is active for
  // this id (would crash the worker on the next saveCampaign).
  if (detailMatch && method === 'DELETE') {
    const id = detailMatch[1]
    if (activeWorkers.has(id)) {
      return json(res, 409, { error: 'cannot delete while campaign is sending — wait for it to finish or fail' })
    }
    let filePath
    try { filePath = campaignPath(id) }
    catch { return json(res, 400, { error: 'invalid campaign id' }) }
    if (!fs.existsSync(filePath)) return json(res, 404, { error: 'not found' })
    try {
      fs.unlinkSync(filePath)
      log(`campaign ${id} deleted`)
      sse.broadcast(`campaign:${id}`, 'campaign-deleted', { id })
      return json(res, 200, { id, deleted: true })
    } catch (e) {
      log(`campaign ${id} delete failed:`, e.message)
      return json(res, 500, { error: 'delete failed: ' + e.message })
    }
  }

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

// ── Gift redirect handler ────────────────────────────────────────────────────
// Public unauthenticated endpoint hit by recipients' email clicks.
// /g/:token → 302 to underlying Giftbit URL, OR branded expired page.
async function handleGiftRedirect (req, res, urlPath) {
  const m = urlPath.match(/^\/g\/([A-Za-z0-9_-]{4,32})$/)
  const respondExpired = (reason) => {
    res.writeHead(reason === 'notfound' ? 404 : 410, {
      'Content-Type':  'text/html; charset=utf-8',
      'Cache-Control': 'no-store',
      'X-Robots-Tag':  'noindex, nofollow',
    })
    res.end(giftExpiredPage(reason))
  }
  if (!m) return respondExpired('notfound')
  const token = m[1]
  const hit = lookupGiftToken(token)
  if (!hit) return respondExpired('notfound')
  const r = hit.recipient
  if (r.gift_revoked) return respondExpired('revoked')
  if (r.gift_expires_at && new Date(r.gift_expires_at) < new Date()) return respondExpired('expired')
  if (!r.gift_url) return respondExpired('notfound')

  // ── Anti-bot : journalise l'ouverture (IP + heure) et BLOQUE les IP cloud/datacenter (AWS, GCP…). ──
  // Les vrais destinataires ouvrent depuis du résidentiel/mobile ; les bots/proxies viennent de datacenters
  // (cf. redemptions observées depuis 35.94.x / 35.91.x = AWS). Tunable via GIFT_BLOCK_CLOUD (défaut on).
  const cloudips = require('./cloud-ips'); cloudips.init()
  const _ips = cloudips.clientIps(req)
  let _cloud = null
  for (const ip of _ips) { const c = cloudips.isCloudIp(ip); if (c.cloud) { _cloud = { ip, provider: c.provider }; break } }
  r.gift_opens = Array.isArray(r.gift_opens) ? r.gift_opens : []
  r.gift_opens.push({ at: new Date().toISOString(), ips: _ips, blocked: _cloud ? _cloud.provider : '' })
  if (r.gift_opens.length > 40) r.gift_opens = r.gift_opens.slice(-40)
  if (_cloud && /^(on|1|true)$/i.test(process.env.GIFT_BLOCK_CLOUD || 'on')) {
    r.gift_blocked_count = (r.gift_blocked_count || 0) + 1
    try { saveCampaign(hit.campaign) } catch (e) {}
    log(`[gift] BLOQUÉ ${_cloud.provider} ${_cloud.ip} — token ${token} (campagne ${hit.campaign.id})`)
    try { sse.broadcast(`campaign:${hit.campaign.id}`, 'recipient-update', { i: hit.row, recipient: r }) } catch (e) {}
    res.writeHead(403, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store', 'X-Robots-Tag': 'noindex, nofollow' })
    return res.end(giftExpiredPage('blocked'))
  }

  // Successful redirect — record analytics, persist async (no await; the
  // recipient is already on their way to Giftbit's page).
  r.gift_redirected_count = (r.gift_redirected_count || 0) + 1
  if (!r.gift_first_redirected_at) r.gift_first_redirected_at = new Date().toISOString()
  // The wrapper redirect is the most reliable click signal we have (it
  // ALWAYS fires on a successful gift click — Mailjet's webhook can lag
  // or get dropped). Treat the first redirect as a confirmed gift click
  // on the recipient row, even if Mailjet's webhook hasn't arrived yet.
  if (!r.gift_link_clicked) {
    r.gift_link_clicked = true
    r.gift_clicked_at = r.gift_first_redirected_at
  }
  try { saveCampaign(hit.campaign) } catch (e) { log(`gift redirect save failed: ${e.message}`) }
  // Broadcast so the live campaign detail page updates the click counter
  sse.broadcast(`campaign:${hit.campaign.id}`, 'recipient-update', { i: hit.row, recipient: r })
  // Reminder click → cascade to parent so the originating campaign's
  // counters reflect the cumulative engagement at a single glance.
  cascadeClickToParent(hit.campaign, r)

  res.writeHead(302, {
    Location: r.gift_url,
    'Cache-Control': 'no-store',
    'X-Robots-Tag': 'noindex, nofollow',
  })
  res.end()
}

// Rebuild the token index from disk on module load so a hub restart
// doesn't break existing wrapper URLs in already-sent emails.
rebuildTokenIndex()

module.exports = {
  handle,
  handleGiftRedirect,
  startGiftReclaimCron,
  // Store d'images auto-hébergé réutilisable (pièces jointes courriel + bibliothèque d'équipements)
  readUpload, persistUpload, decodeDataUrl, uploadUrl, deleteUpload, listUploads,
  // Exposed for testing
  parseCsv, parseMapCsv, parseGiftbitCsv,
  matchCustomer, normalizeCivic, normalizePhone, normalizePostal,
  renderTemplate, renderNamedTemplate,
  // Enregistrement de campagne réutilisable (lib/events.js crée une campagne pour le blast d'invitations :
  // suivi Mailjet via X-MJ-CustomID `<id>:<index>` → webhook existant, report.csv, /campaigns list/détail, SSE).
  newCampaignId, loadCampaign, saveCampaign,
}
