// ─────────────────────────────────────────────────────────────────────────
// format-prefs.jsx — Formateurs communs de Tempo (charte docs/charte-ui.md §1)
//
// Module pur, sans dépendance ni import ES, chargé juste après supabase.jsx et
// avant tous les autres modules. Tout est exposé sur window via
// Object.assign(window, {...}), comme le reste du projet.
//
// Règles appliquées ici, et donc partout où l'on passe par ces fonctions :
//   - Montants : fmtMoneyCur(v, devise, signe). Décimales de la devise avec
//     minimum = maximum (2 pour EUR/USD/GBP/CHF, 0 pour JPY), symbole APRÈS le
//     nombre, précédé d'une espace insécable (« 2 446,70 € »), « + » ou « − »
//     (U+2212), zéro neutre « 0,00 € ». Dans l'app, on appelle fmtMoney(v, signe)
//     (app.jsx), qui lit la devise du profil (window.__appCurrency). La devise
//     ne sert qu'à l'affichage : aucune conversion.
//   - Nombres : virgule décimale, espace fine insécable (U+202F) pour les
//     milliers et avant « % », signe moins typographique.
//   - Dates et heures : toujours dans le fuseau du profil (window.__appTz). Une
//     date civile 'YYYY-MM-DD' n'est jamais projetée dans un fuseau.
//   - Sessions de marché : chaque place dans son propre fuseau, une seule
//     source de vérité (sessionStatusInTz, isCMEOpen).
// Si Intl échoue (moteur très ancien), chaque fonction a un repli manuel qui
// suit les mêmes règles.
// ─────────────────────────────────────────────────────────────────────────

const FP_NBSP  = '\u00A0'; // espace insécable : avant un symbole ou une unité
const FP_NNBSP = '\u202F'; // espace fine insécable : milliers, avant « % »
const FP_MINUS = '\u2212'; // signe moins typographique
const FP_NDASH = '\u2013'; // tiret demi-cadratin des plages

// Valeur renvoyée par parseDecimalFr pour une saisie non vide mais illisible.
// Déclarée ici une seule fois pour toute l'app.
const INVALID_NUM = Symbol('invalid-num');

// ─── Nombres : primitives internes ────────────────────────────────────────

// Arrondi à `dec` décimales. Le facteur (1 + 8ε) corrige les représentations
// binaires juste sous la demie (1,005 → 1,01 et non 1,00).
function fpRound(a, dec) {
  const p = Math.pow(10, dec);
  return Math.round(a * p * (1 + 8 * Number.EPSILON)) / p;
}

// Lit une valeur à afficher : nombre, ou chaîne numérique (« 12.5 », « 12,5 »,
// « −3 »). null, undefined, '' ou une chaîne illisible donnent NaN, donc « — ».
function fpToNum(v) {
  if (typeof v === 'number') return v;
  if (v == null || typeof v === 'boolean') return NaN;
  const s = String(v).trim();
  if (s === '') return NaN;
  const n = Number(s);
  if (Number.isFinite(n)) return n;
  const p = parseDecimalFr(s);
  return typeof p === 'number' ? p : NaN;
}

// Les caches sont des objets sans prototype : une clé comme « toString » ne
// peut pas y trouver une valeur héritée.
const FP_NF_CACHE = Object.create(null);
function fpNF(min, max) {
  const key = min + '|' + max;
  if (key in FP_NF_CACHE) return FP_NF_CACHE[key];
  let nf = null;
  try {
    nf = new Intl.NumberFormat('fr-FR', { minimumFractionDigits: min, maximumFractionDigits: max });
  } catch (e) { nf = null; }
  FP_NF_CACHE[key] = nf;
  return nf;
}

// Valeur absolue (déjà arrondie) en fr-FR : milliers séparés par U+202F,
// virgule décimale, entre `min` et `max` décimales.
function fpFormatAbs(a, min, max) {
  const lo = Math.max(0, Math.min(10, min | 0));
  const hi = Math.max(lo, Math.min(10, max | 0));
  const nf = fpNF(lo, hi);
  if (nf) {
    try { return nf.format(a); } catch (e) { /* repli manuel ci-dessous */ }
  }
  let s = a.toFixed(hi);
  if (hi > lo && s.indexOf('.') >= 0) {
    const cut = s.indexOf('.');
    let end = s.length;
    while (end > cut + 1 + lo && s.charAt(end - 1) === '0') end--;
    s = s.slice(0, end);
    if (s.charAt(s.length - 1) === '.') s = s.slice(0, -1);
  }
  const parts = s.split('.');
  const grouped = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, FP_NNBSP);
  return parts[1] ? grouped + ',' + parts[1] : grouped;
}

// Nombre signé : « − » pour tout négatif non nul, « + » si `sign`. Une valeur
// qui s'arrondit à zéro s'affiche sans signe (« 0,00 », jamais « −0,00 »).
// Les décimales sont bornées comme dans fpFormatAbs (0 à 10), pour que
// l'arrondi et l'affichage portent toujours sur le même nombre de chiffres.
function fpFormatSigned(n, min, max, sign) {
  const lo = Math.max(0, Math.min(10, min | 0));
  const hi = Math.max(lo, Math.min(10, max | 0));
  const r = fpRound(Math.abs(n), hi);
  const body = fpFormatAbs(r, lo, hi);
  if (r === 0) return body;
  return (n < 0 ? FP_MINUS : (sign ? '+' : '')) + body;
}

// ─── Montants ─────────────────────────────────────────────────────────────

// Symboles de repli si Intl ne sait pas formater la devise (vieux moteur).
const FP_CURRENCIES = {
  USD: { symbol: '$',   code: 'USD' },
  EUR: { symbol: '€',   code: 'EUR' },
  GBP: { symbol: '£',   code: 'GBP' },
  CHF: { symbol: 'CHF', code: 'CHF' },
  JPY: { symbol: '¥',   code: 'JPY' },
};
// Devises sans décimales (repli manuel uniquement ; Intl les connaît).
const FP_ZERO_DEC = { JPY: true, KRW: true, VND: true, CLP: true, ISK: true };

function fpAppCurrency() {
  const c = (typeof window !== 'undefined' && window.__appCurrency) || '';
  const u = String(c).trim().toUpperCase();
  return /^[A-Z]{3}$/.test(u) ? u : 'USD';
}

// Normalise une devise quelconque ('usd', ' eur ', null) en code ISO. Sans
// devise lisible, on prend celle du profil, jamais un dollar arbitraire.
function fpNormCurrency(currency) {
  const c = String(currency || '').trim().toUpperCase();
  if (/^[A-Z]{3}$/.test(c)) return c;
  return fpAppCurrency();
}

// Cache par devise : { d: décimales, nf: NumberFormat (min = max = d), sym }.
const FP_MONEY_CACHE = Object.create(null);
function fpMoneyNF(code) {
  if (code in FP_MONEY_CACHE) return FP_MONEY_CACHE[code];
  let entry = null;
  try {
    const base = new Intl.NumberFormat('fr-FR', {
      style: 'currency', currency: code, currencyDisplay: 'narrowSymbol',
    });
    const d = base.resolvedOptions().maximumFractionDigits;
    const nf = new Intl.NumberFormat('fr-FR', {
      style: 'currency', currency: code, currencyDisplay: 'narrowSymbol',
      minimumFractionDigits: d, maximumFractionDigits: d,
    });
    const symPart = nf.formatToParts(0).find(function (p) { return p.type === 'currency'; });
    entry = { d: d, nf: nf, sym: symPart && symPart.value ? symPart.value : code };
  } catch (e) {
    // narrowSymbol inconnu (Safari < 14.1) ou code refusé : format manuel,
    // mêmes règles (décimales de la devise, symbole après, espace insécable).
    let d = FP_ZERO_DEC[code] ? 0 : 2;
    try {
      d = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: code })
        .resolvedOptions().maximumFractionDigits;
    } catch (e2) { /* on garde la valeur par défaut */ }
    entry = { d: d, nf: null, sym: FP_CURRENCIES[code] ? FP_CURRENCIES[code].symbol : code };
  }
  FP_MONEY_CACHE[code] = entry;
  return entry;
}

// currencySymbol(code) → '$' | '€' | '£' | '¥' | 'CHF'… (libellés « Risque (€) »).
function currencySymbol(currency) {
  return fpMoneyNF(fpNormCurrency(currency)).sym;
}

// Nombre de décimales de la devise (2, ou 0 pour le yen). Sert notamment à
// AnimatedMoney pour animer avec la bonne précision.
function currencyDecimals(currency) {
  return fpMoneyNF(fpNormCurrency(currency)).d;
}

// moneySign(value, currency) — signe d'un montant TEL QU'IL S'AFFICHE : 1, −1,
// ou 0 quand il s'arrondit à zéro aux décimales de la devise (celle du profil
// par défaut). Un P&L de 0,001 € s'affiche « 0,00 € » : il est neutre, ni vert
// ni rouge. Règle unique des couleurs de montant (Tableau de bord, Bilan
// hebdo, Stratégies, Statistiques) ; même arrondi que fmtMoneyCur.
function moneySign(value, currency) {
  const n = fpToNum(value);
  if (!Number.isFinite(n)) return 0;
  if (fpRound(Math.abs(n), currencyDecimals(currency)) === 0) return 0;
  return n > 0 ? 1 : -1;
}

// fmtMoneyCur(value, currency, sign) — montant dans la devise donnée :
// décimales de la devise, minimum = maximum ; symbole après le nombre, précédé
// d'une espace insécable ; « + » si sign et gain, « − » (U+2212) pour toute
// perte, même sans sign ; zéro neutre « 0,00 € » ; valeur absente « — ».
//   fmtMoneyCur(2446.7, 'EUR', true) → '+2 446,70 €'
//   fmtMoneyCur(-46.4, 'JPY')        → '−46 ¥'
//   fmtMoneyCur(45.5, 'CHF')         → '45,50 CHF'
function fmtMoneyCur(value, currency, sign = false) {
  const n = fpToNum(value);
  if (!Number.isFinite(n)) return '—';
  const m = fpMoneyNF(fpNormCurrency(currency));
  const r = fpRound(Math.abs(n), m.d);
  let s = null;
  if (m.nf) {
    try { s = m.nf.format(r); } catch (e) { s = null; }
  }
  if (s == null) s = fpFormatAbs(r, m.d, m.d) + FP_NBSP + m.sym;
  if (r === 0) return s;
  return (n < 0 ? FP_MINUS : (sign ? '+' : '')) + s;
}

// fmtMoneyCompactCur(value, currency, sign) — pour les cases étroites
// (calendriers, axes). Montant exact sous 10 000, puis « 12,3 k€ », « 1,2 M€ »
// (espace fine insécable avant l'unité ; « 12,3 k CHF » quand le symbole est un code).
function fmtMoneyCompactCur(value, currency, sign = false) {
  const n = fpToNum(value);
  if (!Number.isFinite(n)) return '—';
  const code = fpNormCurrency(currency);
  const m = fpMoneyNF(code);
  const r = fpRound(Math.abs(n), m.d);
  if (r < 10000) return fmtMoneyCur(n, code, sign);
  let k = fpRound(r / 1000, 1);
  let unit = 'k';
  if (k >= 1000) { k = fpRound(r / 1e6, 1); unit = 'M'; }
  const glue = /^[A-Za-z]/.test(m.sym) ? FP_NBSP : '';
  return (n < 0 ? FP_MINUS : (sign ? '+' : '')) + fpFormatAbs(k, 0, 1) + FP_NNBSP + unit + glue + m.sym;
}

// fmtMoneyCompact(value, sign) — même chose dans la devise du profil (forme de
// la charte). Accepte aussi fmtMoneyCompact(value, 'EUR', sign).
function fmtMoneyCompact(value, a, b) {
  if (typeof a === 'string') return fmtMoneyCompactCur(value, a, !!b);
  return fmtMoneyCompactCur(value, fpAppCurrency(), !!a);
}

// ─── Nombres, pourcentages, R, profit factor, prix, lots ──────────────────

// fmtNum(v, dec, sign) — nombre en français. dec absent : 0 à 2 décimales.
//   fmtNum(2.5, 1) → '2,5' ; fmtNum(-1234.5, 2) → '−1 234,50'
function fmtNum(v, dec, sign = false) {
  const n = fpToNum(v);
  if (!Number.isFinite(n)) return '—';
  if (dec == null) return fpFormatSigned(n, 0, 2, sign);
  return fpFormatSigned(n, dec, dec, sign);
}

// fmtPct(v, dec, sign) — v est déjà un pourcentage (52.2 → « 52,2 % »). Une
// valeur qui s'arrondit à 0 donne « 0,0 % » sans signe.
function fmtPct(v, dec = 1, sign = false) {
  const n = fpToNum(v);
  if (!Number.isFinite(n)) return '—';
  return fpFormatSigned(n, dec, dec, sign) + FP_NNBSP + '%';
}

// fmtR(v, dec) — R multiple, toujours signé, 2 décimales : « +0,51 R »,
// « −0,58 R », « 0,00 R ». Valeur absente → « — ».
function fmtR(v, dec = 2) {
  const n = fpToNum(v);
  if (!Number.isFinite(n)) return '—';
  return fpFormatSigned(n, dec, dec, true) + FP_NBSP + 'R';
}

// fmtPF(pf, count) — profit factor : « — » sans trade, « ∞ » sans perte,
// sinon 2 décimales (« 2,46 »).
function fmtPF(pf, count) {
  if (count != null && Number(count) === 0) return '—';
  if (pf == null || pf === '') return '—';
  const n = fpToNum(pf);
  if (n === Infinity) return '∞';
  if (!Number.isFinite(n)) return '—';
  return fpFormatSigned(n, 2, 2, false);
}

// Décimales « naturelles » d'un prix brut : de 2 à 5, sans le bruit binaire.
function fpPriceDecimals(n) {
  const a = Math.abs(n);
  for (let d = 2; d <= 5; d++) {
    if (Math.abs(fpRound(a, d) - a) < 1e-9 * Math.max(1, a)) return d;
  }
  return 5;
}

// fmtPrice(v, digits) — prix avec les décimales de l'instrument
// (fmtPrice(1.0842, 5) → '1,08420' ; fmtPrice(5278.5) → '5 278,50').
function fmtPrice(v, digits) {
  const n = fpToNum(v);
  if (!Number.isFinite(n)) return '—';
  const d = digits == null ? fpPriceDecimals(n) : Math.max(0, Math.min(10, digits | 0));
  return fpFormatSigned(n, d, d, false);
}

// priceDigitsOf(...prix) — décimales communes à plusieurs prix d'un même trade
// (entrée, sortie), pour qu'ils s'alignent : 1.1148 et 1.11302 → 5
// (« 1,11480 » / « 1,11302 »). Valeurs absentes ignorées ; 2 au minimum.
function priceDigitsOf(...vals) {
  let d = 2;
  for (const v of vals) {
    if (v == null || v === '') continue;
    const n = fpToNum(v);
    if (Number.isFinite(n)) d = Math.max(d, fpPriceDecimals(n));
  }
  return d;
}

// fmtLots(v) — quantité, avec ses décimales RÉELLES (0 à 8) et sans bruit
// binaire : on garde le plus petit nombre de décimales qui redonne la valeur
// à 1e-9 près. 0.004 → '0,004' (jamais « 0 ») ; 0.30000000000000004 → '0,3' ;
// 2 → '2'. Au-delà de 8 décimales, arrondi à 8.
function fmtLots(v) {
  const n = fpToNum(v);
  if (!Number.isFinite(n)) return '—';
  const a = Math.abs(n);
  const tol = 1e-9 * Math.max(1, a);
  let d = 0;
  while (d < 8 && Math.abs(fpRound(a, d) - a) > tol) d++;
  return fpFormatSigned(n, 0, d, false);
}

// fmtCount(n, mot, sep) — « 0 trade », « 1 trade », « 1 246 trades » : nombre
// au format français (séparateur de milliers, charte §1) et accord du mot.
// L'accord suit le nombre AFFICHÉ : la valeur est d'abord arrondie à l'entier
// (1,4 → « 1 trade »), le pluriel commence à 2 en valeur absolue, et une valeur
// illisible ou infinie compte pour 0.
// sep : séparateur entre le nombre et le mot, espace ordinaire par défaut.
function fmtCount(n, mot, sep) {
  const raw = Number(n);
  const v = Number.isFinite(raw) ? (Math.round(raw) || 0) : 0;
  return fmtNum(v, 0) + (sep == null ? ' ' : sep) + mot + (Math.abs(v) >= 2 ? 's' : '');
}

// parseDecimalFr(raw) — lecture STRICTE d'une saisie numérique.
//   '' ou espaces        → null (champ vide)
//   '1 234,5', '1234.5'  → 1234.5 (virgule ou point, espaces ignorées)
//   '−3', '-3', '+3'     → -3 / 3
//   '12abc', '1,2,3'     → INVALID_NUM
function parseDecimalFr(raw) {
  if (raw == null) return null;
  if (typeof raw === 'number') return Number.isFinite(raw) ? raw : INVALID_NUM;
  let s = String(raw).replace(/\s/g, '');
  if (s === '') return null;
  let neg = false;
  const c0 = s.charAt(0);
  if (c0 === '+' || c0 === '-' || c0 === FP_MINUS) {
    neg = c0 !== '+';
    s = s.slice(1);
  }
  if (!/^\d+([.,]\d+)?$/.test(s) && !/^[.,]\d+$/.test(s)) return INVALID_NUM;
  const n = parseFloat(s.replace(',', '.'));
  if (!Number.isFinite(n)) return INVALID_NUM;
  return neg && n !== 0 ? -n : n;
}

// ─── Fuseau horaire (IANA) : primitives internes ──────────────────────────

const FP_TZ_OK = Object.create(null);
function fpTzValid(tz) {
  if (!tz || typeof tz !== 'string') return false;
  if (tz in FP_TZ_OK) return FP_TZ_OK[tz];
  let ok = false;
  try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); ok = true; } catch (e) { ok = false; }
  FP_TZ_OK[tz] = ok;
  return ok;
}

// Fuseau effectif : celui demandé, sinon celui du profil, sinon Paris (le
// même défaut que state.jsx). Jamais le fuseau du navigateur.
function fpTz(timezone) {
  if (fpTzValid(timezone)) return timezone;
  const app = typeof window !== 'undefined' ? window.__appTz : null;
  if (fpTzValid(app)) return app;
  return fpTzValid('Europe/Paris') ? 'Europe/Paris' : 'UTC';
}

// Cache des Intl.DateTimeFormat par (locale|fuseau|options) : en instancier un
// est coûteux.
const FP_DTF_CACHE = Object.create(null);
function fpDTF(timezone, opts, locale) {
  const tz = fpTz(timezone);
  const loc = locale || 'fr-FR';
  const key = loc + '|' + tz + '|' + JSON.stringify(opts);
  if (key in FP_DTF_CACHE) return FP_DTF_CACHE[key];
  let dtf = null;
  try { dtf = new Intl.DateTimeFormat(loc, Object.assign({ timeZone: tz }, opts)); } catch (e) { dtf = null; }
  FP_DTF_CACHE[key] = dtf;
  return dtf;
}

// Coerce une entrée date (Date | ISO | ms | undefined) → Date. Tolérant (les
// helpers historiques renvoient « maintenant » sur une entrée illisible).
function fpToDate(date) {
  if (date == null) return new Date();
  if (date instanceof Date) return isNaN(date.getTime()) ? new Date() : date;
  if (typeof date === 'number') return new Date(date);
  const d = new Date(date);
  return isNaN(d.getTime()) ? new Date() : d;
}

// Variante stricte pour les formateurs d'affichage : null si illisible.
function fpToDateStrict(date) {
  if (date == null || date === '') return null;
  const d = date instanceof Date ? date : new Date(date);
  return isNaN(d.getTime()) ? null : d;
}

function fpIsCivilDay(v) {
  return typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v);
}

function fpPad2(n) { return String(n).padStart(2, '0'); }

const FP_WD_EN = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };

// Composantes civiles d'un instant dans un fuseau :
// { y, mo (1-12), d, h (0-23), mi, s, wd (0 = dimanche) }.
function fpParts(d, timezone) {
  const dtf = fpDTF(timezone, {
    year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short',
    hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
  }, 'en-US');
  if (dtf) {
    try {
      const o = {};
      dtf.formatToParts(d).forEach(function (p) { o[p.type] = p.value; });
      let h = parseInt(o.hour, 10);
      if (h === 24) h = 0; // certains moteurs renvoient « 24 » à minuit
      const r = { y: +o.year, mo: +o.month, d: +o.day, h: h, mi: +o.minute, s: +o.second, wd: FP_WD_EN[o.weekday] };
      if (r.y && r.mo && r.d && !isNaN(r.h) && !isNaN(r.mi) && r.wd != null) return r;
    } catch (e) { /* repli ci-dessous */ }
  }
  // Intl indisponible : repli UTC, déterministe (jamais le fuseau du navigateur).
  return {
    y: d.getUTCFullYear(), mo: d.getUTCMonth() + 1, d: d.getUTCDate(),
    h: d.getUTCHours(), mi: d.getUTCMinutes(), s: d.getUTCSeconds(), wd: d.getUTCDay(),
  };
}

// Décalage du fuseau à l'instant ms (ms à ajouter à l'UTC pour obtenir l'heure locale).
function fpTzOffsetMs(timezone, ms) {
  const p = fpParts(new Date(ms), timezone);
  const asUTC = Date.UTC(p.y, p.mo - 1, p.d, p.h, p.mi, p.s);
  return asUTC - (ms - (((ms % 1000) + 1000) % 1000));
}

// Jour civil 'YYYY-MM-DD' → Date à midi UTC (formatage avec timeZone 'UTC').
function fpCivilNoon(day) {
  const a = day.split('-').map(Number);
  return new Date(Date.UTC(a[0], a[1] - 1, a[2], 12));
}

function fpAddDaysISO(day, n) {
  const dt = fpCivilNoon(day);
  dt.setUTCDate(dt.getUTCDate() + n);
  return dt.toISOString().slice(0, 10);
}

// Jour civil d'une entrée : 'YYYY-MM-DD' tel quel, un instant projeté dans le
// fuseau du profil. null si illisible.
function fpDayOf(input, timezone) {
  if (fpIsCivilDay(input)) return input;
  const d = fpToDateStrict(input);
  if (!d) return null;
  return localDayInTz(d, timezone);
}

function fpCurrentYear(timezone) {
  return +localDayInTz(new Date(), timezone).slice(0, 4);
}

// ─── Noms français (identiques aux abréviations d'Intl fr-FR) ─────────────
// Semaine du lundi au dimanche.
const FR_MONTHS_SHORT = ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.'];
const FR_MONTHS_LONG = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'];
const FR_WEEKDAYS_SHORT = ['lun.', 'mar.', 'mer.', 'jeu.', 'ven.', 'sam.', 'dim.'];
const FR_WEEKDAYS_LONG = ['lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche'];
const FR_WEEKDAYS_2 = ['Lu', 'Ma', 'Me', 'Je', 'Ve', 'Sa', 'Di']; // quand la place manque

// ─── Heure et jour dans le fuseau du profil ───────────────────────────────

// tzTimeIn(date, timezone) → 'HH:MM' (24 h) dans le fuseau donné, sinon celui du profil.
function tzTimeIn(date, timezone) {
  const p = fpParts(fpToDate(date), timezone);
  return fpPad2(p.h) + ':' + fpPad2(p.mi);
}

// tzNow(timezone) → 'HH:MM' courant.
function tzNow(timezone) {
  return tzTimeIn(new Date(), timezone);
}

// tzClock(timezone, withSeconds) → horloge en direct ('HH:MM' ou 'HH:MM:SS').
function tzClock(timezone, withSeconds = false) {
  const p = fpParts(new Date(), timezone);
  const hm = fpPad2(p.h) + ':' + fpPad2(p.mi);
  return withSeconds ? hm + ':' + fpPad2(p.s) : hm;
}

// localDayInTz(date, timezone) → 'YYYY-MM-DD' : le jour civil vécu dans le
// fuseau (du profil par défaut). Sert à regrouper trades et complétions par
// jour, et à définir « aujourd'hui ». Une date civile 'YYYY-MM-DD' est déjà un
// jour : elle est renvoyée telle quelle, sans projection.
function localDayInTz(date, timezone) {
  if (fpIsCivilDay(date)) return date;
  const d = fpToDate(date);
  const dtf = fpDTF(timezone, { year: 'numeric', month: '2-digit', day: '2-digit' }, 'en-US');
  if (dtf) {
    try {
      const o = {};
      dtf.formatToParts(d).forEach(function (p) { o[p.type] = p.value; });
      if (o.year && o.month && o.day) return o.year + '-' + o.month + '-' + o.day;
    } catch (e) { /* repli ci-dessous */ }
  }
  const p = fpParts(d, timezone);
  return p.y + '-' + fpPad2(p.mo) + '-' + fpPad2(p.d);
}

// Jour de la semaine (0 = dimanche) et minute du jour (0-1439) dans un fuseau.
function fpWeekdayMinInTz(timezone, date) {
  const p = fpParts(fpToDate(date), timezone);
  return { wd: p.wd, min: p.h * 60 + p.mi };
}

// ─── Dates affichées ──────────────────────────────────────────────────────
// Toutes acceptent une date civile 'YYYY-MM-DD' (jamais projetée) ou un instant
// (Date, ISO, ms), projeté dans le fuseau du profil. Entrée illisible → « — ».

// fmtDayShort(iso) → « 26 sept. », avec l'année si elle diffère de l'année en
// cours (« 26 sept. 2025 »).
function fmtDayShort(iso, timezone) {
  const day = fpDayOf(iso, timezone);
  if (!day) return '—';
  const a = day.split('-').map(Number);
  const out = fpDayNum(a[2]) + FP_NBSP + FR_MONTHS_SHORT[a[1] - 1];
  return a[0] !== fpCurrentYear(timezone) ? out + ' ' + a[0] : out;
}

// Numéro du jour dans une date isolée : « 1er » pour le premier du mois (usage
// français : « jeudi 1er octobre », « 1er oct. »). Les plages gardent le chiffre.
function fpDayNum(d) { return d === 1 ? '1er' : String(d); }

// fmtDayLong(iso) → « samedi 26 septembre » (année ajoutée si autre année).
function fmtDayLong(iso, timezone) {
  const day = fpDayOf(iso, timezone);
  if (!day) return '—';
  const a = day.split('-').map(Number);
  const wd = (fpCivilNoon(day).getUTCDay() + 6) % 7;
  const out = FR_WEEKDAYS_LONG[wd] + ' ' + fpDayNum(a[2]) + FP_NBSP + FR_MONTHS_LONG[a[1] - 1];
  return a[0] !== fpCurrentYear(timezone) ? out + ' ' + a[0] : out;
}

// fmtWeekdayShort(iso) → « lun. » … « dim. ».
function fmtWeekdayShort(iso, timezone) {
  const day = fpDayOf(iso, timezone);
  if (!day) return '—';
  return FR_WEEKDAYS_SHORT[(fpCivilNoon(day).getUTCDay() + 6) % 7];
}

// fmtDateTime(iso) → « 26 sept. · 14:30 » dans le fuseau du profil. Une date
// civile seule (sans heure) donne juste le jour.
function fmtDateTime(iso, timezone) {
  if (fpIsCivilDay(iso)) return fmtDayShort(iso, timezone);
  const d = fpToDateStrict(iso);
  if (!d) return '—';
  return fmtDayShort(d, timezone) + FP_NBSP + '· ' + tzTimeIn(d, timezone);
}

// fmtMonthYear(iso | 'YYYY-MM') → « septembre 2026 ».
function fmtMonthYear(iso, timezone) {
  const s = typeof iso === 'string' && /^\d{4}-\d{2}$/.test(iso) ? iso + '-01' : iso;
  const day = fpDayOf(s, timezone);
  if (!day) return '—';
  const a = day.split('-').map(Number);
  return FR_MONTHS_LONG[a[1] - 1] + ' ' + a[0];
}

// fmtDayRange(début, fin) → « 21–27 sept. », « 31 août – 6 sept. »,
// « 29 déc. 2025 – 4 janv. 2026 ». L'année n'apparaît que si elle diffère de
// l'année en cours (ou si la plage change d'année).
function fmtDayRange(startISO, endISO, timezone) {
  let s = fpDayOf(startISO, timezone);
  let e = fpDayOf(endISO, timezone);
  if (!s || !e) return '—';
  if (s > e) { const t = s; s = e; e = t; }
  if (s === e) return fmtDayShort(s, timezone);
  const a = s.split('-').map(Number);
  const b = e.split('-').map(Number);
  const cy = fpCurrentYear(timezone);
  const sep = FP_NBSP + FP_NDASH + FP_NBSP;
  if (a[0] !== b[0]) {
    return a[2] + FP_NBSP + FR_MONTHS_SHORT[a[1] - 1] + ' ' + a[0] + sep
      + b[2] + FP_NBSP + FR_MONTHS_SHORT[b[1] - 1] + ' ' + b[0];
  }
  const yr = a[0] !== cy ? ' ' + a[0] : '';
  if (a[1] === b[1]) {
    return a[2] + FP_NDASH + b[2] + FP_NBSP + FR_MONTHS_SHORT[b[1] - 1] + yr;
  }
  return a[2] + FP_NBSP + FR_MONTHS_SHORT[a[1] - 1] + sep + b[2] + FP_NBSP + FR_MONTHS_SHORT[b[1] - 1] + yr;
}

// fmtWeekRange(lundi, fin?) → plage de la semaine (lundi → dimanche par défaut ;
// passer `fin` pour une semaine en cours : « 21–26 sept. »).
function fmtWeekRange(mondayISO, endISO, timezone) {
  const s = fpDayOf(mondayISO, timezone);
  if (!s) return '—';
  return fmtDayRange(s, endISO != null ? endISO : fpAddDaysISO(s, 6), timezone);
}

// isoWeekNum(iso) → numéro de semaine ISO 8601 (semaine du lundi) du jour donné.
function isoWeekNum(iso, timezone) {
  const day = fpDayOf(iso, timezone);
  if (!day) return null;
  const dt = fpCivilNoon(day);
  dt.setUTCDate(dt.getUTCDate() - ((dt.getUTCDay() + 6) % 7) + 3); // jeudi de la semaine
  const jan4 = new Date(Date.UTC(dt.getUTCFullYear(), 0, 4, 12));
  return 1 + Math.round(((dt - jan4) / 86400000 - 3 + ((jan4.getUTCDay() + 6) % 7)) / 7);
}

// fmtWeekLabel(iso, short) → « Semaine 39 » ou « Sem. 39 ».
function fmtWeekLabel(iso, short = false, timezone) {
  const w = isoWeekNum(iso, timezone);
  if (w == null) return '—';
  return (short ? 'Sem.' : 'Semaine') + FP_NBSP + w;
}

// fmtDuration(min) → « < 1 min », « 45 min », « 2 h », « 2 h 20 », « 31 h 03 »,
// « 2 j 5 h ». Toute durée de moins d'une minute donne « < 1 min » (un compte
// à rebours de 40 s n'affiche pas « 1 min ») ; au-delà, le total est d'abord
// arrondi à la minute (jamais « 1 h 60 »).
function fmtDuration(min) {
  const v = fpToNum(min);
  if (!Number.isFinite(v) || v <= 0) return '—';
  if (v < 1) return '<' + FP_NBSP + '1' + FP_NBSP + 'min';
  const t = Math.round(v);
  if (t < 60) return t + FP_NBSP + 'min';
  if (t < 48 * 60) {
    const h = Math.floor(t / 60);
    const m = t % 60;
    return m > 0 ? h + FP_NBSP + 'h' + FP_NBSP + fpPad2(m) : h + FP_NBSP + 'h';
  }
  const j = Math.floor(t / 1440);
  const h = Math.floor((t % 1440) / 60);
  return h > 0 ? j + FP_NBSP + 'j' + FP_NBSP + h + FP_NBSP + 'h' : j + FP_NBSP + 'j';
}

// zonedToUtcISO('2026-09-26', '14:30', tz, opts) → instant UTC ISO d'une date
// et d'une heure SAISIES dans le fuseau (du profil par défaut). Deux passes
// pour les bords de changement d'heure, puis deux contrôles :
//  · une heure qui n'existe pas (passage à l'heure d'été) est décalée de la
//    durée du saut, donc une heure PLUS TARD (02:30 → 03:30), quel que soit le
//    côté de l'UTC où se trouve le fuseau ; à minuit, le résultat reste ainsi
//    dans le jour demandé ;
//  · une heure qui existe deux fois (retour à l'heure d'hiver) désigne par
//    défaut son PREMIER passage, dans tous les fuseaux (même règle que
//    l'agenda). Avec opts = { near: ms }, c'est le plus tardif des deux
//    passages qui ne dépasse pas near : une saisie « maintenant » faite
//    pendant le second passage (near = l'instant présent) n'est ainsi ni
//    reculée d'une heure ni envoyée dans le futur.
// Entrée illisible → null.
function zonedToUtcISO(dayISO, hhmm, timezone, opts) {
  const day = String(dayISO || '').slice(0, 10);
  if (!fpIsCivilDay(day)) return null;
  const raw = hhmm == null || hhmm === '' ? '00:00' : String(hhmm).trim();
  const tm = raw.match(/^(\d{1,2})[:hH](\d{2})(?::(\d{2}))?$/);
  if (!tm) return null;
  const h = +tm[1], mi = +tm[2], sec = tm[3] ? +tm[3] : 0;
  if (h > 23 || mi > 59 || sec > 59) return null;
  const a = day.split('-').map(Number);
  const check = new Date(Date.UTC(a[0], a[1] - 1, a[2]));
  if (check.getUTCMonth() !== a[1] - 1 || check.getUTCDate() !== a[2]) return null; // 30 févr.
  const tz = fpTz(timezone);
  const guess = Date.UTC(a[0], a[1] - 1, a[2], h, mi, sec);
  const off1 = fpTzOffsetMs(tz, guess);
  const off2 = fpTzOffsetMs(tz, guess - off1); // 2e passe : bord de changement d'heure
  let ts = guess - off2;
  // Les deux passes ne divergent qu'à moins d'un jour d'une bascule. Si, en
  // plus, l'instant trouvé ne redonne pas l'heure saisie, cette heure n'existe
  // pas : off1 et off2 sont les décalages d'avant et d'après le saut, et off2
  // seul donnerait une heure plus tard à l'est de l'UTC mais plus TÔT à
  // l'ouest. Le décalage d'avant le saut (le plus petit des deux, l'heure d'été
  // avance toujours) donne partout l'heure saisie plus la durée du saut.
  if (off1 !== off2 && fpTzOffsetMs(tz, ts) !== off2) ts = guess - Math.min(off1, off2);
  // Heure répétée : une bascule à moins de 3 h donne deux décalages possibles ;
  // elle est ambiguë si chacun des deux redonne bien l'heure saisie.
  const offA = fpTzOffsetMs(tz, ts - 3 * 3600000), offB = fpTzOffsetMs(tz, ts + 3 * 3600000);
  if (offA !== offB) {
    const cands = [guess - offA, guess - offB]
      .filter(function (c) { return fpTzOffsetMs(tz, c) === guess - c; })
      .sort(function (x, y) { return x - y; });
    if (cands.length === 2) {
      const near = opts && Number.isFinite(opts.near) ? opts.near : null;
      ts = near != null && cands[1] <= near ? cands[1] : cands[0];
    }
  }
  return new Date(ts).toISOString();
}

// ─── Noms de fuseaux ──────────────────────────────────────────────────────

const TZ_LABELS = {
  'UTC': 'UTC', 'Etc/UTC': 'UTC', 'Etc/GMT': 'UTC',
  'Europe/Paris': 'Paris', 'Europe/London': 'Londres', 'Europe/Brussels': 'Bruxelles',
  'Europe/Zurich': 'Zurich', 'Europe/Luxembourg': 'Luxembourg', 'Europe/Monaco': 'Monaco',
  'Europe/Berlin': 'Berlin', 'Europe/Madrid': 'Madrid', 'Europe/Rome': 'Rome',
  'Europe/Lisbon': 'Lisbonne', 'Europe/Amsterdam': 'Amsterdam', 'Europe/Dublin': 'Dublin',
  'Europe/Vienna': 'Vienne', 'Europe/Prague': 'Prague', 'Europe/Warsaw': 'Varsovie',
  'Europe/Budapest': 'Budapest', 'Europe/Bucharest': 'Bucarest', 'Europe/Athens': 'Athènes',
  'Europe/Stockholm': 'Stockholm', 'Europe/Copenhagen': 'Copenhague', 'Europe/Oslo': 'Oslo',
  'Europe/Helsinki': 'Helsinki', 'Europe/Istanbul': 'Istanbul', 'Europe/Moscow': 'Moscou',
  'America/New_York': 'New York', 'America/Chicago': 'Chicago', 'America/Denver': 'Denver',
  'America/Los_Angeles': 'Los Angeles', 'America/Toronto': 'Toronto', 'America/Montreal': 'Montréal',
  'America/Mexico_City': 'Mexico', 'America/Sao_Paulo': 'São Paulo',
  'America/Guadeloupe': 'Guadeloupe', 'America/Martinique': 'Martinique', 'America/Cayenne': 'Cayenne',
  'Indian/Reunion': 'La Réunion', 'Indian/Mayotte': 'Mayotte', 'Indian/Mauritius': 'Maurice',
  'Pacific/Noumea': 'Nouméa', 'Pacific/Tahiti': 'Tahiti', 'Pacific/Auckland': 'Auckland',
  'Africa/Casablanca': 'Casablanca', 'Africa/Algiers': 'Alger', 'Africa/Tunis': 'Tunis',
  'Africa/Dakar': 'Dakar', 'Africa/Abidjan': 'Abidjan', 'Africa/Cairo': 'Le Caire',
  'Africa/Johannesburg': 'Johannesburg',
  'Asia/Tokyo': 'Tokyo', 'Asia/Hong_Kong': 'Hong Kong', 'Asia/Shanghai': 'Shanghai',
  'Asia/Singapore': 'Singapour', 'Asia/Seoul': 'Séoul', 'Asia/Dubai': 'Dubaï',
  'Asia/Kolkata': 'Calcutta', 'Asia/Bangkok': 'Bangkok', 'Asia/Jerusalem': 'Jérusalem',
  'Asia/Riyadh': 'Riyad',
  'Australia/Sydney': 'Sydney', 'Australia/Melbourne': 'Melbourne', 'Australia/Perth': 'Perth',
};

// tzLabel(tz) → nom lisible (« New York », « Paris »), jamais le code IANA.
// Sans argument : le fuseau du profil. Un fuseau hors table (même inconnu du
// navigateur) est nommé d'après son dernier segment (« Buenos Aires »).
function tzLabel(timezone) {
  const tz = timezone ? String(timezone) : fpTz();
  if (Object.prototype.hasOwnProperty.call(TZ_LABELS, tz)) return TZ_LABELS[tz];
  return tz.split('/').pop().replace(/_/g, ' ');
}

// Noms qui ne suivent pas la règle générale dans une phrase. Le nom court de
// TZ_LABELS reste sans article (le sélecteur de Réglages affiche
// « Guadeloupe (UTC−4) ») ; ici, la forme après « heure » et la forme de lieu
// (« Il est 06:23 en Guadeloupe. », pas « à Guadeloupe »).
const TZ_FR_FORMS = {
  'Guadeloupe': { de: 'de la Guadeloupe', a: 'en Guadeloupe' },
  'Martinique': { de: 'de la Martinique', a: 'en Martinique' },
};
// Élision de « de » : devant une voyelle ou un h muet. Le h muet est une liste
// (« d’Helsinki », « d’Honolulu ») : « de Hong Kong », « de Hanoï » gardent
// « de ». Pas d'élision devant un Y de nom étranger (« de Yangon »).
const TZ_ELISION_RE = /^(?:[AEIOUÀÂÄÉÈÊËÎÏÔÖÛÜŒ]|(?:Helsinki|Honolulu)(?![a-zà-ÿ]))/i;

// tzHourLabel(tz) → « heure de New York », « heure d’Alger », « heure du Caire »
// (« heure UTC » pour UTC). « de » se contracte devant l'article (« Le Caire »,
// « Les … »), qui passe en minuscule au milieu de la phrase (« heure de la
// Réunion »), et s'élide devant une voyelle (apostrophe typographique).
function tzHourLabel(timezone) {
  const l = tzLabel(timezone);
  if (l === 'UTC') return 'heure UTC';
  if (Object.prototype.hasOwnProperty.call(TZ_FR_FORMS, l)) return 'heure ' + TZ_FR_FORMS[l].de;
  if (/^Le /.test(l)) return 'heure du ' + l.slice(3);
  if (/^Les /.test(l)) return 'heure des ' + l.slice(4);
  if (/^La /.test(l)) return 'heure de la ' + l.slice(3);
  if (TZ_ELISION_RE.test(l)) return 'heure d\u2019' + l;
  return 'heure de ' + l;
}

// tzAtLabel(tz) → « à New York », « au Caire », « à la Réunion », « en
// Guadeloupe » (« en UTC » pour UTC), pour une phrase comme « Il est 12:52 à
// New York. ».
function tzAtLabel(timezone) {
  const l = tzLabel(timezone);
  if (l === 'UTC') return 'en UTC';
  if (Object.prototype.hasOwnProperty.call(TZ_FR_FORMS, l)) return TZ_FR_FORMS[l].a;
  if (/^Le /.test(l)) return 'au ' + l.slice(3);
  if (/^Les /.test(l)) return 'aux ' + l.slice(4);
  if (/^La /.test(l)) return 'à la ' + l.slice(3);
  return 'à ' + l;
}

// tzLabelWithOffset(tz) → « New York (UTC−4) », « Paris (UTC+2) », pour Réglages.
function tzLabelWithOffset(timezone, date) {
  const tz = fpTz(timezone);
  const label = tzLabel(tz);
  const ms = fpToDate(date).getTime();
  const off = Math.round(fpTzOffsetMs(tz, ms) / 60000);
  if (off === 0) return label === 'UTC' ? 'UTC' : label + ' (UTC)';
  const a = Math.abs(off);
  const hm = Math.floor(a / 60) + (a % 60 ? ':' + fpPad2(a % 60) : '');
  return label + ' (UTC' + (off < 0 ? FP_MINUS : '+') + hm + ')';
}

// ─── Jours de fermeture des places ────────────────────────────────────────
// fpMarketHolidays(key, year) → Set de jours civils 'YYYY-MM-DD' (dans le fuseau
// de la place) où la place est fermée toute la journée. Clés : 'tokyo',
// 'london', 'newyork', 'cme' (mêmes jours que New York) et 'cmeclose' (les
// seuls jours où le CME Globex ne cote pas du tout, voir isCMEOpen). Règles
// calculées, sans table annuelle (vérifiées sur les calendriers officiels du
// NYSE et du LSE 2025-2028 et du JPX 2025-2027). Non couverts : fermetures
// exceptionnelles (deuil national, couronnement) et demi-séances (veille de
// Noël, lendemain de Thanksgiving).
const FP_HOLIDAY_CACHE = {};
function fpHolISO(y, m, d) { return new Date(Date.UTC(y, m - 1, d, 12)).toISOString().slice(0, 10); } // m 1-12, d peut déborder
function fpHolWd(y, m, d) { return new Date(Date.UTC(y, m - 1, d, 12)).getUTCDay(); }               // 0 = dimanche
// n-ième (1-5) jour de semaine wd du mois ; n = -1 : le dernier.
function fpHolNth(y, m, wd, n) {
  if (n > 0) return fpHolISO(y, m, 1 + ((wd - fpHolWd(y, m, 1) + 7) % 7) + 7 * (n - 1));
  const last = new Date(Date.UTC(y, m, 0, 12)).getUTCDate();
  return fpHolISO(y, m, last - ((fpHolWd(y, m, last) - wd + 7) % 7));
}
// Dimanche de Pâques (calendrier grégorien, algorithme de Meeus) → [mois, jour].
function fpHolEaster(y) {
  const a = y % 19, b = Math.floor(y / 100), c = y % 100, d = Math.floor(b / 4), e = b % 4;
  const f = Math.floor((b + 8) / 25), g = Math.floor((b - f + 1) / 3), h = (19 * a + b - d - g + 15) % 30;
  const i = Math.floor(c / 4), k = c % 4, l = (32 + 2 * e + 2 * i - h - k) % 7, m = Math.floor((a + 11 * h + 22 * l) / 451);
  const mo = Math.floor((h + l - 7 * m + 114) / 31), da = ((h + l - 7 * m + 114) % 31) + 1;
  return [mo, da];
}
function fpMarketHolidays(key, year) {
  const ck = key + year;
  if (FP_HOLIDAY_CACHE[ck]) return FP_HOLIDAY_CACHE[ck];
  const y = year, s = new Set(), E = fpHolEaster(y);
  const goodFriday = fpHolISO(y, E[0], E[1] - 2);
  if (key === 'newyork' || key === 'cme' || key === 'cmeclose') {
    const all = key !== 'cmeclose';       // 'cmeclose' : 1er janvier, Vendredi saint et Noël seulement
    // Bourse de New York (règle 7.2) : un férié à date fixe tombant un dimanche
    // est chômé le lundi, un samedi le vendredi — sauf le 1er janvier un
    // samedi, qui n'est pas reporté (le 31 décembre reste ouvert).
    const fixed = function (m, d, noFriday) {
      const wd = fpHolWd(y, m, d);
      if (wd === 0) s.add(fpHolISO(y, m, d + 1));
      else if (wd === 6) { if (!noFriday) s.add(fpHolISO(y, m, d - 1)); }
      else s.add(fpHolISO(y, m, d));
    };
    fixed(1, 1, true);
    if (all) s.add(fpHolNth(y, 1, 1, 3)); // Martin Luther King : 3e lundi de janvier
    if (all) s.add(fpHolNth(y, 2, 1, 3)); // Presidents' Day : 3e lundi de février
    s.add(goodFriday);                    // Vendredi saint
    if (all) s.add(fpHolNth(y, 5, 1, -1)); // Memorial Day : dernier lundi de mai
    if (all && y >= 2022) fixed(6, 19);   // Juneteenth
    if (all) fixed(7, 4);                 // Independence Day
    if (all) s.add(fpHolNth(y, 9, 1, 1)); // Labor Day : 1er lundi de septembre
    if (all) s.add(fpHolNth(y, 11, 4, 4)); // Thanksgiving : 4e jeudi de novembre
    fixed(12, 25);
    // Le 1er janvier de l'année suivante tombant un samedi n'est pas chômé la veille : rien à ajouter.
  } else if (key === 'london') {
    // Jours fériés d'Angleterre : report au premier jour ouvré libre.
    const sub = function (m, d) {
      let k = 0;
      while (fpHolWd(y, m, d + k) === 0 || fpHolWd(y, m, d + k) === 6 || s.has(fpHolISO(y, m, d + k))) k++;
      s.add(fpHolISO(y, m, d + k));
    };
    sub(1, 1);
    s.add(goodFriday);
    s.add(fpHolISO(y, E[0], E[1] + 1));   // lundi de Pâques
    s.add(fpHolNth(y, 5, 1, 1));          // 1er lundi de mai
    s.add(fpHolNth(y, 5, 1, -1));         // dernier lundi de mai
    s.add(fpHolNth(y, 8, 1, -1));         // dernier lundi d'août
    sub(12, 25); sub(12, 26);
  } else if (key === 'tokyo') {
    const nat = new Set();
    const q = Math.floor((y - 1980) / 4);
    const spring = Math.floor(20.8431 + 0.242194 * (y - 1980)) - q;   // équinoxe de printemps (1980-2099)
    const autumn = Math.floor(23.2488 + 0.242194 * (y - 1980)) - q;   // équinoxe d'automne
    [[1, 1], [2, 11], [2, 23], [3, spring], [4, 29], [5, 3], [5, 4], [5, 5], [8, 11], [9, autumn], [11, 3], [11, 23]]
      .forEach(function (p) { nat.add(fpHolISO(y, p[0], p[1])); });
    nat.add(fpHolNth(y, 1, 1, 2));        // majorité : 2e lundi de janvier
    nat.add(fpHolNth(y, 7, 1, 3));        // mer : 3e lundi de juillet
    nat.add(fpHolNth(y, 9, 1, 3));        // personnes âgées : 3e lundi de septembre
    nat.add(fpHolNth(y, 10, 1, 2));       // sport : 2e lundi d'octobre
    const day = function (iso, n) { const a = iso.split('-').map(Number); return fpHolISO(a[0], a[1], a[2] + n); };
    const all = new Set(nat);
    nat.forEach(function (iso) {          // férié un dimanche : reporté au premier jour non férié
      const a = iso.split('-').map(Number);
      if (fpHolWd(a[0], a[1], a[2]) !== 0) return;
      let n = 1; while (nat.has(day(iso, n))) n++;
      all.add(day(iso, n));
    });
    nat.forEach(function (iso) {          // jour pris entre deux fériés (« pont » légal)
      const mid = day(iso, 1);
      if (!nat.has(mid) && nat.has(day(iso, 2))) all.add(mid);
    });
    all.forEach(function (iso) { s.add(iso); });
    s.add(fpHolISO(y, 1, 2)); s.add(fpHolISO(y, 1, 3)); s.add(fpHolISO(y, 12, 31)); // fermeture de fin d'année de la Bourse
  }
  FP_HOLIDAY_CACHE[ck] = s;
  return s;
}
// fpMarketHoliday(key, dayISO) : la place est-elle fermée ce jour civil (jour de SON fuseau) ?
// Jour illisible ou place inconnue → false.
function fpMarketHoliday(key, dayISO) {
  const day = String(dayISO == null ? '' : dayISO).slice(0, 10);
  if (!fpIsCivilDay(day)) return false;
  return fpMarketHolidays(key, Number(day.slice(0, 4))).has(day);
}

// ─── Sessions de marché ───────────────────────────────────────────────────
// Chaque place est évaluée dans SON fuseau (les décalages été/hiver ne tombent
// pas aux mêmes dates en Europe, aux États-Unis et au Japon), du lundi au
// vendredi, début inclus et fin exclue. Minutes depuis minuit, heure locale.
// Une place est fermée toute la journée les jours fériés de son calendrier
// (fpMarketHoliday). Limites : les demi-séances (veille de Noël, lendemain de
// Thanksgiving) gardent l'horaire habituel et les fermetures exceptionnelles
// ne sont pas connues.
const FP_MARKETS = [
  { key: 'tokyo',   name: 'Tokyo',    tz: 'Asia/Tokyo',       ranges: [[540, 690], [750, 930]] }, // 09:00–11:30, 12:30–15:30
  { key: 'london',  name: 'Londres',  tz: 'Europe/London',    ranges: [[480, 990]] },             // 08:00–16:30
  { key: 'newyork', name: 'New York', tz: 'America/New_York', ranges: [[570, 960]] },             // 09:30–16:00
];

// isCMEOpen(date) — CME Globex, heure de Chicago : fermé le samedi, le dimanche
// avant 17:00, le vendredi dès 16:00 et chaque jour de 16:00 à 17:00 (pause).
// Une séance Globex va de 17:00 la veille à 16:00 : celle du 1er janvier, du
// Vendredi saint et du 25 décembre (dates reportées comme à New York) n'a pas
// lieu, donc fermé ce jour-là avant 17:00 et la veille à partir de 17:00.
// Limite : les autres jours fériés américains (Martin Luther King, Presidents'
// Day, Memorial Day, Juneteenth, 4 juillet, Labor Day, Thanksgiving) gardent
// ici l'horaire habituel alors que Globex s'y arrête plus tôt (vers midi) ;
// demi-séances et fermetures exceptionnelles ne sont pas connues non plus.
function isCMEOpen(date) {
  const d = fpToDate(date);
  const t = fpWeekdayMinInTz('America/Chicago', d);
  if (t.wd === 6) return false;
  if (t.wd === 0 && t.min < 1020) return false;
  if (t.wd === 5 && t.min >= 960) return false;
  if (t.min >= 960 && t.min < 1020) return false;
  // Jour civil de Chicago dont la séance est en cours : le jour même avant
  // 17:00, le lendemain ensuite.
  const day = localDayInTz(d, 'America/Chicago');
  if (!day) return true;
  const a = day.split('-').map(Number);
  const sessionDay = t.min >= 1020 ? fpHolISO(a[0], a[1], a[2] + 1) : day;
  return !fpMarketHoliday('cmeclose', sessionDay);
}

function fpJoinEt(names) {
  if (names.length <= 1) return names.join('');
  return names.slice(0, -1).join(', ') + ' et ' + names[names.length - 1];
}

// sessionStatusInTz(timezone, date) — SEULE source de vérité de l'état des
// marchés. Renvoie :
//   now       'HH:MM' dans le fuseau demandé (du profil par défaut)
//   sessions  [{ key, name, tz, open, label: 'Ouverte' | 'Fermée' }]
//   tokyo, london, newyork  booléens (même forme que l'ancien sessionStatus)
//   cmeOpen, cmeLabel ('CME ouvert' | 'CME fermé')
//   openNames ['Londres', 'New York'], label 'Londres et New York'
function sessionStatusInTz(timezone, date) {
  const d = fpToDate(date);
  const sessions = FP_MARKETS.map(function (mk) {
    const t = fpWeekdayMinInTz(mk.tz, d);
    const weekday = t.wd >= 1 && t.wd <= 5;
    const open = weekday && !fpMarketHoliday(mk.key, localDayInTz(d, mk.tz)) && mk.ranges.some(function (r) { return t.min >= r[0] && t.min < r[1]; });
    return { key: mk.key, name: mk.name, tz: mk.tz, open: open, label: open ? 'Ouverte' : 'Fermée' };
  });
  const byKey = {};
  sessions.forEach(function (s) { byKey[s.key] = s.open; });
  const openNames = sessions.filter(function (s) { return s.open; }).map(function (s) { return s.name; });
  const cmeOpen = isCMEOpen(d);
  return {
    now: tzTimeIn(d, timezone),
    sessions: sessions,
    tokyo: byKey.tokyo,
    london: byKey.london,
    newyork: byKey.newyork,
    cmeOpen: cmeOpen,
    cmeLabel: cmeOpen ? 'CME ouvert' : 'CME fermé',
    openNames: openNames,
    label: openNames.length ? fpJoinEt(openNames) : 'Aucune session ouverte',
  };
}

// ─── Exposition globale (pattern projet) ──────────────────────────────────
Object.assign(window, {
  // Montants
  fmtMoneyCur,
  fmtMoneyCompact,
  fmtMoneyCompactCur,
  currencySymbol,
  currencyDecimals,
  moneySign,
  // Nombres
  fmtNum,
  fmtPct,
  fmtR,
  fmtPF,
  fmtPrice,
  priceDigitsOf,
  fmtLots,
  fmtCount,
  parseDecimalFr,
  INVALID_NUM,
  // Dates
  fmtDayShort,
  fmtDayLong,
  fmtWeekdayShort,
  fmtDateTime,
  fmtMonthYear,
  fmtDayRange,
  fmtWeekRange,
  isoWeekNum,
  fmtWeekLabel,
  fmtDuration,
  zonedToUtcISO,
  FR_MONTHS_SHORT,
  FR_MONTHS_LONG,
  FR_WEEKDAYS_SHORT,
  FR_WEEKDAYS_LONG,
  FR_WEEKDAYS_2,
  // Fuseau
  tzLabel,
  tzHourLabel,
  tzAtLabel,
  tzLabelWithOffset,
  TZ_LABELS,
  tzNow,
  tzTimeIn,
  tzClock,
  localDayInTz,
  // Marchés
  sessionStatusInTz,
  isCMEOpen,
  fpMarketHolidays,
  fpMarketHoliday,
});
