'use strict'
// ─────────────────────────────────────────────────────────────────────────────
// F (gestionclient) → ERPNext — RÉCONCILIATION DES PAIEMENTS, mode PREVIEW.
//
// LECTURE SEULE par défaut. F reste AUTORITAIRE sur la facturation ; ERPNext en
// est une COPIE. On ne touche JAMAIS F. L'« apply » (miroir des paiements) est
// gardé derrière confirm==='F-WINS' et n'écrit que la couche LOG (Payment Entry
// + références), exactement comme la migration l'a fait pour les 542k paiements
// existants.
//
// ÉTAT VÉRIFIÉ EN PROD (2026-06-12) — à garder en tête pour la cohérence :
//   • Sales Invoice : ~629 927 en docstatus=2 (ANNULÉES) → l'AR ERPNext est GELÉ
//     (les factures annulées ont outstanding=0 et n'impactent pas l'AR vivant).
//     3 factures « vivantes » récentes en série SINV-2026-NNNNNN.
//   • Payment Entry : 542 102, nommées PE-{id zfill10}, mode_of_payment peuplé,
//     paid_from='Comptes clients - T', paid_to='Banque - T', docstatus=1.
//     → importées par reimport_payments.py (zfill10 + MODE_MAP). max id = 542325.
//   • GL (3,1M) + PLE (1,06M) construits par clean_reimport.py (contre les
//     factures désormais annulées).
//   • F : 553 372 paiements max → delta ≈ 11k paiements nouveaux depuis le miroir.
//
// CONSÉQUENCE : « appliquer un paiement » ne peut PAS réduire un solde de facture
// ERPNext (factures annulées). La cohérence des SOLDES vit dans F (invoice.billed_amt).
// Ce module : (1) PREVIEW/réconciliation F↔ERPNext, (2) MIROIR du log de paiement
// (PE+PER) pour le delta, idempotent par PE-{id}, gardé. La couche GL/PLE/outstanding
// reste figée tant que la BASCULE de facturation vers ERPNext n'est pas décidée.
//
// Modèle de nommage (= clés d'idempotence, identiques à la migration) :
//   Payment Entry            PE-{legacy_payment_id zfill10}
//   Payment Entry Reference  PER-{legacy_payment_id}-{idx}  → SINV-{invoice_id zfill10}
//   Sales Invoice            SINV-{legacy_invoice_id zfill10}
// ─────────────────────────────────────────────────────────────────────────────
const fs = require('fs')
const path = require('path')
const cfg = require('./config')
const { json, log } = require('./helpers')
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-payments-preview.json')
const RECON_FILE = path.join(REPORT_DIR, 'legacy-payments-recon.json') // cache réconciliation (évite de rescanner F live)
const STATE_FILE = path.join(REPORT_DIR, 'legacy-payments-state.json')
const RUN_FILE = path.join(REPORT_DIR, 'legacy-payments-run.json') // dernier cycle (santé/observabilité)

// ── Legacy type → ERPNext mode_of_payment (IDENTIQUE à reimport_payments.py) ──
const MODE_MAP = {
  ppa: 'Bank Draft',
  'paiement direct': 'Bank Transfer',
  'carte credit': 'Credit Card',
  cheque: 'Cheque',
  comptant: 'Cash',
  reversement: 'Bank Transfer',
  credit: 'Credit Note',
  'credit targo': 'Credit Note',
  'credit facture': 'Credit Note',
}
const CREDIT_TYPES = new Set(['credit', 'credit targo', 'credit facture'])
const PAID_FROM = 'Comptes clients - T' // Receivable (idem migration)
const PAID_TO = 'Banque - T'           // Bank

const pad10 = (n) => String(n).padStart(10, '0')
const peName = (id) => 'PE-' + pad10(id)
const sinvName = (id) => 'SINV-' + pad10(id)
function modeFor (type) { return MODE_MAP[String(type || '').trim().toLowerCase()] || 'Bank Transfer' }
function tsToDate (t) { if (!t || t <= 0) return null; try { return new Date(Number(t) * 1000).toISOString().slice(0, 10) } catch { return null } }
function r2 (v) { return Math.round((Number(v) || 0) * 100) / 100 }

// ── pools ─────────────────────────────────────────────────────────────────────
function pgPool () { return require('./address-db').pool() }   // PG ERPNext (_eb65bdc0c4b1b2d6)
let _my
function myPool () {
  if (!mysql) return null
  if (!_my) _my = 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 _my
}
const pgq = (s, a) => pgPool().query(s, a).then(r => r.rows)

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-payments: state write fail', e.message) } }

// ── état de l'AR ERPNext (factures par docstatus) ────────────────────────────
async function arState () {
  const rows = await pgq('SELECT docstatus, COUNT(*) c FROM "tabSales Invoice" GROUP BY docstatus')
  const by = { draft: 0, submitted: 0, cancelled: 0 }
  for (const r of rows) { if (r.docstatus === 0) by.draft = Number(r.c); else if (r.docstatus === 1) by.submitted = Number(r.c); else if (r.docstatus === 2) by.cancelled = Number(r.c) }
  const frozen = by.submitted <= 5 && by.cancelled > 1000 // l'immense majorité annulée → AR gelé
  return { ...by, ar_frozen: frozen, note: frozen ? 'AR ERPNext GELÉ (factures migrées annulées) — F autoritaire sur les soldes' : 'AR ERPNext actif' }
}

// ── PREVIEW : delta de paiements F non encore reflétés dans ERPNext ───────────
// since = id de paiement plancher (défaut : MAX(legacy_payment_id) dans ERPNext).
async function previewPayments ({ since = null, limit = 20000, window = 0 } = {}) {
  const mp = myPool(); if (!mp) throw new Error('miroir legacy-db indisponible (mysql2 absent)')
  const erpMax = Number((await pgq('SELECT COALESCE(MAX(legacy_payment_id),0) mx FROM "tabPayment Entry"'))[0].mx) || 0
  // GAP-AWARE : on recule le plancher d'une fenêtre → on repère aussi les TROUS sous le max
  // (paiements sautés « sans client » résolus plus tard). Idempotent : les présents sont sautés.
  const sinceId = (since != null) ? Number(since) : Math.max(0, erpMax - Number(window || 0))
  const ar = await arState()

  const [prows] = await mp.query(
    'SELECT id, account_id, date_orig, amount, applied_amt, type, reference, memo FROM payment WHERE id > ? ORDER BY id LIMIT ?',
    [sinceId, limit + 1])
  const hasMore = prows.length > limit
  const payments = hasMore ? prows.slice(0, limit) : prows
  const ids = payments.map(p => p.id)

  // payment_item (allocations) du delta
  const itemsByPay = {}
  if (ids.length) {
    const [ir] = await mp.query(`SELECT payment_id, invoice_id, amount FROM payment_item WHERE payment_id IN (${ids.map(() => '?').join(',')})`, ids)
    for (const it of ir) (itemsByPay[it.payment_id] || (itemsByPay[it.payment_id] = [])).push(it)
  }

  // index Customer (par legacy_account_id) — seulement les comptes concernés
  const acctIds = [...new Set(payments.map(p => Number(p.account_id)))]
  const custMap = {}
  for (let i = 0; i < acctIds.length; i += 1000) {
    const b = acctIds.slice(i, i + 1000); if (!b.length) continue
    for (const x of await pgq('SELECT legacy_account_id, name, customer_name FROM "tabCustomer" WHERE legacy_account_id = ANY($1)', [b])) custMap[Number(x.legacy_account_id)] = x
  }

  // existence des factures cibles (par legacy_invoice_id) + docstatus
  const invIds = [...new Set(Object.values(itemsByPay).flat().map(it => Number(it.invoice_id)).filter(Boolean))]
  const invMap = {}
  for (let i = 0; i < invIds.length; i += 2000) {
    const b = invIds.slice(i, i + 2000); if (!b.length) continue
    for (const x of await pgq('SELECT legacy_invoice_id, name, docstatus FROM "tabSales Invoice" WHERE legacy_invoice_id = ANY($1)', [b])) invMap[Number(x.legacy_invoice_id)] = x
  }

  // PE déjà miroir (idempotence) pour le delta
  const existingPe = new Set()
  if (ids.length) for (const x of await pgq('SELECT legacy_payment_id FROM "tabPayment Entry" WHERE legacy_payment_id = ANY($1)', [ids])) existingPe.add(Number(x.legacy_payment_id))

  // ── classification ──
  const byType = {}; const issues = { no_customer: 0, unallocated: 0, alloc_missing_invoice: 0, alloc_to_cancelled: 0, credits: 0 }
  const custRoll = {}; let totalAmt = 0, wouldCreate = 0, alreadyMirror = 0
  const samples = { no_customer: [], missing_invoice: [], credits: [], to_create: [] }
  let minDate = null, maxDate = null

  for (const p of payments) {
    const type = String(p.type || '').trim().toLowerCase()
    const amt = r2(Math.abs(p.amount))
    const d = tsToDate(p.date_orig)
    if (d) { if (!minDate || d < minDate) minDate = d; if (!maxDate || d > maxDate) maxDate = d }
    byType[type] = byType[type] || { count: 0, amount: 0 }
    byType[type].count++; byType[type].amount = r2(byType[type].amount + amt)
    totalAmt = r2(totalAmt + amt)
    if (CREDIT_TYPES.has(type)) issues.credits++

    const cust = custMap[Number(p.account_id)]
    if (!cust) { issues.no_customer++; if (samples.no_customer.length < 15) samples.no_customer.push({ legacy_payment_id: p.id, account_id: p.account_id, amount: amt, type }); continue }

    const alloc = itemsByPay[p.id] || []
    let resolvedAlloc = 0, missingAlloc = 0, cancelledAlloc = 0
    const refs = []
    for (const it of alloc) {
      const inv = invMap[Number(it.invoice_id)]
      if (!inv) { missingAlloc++; if (samples.missing_invoice.length < 15) samples.missing_invoice.push({ legacy_payment_id: p.id, invoice_id: it.invoice_id }); continue }
      if (inv.docstatus === 2) cancelledAlloc++
      resolvedAlloc++
      refs.push({ reference_name: inv.name, allocated: r2(Math.abs(it.amount)), cancelled: inv.docstatus === 2 })
    }
    if (missingAlloc) issues.alloc_missing_invoice += missingAlloc
    if (cancelledAlloc) issues.alloc_to_cancelled += cancelledAlloc
    if (!alloc.length) issues.unallocated++

    if (existingPe.has(Number(p.id))) { alreadyMirror++; continue }
    wouldCreate++
    const cr = custRoll[cust.name] || (custRoll[cust.name] = { customer: cust.name, customer_name: cust.customer_name, count: 0, amount: 0 })
    cr.count++; cr.amount = r2(cr.amount + amt)
    if (samples.to_create.length < 25) samples.to_create.push({ pe: peName(p.id), date: d, amount: amt, type, mode: modeFor(type), customer_name: cust.customer_name, refs: refs.slice(0, 4), unallocated: !alloc.length })
  }

  const topCustomers = Object.values(custRoll).sort((a, b) => b.amount - a.amount).slice(0, 20)
  const out = {
    mode: 'PREVIEW (lecture seule — aucune écriture ni dans F ni dans ERPNext)',
    generated_at: new Date().toISOString(),
    ar_state: ar,
    watermark: { since_payment_id: sinceId, erp_max_payment_id: erpMax, source: (since != null ? 'override' : 'erp_max') },
    delta: {
      scanned: payments.length, has_more: hasMore, would_create: wouldCreate, already_mirrored: alreadyMirror,
      total_amount: totalAmt, date_range: { from: minDate, to: maxDate },
      by_type: Object.fromEntries(Object.entries(byType).map(([k, v]) => [k, { count: v.count, amount: v.amount, mode_of_payment: modeFor(k) }])),
    },
    issues,
    top_customers: topCustomers,
    samples,
    consistency_note: ar.ar_frozen
      ? "ERPNext AR gelé : le miroir ajoute le LOG de paiement (PE+PER) sans réduire de solde (factures annulées). Les SOLDES restent dans F (invoice.billed_amt). La bascule de facturation vers ERPNext est une décision séparée."
      : 'ERPNext AR actif : le miroir des paiements impacte les soldes.',
  }
  try { if (!fs.existsSync(REPORT_DIR)) fs.mkdirSync(REPORT_DIR, { recursive: true }); fs.writeFileSync(REPORT_FILE, JSON.stringify(out, null, 2)); out.saved_to = REPORT_FILE } catch (e) { log('legacy-payments: report write fail', e.message) }
  return out
}

// F invoice → statut ERPNext REFLÉTANT F (mirroir clean_reimport + fix_invoice_outstanding).
// docstatus=1 (submitted) car « on insère selon le statut qu'elles ont dans F ».
const TODAY = () => new Date().toISOString().slice(0, 10)
function mapInvoice (f, today) {
  const total = r2(f.total_amt); const billed = r2(f.billed_amt)
  const isReturn = total < 0
  const due = tsToDate(f.due_date) || tsToDate(f.date_orig)
  const owed = isReturn ? 0 : r2(Math.max(0, total - billed))
  let status
  if (isReturn) status = 'Return'
  else if (owed <= 0.005) status = 'Paid'
  else if (due && due < (today || TODAY())) status = 'Overdue'
  else status = 'Unpaid'
  return { sinv: sinvName(f.id), legacy_invoice_id: Number(f.id), is_return: isReturn ? 1 : 0, total, billed, outstanding: owed, status, docstatus: 1 }
}

// ── PREVIEW : delta de FACTURES F absentes d'ERPNext (le volet manquant) ──────
// Lecture seule. Quantifie les factures créées dans F depuis le gel de migration.
async function previewInvoices ({ since = null, limit = 30000 } = {}) {
  const mp = myPool(); if (!mp) throw new Error('miroir legacy-db indisponible')
  const erpMax = Number((await pgq('SELECT COALESCE(MAX(legacy_invoice_id),0) mx FROM "tabSales Invoice"'))[0].mx) || 0
  const sinceId = (since != null) ? Number(since) : erpMax
  const [rows] = await mp.query('SELECT id, account_id, date_orig, due_date, total_amt, billed_amt, billing_status FROM invoice WHERE id > ? ORDER BY id LIMIT ?', [sinceId, limit + 1])
  const hasMore = rows.length > limit
  const invs = hasMore ? rows.slice(0, limit) : rows
  const today = TODAY()
  const byStatus = { Paid: 0, Unpaid: 0, Overdue: 0, Return: 0 }

  const acctIds = [...new Set(invs.map(i => Number(i.account_id)))]
  const custMap = {}
  for (let i = 0; i < acctIds.length; i += 1000) { const b = acctIds.slice(i, i + 1000); if (!b.length) continue; for (const x of await pgq('SELECT legacy_account_id, name, customer_name FROM "tabCustomer" WHERE legacy_account_id = ANY($1)', [b])) custMap[Number(x.legacy_account_id)] = x }

  const byMonth = {}; const custRoll = {}
  let invoiced = 0, owed = 0, billed = 0, returns = 0, noCust = 0, unbilledCnt = 0
  let minD = null, maxD = null
  const sampleMapped = []
  for (const i of invs) {
    const tgt = mapInvoice(i, today); byStatus[tgt.status]++
    const tot = tgt.total; const ow = tgt.outstanding
    const d = tsToDate(i.date_orig); if (d) { if (!minD || d < minD) minD = d; if (!maxD || d > maxD) maxD = d }
    invoiced = r2(invoiced + tot); owed = r2(owed + ow); if (Number(i.billing_status) === 1) billed = r2(billed + tot)
    if (tot < 0) returns++
    if (Number(i.billing_status) !== 1) unbilledCnt++
    if (sampleMapped.length < 12) sampleMapped.push({ sinv: tgt.sinv, status: tgt.status, total: tgt.total, outstanding: tgt.outstanding, is_return: tgt.is_return })
    const mo = d ? d.slice(0, 7) : '?'; byMonth[mo] = byMonth[mo] || { count: 0, invoiced: 0, owed: 0 }
    byMonth[mo].count++; byMonth[mo].invoiced = r2(byMonth[mo].invoiced + tot); byMonth[mo].owed = r2(byMonth[mo].owed + ow)
    const cust = custMap[Number(i.account_id)]
    if (!cust) { noCust++; continue }
    const cr = custRoll[cust.name] || (custRoll[cust.name] = { customer: cust.name, customer_name: cust.customer_name, count: 0, owed: 0 })
    cr.count++; cr.owed = r2(cr.owed + ow)
  }
  return {
    mode: 'PREVIEW (lecture seule)', generated_at: new Date().toISOString(),
    watermark: { since_invoice_id: sinceId, erp_max_invoice_id: erpMax },
    delta: { scanned: invs.length, has_more: hasMore, total_invoiced: invoiced, total_billed: billed, total_owed: owed, returns, unbilled: unbilledCnt, no_customer: noCust, date_range: { from: minD, to: maxD } },
    target_status: byStatus, // statut ERPNext qui serait appliqué (reflète F)
    sample_mapped: sampleMapped,
    by_month: byMonth,
    top_customers_by_owed: Object.values(custRoll).sort((a, b) => b.owed - a.owed).slice(0, 20),
    note: "Factures F créées depuis le gel (id > max importé). Volet PRÉREQUIS au miroir des paiements : sans elles, les allocations de paiement restent orphelines.",
  }
}

// ── VUE D'ENSEMBLE : l'écart de facturation complet (factures + paiements) ────
async function billingGap () {
  const [inv, pay, ar] = await Promise.all([previewInvoices(), previewPayments(), arState()])
  return {
    generated_at: new Date().toISOString(),
    ar_state: ar,
    frozen_since: inv.delta.date_range.from,
    invoices_behind: { count: inv.delta.scanned, has_more: inv.delta.has_more, total_invoiced: inv.delta.total_invoiced, total_owed: inv.delta.total_owed, returns: inv.delta.returns, by_month: inv.by_month },
    payments_behind: { count: pay.delta.scanned, has_more: pay.delta.has_more, total_amount: pay.delta.total_amount, by_type: pay.delta.by_type, would_create: pay.delta.would_create, orphan_allocations: pay.issues.alloc_missing_invoice },
    note: 'F autoritaire. ERPNext = copie gelée depuis frozen_since. Rétablir les factures d\'abord rend les allocations de paiement résolvables.',
  }
}

// ── RÉCONCILIATION GLOBALE F ↔ ERPNext (« vérifier ce qu'on a déjà ») ─────────
// Agrégats BORNÉS sur F live (one-shot) + ERPNext, mis EN CACHE (RECON_FILE) pour
// ne JAMAIS rescanner F live à chaque appel. ?fresh=1 force un recalcul.
async function reconcileSummary ({ fresh = false } = {}) {
  if (!fresh) { try { const c = JSON.parse(fs.readFileSync(RECON_FILE, 'utf8')); c.from_cache = true; return c } catch {} }
  const mp = myPool(); if (!mp) throw new Error('miroir indisponible')

  // ── F live : agrégats indexés/one-shot (MVCC, non bloquant) ──
  const [[fi]] = await mp.query(`SELECT COUNT(*) c, MAX(id) mx,
     SUM(CASE WHEN total_amt < 0 THEN 1 ELSE 0 END) returns,
     SUM(CASE WHEN total_amt > billed_amt + 0.005 THEN 1 ELSE 0 END) open_cnt,
     ROUND(SUM(CASE WHEN total_amt > billed_amt THEN total_amt - billed_amt ELSE 0 END), 2) owed
     FROM invoice`)
  const [fbs] = await mp.query('SELECT billing_status, COUNT(*) c FROM invoice GROUP BY billing_status')
  const [[fp]] = await mp.query('SELECT COUNT(*) c, MAX(id) mx FROM payment')

  // ── ERPNext : ce qu'on a déjà ──
  const erpInv = (await pgq('SELECT docstatus, COUNT(*) c FROM "tabSales Invoice" GROUP BY docstatus')).reduce((o, r) => (o[r.docstatus] = Number(r.c), o), {})
  const erpInvLeg = (await pgq('SELECT COUNT(*) c, MAX(legacy_invoice_id) mx FROM "tabSales Invoice" WHERE legacy_invoice_id > 0'))[0]
  const erpPe = (await pgq('SELECT COUNT(*) c, MAX(legacy_payment_id) mx FROM "tabPayment Entry"'))[0]
  const ar = await arState()

  // ── divergence d'un échantillon : factures ERPNext annulées vs statut réel F ──
  const samp = await pgq(`SELECT legacy_invoice_id FROM "tabSales Invoice" WHERE docstatus=2 AND legacy_invoice_id>0 ORDER BY legacy_invoice_id DESC LIMIT 500`)
  let paidInF = 0, owedInF = 0, absentInF = 0; const owedSamples = []
  if (samp.length) {
    const ids = samp.map(r => Number(r.legacy_invoice_id))
    const [frows] = await mp.query(`SELECT id, total_amt, billed_amt, billing_status FROM invoice WHERE id IN (${ids.map(() => '?').join(',')})`, ids)
    const fmap = {}; for (const f of frows) fmap[f.id] = f
    for (const id of ids) { const f = fmap[id]; if (!f) { absentInF++; continue }
      const du = r2(Math.max(0, Number(f.total_amt) - Number(f.billed_amt)))
      if (du > 0.005) { owedInF++; if (owedSamples.length < 8) owedSamples.push({ sinv: sinvName(id), erp_status: 'Cancelled', f_owed: du, f_billing_status: f.billing_status }) } else paidInF++
    }
  }

  const fInvTotal = Number(fi.c); const erpCovered = Number(erpInvLeg.c)
  const out = {
    generated_at: new Date().toISOString(), from_cache: false,
    F: {
      invoices_total: fInvTotal, max_invoice_id: Number(fi.mx),
      by_billing_status: fbs.reduce((o, r) => (o[r.billing_status] = Number(r.c), o), {}),
      reversals: Number(fi.returns), open_invoices: Number(fi.open_cnt), real_AR_owed: Number(fi.owed),
      payments_total: Number(fp.c), max_payment_id: Number(fp.mx),
    },
    ERPNext: {
      invoices_by_docstatus: { draft: erpInv[0] || 0, submitted: erpInv[1] || 0, cancelled: erpInv[2] || 0 },
      invoices_with_legacy_id: erpCovered, max_legacy_invoice_id: Number(erpInvLeg.mx),
      payments: Number(erpPe.c), max_legacy_payment_id: Number(erpPe.mx),
      ar_frozen: ar.ar_frozen,
    },
    coverage: {
      invoices_in_F_not_in_ERPNext: fInvTotal - erpCovered,
      invoices_forward_gap: Math.max(0, Number(fi.mx) - Number(erpInvLeg.mx)), // id > max importé
      payments_forward_gap: Math.max(0, Number(fp.mx) - Number(erpPe.mx)),
    },
    divergence_sample: { of: samp.length, erp_cancelled_but_F_paid: paidInF, erp_cancelled_but_F_owed: owedInF, absent_in_F: absentInF, owed_examples: owedSamples },
    verdict: `F = vérité : ${Number(fi.open_cnt)} factures ouvertes, ${Number(fi.owed)}$ dus réellement. ERPNext reflète actuellement ~0 (toutes annulées). Pour refléter F : insérer/réactiver avec le statut F + outstanding=montant_du.`,
  }
  try { fs.writeFileSync(RECON_FILE, JSON.stringify(out, null, 2)); out.saved_to = RECON_FILE } catch (e) { log('recon write fail', e.message) }
  return out
}

// ── RÉCONCILIATION D'UN COMPTE : timeline factures vs paiements (F autoritaire) ──
// Lecture seule. Sert à VOIR la cohérence pour un client (ERPNext copie vs F vérité).
async function reconcileAccount ({ accountId = null, customer = null } = {}) {
  const mp = myPool(); if (!mp) throw new Error('miroir indisponible')
  let acct = accountId
  if (!acct && customer) {
    const r = await pgq('SELECT legacy_account_id FROM "tabCustomer" WHERE name=$1', [customer])
    acct = r[0] && Number(r[0].legacy_account_id)
  }
  if (!acct) throw new Error('accountId ou customer requis')
  const [invs] = await mp.query('SELECT id, date_orig, total_amt, billed_amt, billing_status, due_date FROM invoice WHERE account_id=? ORDER BY date_orig', [acct])
  const [pays] = await mp.query('SELECT id, date_orig, amount, type, reference FROM payment WHERE account_id=? ORDER BY date_orig', [acct])
  // état ERPNext de ces PE/SINV
  const peIds = pays.map(p => Number(p.id))
  const erpPe = new Set(); if (peIds.length) for (const x of await pgq('SELECT legacy_payment_id FROM "tabPayment Entry" WHERE legacy_payment_id = ANY($1)', [peIds])) erpPe.add(Number(x.legacy_payment_id))
  const f_balance = r2(invs.reduce((s, i) => s + Math.max(0, Number(i.total_amt) - Number(i.billed_amt)), 0))
  const f_invoiced = r2(invs.reduce((s, i) => s + Number(i.total_amt), 0))
  const f_paid = r2(pays.reduce((s, p) => s + Number(p.amount), 0))
  return {
    account_id: acct, customer,
    f_summary: { invoices: invs.length, payments: pays.length, total_invoiced: f_invoiced, total_paid: f_paid, balance_owed: f_balance },
    erp_mirror: { payments_in_erp: erpPe.size, payments_missing: pays.length - erpPe.size },
    invoices: invs.slice(0, 60).map(i => ({ id: i.id, sinv: sinvName(i.id), date: tsToDate(i.date_orig), total: r2(i.total_amt), billed: r2(i.billed_amt), owed: r2(Math.max(0, Number(i.total_amt) - Number(i.billed_amt))), paid_in_F: Number(i.billing_status) === 1 })),
    payments: pays.slice(0, 60).map(p => ({ id: p.id, pe: peName(p.id), date: tsToDate(p.date_orig), amount: r2(p.amount), type: p.type, mirrored_in_erp: erpPe.has(Number(p.id)) })),
    note: 'F est la source de vérité du solde (total_amt − billed_amt). ERPNext est une copie du LOG de paiement.',
  }
}

// ── APPLY (gardé) — MIROIR du log de paiement uniquement (PE + PER), idempotent ──
// N'écrit QUE si confirm==='F-WINS'. Reproduit EXACTEMENT le schéma des 542k PE
// existants (PE-{zfill10}, mode_of_payment, paid_from/paid_to, docstatus=1, refs
// vers SINV existantes). N'écrit PAS GL/PLE/outstanding (couche figée — bascule à décider).
// Sans confirm → dry-run : renvoie le plan (échantillon des lignes qui seraient créées).
async function syncPayments ({ confirm = false, since = null, limit = 5000, window = 0 } = {}) {
  const pre = await previewPayments({ since, limit, window })
  const plan = {
    mode: 'miroir LOG paiement (PE+PER) — GL/PLE/outstanding NON touchés',
    ar_frozen: pre.ar_state.ar_frozen,
    since_payment_id: pre.watermark.since_payment_id,
    scanned: pre.delta.scanned, would_create: pre.delta.would_create, already_mirrored: pre.delta.already_mirrored,
    total_amount: pre.delta.total_amount, by_type: pre.delta.by_type, issues: pre.issues,
    sample: pre.samples.to_create.slice(0, 10),
  }
  if (confirm !== 'F-WINS') {
    return { dry_run: true, ...plan, note: "POST { confirm:'F-WINS' } pour écrire le miroir (PE+PER). limit limite le lot. since force le plancher d'id." }
  }

  // ── écriture idempotente, transactionnelle ──
  const mp = myPool()
  const [prows] = await mp.query('SELECT id, account_id, date_orig, amount, type, reference, memo FROM payment WHERE id > ? ORDER BY id LIMIT ?', [pre.watermark.since_payment_id, limit])
  const ids = prows.map(p => p.id)
  const itemsByPay = {}
  if (ids.length) { const [ir] = await mp.query(`SELECT payment_id, invoice_id, amount FROM payment_item WHERE payment_id IN (${ids.map(() => '?').join(',')})`, ids); for (const it of ir) (itemsByPay[it.payment_id] || (itemsByPay[it.payment_id] = [])).push(it) }
  // index customer + factures existantes
  const acctIds = [...new Set(prows.map(p => Number(p.account_id)))]
  const custMap = {}
  for (let i = 0; i < acctIds.length; i += 1000) { const b = acctIds.slice(i, i + 1000); if (!b.length) continue; for (const x of await pgq('SELECT legacy_account_id, name, customer_name FROM "tabCustomer" WHERE legacy_account_id = ANY($1)', [b])) custMap[Number(x.legacy_account_id)] = x }
  const invIds = [...new Set(Object.values(itemsByPay).flat().map(it => Number(it.invoice_id)).filter(Boolean))]
  const invSet = new Set()
  for (let i = 0; i < invIds.length; i += 2000) { const b = invIds.slice(i, i + 2000); if (!b.length) continue; for (const x of await pgq('SELECT legacy_invoice_id FROM "tabSales Invoice" WHERE legacy_invoice_id = ANY($1)', [b])) invSet.add(Number(x.legacy_invoice_id)) }
  const existingPe = new Set()
  if (ids.length) for (const x of await pgq('SELECT legacy_payment_id FROM "tabPayment Entry" WHERE legacy_payment_id = ANY($1)', [ids])) existingPe.add(Number(x.legacy_payment_id))

  const client = await pgPool().connect()
  let ok = 0, skip = 0, refOk = 0, err = 0; const errors = []; let maxId = pre.watermark.since_payment_id
  try {
    for (const p of prows) {
      maxId = Math.max(maxId, Number(p.id))
      if (existingPe.has(Number(p.id))) { skip++; continue }
      const cust = custMap[Number(p.account_id)]
      if (!cust) { skip++; continue }
      const amt = r2(Math.abs(p.amount)); if (amt <= 0) { skip++; continue }
      const posting = tsToDate(p.date_orig) || new Date().toISOString().slice(0, 10)
      const mode = modeFor(p.type)
      const ref = String(p.reference || '').slice(0, 140)
      const memo = String(p.memo || '').slice(0, 140)
      try {
        await client.query('BEGIN')
        await client.query(
          `INSERT INTO "tabPayment Entry" (name, owner, creation, modified, modified_by, docstatus, posting_date, company,
             payment_type, party_type, party, party_name, paid_from, paid_from_account_currency, paid_to, paid_to_account_currency,
             paid_amount, base_paid_amount, received_amount, base_received_amount, source_exchange_rate, target_exchange_rate,
             mode_of_payment, reference_no, reference_date, status, remarks, legacy_payment_id)
           VALUES ($1,'Administrator',NOW(),NOW(),'Administrator',1,$2,'TARGO',
             'Receive','Customer',$3,$4,$5,'CAD',$6,'CAD',
             $7,$7,$7,$7,1,1,
             $8,$9,$2,'Submitted',$10,$11)
           ON CONFLICT (name) DO NOTHING`,
          [peName(p.id), posting, cust.name, cust.customer_name, PAID_FROM, PAID_TO, amt, mode, ref || null, memo || null, Number(p.id)])
        let j = 0
        for (const it of (itemsByPay[p.id] || [])) {
          if (!invSet.has(Number(it.invoice_id))) continue
          await client.query(
            `INSERT INTO "tabPayment Entry Reference" (name, owner, creation, modified, modified_by, docstatus, idx,
               parent, parentfield, parenttype, reference_doctype, reference_name, allocated_amount, exchange_rate)
             VALUES ($1,'Administrator',NOW(),NOW(),'Administrator',1,$2,$3,'references','Payment Entry','Sales Invoice',$4,$5,1)
             ON CONFLICT (name) DO NOTHING`,
            [`PER-${p.id}-${j}`, j + 1, peName(p.id), sinvName(it.invoice_id), r2(Math.abs(it.amount))])
          j++; refOk++
        }
        await client.query('COMMIT')
        ok++
      } catch (e) { await client.query('ROLLBACK'); err++; if (errors.length < 20) errors.push({ payment_id: p.id, err: String(e.message).slice(0, 200) }) }
    }
  } finally { client.release() }

  const st = loadState(); st.payment_max_id = Math.max(Number(st.payment_max_id) || 0, maxId); st.last_run = new Date().toISOString(); saveState(st)
  log(`legacy-payments syncPayments: ${ok} PE créés, ${refOk} refs, ${skip} ignorés, ${err} err, watermark→${maxId}`)
  return { applied: ok, references: refOk, skipped: skip, errors: err, error_samples: errors, watermark: st.payment_max_id, by_type: pre.delta.by_type }
}

// ── RAFRAÎCHIR LE SOLDE DES FACTURES VIVANTES OUVERTES (F autoritaire) ────────
// Borné + léger : ne recheck que les factures docstatus=1 Unpaid/Overdue (seules
// dont le statut peut basculer vers Paid quand F encaisse). Re-fetch F billed_amt
// par id (WHERE id IN ...). Met à jour outstanding + status d'après F. Sans confirm → dry-run.
async function refreshOpenInvoices ({ confirm = false, limit = 20000 } = {}) {
  const mp = myPool(); if (!mp) throw new Error('miroir indisponible')
  const open = await pgq(`SELECT name, legacy_invoice_id, grand_total, due_date FROM "tabSales Invoice"
     WHERE docstatus=1 AND legacy_invoice_id>0 AND status IN ('Unpaid','Overdue') LIMIT ${Number(limit)}`)
  if (!open.length) return { open: 0, would_update: 0, note: 'aucune facture vivante ouverte' }
  const ids = open.map(r => Number(r.legacy_invoice_id))
  const fmap = {}
  for (let i = 0; i < ids.length; i += 2000) {
    const b = ids.slice(i, i + 2000); if (!b.length) continue
    const [fr] = await mp.query(`SELECT id, total_amt, billed_amt FROM invoice WHERE id IN (${b.map(() => '?').join(',')})`, b)
    for (const f of fr) fmap[f.id] = f
  }
  const td = TODAY(); const changes = []
  for (const inv of open) {
    const f = fmap[Number(inv.legacy_invoice_id)]; if (!f) continue
    const owed = r2(Math.max(0, Number(f.total_amt) - Number(f.billed_amt)))
    const due = (inv.due_date && String(inv.due_date).slice(0, 10)) || td
    let status = owed <= 0.005 ? 'Paid' : (due < td ? 'Overdue' : 'Unpaid')
    if (Math.abs(owed - r2(inv.grand_total)) < 0.005 && owed > 0) { /* still fully open */ }
    const curOwed = null // we don't have current outstanding in this query; update unconditionally if status/owed differ
    changes.push({ name: inv.name, owed, status })
  }
  if (confirm !== 'F-WINS') return { dry_run: true, open: open.length, candidates: changes.length, sample: changes.slice(0, 8) }
  const client = await pgPool().connect(); let upd = 0
  try {
    for (const c of changes) {
      const r = await client.query('UPDATE "tabSales Invoice" SET outstanding_amount=$1, status=$2 WHERE name=$3 AND docstatus=1 AND (ROUND(outstanding_amount::numeric,2)<>$1 OR status<>$2)', [c.owed, c.status, c.name])
      upd += r.rowCount
    }
  } finally { client.release() }
  log(`legacy-payments refreshOpenInvoices: ${upd} factures mises à jour (sur ${open.length} ouvertes)`)
  return { open: open.length, updated: upd }
}

// ── FERMER LA CHAÎNE : créer les Customers manquants derrière la facturation ──
// Racine des « sans client » : des comptes F (recent billing) absents d'ERPNext.
// Création CIBLÉE (seulement les comptes ayant des factures/paiements non synchronisés
// dans la fenêtre) via erp.create (REST → naming/validations/defaults gérés), idempotent
// par legacy_account_id, en réutilisant mapAccount de legacy-sync (même mapping que #62).
// Délègue au créateur CANONIQUE (legacy-sync.createCustomersByIds) — un seul chemin de création.
// Ici on ne fait que CIBLER : les comptes référencés par la facturation récente (fenêtre) absents d'ERPNext.
async function ensureBillingCustomers ({ confirm = false, window = 20000, limit = 1000 } = {}) {
  const mp = myPool(); if (!mp) throw new Error('miroir indisponible')
  const invMax = Number((await pgq('SELECT COALESCE(MAX(legacy_invoice_id),0) mx FROM "tabSales Invoice"'))[0].mx) || 0
  const payMax = Number((await pgq('SELECT COALESCE(MAX(legacy_payment_id),0) mx FROM "tabPayment Entry"'))[0].mx) || 0
  const [ia] = await mp.query('SELECT DISTINCT account_id FROM invoice WHERE id > ?', [Math.max(0, invMax - Number(window))])
  const [pa] = await mp.query('SELECT DISTINCT account_id FROM payment WHERE id > ?', [Math.max(0, payMax - Number(window))])
  const acctIds = [...new Set([...ia, ...pa].map(r => Number(r.account_id)).filter(Boolean))]
  const res = await require('./legacy-sync').createCustomersByIds({ ids: acctIds, confirm, limit }) // créateur canonique
  return { referenced: acctIds.length, ...res }
}

// ── CYCLE DE SYNC (paiements + rafraîchissement soldes) — pour le cron ────────
// Note : l'insertion des FACTURES (Python sync_invoices_incremental.py) tourne en amont
// dans le même cycle cron (cf. /opt/targo-sync/run.sh). Ici : volet hub (paiements + soldes).
async function syncCycle ({ confirm = false, limit = 20000, window = 5000 } = {}) {
  const pay = await syncPayments({ confirm, limit, window }) // window → comble les trous récents (numéros sautés)
  const refresh = await refreshOpenInvoices({ confirm, limit })
  const out = { ran_at: new Date().toISOString(), payments: pay, open_refresh: refresh }
  if (confirm === 'F-WINS') { try { fs.writeFileSync(RUN_FILE, JSON.stringify({ ran_at: out.ran_at, payments_applied: pay.applied || 0, references: pay.references || 0, refreshed: refresh.updated || 0, errors: pay.errors || 0 }, null, 2)) } catch (e) {} }
  return out
}

// ── ÉTAT DE COHÉRENCE (santé) — instantané, léger (MAX(id) indexé + agrégats PG) ──
// Pour un tableau de bord : où en est ERPNext vs F + dernier cycle. Ne scanne PAS F.
async function coherenceStatus () {
  const mp = myPool(); if (!mp) throw new Error('miroir indisponible')
  const erp = (await pgq(`SELECT
     (SELECT COALESCE(MAX(legacy_invoice_id),0) FROM "tabSales Invoice") inv_max,
     (SELECT COUNT(*) FROM "tabSales Invoice" WHERE docstatus=1 AND legacy_invoice_id>638609) inv_live,
     (SELECT COALESCE(SUM(outstanding_amount),0) FROM "tabSales Invoice" WHERE docstatus=1 AND status IN ('Unpaid','Overdue')) ar_open,
     (SELECT COALESCE(MAX(legacy_payment_id),0) FROM "tabPayment Entry") pay_max,
     (SELECT COUNT(*) FROM "tabPayment Entry") pay_count`))[0]
  const [[fi]] = await mp.query('SELECT MAX(id) mx FROM invoice')   // instantané (PK)
  const [[fp]] = await mp.query('SELECT MAX(id) mx FROM payment')
  const lagInv = Number(fi.mx) - Number(erp.inv_max)
  const lagPay = Number(fp.mx) - Number(erp.pay_max)
  let lastRun = null; try { lastRun = JSON.parse(fs.readFileSync(RUN_FILE, 'utf8')) } catch {}
  const ranAgoMin = lastRun && lastRun.ran_at ? Math.round((Date.now() - Date.parse(lastRun.ran_at)) / 60000) : null
  return {
    checked_at: new Date().toISOString(),
    invoices: { f_max_id: Number(fi.mx), erp_max_synced: Number(erp.inv_max), lag: lagInv, live_synced: Number(erp.inv_live), ar_open: r2(erp.ar_open) },
    payments: { f_max_id: Number(fp.mx), erp_max_synced: Number(erp.pay_max), lag: lagPay, total: Number(erp.pay_count) },
    last_cycle: lastRun, last_cycle_min_ago: ranAgoMin,
    healthy: ranAgoMin != null && ranAgoMin < 90, // cron horaire → < 90 min = sain
    note: 'lag = numéros F créés depuis le dernier cycle (se résorbe à l\'heure suivante). Cron horaire :15.',
  }
}

// ── routes HTTP (gated ; preview = sûr) ───────────────────────────────────────
const { parseBody } = require('./helpers')
async function handle (req, res, method, p, url) {
  if (p === '/legacy-payments/preview' && method === 'GET') {
    try { const since = url && url.searchParams.get('since'); const limit = url && url.searchParams.get('limit'); return json(res, 200, await previewPayments({ since: since != null ? Number(since) : null, limit: limit ? Number(limit) : 20000 })) }
    catch (e) { log('legacy-payments preview error:', e.message); return json(res, 500, { error: e.message }) }
  }
  if (p === '/legacy-payments/report' && method === 'GET') {
    try { return json(res, 200, JSON.parse(fs.readFileSync(REPORT_FILE, 'utf8'))) } catch { return json(res, 404, { error: 'aucun rapport — lancer /legacy-payments/preview' }) }
  }
  if (p === '/legacy-payments/invoices-preview' && method === 'GET') {
    try { const since = url && url.searchParams.get('since'); const limit = url && url.searchParams.get('limit'); return json(res, 200, await previewInvoices({ since: since != null ? Number(since) : null, limit: limit ? Number(limit) : 30000 })) }
    catch (e) { log('legacy-payments invoices-preview error:', e.message); return json(res, 500, { error: e.message }) }
  }
  if (p === '/legacy-payments/billing-gap' && method === 'GET') {
    try { return json(res, 200, await billingGap()) } catch (e) { return json(res, 500, { error: e.message }) }
  }
  if (p === '/legacy-payments/reconcile-summary' && method === 'GET') {
    try { const fresh = url && (url.searchParams.get('fresh') === '1'); return json(res, 200, await reconcileSummary({ fresh })) } catch (e) { return json(res, 500, { error: e.message }) }
  }
  if (p === '/legacy-payments/reconcile' && method === 'GET') {
    try { const acct = url && url.searchParams.get('account'); const cust = url && url.searchParams.get('customer'); return json(res, 200, await reconcileAccount({ accountId: acct ? Number(acct) : null, customer: cust || null })) }
    catch (e) { return json(res, 500, { error: e.message }) }
  }
  if (p === '/legacy-payments/run' && method === 'POST') {
    try { const b = await parseBody(req); return json(res, 200, await syncPayments({ confirm: b.confirm, since: b.since != null ? Number(b.since) : null, limit: b.limit ? Number(b.limit) : 5000 })) }
    catch (e) { return json(res, 500, { error: e.message }) }
  }
  if (p === '/legacy-payments/ensure-customers' && method === 'POST') {
    try { const b = await parseBody(req); return json(res, 200, await ensureBillingCustomers({ confirm: b.confirm, window: b.window ? Number(b.window) : 20000, limit: b.limit ? Number(b.limit) : 1000 })) } catch (e) { return json(res, 500, { error: e.message }) }
  }
  if (p === '/legacy-payments/refresh-open' && method === 'POST') {
    try { const b = await parseBody(req); return json(res, 200, await refreshOpenInvoices({ confirm: b.confirm, limit: b.limit ? Number(b.limit) : 20000 })) } catch (e) { return json(res, 500, { error: e.message }) }
  }
  if (p === '/legacy-payments/sync-cycle' && method === 'POST') {
    try { const b = await parseBody(req); return json(res, 200, await syncCycle({ confirm: b.confirm, limit: b.limit ? Number(b.limit) : 20000 })) } catch (e) { return json(res, 500, { error: e.message }) }
  }
  if (p === '/legacy-payments/coherence' && method === 'GET') {
    try { return json(res, 200, await coherenceStatus()) } catch (e) { return json(res, 500, { error: e.message }) }
  }
  if (p === '/legacy-payments/state' && method === 'GET') return json(res, 200, loadState())
  return json(res, 404, { error: 'route legacy-payments inconnue' })
}

module.exports = { handle, previewPayments, previewInvoices, billingGap, reconcileSummary, reconcileAccount, syncPayments, refreshOpenInvoices, ensureBillingCustomers, syncCycle, coherenceStatus, arState, MODE_MAP, modeFor, peName, sinvName }
