// Tempo — Trades data layer (CRUD + stats) on top of Supabase
//
// Contrat des erreurs (charte §7) : le message brut part dans
// console.warn('[Tempo] …') ; l'appelant affiche sbErrorMessage(error), jamais
// error.message tel quel. Une erreur de chargement n'est JAMAIS un compte vide.

// Message quand le client de données n'est pas chargé (aucun mot technique).
const TA_UNAVAILABLE_MSG = 'Le service est momentanément indisponible. Réessaie dans un instant.';
// Lignes lues par requête. Le serveur plafonne une réponse à 1 000 lignes :
// au-delà, on pagine au lieu de tronquer silencieusement l'historique.
const TA_PAGE_SIZE = 1000;
// Identifiants externes vérifiés par requête lors d'un import (URL bornée).
const TA_DEDUPE_CHUNK = 200;

function taUnavailable() {
  return { message: TA_UNAVAILABLE_MSG };
}

// listTradesResult({ from, to, symbol }) → { data, error }.
// Succès : data = tableau des trades (du plus récent au plus ancien), error = null.
// Échec : data = null, error = l'erreur brute. L'appelant garde alors les trades
// déjà chargés et affiche un bandeau « Impossible de charger tes trades » avec
// [Réessayer] : un échec ne doit jamais ressembler à un compte vide (sinon
// l'utilisateur réimporte et crée des doublons).
// Tout l'historique est lu, page par page, quel que soit le nombre de trades.
async function listTradesResult({ from, to, symbol } = {}) {
  if (!window.sb) {
    console.warn('[Tempo] listTradesResult : client de données indisponible.');
    return { data: null, error: taUnavailable() };
  }
  const all = [];
  const seenIds = new Set();
  let total = null;
  for (let offset = 0; ; ) {
    let q = window.sb.from('trades')
      .select('*', offset === 0 ? { count: 'exact' } : undefined)
      .order('executed_at', { ascending: false })
      .order('id', { ascending: false }) // ordre stable d'une page à l'autre
      .range(offset, offset + TA_PAGE_SIZE - 1);
    if (from)   q = q.gte('executed_at', from);
    if (to)     q = q.lte('executed_at', to);
    if (symbol) q = q.eq('symbol', symbol);
    const { data, error, count } = await q;
    if (error) {
      console.warn('[Tempo] listTradesResult :', error);
      return { data: null, error };
    }
    if (offset === 0 && Number.isFinite(count)) total = count;
    const page = data || [];
    for (const t of page) {
      if (t && t.id != null) {
        if (seenIds.has(t.id)) continue;
        seenIds.add(t.id);
      }
      all.push(t);
    }
    offset += page.length;
    // Fin : page vide, total atteint, ou (total inconnu) page incomplète.
    if (page.length === 0) break;
    if (total != null ? offset >= total : page.length < TA_PAGE_SIZE) break;
  }
  return { data: all, error: null };
}

// listTrades — ancien contrat, conservé pour compatibilité : renvoie TOUJOURS
// un tableau ([] en cas d'échec, après un console.warn). Il ne distingue donc
// pas un échec d'un compte vide : tout nouvel appel passe par listTradesResult.
async function listTrades(opts = {}) {
  const { data } = await listTradesResult(opts);
  return data || [];
}

async function getTrade(id) {
  if (!window.sb) return null;
  const { data, error } = await window.sb.from('trades').select('*').eq('id', id).maybeSingle();
  if (error) { console.warn('[Tempo] getTrade :', error); return null; }
  return data;
}

// Rétrocompatibilité durée : tant que la migration
// supabase-migration-trades-duree.sql n'est pas exécutée, la colonne
// duration_min n'existe pas. On avertit UNIQUEMENT en console et on réessaie
// sans le champ : un trade n'est jamais perdu à cause de cette colonne.
function taIsMissingDuration(error) {
  return !!error && /duration_min/i.test(String(error.message || '') + ' ' + String(error.details || ''));
}
function taWarnMissingDuration() {
  console.warn('[Tempo] La colonne duration_min est absente de la table trades : exécute supabase-migration-trades-duree.sql. Trade enregistré sans la durée.');
}
// Copie explicite, SANS déstructuration « { duration_min, ...rest } » : Babel la
// compile en une variable globale « _excluded » partagée par tous les fichiers
// (aucun bundler, tout est global). Un autre fichier qui utilise la même
// écriture la remplace, et la durée n'était alors plus retirée.
function taWithoutDuration(row) {
  const rest = {};
  for (const k of Object.keys(row || {})) {
    if (k !== 'duration_min') rest[k] = row[k];
  }
  return rest;
}

// Date d'exécution portée par un trade à enregistrer : executed_at s'il est
// renseigné, sinon date. Choix par PRÉSENCE (ni undefined, ni null, ni '') et
// non par vérité : NaN ou 0 sont des valeurs fournies, à contrôler comme les
// autres, pas une date absente. Seule fonction de choix, commune au contrôle
// (taPayloadProblem) et à l'écriture (sanitizeTradePayload) : les deux lisent
// toujours la même valeur.
function taPayloadWhen(p) {
  const o = p || {};
  const a = o.executed_at;
  return (a !== undefined && a !== null && a !== '') ? a : o.date;
}

// Ce qui rend un trade inenregistrable : une date illisible (elle serait
// remplacée par « maintenant ») ou un P&L présent mais illisible (il serait
// remplacé par 0). Renvoie le message à afficher, ou null. Une date vide
// (maintenant) et un P&L absent (0) restent admis au niveau de l'API : le
// formulaire les exige lui-même.
function taPayloadProblem(p) {
  const o = p || {};
  const when = taPayloadWhen(o);
  if (when !== null && when !== undefined && when !== '' && taReadInstant(when) === null) {
    return 'La date d’un trade est illisible.';
  }
  const pnl = o.pnl;
  if (pnl !== null && pnl !== undefined && String(pnl).trim() !== '' && numOrNull(pnl) === null) {
    return 'Le P&L d’un trade est illisible.';
  }
  return null;
}

async function addTrade(payload) {
  if (!window.sb) return { data: null, error: taUnavailable() };
  const { data: userData } = await window.sb.auth.getUser();
  const user_id = userData?.user?.id;
  if (!user_id) return { data: null, error: { message: 'Tu dois être connecté.' } };
  const problem = taPayloadProblem(payload);
  if (problem) {
    console.warn('[Tempo] addTrade : trade refusé avant écriture —', problem, payload);
    return { data: null, error: { message: problem } };
  }
  const row = sanitizeTradePayload(payload, user_id);
  let { data, error } = await window.sb.from('trades').insert(row).select().single();
  if (error && row.duration_min != null && taIsMissingDuration(error)) {
    taWarnMissingDuration();
    ({ data, error } = await window.sb.from('trades').insert(taWithoutDuration(row)).select().single());
  }
  if (error) console.warn('[Tempo] addTrade :', error);
  return { data, error };
}

async function updateTrade(id, payload) {
  if (!window.sb) return { data: null, error: taUnavailable() };
  const patch = { ...payload };
  delete patch.id; delete patch.user_id; delete patch.created_at;
  let { data, error } = await window.sb.from('trades').update(patch).eq('id', id).select().single();
  if (error && patch.duration_min != null && taIsMissingDuration(error)) {
    taWarnMissingDuration();
    ({ data, error } = await window.sb.from('trades').update(taWithoutDuration(patch)).eq('id', id).select().single());
  }
  if (error) console.warn('[Tempo] updateTrade :', error);
  return { data, error };
}

async function deleteTrade(id) {
  if (!window.sb) return { error: taUnavailable() };
  const { error } = await window.sb.from('trades').delete().eq('id', id);
  if (error) console.warn('[Tempo] deleteTrade :', error);
  return { error };
}

// Lecture paginée d'une requête sur trades (le serveur plafonne une réponse à
// TA_PAGE_SIZE lignes). build() renvoie une requête neuve, déjà filtrée ; l'ordre
// par id rend les pages stables. Renvoie { data, error }.
async function taSelectAllPages(build) {
  const all = [];
  for (let offset = 0; ; offset += TA_PAGE_SIZE) {
    const { data, error } = await build().order('id', { ascending: true }).range(offset, offset + TA_PAGE_SIZE - 1);
    if (error) return { data: null, error };
    const page = data || [];
    for (const row of page) all.push(row);
    if (page.length < TA_PAGE_SIZE) break;
  }
  return { data: all, error: null };
}

// P&L comparé aux décimales de la devise du profil : un import d'avant
// l'arrondi (131,5 en yens) reconnaît le même trade relu aujourd'hui (132).
function taPnlKey(pnl) {
  const cur = (typeof window !== 'undefined' && window.__appCurrency) || 'USD';
  const dec = typeof currencyDecimals === 'function' ? currencyDecimals(cur) : 2;
  const f = Math.pow(10, dec);
  const n = Number(pnl) || 0;
  const r = (n < 0 ? -1 : 1) * Math.round(Math.abs(n) * f + 1e-9 * f) / f;
  return String(r === 0 ? 0 : r);
}

// Import en lot, avec dédoublonnage sur (imported_from, external_id) quand
// external_id est présent : un fichier réimporté ne crée pas de doublons.
// Renvoie { data, error, skipped } (skipped = lignes déjà présentes).
// Tout ou rien : les doublons sont recherchés AVANT d'écrire, puis une seule
// insertion est faite. Si une recherche échoue, rien n'est écrit (sinon tout
// serait réinséré en double). Une ligne à la date ou au P&L illisible fait
// refuser le lot entier, sans rien écrire.
// Trois recherches :
// 1. identifiant exact (cas général) ;
// 2. lignes à empreinte de contenu « h_… » (fichier sans identifiant fiable,
//    import-modal.jsx) : comparaison avec les trades de même origine importés
//    AVANT les empreintes (external_id nul ou identifiant nu du fichier), sur
//    symbole + instant + P&L, sinon le premier réimport d'un ancien fichier
//    doublerait tout ;
// 3. relevé MetaTrader (ticket nu) : comparaison avec les positions déjà
//    synchronisées par le courtier, enregistrées « login:ticket ».
async function bulkInsertTrades(rows) {
  if (!window.sb) return { data: null, error: taUnavailable() };
  const { data: userData } = await window.sb.auth.getUser();
  const user_id = userData?.user?.id;
  if (!user_id) return { data: null, error: { message: 'Tu dois être connecté.' } };
  const list = Array.isArray(rows) ? rows : [];
  for (const r of list) {
    const problem = taPayloadProblem(r);
    if (problem) {
      console.warn('[Tempo] bulkInsertTrades : lot refusé avant écriture —', problem, r);
      return { data: null, error: { message: problem } };
    }
  }
  const sanitized = list.map(r => sanitizeTradePayload(r, user_id));
  if (sanitized.length === 0) return { data: [], error: null, skipped: 0 };
  const failSearch = (searchError) => {
    console.warn('[Tempo] bulkInsertTrades (recherche des doublons) :', searchError);
    return { data: null, error: searchError };
  };
  const keyOf = r => `${r.imported_from || ''}::${r.external_id}`;
  const withExt = sanitized.filter(r => r.external_id);
  const noExt   = sanitized.filter(r => !r.external_id);
  let fresh = [];
  if (withExt.length) {
    // 1. Identifiant exact.
    const ids = Array.from(new Set(withExt.map(r => r.external_id)));
    const seen = new Set();
    for (let i = 0; i < ids.length; i += TA_DEDUPE_CHUNK) {
      const found = await window.sb.from('trades')
        .select('external_id, imported_from')
        .in('external_id', ids.slice(i, i + TA_DEDUPE_CHUNK));
      if (found.error) return failSearch(found.error);
      for (const e of found.data || []) seen.add(keyOf(e));
    }
    // Déjà en base, ou présent deux fois dans le même fichier : ignoré.
    for (const r of withExt) {
      const k = keyOf(r);
      if (seen.has(k)) continue;
      seen.add(k);
      fresh.push(r);
    }

    // 2. Empreintes de contenu face aux imports d'avant les empreintes.
    const isPrint = r => String(r.external_id).startsWith('h_');
    const sources = Array.from(new Set(fresh.filter(isPrint).map(r => r.imported_from || '')));
    for (const src of sources) {
      if (!src) continue; // une empreinte vient toujours d'un fichier importé
      const batch = fresh.filter(r => isPrint(r) && (r.imported_from || '') === src);
      let min = batch[0].executed_at, max = batch[0].executed_at;
      for (const r of batch) {
        if (r.executed_at < min) min = r.executed_at;
        if (r.executed_at > max) max = r.executed_at;
      }
      const older = await taSelectAllPages(() => window.sb.from('trades')
        .select('id, external_id, symbol, executed_at, pnl')
        .eq('imported_from', src).gte('executed_at', min).lte('executed_at', max));
      if (older.error) return failSearch(older.error);
      const contentKey = r => `${String(r.symbol || '').toUpperCase()}|${Date.parse(r.executed_at)}|${taPnlKey(r.pnl)}`;
      // Multi-ensemble : n anciennes lignes n'absorbent que n lignes du lot.
      const legacy = new Map();
      for (const e of older.data || []) {
        if (e.external_id != null && String(e.external_id).startsWith('h_')) continue;
        const k = contentKey(e);
        legacy.set(k, (legacy.get(k) || 0) + 1);
      }
      if (legacy.size) {
        fresh = fresh.filter(r => {
          if (!isPrint(r) || (r.imported_from || '') !== src) return true;
          const k = contentKey(r), left = legacy.get(k) || 0;
          if (!left) return true;
          legacy.set(k, left - 1);
          return false;
        });
      }
    }

    // 3. Tickets MetaTrader nus face aux positions synchronisées « login:ticket ».
    // Le symbole doit concorder : les tickets sont des compteurs par serveur,
    // deux comptes peuvent porter le même numéro. Le P&L n'est pas comparé (la
    // commission d'entrée peut le faire différer d'une source à l'autre).
    const isBareTicket = r => r.imported_from === 'mt5' && !isPrint(r) && !String(r.external_id).includes(':');
    if (fresh.some(isBareTicket)) {
      const synced = await taSelectAllPages(() => window.sb.from('trades')
        .select('id, external_id, symbol').eq('imported_from', 'mt5').like('external_id', '%:%'));
      if (synced.error) return failSearch(synced.error);
      const known = new Set();
      for (const e of synced.data || []) {
        const ext = String(e.external_id || '');
        const cut = ext.lastIndexOf(':');
        if (cut <= 0 || !/^\d+$/.test(ext.slice(0, cut))) continue; // seule la forme « login:ticket »
        known.add(ext.slice(cut + 1) + '|' + String(e.symbol || '').toUpperCase());
      }
      if (known.size) {
        fresh = fresh.filter(r => !(isBareTicket(r) && known.has(String(r.external_id) + '|' + String(r.symbol || '').toUpperCase())));
      }
    }
  }
  const toInsert = noExt.concat(fresh);
  if (toInsert.length === 0) return { data: [], error: null, skipped: sanitized.length };
  let { data, error } = await window.sb.from('trades').insert(toInsert).select();
  if (error && toInsert.some(r => r.duration_min != null) && taIsMissingDuration(error)) {
    taWarnMissingDuration();
    ({ data, error } = await window.sb.from('trades').insert(toInsert.map(taWithoutDuration)).select());
  }
  if (error) {
    console.warn('[Tempo] bulkInsertTrades :', error);
    return { data: null, error };
  }
  const inserted = data || [];
  return { data: inserted, error: null, skipped: sanitized.length - inserted.length };
}

function sanitizeTradePayload(p, user_id) {
  const dir = String(p.direction || p.dir || '').toLowerCase();
  // Durée (min) : envoyée UNIQUEMENT si renseignée et valide — un payload sans
  // duration_min reste byte-compatible avec une DB non migrée.
  const durRaw = numOrNull(p.duration_min);
  const dur = durRaw != null && durRaw > 0 ? Math.round(durRaw) : null;
  const row = {
    user_id,
    symbol: String(p.symbol || '').toUpperCase().trim() || 'UNKNOWN',
    direction: dir === 'short' || dir === 's' || dir === 'sell' ? 'short' : 'long',
    entry: numOrNull(p.entry),
    exit_price: numOrNull(p.exit_price ?? p.exit),
    lots: numOrNull(p.lots) ?? 1,
    pnl: numOrNull(p.pnl) ?? 0,
    r_multiple: numOrNull(p.r_multiple ?? p.r),
    setup: p.setup || null,
    notes: p.notes || null,
    screenshot_url: p.screenshot_url || null,
    executed_at: toISOorNow(taPayloadWhen(p)),
    imported_from: p.imported_from || null,
    external_id: p.external_id ? String(p.external_id) : null,
  };
  if (dur != null) row.duration_min = dur;
  return row;
}

// ─── Durée des trades — parsing tolérant + formatage ─────────────
// parseDurationMin('90') → 90 ; '1h30' → 90 ; '1 h' → 60 ; '90 min' → 90 ;
// '1:30' → 90. Retourne null si vide, NaN si non interprétable.
function parseDurationMin(raw) {
  if (raw === null || raw === undefined) return null;
  const s = String(raw).trim().toLowerCase().replace(/\s+/g, '');
  if (s === '') return null;
  // "1h30", "1h", "1:30", "2h05"
  const hm = s.match(/^(\d{1,3})[h:](\d{1,2})?(?:min?)?$/);
  if (hm) {
    const h = parseInt(hm[1], 10);
    const m = hm[2] ? parseInt(hm[2], 10) : 0;
    if (m >= 60) return NaN;
    return h * 60 + m;
  }
  // "90", "90min", "90mn", "90m", "1,5h" refusé (ambigu) — décimales de minutes arrondies
  const mn = s.match(/^(\d+(?:[.,]\d+)?)(?:min|mn|m)?$/);
  if (mn) {
    const v = Math.round(parseFloat(mn[1].replace(',', '.')));
    return Number.isFinite(v) ? v : NaN;
  }
  return NaN;
}

// fmtDurationMin(90) → « 1 h 30 » ; 60 → « 1 h » ; 45 → « 45 min ».
// Délègue à fmtDuration (format-prefs.jsx, charte §4) ; contrat conservé :
// chaîne vide pour une durée absente ou nulle (l'appelant masque la ligne).
function fmtDurationMin(min) {
  if (min === null || min === undefined || min === '') return '';
  const v = Number(min);
  if (!Number.isFinite(v) || v <= 0) return '';
  return fmtDuration(v);
}

// Nombre d'une ligne à enregistrer : un nombre fini passe tel quel ; une chaîne
// est lue strictement par parseDecimalFr (virgule ou point, espaces). Vide ou
// illisible (« 12abc », « 1,234.5 ») → null, jamais un nombre tronqué.
function numOrNull(v) {
  if (typeof v === 'number') return Number.isFinite(v) ? v : null;
  const n = parseDecimalFr(v);
  return typeof n === 'number' ? n : null;
}

// Fin d'un texte qui porte un fuseau explicite (voir taReadInstant) : un
// décalage n'est reconnu qu'après une heure « HH:MM[:SS[.fff]] », ou après
// GMT / UTC.
const TA_EXPLICIT_ZONE = /(?:\d{1,2}:\d{2}(?::\d{2})?(?:\.\d+)?\s*(?:z|[+-]\d{2}:?\d{2})|\b(?:GMT|UTC)\s*(?:[+-]\d{2}:?\d{2})?)$/i;

// Instant d'exécution → ISO UTC, ou null s'il est illisible.
// - Date, millisecondes, ou instant complet : tel quel. Un instant complet se
//   termine par un fuseau explicite : « Z », « ±HH:MM » ou « ±HHMM » placés
//   APRÈS une heure (« 14:30:00+02:00 »), ou « GMT » / « UTC » avec ou sans
//   décalage. La fin « -2026 » de « 09-26-2026 » n'est pas un décalage.
// - Date ou date-heure SANS fuseau au format AAAA-MM-JJ (« 2026-09-26 »,
//   « 2026-09-26 14:30 », « 2026-09-26T14:30:00 ») : c'est une saisie dans le
//   fuseau du profil, lue par zonedToUtcISO (charte §4). Jamais
//   new Date('YYYY-MM-DD'), qui la placerait à minuit UTC (la veille à New
//   York).
// - Tout autre texte sans fuseau explicite (« 2026.09.26 14:30 »,
//   « 26/09/2026 14:30 ») : illisible. new Date() le lirait dans le fuseau du
//   NAVIGATEUR, que Tempo n'utilise jamais.
function taReadInstant(v) {
  if (v instanceof Date || typeof v === 'number') {
    const d = new Date(v);
    return isNaN(d.getTime()) ? null : d.toISOString();
  }
  const s = String(v == null ? '' : v).trim();
  const civil = s.match(/^(\d{4}-\d{2}-\d{2})(?:[T ](\d{1,2}:\d{2}(?::\d{2})?)(?:\.\d+)?)?$/);
  if (civil) return zonedToUtcISO(civil[1], civil[2] || '00:00') || null;
  if (!TA_EXPLICIT_ZONE.test(s)) return null;
  const d = new Date(s);
  return isNaN(d.getTime()) ? null : d.toISOString();
}

// toISOorNow — contrat historique : vide → maintenant ; illisible → maintenant,
// avec un avertissement console. addTrade et bulkInsertTrades refusent une date
// illisible AVANT d'arriver ici (taPayloadProblem) : ce repli ne sert plus qu'à
// un appelant direct.
function toISOorNow(v) {
  if (v === null || v === undefined || v === '') return new Date().toISOString();
  const iso = taReadInstant(v);
  if (iso) return iso;
  console.warn('[Tempo] Date de trade illisible, remplacée par maintenant :', v);
  return new Date().toISOString();
}

// Indicateurs agrégés d'une liste de trades (pur, rapide). Définitions de la
// charte §3 :
// - wins / losses / breakeven : trades gagnants, perdants et nuls (P&L = 0).
//   Sous-titre : « 24 G · 22 P · 1 nul » ; wins + losses + breakeven = count.
// - winRate (Taux de réussite, en %) = gagnants ÷ nombre de trades (nuls compris).
// - profitFactor : Infinity s'il y a des gains et aucune perte (affiché « ∞ »
//   par fmtPF, jamais remplacé par 0) ; 0 sans gain ni perte (les écrans
//   affichent alors « — »).
// - avgWin / avgLoss : Gain moyen et Perte moyenne en VALEUR ABSOLUE (l'écran
//   affiche la perte signée : fmtMoney(-avgLoss)).
// - avgR (R moyen) sur les seuls trades notés en R ; rCount = leur nombre.
// - expectancy (Espérance par trade) = P&L net ÷ nombre de trades.
function computeStats(trades) {
  if (!Array.isArray(trades) || trades.length === 0) {
    return { netPnl: 0, winRate: 0, profitFactor: 0, avgR: 0, rCount: 0, expectancy: 0, count: 0, wins: 0, losses: 0, breakeven: 0, avgWin: 0, avgLoss: 0 };
  }
  let netPnl = 0, wins = 0, losses = 0, breakeven = 0, totalWin = 0, totalLoss = 0, sumR = 0, rCount = 0;
  for (const t of trades) {
    const p = Number(t.pnl) || 0;
    netPnl += p;
    if (p > 0) { wins++; totalWin += p; }
    else if (p < 0) { losses++; totalLoss += Math.abs(p); }
    else { breakeven++; }
    // R moyen : ne compter QUE les trades réellement notés en R (nombre fini,
    // non vide). Les 0 sont traités comme « non renseignés » (et non comme un
    // break-even saisi), sinon ils écrasent artificiellement la moyenne — c'est
    // exactement le symptôme du R moyen rabaissé. On exclut null/undefined/''/NaN/0.
    const rv = Number(t.r_multiple);
    if (t.r_multiple !== null && t.r_multiple !== undefined && t.r_multiple !== '' && Number.isFinite(rv) && rv !== 0) {
      sumR += rv; rCount++;
    }
  }
  const count = trades.length;
  const winRate = count > 0 ? (wins / count) * 100 : 0;
  const profitFactor = totalLoss > 0 ? totalWin / totalLoss : (totalWin > 0 ? Infinity : 0);
  const avgR = rCount > 0 ? sumR / rCount : 0;
  const avgWin  = wins > 0 ? totalWin / wins : 0;
  const avgLoss = losses > 0 ? totalLoss / losses : 0;
  // Espérance = P&L moyen par trade (sur tous les trades, y compris break-even).
  const expectancy = count > 0 ? netPnl / count : 0;
  return { netPnl, winRate, profitFactor, avgR, rCount, expectancy, count, wins, losses, breakeven, avgWin, avgLoss };
}

// getStatsResult({ from, to }) → { data, error } : data = computeStats(...) en
// cas de succès, null en cas d'échec (même contrat que listTradesResult).
async function getStatsResult({ from, to } = {}) {
  const { data, error } = await listTradesResult({ from, to });
  return { data: error ? null : computeStats(data), error: error || null };
}

// getStats — ancien contrat, conservé pour compatibilité : renvoie toujours un
// objet d'indicateurs (celui d'un compte vide en cas d'échec). Préférer
// getStatsResult, qui distingue un échec d'un compte vide.
async function getStats({ from, to } = {}) {
  const { data } = await getStatsResult({ from, to });
  return data || computeStats([]);
}

// Jour civil 'YYYY-MM-DD' d'un executed_at, dans le fuseau du profil
// (window.__appTz, ou celui passé en paramètre). On NE fait PAS un slice(0,10)
// brut : executed_at est stocké en UTC, le slice rangerait un trade de 01:00
// à Paris la veille. localDayInTz (format-prefs.jsx) projette l'instant dans le
// fuseau : même regroupement que le calendrier, le Bilan hebdo et les
// Statistiques. Une date civile passe telle quelle ; une date illisible donne ''
// (le trade n'est rangé dans aucun jour plutôt que dans « aujourd'hui »).
function localDayKey(iso, tz) {
  if (!iso) return '';
  if (typeof iso === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(iso)) return iso;
  const d = iso instanceof Date ? iso : new Date(iso);
  if (isNaN(d.getTime())) return '';
  return localDayInTz(d, tz);
}

// Trades regroupés par jour civil (fuseau du profil) pour le calendrier.
// Chaque jour : { day, trades, pnl, wins, losses, breakeven } — mêmes
// définitions que computeStats.
function groupTradesByDay(trades, tz) {
  const out = {};
  for (const t of Array.isArray(trades) ? trades : []) {
    const day = localDayKey(t.executed_at, tz);
    if (!day) continue;
    if (!out[day]) out[day] = { day, trades: [], pnl: 0, wins: 0, losses: 0, breakeven: 0 };
    const p = Number(t.pnl) || 0;
    out[day].trades.push(t);
    out[day].pnl += p;
    if (p > 0) out[day].wins++;
    else if (p < 0) out[day].losses++;
    else out[day].breakeven++;
  }
  return out;
}

Object.assign(window, {
  listTrades, listTradesResult, getTrade, addTrade, updateTrade, deleteTrade,
  bulkInsertTrades, computeStats, getStats, getStatsResult, groupTradesByDay, localDayKey,
  parseDurationMin, fmtDurationMin,
});
