'use strict'
// ─────────────────────────────────────────────────────────────────────────────
// F (gestionclient) → ERPNext — moteur de SYNC DELTA, mode PREVIEW.
//
// LECTURE SEULE : ne fait AUCUN erp.create / erp.update / écriture MySQL.
// Lit le miroir local `legacy-db` (jamais le F live) + ERPNext, et calcule ce
// qui SERAIT créé / mis à jour côté ERPNext, par clé legacy_* :
//   account  → Customer            (clé legacy_account_id)
//   delivery → Service Location    (clé legacy_delivery_id)
//   service  → Service Subscription(clé legacy_service_id)
//
// Détection de changement (cf. analyse 2026-06-10) :
//   • INSERT  = id F absent côté ERPNext (par clé legacy_*).
//   • UPDATE  = `account.date_last` (vraie date de modif, 86% des comptes) au-delà
//               du watermark ; en preview on compare directement les champs.
//
// F reste autoritaire pour la facturation : on NE synchronise PAS ici les
// invoice/payment/GL/PLE (ça vit dans F). On synchronise la couche CLIENT
// (comptes / adresses / services) nécessaire au funnel lead→abo→onboarding→RDV.
// ─────────────────────────────────────────────────────────────────────────────
const fs = require('fs')
const path = require('path')
const cfg = require('./config')
const { json, log } = require('./helpers')
const erp = require('./erp')
let mysql; try { mysql = require('mysql2/promise') } catch (e) { mysql = null }

const REPORT_DIR = path.join(__dirname, '..', 'data')
const REPORT_FILE = path.join(REPORT_DIR, 'legacy-sync-preview.json')
const STATE_FILE = path.join(REPORT_DIR, 'legacy-sync-state.json') // watermark incrémental

const RUN_LOG = path.join(REPORT_DIR, 'legacy-sync-run.json') // progression de l'apply (pollable)
function loadState () { try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')) } catch { return {} } }
function saveState (s) {
  try { if (!fs.existsSync(REPORT_DIR)) fs.mkdirSync(REPORT_DIR, { recursive: true }); fs.writeFileSync(STATE_FILE, JSON.stringify(s, null, 2)) }
  catch (e) { log('legacy-sync: échec écriture state:', e.message) }
}
function writeRun (o) { try { fs.writeFileSync(RUN_LOG, JSON.stringify(o)) } catch (e) {} }
const ERRORS_FILE = path.join(REPORT_DIR, 'legacy-sync-errors.json') // liste COMPLÈTE des erreurs d'apply + raisons
function writeErrors (o) { try { fs.writeFileSync(ERRORS_FILE, JSON.stringify(o, null, 2)) } catch (e) {} }
// Classe le message d'erreur ERPNext en raison courte (pour grouper/lister).
function errReason (msg) {
  const m = String(msg || '')
  if (/InvalidEmailAddressError/i.test(m)) return 'email_invalide'        // ex. emails multiples « a@x;b@y »
  if (/InvalidPhoneNumber|phone/i.test(m)) return 'téléphone_invalide'
  if (/LinkValidationError|Could not find|does not exist/i.test(m)) return 'lien_introuvable'
  if (/MandatoryError|is required|Mandatory/i.test(m)) return 'champ_obligatoire'
  if (/Duplicate|already exists/i.test(m)) return 'doublon'
  if (/socket hang up|ECONNRESET|ETIMEDOUT|timeout|EAI_AGAIN|50[234]/i.test(m)) return 'réseau'
  if (/Permission|not permitted|Forbidden|403/i.test(m)) return 'permission'
  if (/CharacterLengthExceeded|too long/i.test(m)) return 'trop_long'
  return 'autre'
}

let _pool
function pool () {
  if (!mysql) return null
  if (!_pool) {
    _pool = mysql.createPool({
      host: cfg.LEGACY_DB_HOST, user: cfg.LEGACY_DB_USER, password: cfg.LEGACY_DB_PASS,
      database: cfg.LEGACY_DB_NAME, connectionLimit: 2, waitForConnections: true, connectTimeout: 8000,
    })
  }
  return _pool
}

// ── client du pont PHP (lecture FRAÎCHE depuis F live ; le miroir peut être en retard) ──
const https = require('https')
const querystring = require('querystring')
function bridgeUrl () { return process.env.OPS_LEGACY_URL || 'https://facturation.targo.ca/ops_reassign.php' }
function bridgeToken () { try { return (process.env.OPS_LEGACY_TOKEN || fs.readFileSync('/app/data/ops_legacy.token', 'utf8') || '').trim() } catch { return (process.env.OPS_LEGACY_TOKEN || '').trim() } }
function bridgeCall (params) {
  return new Promise((resolve, reject) => {
    const body = querystring.stringify(params)
    const u = new URL(bridgeUrl())
    const req = https.request({ hostname: u.hostname, path: u.pathname + u.search, method: 'POST', timeout: 25000,
      headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Ops-Token': bridgeToken(), 'Content-Length': Buffer.byteLength(body) } },
      res => { let b = ''; res.on('data', c => b += c); res.on('end', () => { try { resolve(JSON.parse(b)) } catch { reject(new Error('bridge parse: ' + String(b).slice(0, 160))) } }) })
    req.on('error', reject); req.on('timeout', () => req.destroy(new Error('bridge timeout'))); req.write(body); req.end()
  })
}
// Tire les comptes changés/nouveaux depuis F live, paginé par (date_last, id).
async function fetchAccountsViaBridge (since, sinceId) {
  const sinceFixed = Number(since) || 0
  if (sinceFixed > 0) {
    // INCRÉMENTAL : `date_last > since` couvre create+update ; le delta est petit → 1 page large.
    const d = await withRetry(() => bridgeCall({ action: 'changes_since', entity: 'account', since: String(sinceFixed), since_id: '0', limit: '5000' }), 'bridge delta')
    if (!d || !d.ok) throw new Error('bridge changes_since: ' + ((d && d.error) || 'réponse invalide'))
    if (d.has_more) log('legacy-sync: ⚠ delta > 5000 lignes — pagination incrémentale à étendre')
    return d.rows || []
  }
  // FULL : keyset PUR sur id (since=0 → le PHP utilise `id > sinceId`)
  const rows = []; let sid = Number(sinceId) || 0
  for (let page = 0; page < 100; page++) {
    const d = await withRetry(() => bridgeCall({ action: 'changes_since', entity: 'account', since: '0', since_id: String(sid), limit: '1000' }), 'bridge full')
    if (!d || !d.ok) throw new Error('bridge full: ' + ((d && d.error) || 'réponse invalide'))
    rows.push(...(d.rows || []))
    if (!d.has_more) break
    const next = Number(d.max_id) || 0
    if (next <= sid) break // garde-fou anti-boucle
    sid = next
  }
  return rows
}

// Services F (statut frais) — keyset pur sur id (service n'a pas de date de modif → scan complet périodique).
async function fetchServicesViaBridge () {
  const rows = []; let sid = 0
  for (let page = 0; page < 200; page++) {
    const d = await withRetry(() => bridgeCall({ action: 'changes_since', entity: 'service', since: '0', since_id: String(sid), limit: '2000' }), 'bridge services')
    if (!d || !d.ok) throw new Error('bridge service: ' + ((d && d.error) || 'réponse invalide'))
    rows.push(...(d.rows || []))
    if (!d.has_more) break
    const next = Number(d.max_id) || 0; if (next <= sid) break; sid = next
  }
  return rows
}
// F service.status → statut Service Subscription. CORRIGÉ 2026-06-13 : dans F, **status=1 = ACTIF**, point.
// `date_suspended` n'est que l'HISTORIQUE d'une ancienne suspension (service réactivé) → l'IGNORER quand status=1.
// (L'ancien code `status==1 && date_suspended>0 → Suspendu` excluait à tort les services réactivés du total :
//  prouvé sur Schink #38243 et Marianne #31841 → totaux F restaurés. Cf. feedback_billing_invariants.)
function svcStatus (s, dateSusp) {
  if (Number(s) === 1) return 'Actif' // F status=1 = ACTIF ; date_suspended ignoré (historique d'une suspension passée)
  return 'Annulé' // status=0 = exclu du total (suspendu/annulé) — on garde 'Annulé' comme avant pour limiter le blast-radius aux 177 corrections de facturation
}

// ── helpers de normalisation ────────────────────────────────────────────────
function clean (v) {
  if (v == null) return ''
  let s = String(v)
  s = s.replace(/&amp;/g, '&').replace(/&#0?39;|&apos;/g, "'").replace(/&quot;/g, '"')
       .replace(/&lt;/g, '<').replace(/&gt;/g, '>')
       .replace(/&#0?(\d+);/g, (_, n) => { try { return String.fromCharCode(+n) } catch { return _ } })
  return s.trim()
}
function digits (v) { return String(v == null ? '' : v).replace(/\D/g, '') }
// F met parfois PLUSIEURS emails dans un champ (couple/entreprise/partenaires qui suivent la facturation),
// séparés par ; ou ,. ERPNext.email_id n'accepte qu'UN email → on garde le 1er valide comme principal
// et la LISTE COMPLÈTE va dans email_billing (CC facturation, emails secondaires importés).
function emailList (v) {
  // F contient parfois des caractères INVISIBLES dans les courriels (soft hyphen U+00AD, zero-width — collages
  // Outlook/Word). Ils passent notre regex (pas des \s) mais ERPNext les rejette (417 InvalidEmailAddress en
  // boucle à chaque sync, ex. reynet69[U+00AD]@yahoo.com). On les strippe AVANT de valider.
  return clean(v).toLowerCase().replace(/[\u00ad\u200b-\u200d\u2060\ufeff]/g, '').split(/[;,]/).map(s => s.trim()).filter(s => /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s))
}
function firstEmail (v) { return emailList(v)[0] || '' }

const GROUP_MAP = { 1: 'Individual', 4: 'Commercial', 5: 'Individual', 6: 'Individual', 7: 'Individual', 8: 'Commercial', 9: 'Government', 10: 'Non Profit' }

// account (F) → sous-ensemble HAUTE-CONFIANCE des champs Customer (ceux que la
// migration peuple de façon déterministe). L'enrichissement plus fin viendra plus tard.
function mapAccount (a) {
  const first = clean(a.first_name), last = clean(a.last_name), company = clean(a.company)
  const isCompany = !!company
  return {
    legacy_account_id: Number(a.id),
    customer_name: isCompany ? company : (`${first} ${last}`.trim() || `Client-${a.id}`),
    customer_type: isCompany ? 'Company' : 'Individual',
    customer_group: GROUP_MAP[a.group_id] || 'Individual',
    language: a.language_id === 'francais' ? 'fr' : 'en',
    disabled: [1, 2].includes(Number(a.status)) ? 0 : 1, // actif/suspendu = visible ; résilié (3,4,5) = désactivé
    is_suspended: Number(a.status) === 2 ? 1 : 0,
    email_id: firstEmail(a.email) || null,
    email_billing: (() => { const e = emailList(a.email); return e.length > 1 ? e.join(';') : null })(), // emails secondaires (couple/entreprise/partenaires) → CC facturation
    tel_home: digits(a.tel_home) || null,
    cell_phone: digits(a.cell) || null,
    stripe_id: clean(a.stripe_id) || null,
    ppa_enabled: Number(a.ppa) ? 1 : 0,            // pont PHP renvoie "0"/"1" (string) → Number() requis ("0" est truthy en JS)
    is_commercial: Number(a.commercial) ? 1 : 0,
    is_bad_payer: Number(a.mauvais_payeur) ? 1 : 0,
    mandataire: clean(a.mandataire) || null,         // représentant autorisé (titulaire ≠ payeur/écrivain) — recherchable
    contact_name_legacy: clean(a.contact) || null,   // personne-contact détaillée
    _status: Number(a.status),
  }
}

// Statut de compte GLOBAL F par id (account.status : 1=actif, 2=suspendu, 3/4/5=résilié). PK indexée → batch rapide/sûr.
const F_ACCT_STATUS_LABEL = { 1: 'Actif', 2: 'Suspendu', 3: 'Résilié', 4: 'Résilié', 5: 'Résilié' }
async function fAccountStatuses (ids) {
  ids = [...new Set((ids || []).map(Number).filter(Boolean))]
  if (!ids.length) return {}
  const p = pool(); if (!p) return {}
  const out = {}
  for (let i = 0; i < ids.length; i += 500) {
    const b = ids.slice(i, i + 500)
    const [rows] = await p.query({ sql: 'SELECT id, status FROM account WHERE id IN (' + b.map(() => '?').join(',') + ')', timeout: 6000 }, b)
    for (const r of rows) out[Number(r.id)] = Number(r.status)
  }
  return out
}

// IDs des comptes F RÉSILIÉS (status 3/4/5). Scan léger d'account (~17k lignes).
async function fResiliatedAccountIds () {
  const p = pool(); if (!p) return []
  const [rows] = await p.query({ sql: 'SELECT id FROM account WHERE status IN (3,4,5)', timeout: 10000 })
  return rows.map(r => Number(r.id))
}

// Champs texte : politique ADD / CHANGE / NEVER-CLOBBER (cf. preview 2026-06-11).
const TEXT_FIELDS = ['customer_name', 'customer_type', 'customer_group', 'language', 'email_id', 'email_billing', 'tel_home', 'cell_phone', 'stripe_id', 'mandataire', 'contact_name_legacy']
// Drapeaux booléens SYNCHRONISÉS (F autoritaire, vont dans « à réviser »).
const BOOL_FIELDS = ['ppa_enabled', 'is_commercial', 'is_bad_payer']
// Statut client = DÉRIVÉ des abonnements (Service Subscription) — affiché en divergence
// mais JAMAIS synchronisé/flippé depuis F (cf. feedback_customer_status_policy : pas de faux « churn »,
// le client reste vendable/cross-sell). Fetché pour l'info, hors review/sync.
const STATUS_FIELDS = ['disabled', 'is_suspended']
const CMP = [...TEXT_FIELDS, ...BOOL_FIELDS, ...STATUS_FIELDS]
const PHONE_FIELDS = new Set(['tel_home', 'cell_phone'])
function normField (field, v) {
  if (v == null) return ''
  if (PHONE_FIELDS.has(field)) return digits(v)
  if (field === 'email_id') return String(v).trim().toLowerCase()
  if (typeof v === 'number') return String(v)
  return String(v).trim()
}
function isEmpty (v) { return v == null || String(v).trim() === '' }

// Retry sur erreurs réseau transitoires (socket hang up, ECONNRESET, timeouts, 5xx) — « non bloquant si surcharge ».
async function withRetry (fn, label, tries = 5) {
  let last
  for (let i = 0; i < tries; i++) {
    try { return await fn() } catch (e) {
      last = e
      const transient = /socket hang up|ECONNRESET|ETIMEDOUT|EAI_AGAIN|EPIPE|network|timeout|50[234]/i.test(e.message || '')
      if (!transient || i === tries - 1) throw e
      await new Promise(r => setTimeout(r, 500 * (i + 1)))
      log(`legacy-sync retry ${label} #${i + 1}: ${e.message}`)
    }
  }
  throw last
}

// ── chargement de l'index ERPNext (paginé) ──────────────────────────────────
async function loadErpIndex (doctype, key, fields) {
  const idx = new Map()
  let start = 0; const lim = 1000; let available = null
  for (;;) {
    const rows = await withRetry(() => erp.list(doctype, { filters: [[key, '>', 0]], fields: ['name', key, ...fields], limit: lim, start }), `list ${doctype} @${start}`)
    if (!rows || !rows.length) break
    if (available === null) available = fields.filter(f => f in rows[0]) // v16 PG peut bloquer des champs
    for (const r of rows) idx.set(Number(r[key]), r)
    if (rows.length < lim) break
    start += lim
  }
  return { idx, available: available || fields }
}

// Fetch ciblé (mode incrémental) : ne charge QUE les enregistrements ERPNext dont la clé change.
async function loadErpByIds (doctype, key, fields, ids) {
  const idx = new Map(); let available = null
  for (let i = 0; i < ids.length; i += 100) {
    const batch = ids.slice(i, i + 100)
    if (!batch.length) continue
    const rows = await withRetry(() => erp.list(doctype, { filters: [[key, 'in', batch]], fields: ['name', key, ...fields], limit: 500 }), `list ${doctype} in-batch`)
    if (available === null && rows && rows[0]) available = fields.filter(f => f in rows[0])
    for (const r of (rows || [])) idx.set(Number(r[key]), r)
  }
  return { idx, available: available || fields }
}

// ── PREVIEW : comptes → Customer ─────────────────────────────────────────────
function bump (obj, k) { obj[k] = (obj[k] || 0) + 1 }

async function previewCustomers ({ sinceDateLast = 0, sinceId = 0, source = 'mirror' } = {}) {
  let rows
  if (source === 'bridge') {
    rows = await fetchAccountsViaBridge(sinceDateLast, sinceId) // lecture FRAÎCHE de F live (miroir périmé contourné)
  } else {
    const p = pool(); if (!p) throw new Error('miroir legacy-db indisponible (mysql2 absent)')
    const where = Number(sinceDateLast) > 0 ? ` WHERE date_last > ${Number(sinceDateLast)}` : ''
    ;[rows] = await p.query('SELECT id, first_name, last_name, company, group_id, language_id, status, email, tel_home, cell, commercial, mauvais_payeur, contact, mandataire, stripe_id, ppa, date_last FROM account' + where)
  }
  const maxDateLast = rows.reduce((m, r) => Math.max(m, Number(r.date_last) || 0), Number(sinceDateLast) || 0)
  const maxId = rows.reduce((m, r) => Math.max(m, Number(r.id) || 0), Number(sinceId) || 0)
  // Index ERPNext : ciblé si jeu borné (bridge/incrémental & ≤3000), sinon index complet.
  const targeted = (source === 'bridge' || Number(sinceDateLast) > 0) && rows.length <= 3000
  const { idx: erpIdx, available } = targeted
    ? await loadErpByIds('Customer', 'legacy_account_id', CMP, rows.map(r => Number(r.id)))
    : await loadErpIndex('Customer', 'legacy_account_id', CMP)
  const fullIndexLoaded = !targeted
  const textF = TEXT_FIELDS.filter(f => available.includes(f))
  const boolF = BOOL_FIELDS.filter(f => available.includes(f))
  const hasDisabled = available.includes('disabled')

  const out = {
    f_total: rows.length, erp_total: erpIdx.size,
    compared_text: textF, compared_bool: boolF, dropped: CMP.filter(f => !available.includes(f)),
    creates: [], safe_add: [], needs_review: [], unchanged: 0,
    add_by_field: {}, review_by_field: {}, never_clobber_by_field: {},
    status_divergence: { f_active_erp_disabled: 0, f_inactive_erp_enabled: 0, samples: [] },
    erp_only: [],
  }

  for (const a of rows) {
    const m = mapAccount(a)
    const ex = erpIdx.get(Number(a.id))
    if (!ex) { out.creates.push({ legacy_account_id: m.legacy_account_id, customer_name: m.customer_name, status: m._status }); continue }

    const add = [], change = [], skipNull = []
    // champs texte → ADD (ERPNext vide) / CHANGE (les deux diffèrent) / NEVER-CLOBBER (F vide)
    for (const f of textF) {
      if (normField(f, m[f]) === normField(f, ex[f])) continue
      const fEmpty = isEmpty(m[f]), eEmpty = isEmpty(ex[f])
      if (fEmpty && !eEmpty) { skipNull.push({ field: f, erp: ex[f] }); bump(out.never_clobber_by_field, f) }
      else if (eEmpty && !fEmpty) { add.push({ field: f, to: m[f] }); bump(out.add_by_field, f) }
      else { change.push({ field: f, from: ex[f], to: m[f] }); bump(out.review_by_field, f) }
    }
    // drapeaux booléens → toujours review (jamais d'auto-bascule)
    for (const f of boolF) {
      if (normField(f, m[f]) !== normField(f, ex[f])) { change.push({ field: f, from: ex[f], to: m[f] }); bump(out.review_by_field, f) }
    }
    // disabled : divergence de statut (NON synchronisée — ERPNext la dérive des abonnements)
    if (hasDisabled && normField('disabled', m.disabled) !== normField('disabled', ex.disabled)) {
      if (m.disabled === 0 && ex.disabled === 1) out.status_divergence.f_active_erp_disabled++
      else if (m.disabled === 1 && ex.disabled === 0) out.status_divergence.f_inactive_erp_enabled++
      if (out.status_divergence.samples.length < 30) out.status_divergence.samples.push({ legacy_account_id: m.legacy_account_id, customer_name: m.customer_name, f_status: m._status, erp_disabled: ex.disabled })
    }

    const rec = { legacy_account_id: m.legacy_account_id, name: ex.name, customer_name: m.customer_name }
    if (change.length) { rec.change = change; if (add.length) rec.add = add; if (skipNull.length) rec.skip_null = skipNull; out.needs_review.push(rec) }
    else if (add.length) { rec.add = add; if (skipNull.length) rec.skip_null = skipNull; out.safe_add.push(rec) }
    else out.unchanged++
  }

  // erp_only n'a de sens qu'en scan complet (sinon on n'a chargé qu'un sous-ensemble)
  if (fullIndexLoaded) { const fIds = new Set(rows.map(r => Number(r.id))); out.erp_only = [...erpIdx.keys()].filter(id => !fIds.has(id)) }
  out.incremental = Number(sinceDateLast) > 0 || source === 'bridge'
  out.source = source
  out.since_date_last = Number(sinceDateLast) || 0
  out.max_date_last = maxDateLast
  out.max_id = maxId
  return out
}

// ── PREVIEW générique INSERT-only (delivery / service) ───────────────────────
async function previewInsertsOnly ({ table, idCol = 'id', doctype, key, label, where = '' }) {
  const p = pool(); if (!p) throw new Error('miroir indisponible')
  const [rows] = await p.query(`SELECT ${idCol} AS id FROM \`${table}\`${where ? ` WHERE ${where}` : ''}`)
  const { idx } = await loadErpIndex(doctype, key, [])
  const sample = []; let would = 0
  for (const r of rows) { if (!idx.has(Number(r.id))) { would++; if (sample.length < 30) sample.push(Number(r.id)) } }
  return { label, where: where || '(toutes)', f_total: rows.length, erp_total: idx.size, would_create: would, sample }
}

// ── orchestrateur preview ────────────────────────────────────────────────────
async function runPreview ({ persist = true } = {}) {
  const startedAt = new Date().toISOString()
  const customers = await previewCustomers()
  const locations = await previewInsertsOnly({ table: 'delivery', doctype: 'Service Location', key: 'legacy_delivery_id', label: 'delivery→Service Location' })
  const services = await previewInsertsOnly({ table: 'service', doctype: 'Service Subscription', key: 'legacy_service_id', label: 'service ACTIF→Service Subscription', where: 'status=1' })

  const c = customers
  const report = {
    mode: 'PREVIEW (lecture seule — aucune écriture ni dans F ni dans ERPNext)',
    started_at: startedAt,
    customers: {
      summary: {
        f_total: c.f_total, erp_total: c.erp_total,
        would_create: c.creates.length,
        safe_add: c.safe_add.length,         // ⇐ enrichissement SÛR : remplir des champs vides (email/tél…)
        needs_review: c.needs_review.length, // ⇐ valeur divergente → révision humaine avant écriture
        unchanged: c.unchanged,
        erp_only_not_in_F: c.erp_only.length,
      },
      add_by_field: c.add_by_field,               // ce qui serait REMPLI (sûr)
      review_by_field: c.review_by_field,         // ce qui DIVERGE (à réviser)
      never_clobber_by_field: c.never_clobber_by_field, // F vide → JAMAIS écrasé (ex. stripe_id)
      status_divergence: { f_active_erp_disabled: c.status_divergence.f_active_erp_disabled, f_inactive_erp_enabled: c.status_divergence.f_inactive_erp_enabled, samples: c.status_divergence.samples.slice(0, 15) },
      compared_text: c.compared_text, compared_bool: c.compared_bool, dropped: c.dropped,
      creates_sample: c.creates.slice(0, 30),
      safe_add_sample: c.safe_add.slice(0, 15),
      needs_review_sample: c.needs_review.slice(0, 20),
      erp_only_sample: c.erp_only.slice(0, 30),
    },
    locations,
    services,
    finished_at: new Date().toISOString(),
  }
  // rapport COMPLET (toutes les listes) à part, pour révision/itération
  const full = { started_at: startedAt, customers_creates: c.creates, customers_safe_add: c.safe_add, customers_needs_review: c.needs_review, customers_erp_only: c.erp_only, status_divergence_samples: c.status_divergence.samples }
  if (persist) {
    try {
      if (!fs.existsSync(REPORT_DIR)) fs.mkdirSync(REPORT_DIR, { recursive: true })
      fs.writeFileSync(REPORT_FILE, JSON.stringify({ report, full }, null, 2))
      report.saved_to = REPORT_FILE
    } catch (e) { log('legacy-sync: échec écriture rapport:', e.message) }
  }
  return report
}

// Écrit un patch de champs Customer via le POOL PG (PAS erp.update : ERPNext re-dérive
// email_id/tél du Contact lié → renvoie ok mais NO-OP silencieux ; vérifié 2026-06-12).
// fieldnames = allowlist fixe TEXT_FIELDS+BOOL_FIELDS (pas d'injection). Never-clobber par du vide.
async function writeCustomerPatch (name, patch) {
  const SYNCABLE = new Set([...TEXT_FIELDS, ...BOOL_FIELDS])
  const keys = Object.keys(patch).filter(f => SYNCABLE.has(f) && patch[f] != null && String(patch[f]) !== '')
  if (!keys.length) return { ok: true, fields: 0 }
  const pgp = require('./address-db').pool()
  const sets = keys.map((f, i) => `"${f}"=$${i + 1}`).join(', ')
  await pgp.query(`UPDATE "tabCustomer" SET ${sets}, modified=NOW() WHERE name=$${keys.length + 1}`, [...keys.map(f => patch[f]), name])
  return { ok: true, fields: keys.length }
}

// ── APPLY (mode SAFE-ADD uniquement) — dry-run par défaut, écriture gardée ────
// N'applique QUE le bucket safe_add : remplir des champs ERPNext VIDES avec la
// valeur de F (email/tél/stripe). Écrit via le pool PG. Ne touche aucun autre champ.
// N'écrit JAMAIS sans confirm==='SAFE-ADD'. Ne touche pas needs_review ni disabled.
const { parseBody } = require('./helpers')
async function applySafeAdds ({ confirm = false, limit = 0 } = {}) {
  const c = await previewCustomers()
  const targets = limit ? c.safe_add.slice(0, limit) : c.safe_add
  if (confirm !== 'SAFE-ADD') {
    return { dry_run: true, eligible: c.safe_add.length, would_update: targets.length, sample: targets.slice(0, 10).map(t => ({ name: t.name, add: t.add })) }
  }
  let ok = 0, err = 0; const errors = []
  for (const t of targets) {
    const patch = {}; for (const a of t.add) patch[a.field] = a.to
    try {
      const r = await writeCustomerPatch(t.name, patch)
      if (r && r.ok !== false) ok++; else { err++; if (errors.length < 20) errors.push({ name: t.name, err: (r && r.error) || 'update failed' }) }
    } catch (e) { err++; if (errors.length < 20) errors.push({ name: t.name, err: e.message }) }
  }
  log(`legacy-sync apply SAFE-ADD: ${ok} ok, ${err} err`)
  return { applied: ok, errors: err, error_samples: errors }
}

// ── SYNC INCRÉMENTAL F→OPS (watermark date_last) — ERPNext = test, F autoritaire ──
// Applique ADD + CHANGE (F gagne) sur les Customers EXISTANTS. PUT partiel → ne touche
// QUE les champs mappés (l'enrichissement ERPNext-only : geocoding/fibre/AQ reste intact).
// JAMAIS `disabled` (dérivé des abonnements), JAMAIS écraser une valeur par du vide.
// Les CRÉATIONS (nouveaux comptes F) sont comptées mais gérées par un step dédié.
// dry-run sauf confirm==='F-WINS'. Avance le watermark seulement après application.
async function syncCustomers ({ confirm = false, full = false, source = 'bridge' } = {}) {
  const st = loadState()
  const since = full ? 0 : (Number(st.account_date_last) || 0)
  const sinceId = full ? 0 : (Number(st.account_max_id) || 0)
  const c = await previewCustomers({ sinceDateLast: since, sinceId, source })
  const plan = { mode: (since || sinceId) ? 'incrémental' : 'complet', source, since_date_last: since, since_id: sinceId, scanned: c.f_total, would_create: c.creates.length, would_update_safe: c.safe_add.length, would_update_review: c.needs_review.length, max_date_last: c.max_date_last, max_id: c.max_id }
  if (confirm !== 'F-WINS') return { dry_run: true, ...plan, note: "POST { confirm:'F-WINS' } pour appliquer les MAJ. full:true = ré-scan complet. source:'mirror' = lire le miroir au lieu de F live." }

  // « sans briser rien » : on ne RÉGRESSE pas des champs enrichis côté ERPNext.
  // customer_name = nettoyé à la migration (accents/composés, tâche #12) → on ne l'écrase
  // jamais par le brut de F. (Il n'apparaît que dans CHANGE, jamais dans ADD.)
  const PROTECT = new Set(['customer_name'])
  let ok = 0, err = 0, protectedSkips = 0; const errors = []
  const apply = async (rec) => {
    const patch = {}
    for (const a of (rec.add || [])) patch[a.field] = a.to
    for (const d of (rec.change || [])) { if (PROTECT.has(d.field)) { protectedSkips++; continue } patch[d.field] = d.to }
    if (!Object.keys(patch).length) return
    const fields = Object.keys(patch)
    const fail = (msg) => { err++; errors.push({ name: rec.name, legacy_account_id: rec.legacy_account_id, customer_name: rec.customer_name, fields, reason: errReason(msg), err: String(msg || 'fail').slice(0, 300) }) }
    try { const r = await writeCustomerPatch(rec.name, patch); if (r && r.ok !== false) ok++; else fail(r && r.error) }
    catch (e) { fail(e.message) }
  }
  const all = [...c.safe_add, ...c.needs_review]
  const total = all.length; let done = 0
  writeRun({ status: 'running', mode: plan.mode, total, done: 0, ok: 0, err: 0, started_at: new Date().toISOString() })
  for (const rec of all) {
    await apply(rec); done++
    if (done % 50 === 0) writeRun({ status: 'running', mode: plan.mode, total, done, ok, err })
    if (done % 25 === 0) await new Promise(r => setTimeout(r, 200)) // throttle : laisse ERPNext respirer (ne pas saturer l'UI dispatch)
  }
  const byReason = {}; for (const e of errors) byReason[e.reason] = (byReason[e.reason] || 0) + 1
  writeErrors({ ts: new Date().toISOString(), mode: plan.mode, applied: ok, total_errors: errors.length, by_reason: byReason, errors })
  st.account_date_last = Math.max(Number(st.account_date_last) || 0, c.max_date_last || 0)
  st.account_max_id = Math.max(Number(st.account_max_id) || 0, c.max_id || 0)
  st.last_run = new Date().toISOString(); saveState(st)
  writeRun({ status: 'done', mode: plan.mode, total, done, ok, err, errors_by_reason: byReason, protected_name_skips: protectedSkips, watermark: st.account_date_last, creates_deferred: c.creates.length, finished_at: new Date().toISOString() })
  log(`legacy-sync syncCustomers (${plan.mode}): ${ok} maj, ${err} err ${JSON.stringify(byReason)}, watermark→${c.max_date_last}`)
  return { ...plan, applied: ok, errors: err, errors_by_reason: byReason, protected_name_skips: protectedSkips, error_samples: errors.slice(0, 20), watermark: st.account_date_last, creates_deferred: c.creates.length }
}

// ── SYNC STATUT DE SERVICE (subscription) F→ERPNext — F autoritaire ──────────
// service n'a pas de date de modif → SCAN COMPLET (statut peut changer sans changer l'id).
// Met à jour le statut des Service Subscription EXISTANTes (par legacy_service_id) : Actif/Suspendu/Annulé.
// dry-run sauf confirm==='F-WINS'. (Créations des services F manquants = step séparé.)
async function syncServices ({ confirm = false } = {}) {
  const fsvc = await fetchServicesViaBridge()
  const want = new Map()
  for (const s of fsvc) want.set(Number(s.id), svcStatus(s.status, s.date_suspended))
  // index ERPNext Service Subscription (legacy_service_id → {name,status})
  const idx = new Map(); let start = 0
  for (;;) {
    const r = await withRetry(() => erp.list('Service Subscription', { filters: [['legacy_service_id', '>', 0]], fields: ['name', 'legacy_service_id', 'status'], limit: 1000, start }), 'list SS')
    if (!r || !r.length) break
    for (const x of r) idx.set(Number(x.legacy_service_id), x)
    if (r.length < 1000) break
    start += 1000
  }
  const changes = []
  for (const [lid, sub] of idx) { const w = want.get(lid); if (w && w !== sub.status) changes.push({ name: sub.name, from: sub.status, to: w, lid }) }
  const byTrans = {}; for (const c of changes) { const k = (c.from || '∅') + '→' + c.to; byTrans[k] = (byTrans[k] || 0) + 1 }
  const plan = { f_services: fsvc.length, erp_subs: idx.size, would_update: changes.length, by_transition: byTrans }
  if (confirm !== 'F-WINS') return { dry_run: true, ...plan, sample: changes.slice(0, 20) }
  let ok = 0, err = 0; const errs = []
  let i = 0
  for (const c of changes) {
    try { const r = await withRetry(() => erp.update('Service Subscription', c.name, { status: c.to }), 'svc ' + c.name); if (r && r.ok !== false) ok++; else { err++; if (errs.length < 20) errs.push({ name: c.name, err: (r && r.error) || 'fail' }) } }
    catch (e) { err++; if (errs.length < 20) errs.push({ name: c.name, err: e.message }) }
    if (++i % 25 === 0) await new Promise(r => setTimeout(r, 200)) // throttle
  }
  log(`legacy-sync syncServices: ${ok} maj statut, ${err} err ${JSON.stringify(byTrans)}`)
  return { ...plan, applied: ok, errors: err, error_samples: errs }
}

// ── CORRECTION : abonnements « fantômes » sur comptes F RÉSILIÉS ─────────────
// Bug : F résilie au niveau COMPTE (account.status 3/4/5) mais laisse service.status=1 → le
// mapping svcStatus (basé sur service.status seul) garde l'abonnement 'Actif'. Ici on force
// 'Annulé' pour tout abonnement 'Actif' dont le client a un legacy_account_id RÉSILIÉ.
// Robuste (indépendant du bridge : fResiliatedAccountIds + jointure PG). Idempotent → réexécutable
// périodiquement (fix « forward »). dry-run sauf confirm==='F-WINS'. Cf feedback_ghost_active_subscriptions.
async function annulResiliatedSubscriptions ({ confirm = false, limit = 20000 } = {}) {
  const resil = await fResiliatedAccountIds()
  if (!resil || !resil.length) return { resiliated_accounts: 0, would_update: 0 }
  const pg = require('./address-db').pool()
  const rows = []
  for (let i = 0; i < resil.length; i += 2000) {
    const b = resil.slice(i, i + 2000)
    const r = await pg.query('SELECT ss.name, ss.customer, ss.monthly_price FROM "tabService Subscription" ss JOIN "tabCustomer" c ON ss.customer=c.name WHERE ss.status=$1 AND c.legacy_account_id = ANY($2)', ['Actif', b])
    rows.push(...r.rows)
  }
  const plan = { resiliated_accounts: resil.length, would_update: rows.length, monthly_sum: Math.round(rows.reduce((s, x) => s + (Number(x.monthly_price) || 0), 0)) }
  if (confirm !== 'F-WINS') return { dry_run: true, ...plan, sample: rows.slice(0, 15).map(x => ({ name: x.name, customer: x.customer })) }
  let ok = 0; let err = 0; const errs = []; let i = 0
  for (const x of rows.slice(0, limit)) {
    try { const r = await withRetry(() => erp.update('Service Subscription', x.name, { status: 'Annulé' }), 'annul ' + x.name); if (r && r.ok !== false) ok++; else { err++; if (errs.length < 15) errs.push({ name: x.name, err: (r && r.error) || 'fail' }) } }
    catch (e) { err++; if (errs.length < 15) errs.push({ name: x.name, err: e.message }) }
    if (++i % 25 === 0) await new Promise(r => setTimeout(r, 200)) // throttle
  }
  log(`legacy-sync annulResiliatedSubscriptions: ${ok} annulés, ${err} err`)
  return { ...plan, applied: ok, errors: err, error_samples: errs }
}

// ── Tableau de bord de réconciliation : F (mysql) vs ERPNext (pg), par entité ──
// Chaque compte est indépendant (try/catch → null si table absente) pour ne jamais casser la vue.
async function scoreboard () {
  const pg = require('./address-db').pool()
  const pgc = async (sql, params) => { try { return Number((await pg.query(sql, params)).rows[0].c) } catch (e) { return null } }
  const mp = pool()
  const myc = async (sql) => { if (!mp) return null; try { const [r] = await mp.query({ sql, timeout: 8000 }); return Number(r[0].c) } catch (e) { return null } }
  let resil = []; try { resil = await fResiliatedAccountIds() } catch (e) {}
  const [fAcct, fSvcActive, fDelivery, fTicket, fInvoice, fPayment, fDevice,
    eCust, eSubActif, eLoc, eIssue, eInv, ePay, eDevice, ghost] = await Promise.all([
    myc('SELECT COUNT(*) c FROM account'),
    // F actif = service.status=1 BRUT (ce que F croit actif, fantômes F-non-cascadés inclus) ; l'écart positif = fantômes avérés qu'ERPNext a retirés (raffinage 2026-07-06)
    myc('SELECT COUNT(*) c FROM service WHERE status=1'),
    myc('SELECT COUNT(*) c FROM delivery'),
    myc('SELECT COUNT(*) c FROM ticket'),
    myc('SELECT COUNT(*) c FROM invoice'),
    myc('SELECT COUNT(*) c FROM payment'),
    myc('SELECT COUNT(*) c FROM device'),
    pgc('SELECT COUNT(*) c FROM "tabCustomer"'),
    pgc('SELECT COUNT(*) c FROM "tabService Subscription" WHERE status=$1', ['Actif']),
    pgc('SELECT COUNT(*) c FROM "tabService Location"'),
    pgc('SELECT COUNT(*) c FROM "tabIssue"'),
    pgc('SELECT COUNT(*) c FROM "tabSales Invoice"'),
    pgc('SELECT COUNT(*) c FROM "tabPayment Entry"'),
    pgc('SELECT COUNT(*) c FROM "tabService Equipment"'),
    resil.length ? pgc('SELECT COUNT(*) c FROM "tabService Subscription" ss JOIN "tabCustomer" c ON ss.customer=c.name WHERE ss.status=$1 AND c.legacy_account_id = ANY($2)', ['Actif', resil]) : 0,
  ])
  const row = (key, label, f, erp, note) => ({ key, label, f, erp, delta: (f != null && erp != null) ? f - erp : null, note: note || '' })
  return {
    generated_at: new Date().toISOString(),
    resiliated_f_accounts: resil.length,
    ghost_active_subscriptions: ghost,
    entities: [
      row('customers', 'Clients', fAcct, eCust, 'F account ↔ Customer'),
      row('locations', 'Lieux de service', fDelivery, eLoc, 'F delivery ↔ Service Location'),
      row('subscriptions', 'Abonnements actifs', fSvcActive, eSubActif, `écart = fantômes avérés retirés d'ERPNext (payants, résiliés, F silencieux >24 mois) ; ${ghost} actifs sur compte résilié restants à revoir`),
      row('devices', 'Appareils', fDevice, eDevice, 'F device ↔ Service Equipment (liens orphelins possibles)'),
      row('tickets', 'Tickets', fTicket, eIssue, 'F ticket ↔ Issue (import à réparer)'),
      row('invoices', 'Factures', fInvoice, eInv, 'F invoice ↔ Sales Invoice'),
      row('payments', 'Paiements', fPayment, ePay, 'F payment ↔ Payment Entry'),
    ],
  }
}

// ── CRÉATEUR DE CLIENTS CANONIQUE (utilisé par billing + orchestrateur) ──────
// Crée les Customers ERPNext manquants depuis F (par legacy_account_id) via erp.create
// (REST → naming/validations/defaults gérés). Idempotent. POLITIQUE : créé VISIBLE
// (disabled=0) — le statut actif/inactif est DÉRIVÉ des abonnements, jamais imposé par F
// (cf. feedback_customer_status_policy : pas de faux « churn », client reste vendable/cross-sell).
async function createCustomersByIds ({ ids = [], confirm = false, limit = 2000 } = {}) {
  ids = [...new Set((ids || []).map(Number).filter(Boolean))]
  if (!ids.length) return { requested: 0, missing: 0, created: 0 }
  // existence via le pool PG (ANY paramétré) — pas d'erp.list 'in' (URL trop longue pour de gros lots)
  const pgp = require('./address-db').pool()
  const have = new Set()
  for (let i = 0; i < ids.length; i += 2000) {
    const b = ids.slice(i, i + 2000)
    const r = await pgp.query('SELECT legacy_account_id FROM "tabCustomer" WHERE legacy_account_id = ANY($1)', [b])
    for (const x of r.rows) have.add(Number(x.legacy_account_id))
  }
  const missing = ids.filter(id => !have.has(id)).slice(0, limit)
  if (!missing.length) return { requested: ids.length, missing: 0, created: 0 }
  const p = pool(); if (!p) throw new Error('F indisponible (mysql2 absent)')
  const accts = []
  for (let i = 0; i < missing.length; i += 1000) {
    const b = missing.slice(i, i + 1000)
    const [rows] = await p.query(`SELECT id, first_name, last_name, company, group_id, language_id, status, email, tel_home, cell, commercial, mauvais_payeur, contact, mandataire, stripe_id, ppa FROM account WHERE id IN (${b.map(() => '?').join(',')})`, b)
    accts.push(...rows)
  }
  if (confirm !== 'F-WINS') return { dry_run: true, requested: ids.length, missing: missing.length, would_create: accts.length, sample: accts.slice(0, 8).map(a => ({ legacy_account_id: Number(a.id), customer_name: mapAccount(a).customer_name })) }
  let ok = 0, err = 0; const errors = []
  for (const a of accts) {
    const m = mapAccount(a)
    const doc = { customer_name: m.customer_name, customer_type: m.customer_type, customer_group: m.customer_group, legacy_account_id: m.legacy_account_id, disabled: 0 } // créé visible (politique de statut)
    if (m.email_id) doc.email_id = m.email_id
    if (m.cell_phone) doc.mobile_no = m.cell_phone
    try { const r = await withRetry(() => erp.create('Customer', doc), 'create cust'); if (r && r.ok !== false) ok++; else { err++; if (errors.length < 15) errors.push({ legacy_account_id: m.legacy_account_id, err: (r && r.error) || 'fail' }) } }
    catch (e) { err++; if (errors.length < 15) errors.push({ legacy_account_id: m.legacy_account_id, err: String(e.message).slice(0, 160) }) }
    if ((ok + err) % 25 === 0) await new Promise(r => setTimeout(r, 200)) // throttle
  }
  log(`legacy-sync createCustomersByIds: ${ok} créés, ${err} err (sur ${missing.length} manquants)`)
  return { requested: ids.length, missing: missing.length, created: ok, errors: err, error_samples: errors }
}
// Applique les changements RÉVISÉS EN LOT depuis le dernier rapport (REPORT_FILE.full.customers_needs_review).
// field précisé → n'applique QUE ce champ (ex. tous les email_id) ; sinon TOUS les champs divergents (sauf PROTECT).
// Écrit via writeCustomerPatch (pool PG). Idempotent. dry-run sauf confirm==='F-WINS'.
async function applyReviewBatch ({ field = null, confirm = false, limit = 0 } = {}) {
  let saved
  try { saved = JSON.parse(fs.readFileSync(REPORT_FILE, 'utf8')).full } catch { throw new Error('aucun rapport — lancer /legacy-sync/preview d\'abord') }
  const PROTECT = new Set(['customer_name'])
  let items = (saved.customers_needs_review || []).filter(r => Array.isArray(r.change) && r.change.length)
  if (field) items = items.filter(r => r.change.some(d => d.field === field && !PROTECT.has(d.field)))
  if (limit) items = items.slice(0, limit)
  // décompte par champ (pour le détail)
  const byField = {}
  for (const r of items) for (const d of r.change) { if (PROTECT.has(d.field)) continue; if (field && d.field !== field) continue; byField[d.field] = (byField[d.field] || 0) + 1 }
  if (confirm !== 'F-WINS') return { dry_run: true, field: field || 'tous', would_apply: items.length, by_field: byField, sample: items.slice(0, 10).map(r => ({ customer_name: r.customer_name, legacy_account_id: r.legacy_account_id, change: r.change.filter(d => !PROTECT.has(d.field) && (!field || d.field === field)) })) }
  let ok = 0, err = 0, protectedSkips = 0; const errors = []
  for (const r of items) {
    const patch = {}
    for (const d of r.change) { if (PROTECT.has(d.field)) { protectedSkips++; continue } if (field && d.field !== field) continue; patch[d.field] = d.to }
    if (!Object.keys(patch).length) continue
    try { await writeCustomerPatch(r.name, patch); ok++ } catch (e) { err++; if (errors.length < 20) errors.push({ name: r.name, err: String(e.message).slice(0, 160) }) }
  }
  log(`legacy-sync applyReviewBatch (${field || 'tous'}): ${ok} appliqués, ${err} err`)
  return { applied: ok, errors: err, field: field || 'tous', by_field: byField, protected_name_skips: protectedSkips, error_samples: errors }
}

// Crée TOUS les nouveaux comptes F (bucket `creates` de la preview) — pour l'orchestrateur « clients ».
async function createCustomers ({ confirm = false, limit = 1000 } = {}) {
  const c = await previewCustomers()
  return await createCustomersByIds({ ids: c.creates.map(x => x.legacy_account_id), confirm, limit })
}
// Appliquer un changement RÉVISÉ d'UN compte (F gagne, ciblé). Ex. email hotmail→gmail.
// Re-dérive les valeurs F (jamais la valeur stale de l'UI), n'applique QUE les champs divergents,
// JAMAIS customer_name (protégé) ni disabled/is_suspended (dérivés), JAMAIS écraser par du vide.
async function applyAccountChange ({ legacy_account_id, fields = null, confirm = false } = {}) {
  legacy_account_id = Number(legacy_account_id)
  if (!legacy_account_id) throw new Error('legacy_account_id requis')
  const p = pool(); if (!p) throw new Error('F indisponible')
  const [rows] = await p.query('SELECT id, first_name, last_name, company, group_id, language_id, status, email, tel_home, cell, commercial, mauvais_payeur, contact, mandataire, stripe_id, ppa FROM account WHERE id=?', [legacy_account_id])
  if (!rows.length) throw new Error('compte F introuvable')
  const m = mapAccount(rows[0])
  const PROTECT = new Set(['customer_name']) // nettoyé à la migration, jamais réécrit par le brut F
  const SYNCABLE = [...TEXT_FIELDS, ...BOOL_FIELDS] // exclut STATUS_FIELDS (disabled/is_suspended = dérivés)
  // Écriture via le pool PG direct (comme tout le reste de la sync) — erp.update (REST) ne persiste pas
  // email_id/tel (ERPNext re-dérive du Contact lié). Les fieldnames sont une allowlist fixe (pas d'injection).
  const pgp = require('./address-db').pool()
  const cols = SYNCABLE.map(f => `"${f}"`).join(', ')
  const exRows = (await pgp.query(`SELECT name, ${cols} FROM "tabCustomer" WHERE legacy_account_id=$1 LIMIT 1`, [legacy_account_id])).rows
  if (!exRows.length) throw new Error('Customer ERPNext introuvable')
  const ex = exRows[0]
  const cand = (fields && fields.length) ? fields : SYNCABLE
  const patch = {}
  for (const f of cand) {
    if (PROTECT.has(f) || !SYNCABLE.includes(f)) continue
    if (normField(f, m[f]) !== normField(f, ex[f]) && !isEmpty(m[f])) patch[f] = m[f] // F gagne, jamais par du vide
  }
  const keys = Object.keys(patch)
  if (!keys.length) return { applied: 0, name: ex.name, note: 'aucun changement applicable (ou déjà à jour)' }
  if (confirm !== 'F-WINS') return { dry_run: true, name: ex.name, patch }
  await writeCustomerPatch(ex.name, patch) // helper partagé (pool PG)
  log(`legacy-sync applyAccountChange #${legacy_account_id}: ${keys.join(',')}`)
  return { applied: keys.length, name: ex.name, patch }
}

// Compteurs F LÉGERS (petites tables : account/delivery/service) — pour le tableau de bord. À mettre en cache côté appelant.
async function fCounts () {
  const p = pool(); if (!p) return {}
  const [[a]] = await p.query('SELECT COUNT(*) c FROM account')
  const [[d]] = await p.query('SELECT COUNT(*) c FROM delivery')
  const [[s]] = await p.query('SELECT COUNT(*) c FROM service WHERE status=1')
  return { accounts: Number(a.c), deliveries: Number(d.c), services_active: Number(s.c) }
}

// Écart EXACT par domaine (set-diff d'ids, pas une soustraction de compte) → « à créer » juste + orphelins
// (ids ERPNext sans source F = comptes supprimés/fusionnés dans F). Léger : ids seuls, petites tables ; à cacher.
async function domainCounts () {
  const p = pool(); if (!p) return null
  const pgp = require('./address-db').pool()
  // missing (à créer) = F-actif absent d'ERPNext ; orphans = ERPNext absent de F-TOTAL (supprimé de F,
  // PAS juste annulé — un service annulé garde sa SS et n'est pas orphelin).
  const d = async (fActiveSql, pgSql, fAllSql = null) => {
    const [fa] = await p.query(fActiveSql); const fActive = new Set(fa.map(r => Number(r.id)))
    const er = (await pgp.query(pgSql)).rows.map(r => Number(r.k)); const eset = new Set(er)
    let missing = 0; for (const id of fActive) if (!eset.has(id)) missing++
    let fAll = fActive
    if (fAllSql) { const [fr] = await p.query(fAllSql); fAll = new Set(fr.map(r => Number(r.id))) }
    let orphans = 0; for (const id of eset) if (!fAll.has(id)) orphans++
    return { f: fActive.size, erp: eset.size, missing, orphans }
  }
  return {
    clients: await d('SELECT id FROM account', 'SELECT legacy_account_id k FROM "tabCustomer" WHERE legacy_account_id>0'),
    adresses: await d('SELECT id FROM delivery', 'SELECT legacy_delivery_id k FROM "tabService Location" WHERE legacy_delivery_id>0'),
    services: await d('SELECT id FROM service WHERE status=1', 'SELECT legacy_service_id k FROM "tabService Subscription" WHERE legacy_service_id>0', 'SELECT id FROM service'),
  }
}

// ── RÉCONCILIATION SERVICES PAR COMPTE (F→ERPNext) — APERÇU lecture seule ─────
// Cause vue (Schink/Cousineau/Marianne) : ERPNext a gardé l'ANCIEN forfait (suspendu) et n'a jamais
// reçu le NOUVEAU forfait ACTIF de F → total faux. On lit F par delivery_id (INDEXÉ ; dérivé d'ERPNext
// pour ÉVITER la jointure account non indexée qui figeait F), on diffe vs ERPNext. AUCUNE écriture.
// 1 compte à la fois = requête F légère, pas de scan complet, pas de rafale.
async function previewAccountServiceReconcile (customer) {
  if (!customer) throw new Error('customer requis')
  const pgp = require('./address-db').pool(); if (!pgp) throw new Error('ERPNext PG indisponible')
  const locRows = (await pgp.query('SELECT name, legacy_delivery_id FROM "tabService Location" WHERE customer=$1', [customer])).rows
  const dids = [...new Set(locRows.map(l => Number(l.legacy_delivery_id)).filter(Boolean))]
  const p = pool(); if (!p) throw new Error('F indisponible (mysql2 absent)')
  let fsvc = []
  // PAS de jointure (les lectures de lignes service + jointure figent F) → 2 requêtes simples.
  if (dids.length) {
    const [rows] = await p.query({ sql: `SELECT id, status, date_suspended, delivery_id, product_id, hijack, hijack_price, hijack_desc FROM service WHERE delivery_id IN (${dids.map(() => '?').join(',')})`, timeout: 6000 }, dids)
    fsvc = rows
  }
  // prix/sku produit par PK (rapide). F : prix effectif = hijack ? hijack_price : product.price (le « hijack » = override par service).
  const prodIds = [...new Set(fsvc.map(s => Number(s.product_id)).filter(Boolean))]
  const prod = {}
  if (prodIds.length) { try { const [pr] = await p.query({ sql: `SELECT id, sku, price FROM product WHERE id IN (${prodIds.map(() => '?').join(',')})`, timeout: 6000 }, prodIds); for (const x of pr) prod[Number(x.id)] = x } catch (e) { /* produit optionnel */ } }
  const effPrice = s => (Number(s.hijack) === 1) ? Number(s.hijack_price || 0) : Number((prod[Number(s.product_id)] || {}).price || 0)
  const effDesc = s => (Number(s.hijack) === 1 && s.hijack_desc) ? s.hijack_desc : ((prod[Number(s.product_id)] || {}).sku || ('prod ' + s.product_id))
  const subs = await erp.list('Service Subscription', { filters: [['customer', '=', customer]], fields: ['name', 'legacy_service_id', 'status', 'monthly_price', 'plan_name', 'service_location'], limit: 300 }) || []
  const erpByLid = new Map(); for (const s of subs) erpByLid.set(Number(s.legacy_service_id || 0), s)
  const isFActive = s => Number(s.status) === 1 // F: status=1 = ACTIF (date_suspended = historique, ignoré — cf. svcStatus corrigé)
  const fActive = fsvc.filter(isFActive)
  const missingActive = [] // F actif mais ABSENT/INACTIF dans ERPNext → à créer/réactiver
  for (const s of fActive) {
    const e = erpByLid.get(Number(s.id))
    if (!e) missingActive.push({ legacy: Number(s.id), product: effDesc(s), price: Math.round(effPrice(s) * 100) / 100, erp: 'absent' })
    else if (e.status !== 'Actif') missingActive.push({ legacy: Number(s.id), product: effDesc(s), price: Math.round(effPrice(s) * 100) / 100, erp: e.status, erp_name: e.name })
  }
  const erpActiveButFInactive = [] // ERPNext actif mais F inactif → à suspendre/annuler
  for (const e of subs) {
    if (e.status !== 'Actif' || !e.legacy_service_id) continue
    const f = fsvc.find(x => Number(x.id) === Number(e.legacy_service_id))
    if (f && !isFActive(f)) erpActiveButFInactive.push({ legacy: Number(e.legacy_service_id), name: e.name, plan: e.plan_name, price: Number(e.monthly_price || 0), f_status: Number(f.status), f_susp: Number(f.date_suspended || 0) })
  }
  const round = n => Math.round(n * 100) / 100
  return {
    customer, deliveries: dids,
    f_active_total: round(fActive.reduce((a, s) => a + effPrice(s), 0)),
    erp_active_total: round(subs.filter(s => s.status === 'Actif').reduce((a, s) => a + Number(s.monthly_price || 0), 0)),
    missing_active: missingActive,
    erp_active_f_inactive: erpActiveButFInactive,
    aligned: missingActive.length === 0 && erpActiveButFInactive.length === 0,
  }
}

// ── routes HTTP (gated ; preview = sûr) ──────────────────────────────────────
async function handle (req, res, method, p, url) {
  if (p === '/legacy-sync/reconcile-account' && method === 'GET') {
    try { return json(res, 200, await previewAccountServiceReconcile(url.searchParams.get('customer') || '')) }
    catch (e) { log('reconcile-account error:', e.message); return json(res, 200, { error: e.message }) }
  }
  if (p === '/legacy-sync/preview' && method === 'GET') {
    try { return json(res, 200, await runPreview({ persist: true })) }
    catch (e) { log('legacy-sync preview error:', e.message); return json(res, 500, { error: e.message }) }
  }
  if (p === '/legacy-sync/report' && method === 'GET') {
    try { return json(res, 200, JSON.parse(fs.readFileSync(REPORT_FILE, 'utf8'))) }
    catch (e) { return json(res, 404, { error: 'aucun rapport — lancer /legacy-sync/preview' }) }
  }
  // Écriture gardée : sans body.confirm==='SAFE-ADD' → dry-run (n'écrit rien).
  if (p === '/legacy-sync/apply-safe-adds' && method === 'POST') {
    try { const b = await parseBody(req); return json(res, 200, await applySafeAdds({ confirm: b.confirm, limit: b.limit || 0 })) }
    catch (e) { return json(res, 500, { error: e.message }) }
  }
  // Sync incrémental F→OPS (watermark). dry-run sauf { confirm:'F-WINS' }. { full:true } = ignorer le watermark.
  if (p === '/legacy-sync/run' && method === 'POST') {
    try { const b = await parseBody(req); return json(res, 200, await syncCustomers({ confirm: b.confirm, full: !!b.full, source: b.source || 'bridge' })) }
    catch (e) { return json(res, 500, { error: e.message }) }
  }
  // Sync statut de service (subscription). dry-run sauf { confirm:'F-WINS' }.
  if (p === '/legacy-sync/run-services' && method === 'POST') {
    try { const b = await parseBody(req); return json(res, 200, await syncServices({ confirm: b.confirm })) }
    catch (e) { return json(res, 500, { error: e.message }) }
  }
  // Appliquer les changements révisés EN LOT (par champ ou tous). dry-run sauf { confirm:'F-WINS' }.
  if (p === '/legacy-sync/apply-review-batch' && method === 'POST') {
    try { const b = await parseBody(req); return json(res, 200, await applyReviewBatch({ field: b.field || null, confirm: b.confirm, limit: b.limit || 0 })) }
    catch (e) { return json(res, 500, { error: e.message }) }
  }
  // Appliquer un changement révisé d'un compte (F gagne, ciblé). dry-run sauf { confirm:'F-WINS' }.
  if (p === '/legacy-sync/apply-change' && method === 'POST') {
    try { const b = await parseBody(req); return json(res, 200, await applyAccountChange({ legacy_account_id: b.legacy_account_id, fields: b.fields, confirm: b.confirm })) }
    catch (e) { return json(res, 500, { error: e.message }) }
  }
  // Création des comptes F manquants (créateur canonique). dry-run sauf { confirm:'F-WINS' }.
  if (p === '/legacy-sync/create-customers' && method === 'POST') {
    try { const b = await parseBody(req); return json(res, 200, b.ids ? await createCustomersByIds({ ids: b.ids, confirm: b.confirm, limit: b.limit || 2000 }) : await createCustomers({ confirm: b.confirm, limit: b.limit || 1000 })) }
    catch (e) { return json(res, 500, { error: e.message }) }
  }
  // Monitoring de F via le pont (fraîcheur + compteurs) — lecture seule.
  if (p === '/legacy-sync/f-ping' && method === 'GET') {
    try { return json(res, 200, await bridgeCall({ action: 'ping' })) }
    catch (e) { return json(res, 502, { error: e.message }) }
  }
  if (p === '/legacy-sync/scoreboard' && method === 'GET') { try { return json(res, 200, await scoreboard()) } catch (e) { return json(res, 500, { error: e.message }) } }
  if (p === '/legacy-sync/state' && method === 'GET') return json(res, 200, loadState())
  if (p === '/legacy-sync/run-status' && method === 'GET') { try { return json(res, 200, JSON.parse(fs.readFileSync(RUN_LOG, 'utf8'))) } catch { return json(res, 200, { status: 'idle' }) } }
  // Liste COMPLÈTE des erreurs du dernier apply, groupées par raison. ?reason=email_invalide pour filtrer.
  if (p === '/legacy-sync/errors' && method === 'GET') {
    try {
      const d = JSON.parse(fs.readFileSync(ERRORS_FILE, 'utf8'))
      const filt = url && url.searchParams.get('reason')
      if (filt) d.errors = (d.errors || []).filter(e => e.reason === filt)
      return json(res, 200, d)
    } catch { return json(res, 200, { total_errors: 0, by_reason: {}, errors: [], note: 'aucun apply exécuté' }) }
  }
  return json(res, 404, { error: 'route legacy-sync inconnue' })
}

module.exports = { handle, runPreview, previewCustomers, applySafeAdds, syncCustomers, syncServices, annulResiliatedSubscriptions, scoreboard, createCustomers, createCustomersByIds, applyAccountChange, applyReviewBatch, fCounts, domainCounts, loadState, mapAccount, svcStatus, fAccountStatuses, fResiliatedAccountIds, F_ACCT_STATUS_LABEL, GROUP_MAP, CMP }
