'use strict'
/**
 * legacy-reports.js — analytical reports that query the LEGACY MariaDB
 * (gestionclient) directly, read-only. Distinct from lib/reports.js which
 * targets the migrated ERPNext PostgreSQL data.
 *
 * Connection reuses cfg.LEGACY_DB_* (same vars as auth.js sync-legacy).
 * A small pool is kept alive across requests for snappy report loads.
 *
 * Routes:
 *   GET /reports/legacy/overpriced-internet?threshold=90&segment=residential&addons=1
 *       → service addresses whose net MONTHLY Internet bill exceeds a
 *         threshold, discounts included. Used by Ops to spot clients who
 *         could be moved to a better-fit plan.
 *   GET /reports/legacy/overpriced-internet.csv  (same params)
 *
 * Data model recap (validated 2026-06):
 *   account (commercial: 0=residential, 1=commercial)
 *     → delivery (account_id)              one row per service address
 *       → service (delivery_id, status=1)  active service line
 *         → product (category, price)      the plan; price<0 = recurring discount
 *
 *   Account-level filters (mirror the legacy recurring-billing logic in
 *   LEGACY-ACCOUNTING-ANALYSIS.md §6.1, which bills only when BOTH
 *   service.status=1 AND account.status=1):
 *     account.status = 1     → active account. status=4 is terminated
 *                              (8602 accounts, most with a terminate_date,
 *                              e.g. Or Viande Inc closed in 2014 but still
 *                              carrying an orphan service.status=1 line).
 *     account.group_id = 5   → "Client" per the account_group table. Excludes
 *                              6 Prospect, 7 Fournisseur, 8 Relais (network
 *                              infrastructure accounts like a tower-host),
 *                              10 Équipement motorisé — none are billable
 *                              residential/commercial customers.
 *     customer_id NOT LIKE   → 59 "PROPRIO*" accounts sit inside group 5 but
 *       'PROPRIO%'             are landowners hosting our relay/antenna gear
 *                              under a special arrangement (e.g. Denis
 *                              Henderson "PROPRIOH_STCHARLES"), not regular
 *                              paying customers. Excluded by their id prefix.
 *
 *   Effective monthly price of a service line:
 *     base   = service.hijack=1 ? service.hijack_price : product.price
 *     // product.price is ALREADY the monthly unit price regardless of the
 *     // service's billing frequency — a semestrial FTTH1500I has price
 *     // 109.95 (same as the monthly one) and is billed 6×109.95 every 6
 *     // months. Verified against the legacy billing logic (LEGACY-
 *     // ACCOUNTING-ANALYSIS.md §6.1: "prix = quantité × prix_unitaire").
 *     // So we do NOT divide by service.payment_recurrence.
 *     // The one exception: true annual plans (SKU ends in "ANN", e.g.
 *     // FTTH_ANN @ 480$/yr where price IS the yearly amount) → normalize /12.
 *     monthly = base / (sku LIKE '%ANN' ? 12 : 1)
 *
 *   Only recurring lines count: product.price_recurr_type = 1. Type 0 is
 *   one-time charges (equipment, installation) which don't belong on a
 *   recurring monthly bill.
 *
 *   Expired credits excluded: a discount line (price<0) whose service
 *   actif_until is in the past no longer reduces the real bill — counting
 *   it would understate what the client actually pays today. (Per
 *   LEGACY-ACCOUNTING-ANALYSIS.md §10: promo credits carry an actif_until
 *   end date.)
 *
 *   Internet product categories:
 *     monthly:   32 Mensualités fibre, 4 Mensualités sans-fil, 23 camping
 *     equipment: 26/29 équipement fibre, 7/8 équipement sans-fil
 *                (recurring modem/router rentals + Internet discounts that
 *                 live here, e.g. RAB_FTTH_URBA). One-time install charges
 *                 in these cats are dropped by the price_recurr_type=1 filter.
 *     add-ons:   16 Téléch. supp, 17 IP fixe, 21 Location point-à-point
 *   Excluded entirely: 9 Téléphonie, 33 Télévision, 34 Install télé
 */

const cfg = require('./config')
const { log, json } = require('./helpers')
const muni = require('./municipality') // résolveur de villes canoniques QC (nom officiel + code + MRC)

let mysql
try { mysql = require('mysql2/promise') } catch { /* optional dep */ }

// Internet category sets. CORE = monthly plans + Internet equipment.
// The equipment categories (26/29 fibre, 7/8 wireless) carry recurring
// modem/router rentals (FTTH_LOCMOD +10, LOC_TPL +5) AND recurring Internet
// discounts (RAB_FTTH_URBA), which are part of the real Internet bill — a
// client's net Internet cost is wrong without them. Example: Claude Bergeron
// shows 94.95 from cat 32 alone, but a -60$ RAB_FTTH_URBA discount lives in
// cat 26, so his true net is 44.95 (and he correctly drops off the report).
// The price_recurr_type=1 filter still excludes one-time install charges
// (INSTFIBRE -199, etc.) that share these equipment categories.
const CAT_INTERNET_CORE = [32, 4, 23, 26, 29, 7, 8]
const CAT_INTERNET_ADDONS = [16, 17, 21]   // extra download, static IP, point-to-point
// Always excluded: 9 Téléphonie, 33 Télévision, 34 Installation/équip télé.

// Monthly-normalized effective price expression (see header comment).
const EFF = `((CASE WHEN s.hijack=1 THEN s.hijack_price ELSE p.price END) / (CASE WHEN p.sku LIKE '%ANN' THEN 12 ELSE 1 END))`

let _pool = null
function getPool () {
  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: 3, waitForConnections: true, queueLimit: 0,
      idleTimeout: 60000, enableKeepAlive: true,
    })
  }
  return _pool
}

// Build the shared SQL + params from the request's query string. Used by
// both the JSON and CSV handlers so the two never drift apart.
function buildQuery (url) {
  const threshold = Math.max(0, parseFloat(url.searchParams.get('threshold') || '90'))
  const segment = url.searchParams.get('segment') || 'residential'   // residential | commercial | all
  const includeAddons = url.searchParams.get('addons') !== '0'
  const limit = Math.min(5000, parseInt(url.searchParams.get('limit') || '2000', 10))

  const cats = includeAddons ? [...CAT_INTERNET_CORE, ...CAT_INTERNET_ADDONS] : CAT_INTERNET_CORE
  const catPlaceholders = cats.map(() => '?').join(',')

  // A non-empty company name means a business even when the legacy `commercial`
  // flag wasn't set — 98 such accounts (Ferme X Inc., Assurances …, Ville de
  // Farnham, Les Jardins Sorel) leaked into "residential". So the rule is:
  // commercial = (commercial flag set) OR (company name present). Residential
  // therefore excludes any account carrying a company name.
  const HAS_COMPANY = "(a.company IS NOT NULL AND TRIM(a.company) <> '')"
  let commercialClause = ''
  if (segment === 'residential') commercialClause = `AND a.commercial = 0 AND NOT ${HAS_COMPANY}`
  else if (segment === 'commercial') commercialClause = `AND (a.commercial = 1 OR ${HAS_COMPANY})`

  const sql = `
    SELECT
      a.id            AS account_id,
      a.customer_id   AS customer_id,
      TRIM(CONCAT(COALESCE(a.first_name,''),' ',COALESCE(a.last_name,''))) AS client_name,
      a.company       AS company,
      a.email         AS email,
      a.cell          AS cell,
      a.tel_home      AS tel_home,
      a.commercial    AS commercial,
      d.id            AS delivery_id,
      d.address1      AS address1,
      d.city          AS city,
      d.zip           AS zip,
      ROUND(SUM(${EFF}), 2)                                          AS net_internet,
      ROUND(SUM(CASE WHEN (${EFF}) > 0 THEN (${EFF}) ELSE 0 END), 2) AS gross,
      ROUND(SUM(CASE WHEN (${EFF}) < 0 THEN (${EFF}) ELSE 0 END), 2) AS discounts,
      COUNT(*)        AS n_lines,
      SUBSTRING(GROUP_CONCAT(
        CONCAT(p.sku, '=', FORMAT(${EFF}, 2))
        ORDER BY (${EFF}) DESC SEPARATOR ' · '
      ), 1, 500)      AS detail,
      -- Recently-expired discounts on this address: deactivated credit
      -- lines (status=0, price<0) whose actif_until fell in the last 180
      -- days. Explains a sudden bill jump (e.g. Julie Dupuis lost
      -- RAB24M -15 + RAB_X -35 on 2026-03-01 → bill went 103→145$). The
      -- prime retention signal — these clients are about to see the
      -- increase and may shop around.
      (SELECT ROUND(SUM(CASE WHEN s2.hijack=1 THEN s2.hijack_price ELSE p2.price END), 2)
         FROM service s2 JOIN product p2 ON p2.id = s2.product_id
        WHERE s2.delivery_id = d.id AND s2.status = 0 AND p2.price < 0
          AND s2.actif_until IS NOT NULL
          AND s2.actif_until < UNIX_TIMESTAMP()
          AND s2.actif_until > (UNIX_TIMESTAMP() - 15552000)
      ) AS recent_expired_discount
    FROM account a
    JOIN delivery d ON d.account_id = a.id
    JOIN service  s ON s.delivery_id = d.id AND s.status = 1
    JOIN product  p ON p.id = s.product_id
    WHERE p.category IN (${catPlaceholders})
      AND a.status = 1
      AND a.group_id = 5
      AND a.customer_id NOT LIKE 'PROPRIO%'
      AND p.price_recurr_type = 1
      AND NOT (p.price < 0 AND s.actif_until IS NOT NULL AND s.actif_until > 0 AND s.actif_until < UNIX_TIMESTAMP())
      ${commercialClause}
    GROUP BY d.id
    HAVING net_internet > ?
    ORDER BY net_internet DESC
    LIMIT ?`

  const params = [...cats, threshold, limit]
  return { sql, params, meta: { threshold, segment, includeAddons, cats, limit } }
}

// Freshness probe — the copy is a one-shot snapshot (no auto-sync), so the
// report advertises its as-of date so nobody acts on stale prices.
//
// This report reads SERVICES (active plans/discounts), not invoices, so the
// relevant freshness is the newest service.date_orig — NOT the newest
// invoice date. They differ: the snapshot carries services created up to
// ~May 22 but invoices only through Apr 30 (May's recurring billing run
// hadn't happened at dump time). Using the invoice date understated
// freshness by ~3 weeks. We return both; the UI shows the service one.
// (A discount added after the snapshot — e.g. Marc Robidoux's loyalty
// credit, service id 74448 > the copy's max 74393 — still won't appear
// until the copy is refreshed.)
async function fetchDataAsOf (pool) {
  const out = { services: null, invoices: null }
  try {
    const [s] = await pool.execute('SELECT MAX(date_orig) AS max_ts FROM service')
    if (s?.[0]?.max_ts) out.services = new Date(s[0].max_ts * 1000).toISOString()
  } catch { /* ignore */ }
  try {
    const [i] = await pool.execute('SELECT MAX(date_orig) AS max_ts FROM invoice')
    if (i?.[0]?.max_ts) out.invoices = new Date(i[0].max_ts * 1000).toISOString()
  } catch { /* ignore */ }
  return out
}

async function handleJson (req, res, url) {
  const pool = getPool()
  if (!pool) return json(res, 503, { error: 'mysql2 not installed on hub' })
  const { sql, params, meta } = buildQuery(url)
  try {
    const t0 = Date.now()
    const [rows] = await pool.execute(sql, params)
    const asOf = await fetchDataAsOf(pool)
    log(`legacy report overpriced-internet: ${rows.length} rows in ${Date.now() - t0}ms (threshold=${meta.threshold}, segment=${meta.segment}, addons=${meta.includeAddons})`)
    return json(res, 200, {
      threshold: meta.threshold, segment: meta.segment,
      include_addons: meta.includeAddons, categories: meta.cats,
      // data_as_of = service freshness (the relevant one for this report);
      // last_invoice kept for reference.
      data_as_of: asOf.services,
      last_invoice: asOf.invoices,
      count: rows.length, rows,
    })
  } catch (e) {
    log(`legacy report error: ${e.message}`)
    return json(res, 500, { error: 'legacy query failed: ' + e.message })
  }
}

async function handleCsv (req, res, url) {
  const pool = getPool()
  if (!pool) return json(res, 503, { error: 'mysql2 not installed on hub' })
  const { sql, params, meta } = buildQuery(url)
  try {
    const [rows] = await pool.execute(sql, params)
    const headers = ['account_id', 'customer_id', 'client_name', 'company', 'email', 'cell', 'tel_home',
      'address1', 'city', 'zip', 'net_internet', 'gross', 'discounts', 'recent_expired_discount', 'n_lines', 'detail']
    const esc = (v) => {
      if (v == null) return ''
      const s = String(v)
      return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s
    }
    const lines = [headers.join(',')]
    for (const r of rows) lines.push(headers.map(h => esc(r[h])).join(','))
    const csv = '﻿' + lines.join('\r\n') + '\r\n'
    res.writeHead(200, {
      'Content-Type': 'text/csv; charset=utf-8',
      'Content-Disposition': `attachment; filename="internet-cher-${meta.segment}-${meta.threshold}.csv"`,
      'Cache-Control': 'no-store',
    })
    return res.end(csv)
  } catch (e) {
    return json(res, 500, { error: 'legacy query failed: ' + e.message })
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// EXPLORATEUR DE REVENUS — un seul rapport « revenu récurrent (MRR) par X »,
// où X (la DIMENSION) est choisie côté client. Remplace les rapports F fixes
// séparés (report_client_revenu / rapport_ville / report_count_service /
// report_count_res_com / rapport_poste …) par un group-by paramétrable.
//
//   GET /reports/legacy/revenue-explorer
//       ?dimension = category | zip | city | segment | plan   (défaut category)
//       &segment   = residential | commercial | all           (défaut all)
//       &type      = internet | tv | phone | all              (défaut all)
//       &active    = 1 (défaut) | 0
//       &threshold = 0   (net MRR minimum par groupe pour l'inclure)
//       &limit     = 300
//   GET /reports/legacy/revenue-explorer.csv  (mêmes params)
//
// Métrique = MRR net = Σ prix effectif mensuel (EFF) des lignes de service
// récurrentes actives (mêmes exclusions que le rapport internet-cher :
// group_id=5, pas PROPRIO, price_recurr_type=1, rabais expirés écartés).
// ─────────────────────────────────────────────────────────────────────────────

// Noms de catégories F (product.category est numérique). Source : en-tête de ce
// fichier + LEGACY-ACCOUNTING-ANALYSIS. Inconnues → « Catégorie N ».
const CATEGORY_NAMES = {
  32: 'Mensualités fibre', 4: 'Mensualités sans fil', 23: 'Internet camping',
  26: 'Équipement internet fibre', 29: 'Équipement internet fibre',
  7: 'Équipement internet sans fil', 8: 'Équipement internet sans fil',
  16: 'Téléchargement supplémentaire', 17: 'Adresse IP fixe', 21: 'Location point à point',
  9: 'Téléphonie', 33: 'Mensualités télévision', 34: 'Installation/équipement télé',
}

// Exceptions de libellé de ville (« fichier de traduction ») : quand le nom officiel/dominant
// n'est pas celui qu'on veut afficher. Clé = libellé calculé, valeur = libellé voulu. À enrichir au besoin.
const CITY_LABEL_OVERRIDE = {
  'Sainte-Clotilde-de-Châteauguay': 'Sainte-Clotilde',
}

const HAS_COMPANY = "(a.company IS NOT NULL AND TRIM(a.company) <> '')"
const EXPLORER_DIMS = {
  category: 'p.category',
  zip: "COALESCE(NULLIF(TRIM(d.zip),''),'(sans code postal)')",
  city: "COALESCE(NULLIF(TRIM(d.city),''),'(sans ville)')",
  segment: `CASE WHEN (a.commercial = 1 OR ${HAS_COMPANY}) THEN 'Commercial' ELSE 'Résidentiel' END`,
  plan: "COALESCE(NULLIF(TRIM(p.name),''),p.sku,'(sans nom)')",
}
const DIM_LABELS = { category: 'Catégorie', zip: 'Code postal', city: 'Ville', segment: 'Segment', plan: 'Plan', mrc: 'MRC' }
// city + mrc : regroupés en SQL par d.city (ville brute), PUIS canonicalisés/agrégés en JS via le résolveur muni
// (unifie « Saint-Anicet » / « St-Anicet » / « Sainte Clotilde » / « Ste-Clotilde »… → un seul nom officiel).
const VALID_DIMS = ['category', 'zip', 'city', 'segment', 'plan', 'mrc']
const TYPE_CATS = {
  internet: [...CAT_INTERNET_CORE, ...CAT_INTERNET_ADDONS],
  tv: [33, 34],
  phone: [9],
}

function buildExplorerQuery (url) {
  const dimParam = url.searchParams.get('dimension')
  const dimension = VALID_DIMS.includes(dimParam) ? dimParam : 'category'
  const dimCol = EXPLORER_DIMS[dimension === 'mrc' ? 'city' : dimension]  // mrc/city → SQL par ville, canonicalisé en JS
  const segment = url.searchParams.get('segment') || 'all'
  const type = url.searchParams.get('type') || 'all'
  const active = url.searchParams.get('active') !== '0'
  const threshold = Math.max(0, parseFloat(url.searchParams.get('threshold') || '0') || 0)
  const limit = Math.min(1000, parseInt(url.searchParams.get('limit') || '300', 10))

  let segmentClause = ''
  if (segment === 'residential') segmentClause = `AND a.commercial = 0 AND NOT ${HAS_COMPANY}`
  else if (segment === 'commercial') segmentClause = `AND (a.commercial = 1 OR ${HAS_COMPANY})`

  const typeCats = TYPE_CATS[type] || null
  const typeClause = typeCats ? `AND p.category IN (${typeCats.map(() => '?').join(',')})` : ''

  const WHERE = `
    FROM account a
    JOIN delivery d ON d.account_id = a.id
    JOIN service  s ON s.delivery_id = d.id ${active ? 'AND s.status = 1' : ''}
    JOIN product  p ON p.id = s.product_id
    WHERE a.group_id = 5
      AND a.customer_id NOT LIKE 'PROPRIO%'
      ${active ? 'AND a.status = 1' : ''}
      AND p.price_recurr_type = 1
      AND NOT (p.price < 0 AND s.actif_until IS NOT NULL AND s.actif_until > 0 AND s.actif_until < UNIX_TIMESTAMP())
      ${typeClause} ${segmentClause}`

  const sql = `
    SELECT ${dimCol} AS dim_key,
      ROUND(SUM(${EFF}), 2)                                          AS net,
      ROUND(SUM(CASE WHEN (${EFF}) > 0 THEN (${EFF}) ELSE 0 END), 2) AS gross,
      ROUND(SUM(CASE WHEN (${EFF}) < 0 THEN (${EFF}) ELSE 0 END), 2) AS discounts,
      COUNT(DISTINCT d.id) AS n_addresses,
      COUNT(*)             AS n_lines
    ${WHERE}
    GROUP BY dim_key
    ${threshold > 0 ? 'HAVING net >= ?' : ''}
    ORDER BY net DESC
    LIMIT ?`
  const totalSql = `
    SELECT ROUND(SUM(${EFF}), 2) AS net,
      ROUND(SUM(CASE WHEN (${EFF}) > 0 THEN (${EFF}) ELSE 0 END), 2) AS gross,
      ROUND(SUM(CASE WHEN (${EFF}) < 0 THEN (${EFF}) ELSE 0 END), 2) AS discounts,
      COUNT(DISTINCT d.id) AS n_addresses, COUNT(*) AS n_lines
    ${WHERE}`

  const baseParams = typeCats ? [...typeCats] : []
  const params = [...baseParams]
  if (threshold > 0) params.push(threshold)
  params.push(limit)
  return { sql, params, totalSql, totalParams: baseParams, meta: { dimension, segment, type, active, threshold, limit } }
}

function labelOf (meta, key) {
  return meta.dimension === 'category' ? (CATEGORY_NAMES[key] || ('Catégorie ' + key)) : String(key)
}
function round2 (x) { return Math.round((Number(x) || 0) * 100) / 100 }

// Finalise les groupes. Pour city/mrc : canonicalise chaque ville brute via le résolveur muni
// (nom officiel QC + MRC) et RÉAGRÈGE — les variantes d'un même nom fusionnent en une seule ligne.
// Pour les autres dimensions : applique simplement le libellé.
function titleCase (s) {
  return String(s || '').toLowerCase().replace(/\b([a-zàâäéèêëîïôöùûüç])/g, c => c.toUpperCase())
}
async function finalizeRows (rows, meta) {
  if (meta.dimension !== 'city' && meta.dimension !== 'mrc') {
    return rows.map(r => ({ ...r, dim_label: labelOf(meta, r.dim_key) }))
  }
  try { await muni.load() } catch { /* résolveur indispo → on garde les villes brutes */ }
  const merged = {}
  for (const r of rows) {
    let res = null
    try { res = muni.resolveSync(r.dim_key) } catch { res = null }
    const raw = String(r.dim_key || '').trim()
    let key
    if (meta.dimension === 'mrc') key = (res && res.matched && res.mrc) ? res.mrc : '(hors MRC)'
    else key = (res && res.matched && res.canonical) ? res.canonical : (raw || '(sans ville)')
    const g = merged[key] || (merged[key] = { dim_key: key, dim_label: null, _bestN: -1, net: 0, gross: 0, discounts: 0, n_addresses: 0, n_lines: 0 })
    // Libellé ville = la graphie RÉELLE dominante du groupe (ex. « Saint-Anicet », pas « ST ANICET »).
    const n = Number(r.n_addresses || 0)
    if (meta.dimension === 'city' && raw && n > g._bestN) { g._bestN = n; g.dim_label = raw }
    g.net += Number(r.net || 0); g.gross += Number(r.gross || 0); g.discounts += Number(r.discounts || 0)
    g.n_addresses += n; g.n_lines += Number(r.n_lines || 0)
  }
  return Object.values(merged)
    .map(g => ({
      dim_key: g.dim_key,
      dim_label: meta.dimension === 'mrc'
        ? titleCase(g.dim_key)
        : (CITY_LABEL_OVERRIDE[g.dim_label || g.dim_key] || g.dim_label || g.dim_key),
      net: round2(g.net), gross: round2(g.gross), discounts: round2(g.discounts),
      n_addresses: g.n_addresses, n_lines: g.n_lines,
    }))
    .sort((a, b) => b.net - a.net)
}

// Cache stale-while-revalidate : la requête d'agrégation F (~2 s) est chère mais les revenus bougent lentement (sync horaire).
// Clé = signature (dimension + filtres). Frais → instantané ; périmé → sert + rafraîchit en fond ; à froid → requête live.
const _explorerCache = new Map()
const EXPLORER_TTL = 10 * 60 * 1000 // 10 min

async function runExplorer (url) {
  const { sql, params, totalSql, totalParams, meta } = buildExplorerQuery(url)
  const pool = getPool()
  const t0 = Date.now()
  const [rows] = await pool.execute(sql, params)
  const [totalRows] = await pool.execute(totalSql, totalParams)
  const asOf = await fetchDataAsOf(pool)
  const labelled = await finalizeRows(rows, meta)
  log(`revenue-explorer dim=${meta.dimension} seg=${meta.segment} type=${meta.type}: ${rows.length} raw → ${labelled.length} groups in ${Date.now() - t0}ms`)
  return {
    dimension: meta.dimension, dimension_label: DIM_LABELS[meta.dimension],
    segment: meta.segment, type: meta.type, active: meta.active, threshold: meta.threshold,
    data_as_of: asOf.services, count: labelled.length,
    total: totalRows[0] || null, rows: labelled,
  }
}

async function handleExplorer (req, res, url) {
  const pool = getPool()
  if (!pool) return json(res, 503, { error: 'mysql2 not installed on hub' })
  const key = url.search || '?' // signature de la requête (dim + filtres)
  const now = Date.now(); const hit = _explorerCache.get(key)
  if (hit) {
    if (now - hit.ts > EXPLORER_TTL && !hit.refreshing) { // périmé → rafraîchit en fond, sert le cache tout de suite
      hit.refreshing = true
      runExplorer(url).then(d => _explorerCache.set(key, { data: d, ts: Date.now() })).catch(() => {}).finally(() => { hit.refreshing = false })
    }
    return json(res, 200, { ...hit.data, cached: true })
  }
  try {
    const data = await runExplorer(url)
    _explorerCache.set(key, { data, ts: Date.now() })
    if (_explorerCache.size > 60) { const oldest = [..._explorerCache.entries()].sort((a, b) => a[1].ts - b[1].ts)[0]; if (oldest) _explorerCache.delete(oldest[0]) } // borne le cache
    return json(res, 200, data)
  } catch (e) {
    log(`revenue-explorer error: ${e.message}`)
    return json(res, 500, { error: 'legacy query failed: ' + e.message })
  }
}

async function handleExplorerCsv (req, res, url) {
  const pool = getPool()
  if (!pool) return json(res, 503, { error: 'mysql2 not installed on hub' })
  const { sql, params, meta } = buildExplorerQuery(url)
  try {
    const [rawRows] = await pool.execute(sql, params)
    const rows = await finalizeRows(rawRows, meta)
    const headers = [DIM_LABELS[meta.dimension], 'net_mrr', 'brut', 'rabais', 'n_adresses', 'n_lignes']
    const esc = (v) => { if (v == null) return ''; const s = String(v); return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s }
    const lines = [headers.join(',')]
    for (const r of rows) lines.push([esc(r.dim_label), r.net, r.gross, r.discounts, r.n_addresses, r.n_lines].join(','))
    const csv = '﻿' + lines.join('\r\n') + '\r\n'
    res.writeHead(200, {
      'Content-Type': 'text/csv; charset=utf-8',
      'Content-Disposition': `attachment; filename="revenus-${meta.dimension}-${meta.segment}.csv"`,
      'Cache-Control': 'no-store',
    })
    return res.end(csv)
  } catch (e) {
    return json(res, 500, { error: 'legacy query failed: ' + e.message })
  }
}

async function handle (req, res, method, path, url) {
  if (path === '/reports/legacy/overpriced-internet' && method === 'GET') return handleJson(req, res, url)
  if (path === '/reports/legacy/overpriced-internet.csv' && method === 'GET') return handleCsv(req, res, url)
  if (path === '/reports/legacy/revenue-explorer' && method === 'GET') return handleExplorer(req, res, url)
  if (path === '/reports/legacy/revenue-explorer.csv' && method === 'GET') return handleExplorerCsv(req, res, url)
  return json(res, 404, { error: 'legacy report not found' })
}

module.exports = { handle }
