// Tempo — fenêtre « Ajouter des trades » : import de fichier (relevé
// MetaTrader 5, Tradovate, fichier générique), saisie manuelle et connexion
// d'un courtier. Charte : docs/charte-ui.md (§2 montants, §4 fuseau, §8 modales).

const IM_NB = ' ';    // espace insécable : avant « : », « ko », dans « »
const IM_NNB = ' ';   // espace fine insécable : avant « ? ! ; », milliers
const IM_MINUS = '−'; // signe moins typographique

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

// ─── Lecture du fichier ──────────────────────────────────────────
// Les relevés HTML de MetaTrader 5 sont en UTF-16 et les exports Excel
// européens souvent en Windows-1252 : on lit les octets et on choisit
// l'encodage (BOM, octets nuls d'UTF-16, UTF-8 strict, puis Windows-1252).
function imDecodeBytes(buf) {
  const bytes = new Uint8Array(buf || new ArrayBuffer(0));
  let enc = 'utf-8', start = 0;
  if (bytes[0] === 0xFF && bytes[1] === 0xFE) { enc = 'utf-16le'; start = 2; }
  else if (bytes[0] === 0xFE && bytes[1] === 0xFF) { enc = 'utf-16be'; start = 2; }
  else if (bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF) { start = 3; }
  else {
    const n = Math.min(bytes.length, 512);
    let zOdd = 0, zEven = 0;
    for (let i = 0; i < n; i++) if (bytes[i] === 0) { if (i % 2) zOdd++; else zEven++; }
    if (n && zOdd > n / 4) enc = 'utf-16le';
    else if (n && zEven > n / 4) enc = 'utf-16be';
  }
  const body = bytes.subarray(start);
  if (enc !== 'utf-8') return new TextDecoder(enc).decode(body);
  try { return new TextDecoder('utf-8', { fatal: true }).decode(body); }
  catch (e) { return new TextDecoder('windows-1252').decode(body); }
}

// Fichier qui ne peut pas contenir de trades, d'après son texte décodé :
// 'empty' (0 octet, ou rien d'autre que des espaces), 'binary' (classeur Excel
// ou archive « PK », PDF, image… : des caractères de contrôle qu'un export
// texte ne contient pas), sinon null. Le glisser-déposer ne filtre pas les
// types de fichiers : un .xlsx arrive ici.
function imFileProblem(text, size) {
  const t = String(text == null ? '' : text);
  if (!(size > 0) || t.replace(/^\uFEFF/, '').trim() === '') return 'empty';
  const head = t.slice(0, 4000).replace(/\u001a$/, ''); // Ctrl-Z final des vieux exports DOS
  if (/^(?:PK[\u0003\u0005\u0007]|%PDF)/.test(head)) return 'binary';
  if (/[\u0000-\u0008\u000e-\u001f\ufffd]/.test(head)) return 'binary';
  return null;
}
// Le glisser en cours transporte-t-il des fichiers (et non du texte sélectionné) ?
function imDragHasFiles(e) {
  const dt = e && e.dataTransfer;
  return !!dt && Array.from(dt.types || []).includes('Files');
}
const IM_FILE_PROBLEMS = {
  empty: 'Le fichier est vide.',
  binary: 'Ce n’est pas un fichier CSV ou HTML. Depuis MetaTrader 5, exporte le relevé en HTML (et non en Excel).',
};

function readFileAsText(file) {
  return new Promise((resolve, reject) => {
    const r = new FileReader();
    r.onerror = () => reject(r.error || new Error('file read failed'));
    if (typeof TextDecoder !== 'function') {
      r.onload = () => resolve(String(r.result || ''));
      r.readAsText(file);
      return;
    }
    r.onload = () => {
      try { resolve(imDecodeBytes(r.result)); } catch (e) { reject(e); }
    };
    r.readAsArrayBuffer(file);
  });
}

// ─── CSV ─────────────────────────────────────────────────────────
// Parseur CSV/TSV : champs entre guillemets, séparateur virgule, point-virgule
// ou tabulation (deviné sur la première ligne, ou imposé par « sep=; »).
// Un guillemet n'ouvre une zone protégée qu'en tête de champ, comme dans un
// tableur : ailleurs (« écran 27" trop petit ») c'est un caractère ordinaire,
// qui ne doit pas avaler les lignes suivantes.
function parseCSV(text) {
  if (!text) return { headers: [], rows: [] };
  text = String(text).replace(/^﻿/, '');
  let forced = null;
  const sepLine = text.match(/^sep=(.)\r?\n/i);
  if (sepLine) { forced = sepLine[1]; text = text.slice(sepLine[0].length); }
  const nl = text.indexOf('\n');
  const firstLine = nl >= 0 ? text.slice(0, nl) : text;
  let delim = forced || ',';
  if (!forced) {
    const counts = { ',': (firstLine.match(/,/g) || []).length, ';': (firstLine.match(/;/g) || []).length, '\t': (firstLine.match(/\t/g) || []).length };
    if (counts['\t'] > counts[','] && counts['\t'] > counts[';']) delim = '\t';
    else if (counts[';'] > counts[',']) delim = ';';
  }

  const rows = [];
  let cur = [], field = '', inQ = false;
  for (let i = 0; i < text.length; i++) {
    const c = text[i];
    if (inQ) {
      if (c === '"') {
        if (text[i + 1] === '"') { field += '"'; i++; }
        else { inQ = false; }
      } else { field += c; }
    } else {
      if (c === '"' && field.trim() === '') { inQ = true; }
      else if (c === delim) { cur.push(field); field = ''; }
      else if (c === '\n') { cur.push(field); rows.push(cur); cur = []; field = ''; }
      else if (c === '\r') { /* ignoré */ }
      else { field += c; }
    }
  }
  if (field.length > 0 || cur.length > 0) { cur.push(field); rows.push(cur); }
  const cleaned = rows.map(r => r.map(c => (c || '').trim())).filter(r => r.some(c => c !== ''));
  if (cleaned.length === 0) return { headers: [], rows: [] };
  return { headers: cleaned[0], rows: cleaned.slice(1) };
}

// ─── Relevé HTML MetaTrader 5 ────────────────────────────────────
function imHtmlText(s) {
  return String(s || '')
    .replace(/<[^>]+>/g, '')
    .replace(/&nbsp;/gi, ' ')
    .replace(/&#(\d+);/g, (m, n) => String.fromCharCode(+n))
    .replace(/&lt;/gi, '<').replace(/&gt;/gi, '>').replace(/&quot;/gi, '"').replace(/&#39;|&apos;/gi, "'")
    .replace(/&amp;/gi, '&')
    .replace(/\s+/g, ' ')
    .trim();
}

// Cellule que le relevé n'affiche pas : le terminal place dans chaque ligne de
// position, après le Type, un <td class="hidden" colspan="8"> absent de la
// ligne d'en-têtes (.hidden { display: none }). La compter décalerait toutes
// les colonnes. Seule la balise ouvrante est examinée.
function imHtmlCellHidden(cellHtml) {
  const open = cellHtml.slice(0, cellHtml.indexOf('>') + 1);
  return /\bclass\s*=\s*["']?[^"'>]*\bhidden\b/i.test(open) || /display\s*:\s*none/i.test(open);
}

// Extrait le premier tableau de trades (section « Positions » du relevé,
// en anglais ou en français) : ligne d'en-têtes avec l'heure et le prix,
// le volume ou le profit, puis les lignes de même largeur.
function parseMT5Html(text) {
  const trs = String(text || '').match(/<tr[^>]*>([\s\S]*?)<\/tr>/gi) || [];
  const rows = trs.map(tr => (tr.match(/<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi) || [])
    .filter(c => !imHtmlCellHidden(c)).map(imHtmlText))
    .filter(r => r.length > 0);
  if (rows.length === 0) return { headers: [], rows: [] };
  let headerIdx = -1;
  for (let i = 0; i < rows.length; i++) {
    const joined = rows[i].join('|').toLowerCase();
    if ((joined.includes('time') || joined.includes('heure'))
      && (joined.includes('price') || joined.includes('prix') || joined.includes('profit') || joined.includes('volume'))) {
      headerIdx = i; break;
    }
  }
  if (headerIdx === -1) return { headers: rows[0] || [], rows: rows.slice(1) };
  const headers = rows[headerIdx];
  const data = [];
  for (let i = headerIdx + 1; i < rows.length; i++) {
    if (rows[i].length < headers.length - 1) break;
    data.push(rows[i]);
  }
  return { headers, rows: data };
}

// ─── En-têtes ────────────────────────────────────────────────────
// Forme comparable d'un en-tête : minuscules, sans accents, sans unité entre
// parenthèses (« P&L (€) » → « p&l »), sans espaces ni ponctuation de liaison.
function imNormHeader(h) {
  let s = String(h == null ? '' : h).toLowerCase();
  try { s = s.normalize('NFD').replace(/[̀-ͯ]/g, ''); } catch (e) { /* moteur ancien */ }
  return s.replace(/\([^)]*\)/g, '').replace(/[\s_\-'’.#:]+/g, '');
}

// Valeurs de la colonne Direction du tableau « Transactions » de MetaTrader :
// entrée, sortie, retournement, clôture par une position opposée. Une seule
// définition, pour la détection du format comme pour la lecture des lignes.
const IM_DEAL_DIR = /^(in|out|in\/out|out by)$/i;

// Format d'un fichier CSV/TSV d'après ses en-têtes et, pour le tableau des
// transactions MetaTrader, d'après les valeurs de sa colonne Direction (rows :
// les lignes de données). Un relevé HTML est reconnu avant, par handleFiles
// (extension ou balise <table>).
function detectFormat(headers, rawText, rows) {
  const h = (headers || []).map(imNormHeader);
  const raw = (rawText || '').slice(0, 4000).toLowerCase();
  if (h.some(x => /position/.test(x)) && h.some(x => /^symbol/.test(x)) && h.some(x => /profit|p\/l/.test(x))) return 'mt5_csv';
  // Tableau « Transactions » (Deals) exporté sans colonne Position : Time,
  // Deal, Symbol, Type, Direction (in/out), …, Profit. Les en-têtes ne
  // suffisent pas : un journal personnel peut avoir des colonnes
  // « Transaction » et « Direction » (Achat/Vente). Il faut au moins une
  // valeur in / out / in/out / out by dans la colonne Direction.
  const iDir = h.indexOf('direction');
  const dealDir = iDir >= 0 && (rows || []).some(r => IM_DEAL_DIR.test(String(r && r[iDir] != null ? r[iDir] : '').trim()));
  if ((h.includes('deal') || h.includes('transaction')) && dealDir
    && h.some(x => /^symbol/.test(x)) && h.some(x => /profit|benefice/.test(x))) return 'mt5_csv';
  if (h.some(x => /tradedate|filltime|fillprice|boughttimestamp|soldtimestamp|buyfillid/.test(x))) return 'tradovate';
  if (raw.includes('metatrader') && h.some(x => /profit/.test(x))) return 'mt5_csv';
  return 'generic';
}

// Index de la colonne : correspondance exacte d'abord (tous candidats),
// puis recherche partielle, seulement pour les candidats de 4 caractères ou
// plus (« R » ne doit jamais tomber sur « Entry », ni « Id » sur « Side »).
function findIndex(headers, ...candidates) {
  const low = (headers || []).map(imNormHeader);
  const cands = candidates.map(imNormHeader).filter(Boolean);
  for (const c of cands) { const i = low.indexOf(c); if (i >= 0) return i; }
  for (const c of cands) {
    if (c.length < 4) continue;
    const i = low.findIndex(x => x.includes(c));
    if (i >= 0) return i;
  }
  return -1;
}

function findIndexExact(headers, ...candidates) {
  const low = (headers || []).map(imNormHeader);
  for (const c of candidates.map(imNormHeader)) { const i = low.indexOf(c); if (i >= 0) return i; }
  return -1;
}

function imAllIndexes(headers, ...candidates) {
  const cands = candidates.map(imNormHeader);
  const out = [];
  (headers || []).forEach((h, i) => { if (cands.includes(imNormHeader(h))) out.push(i); });
  return out;
}

// ─── Nombres du fichier ──────────────────────────────────────────
// Espaces (dont insécable U+00A0 et fine insécable U+202F), symboles et codes
// de devise que portent les nombres d'un fichier.
const IM_NUM_SPACES = /[\s  ]/g;
const IM_NUM_CURRENCY = /[$€£¥]|USD|EUR|GBP|CHF|JPY|CAD|AUD/gi;

// « 1,234.50 », « 1.234,50 », « 1 234,50 € », « (50.00) », « $(25.00) »,
// « −12 » → nombre ; vide → null ; illisible (« 12abc ») → INVALID_NUM.
// La lecture finale est celle de parseDecimalFr (format-prefs.jsx).
// conv = convention du fichier (imNumberConvention) : avec 'dot' (point
// décimal), « 1,500 » vaut 1 500 ; avec 'comma' (virgule décimale), « 1.500 »
// vaut 1 500. Sans convention connue, un séparateur seul est une décimale.
function imFileNumber(v, conv) {
  if (v === null || v === undefined) return null;
  if (typeof v === 'number') return Number.isFinite(v) ? v : INVALID_NUM;
  let s = String(v).replace(IM_NUM_SPACES, '').replace(/−/g, '-');
  s = s.replace(IM_NUM_CURRENCY, '');
  if (s === '' || s === '-' || s === '—' || /^n\/?a$/i.test(s)) return null;
  let neg = false;
  if (/^-?\(.*\)$/.test(s)) { neg = true; s = s.replace(/^-?\(/, '').replace(/\)$/, ''); }
  const lastComma = s.lastIndexOf(','), lastDot = s.lastIndexOf('.');
  if (lastComma >= 0 && lastDot >= 0) {
    s = lastComma > lastDot ? s.replace(/\./g, '').replace(',', '.') : s.replace(/,/g, '');
  } else if ((s.match(/,/g) || []).length > 1) {
    s = s.replace(/,/g, '');
  } else if ((s.match(/\./g) || []).length > 1) {
    s = s.replace(/\./g, '');
  } else if (conv === 'dot' && /^[+-]?[1-9]\d{0,2},\d{3}$/.test(s)) {
    s = s.replace(',', ''); // « 1,500 » dans un fichier à point décimal
  } else if (conv === 'comma' && /^[+-]?[1-9]\d{0,2}\.\d{3}$/.test(s)) {
    s = s.replace('.', ''); // « 1.500 » dans un fichier à virgule décimale
  }
  const n = parseDecimalFr(s);
  if (n === null || n === INVALID_NUM) return n;
  return neg ? -Math.abs(n) : n;
}

// Ce qu'une valeur dit de la convention du fichier : 'dot' (point décimal,
// virgule des milliers), 'comma' (l'inverse) ou null (rien : entier, vide,
// texte, ou forme ambiguë « 1,500 »).
function imNumberHint(v) {
  const s = String(v == null ? '' : v).replace(IM_NUM_SPACES, '').replace(IM_NUM_CURRENCY, '').replace(/[()+\-−]/g, '');
  if (!/^[\d.,]+$/.test(s)) return null;
  const commas = (s.match(/,/g) || []).length, dots = (s.match(/\./g) || []).length;
  if (commas && dots) return s.lastIndexOf('.') > s.lastIndexOf(',') ? 'dot' : 'comma';
  if (!commas && !dots) return null;
  const sep = commas ? ',' : '.';
  const self = commas ? 'comma' : 'dot', other = commas ? 'dot' : 'comma';
  if (commas + dots > 1) { // séparateur répété : des milliers (« 1,234,567 »)
    return new RegExp('^\\d{1,3}(?:\\' + sep + '\\d{3})+$').test(s) ? other : null;
  }
  const i = s.indexOf(sep);
  // Un groupe de milliers fait exactement 3 chiffres après 1 à 3 chiffres sans
  // zéro en tête : « 135,00 », « 1,5 », « 0,500 » et « 1234,500 » sont des décimales.
  if (s.length - i - 1 !== 3 || !/^[1-9]\d{0,2}$/.test(s.slice(0, i))) return self;
  return null;
}

// Convention d'un ensemble de valeurs : 'dot', 'comma', ou null si les indices
// manquent ou se contredisent (lecture valeur par valeur, comme avant).
function imNumberConvention(values) {
  let dot = false, comma = false;
  for (const v of values) {
    const h = imNumberHint(v);
    if (h === 'dot') dot = true;
    else if (h === 'comma') comma = true;
    if (dot && comma) return null;
  }
  return dot ? 'dot' : comma ? 'comma' : null;
}

// ─── Devise du fichier ───────────────────────────────────────────
// Symbole ou code de devise porté par un texte (« $450.00 », « P&L (USD) ») :
// '$', '€', '£', '¥' ou un code ISO en majuscules ; null s'il n'y en a pas.
function imCurrencyToken(text) {
  const m = String(text == null ? '' : text).match(/[$€£¥]|\b(?:USD|EUR|GBP|CHF|JPY|CAD|AUD)\b/i);
  return m ? m[0].toUpperCase() : null;
}

// Seule table de correspondance symbole/code → nom français (au pluriel, pour
// « en dollars »). Le symbole du profil vient de currencySymbol.
const IM_CURRENCY_NAMES = {
  '$': 'dollars', '€': 'euros', '£': 'livres sterling', '¥': 'yens',
  USD: 'dollars', EUR: 'euros', GBP: 'livres sterling', CHF: 'francs suisses', JPY: 'yens',
  CAD: 'dollars canadiens', AUD: 'dollars australiens',
};
// Codes qu'un symbole peut désigner : « P&L (EUR) » en en-tête et « 135,00 € »
// dans les montants écrivent la même devise de deux façons.
const IM_CURRENCY_SYMBOL_CODES = { '€': ['EUR'], '£': ['GBP'], '¥': ['JPY'], '$': ['USD', 'CAD', 'AUD'] };

// Devise unique désignée par les jetons relevés dans une colonne (symboles et
// codes ISO, voir imCurrencyToken), ou null. Un seul jeton : lui-même. Un code
// et un symbole qui lui convient : le code, plus précis (« $ » + « CAD » sur
// un profil en USD doit avertir). Deux codes, deux symboles, ou un symbole qui
// ne convient pas au code : plusieurs devises, aucune n'est retenue.
function imSingleCurrency(tokens) {
  const all = Array.from(new Set(tokens || []));
  const codes = all.filter(t => /^[A-Z]{3}$/.test(t));
  const syms = all.filter(t => !/^[A-Z]{3}$/.test(t));
  if (codes.length > 1 || syms.length > 1) return null;
  if (codes.length && syms.length) {
    return (IM_CURRENCY_SYMBOL_CODES[syms[0]] || []).includes(codes[0]) ? codes[0] : null;
  }
  return codes[0] || syms[0] || null;
}

// Phrase d'avertissement quand la devise du fichier n'est pas celle du profil
// (charte §2 : aucune conversion, la devise ne sert qu'à l'affichage), sinon ''.
// Un symbole est comparé au symbole du profil (« $ » convient à USD, CAD, AUD),
// un code ISO au code du profil.
function imCurrencyMismatchText(fileCur) {
  if (!fileCur) return '';
  const profile = String((typeof window !== 'undefined' && window.__appCurrency) || 'USD').toUpperCase();
  const isCode = /^[A-Z]{3}$/.test(fileCur);
  const profileSym = typeof currencySymbol === 'function' ? currencySymbol(profile) : profile;
  if (isCode ? fileCur === profile : fileCur === profileSym) return '';
  const fileName = IM_CURRENCY_NAMES[fileCur];
  const fileLabel = fileName ? fileName + ' (' + fileCur + ')' : fileCur;
  const profileLabel = IM_CURRENCY_NAMES[profile] || profile;
  return 'Ce fichier est en ' + fileLabel + ' et ton profil est en ' + profileLabel + IM_NB
    + ': les montants sont repris tels quels, sans conversion.';
}

// ─── Dates du fichier ────────────────────────────────────────────
// Une heure sans fuseau est lue dans le fuseau choisi à l'aperçu : celui du
// profil par défaut, UTC, ou l'heure des serveurs MetaTrader (UTC+2, UTC+3
// en été, soit l'heure de New York + 7 h). Une date seule vaut midi.
const IM_YMD = /^(\d{4})[-./](\d{1,2})[-./](\d{1,2})(?:[ T]+(\d{1,2}):(\d{2})(?::(\d{2}))?(?:[.,]\d+)?(?:\s*([ap])\.?m\.?)?)?$/i;
const IM_DMY = /^(\d{1,2})[-./](\d{1,2})[-./](\d{4}|\d{2})(?:[ T,]+(\d{1,2}):(\d{2})(?::(\d{2}))?(?:[.,]\d+)?(?:\s*([ap])\.?m\.?)?)?$/i;
// Fuseau en fin de chaîne : « Z », « UTC », « +02:00 », « +0200 » ou la forme
// courte « +02 » (minutes facultatives).
const IM_ZONE_END = /(?:\s*(z|utc|gmt)|\s*([+-])(\d{2})(?::?(\d{2}))?)$/i;

function imWallToUtc(day, time, tzMode) {
  if (typeof zonedToUtcISO !== 'function') return null;
  if (tzMode === 'mt5server') {
    const utc = zonedToUtcISO(day, time, 'UTC'); // valide la date civile
    if (!utc) return null;
    const ny = new Date(Date.parse(utc) - 7 * 3600000).toISOString();
    return zonedToUtcISO(ny.slice(0, 10), ny.slice(11, 19), 'America/New_York');
  }
  return zonedToUtcISO(day, time, tzMode === 'utc' ? 'UTC' : undefined);
}

function imHasZone(s) {
  const str = String(s || '').trim();
  return /\d{1,2}:\d{2}/.test(str) && IM_ZONE_END.test(str);
}

// JJ/MM ou MM/JJ se décide pour tout le fichier, jamais ligne par ligne.
function imDayFirst(values, format) {
  let first = false, second = false;
  for (const v of values) {
    const m = String(v == null ? '' : v).trim().match(/^(\d{1,2})[-./](\d{1,2})[-./]\d{2,4}/);
    if (!m) continue;
    if (+m[1] > 12) first = true;
    else if (+m[2] > 12) second = true;
  }
  if (first) return true;
  if (second) return false;
  return format !== 'tradovate'; // Tradovate exporte en MM/JJ ; sinon JJ/MM
}

// parseDateLoose(s, { tz, dayFirst }) → instant ISO UTC, ou null si illisible.
function parseDateLoose(s, opts) {
  const o = opts || {};
  if (s == null) return null;
  let str = String(s).trim().replace(/\s+/g, ' ');
  if (!str) return null;
  let zoneMin = null;
  if (/\d{1,2}:\d{2}/.test(str)) {
    const z = str.match(IM_ZONE_END);
    if (z) {
      zoneMin = z[1] ? 0 : (z[2] === '-' ? -1 : 1) * (+z[3] * 60 + +(z[4] || 0));
      str = str.slice(0, z.index).trim();
    }
  }
  let y, mo, d, hh, mi, ss, ap;
  let m = str.match(IM_YMD);
  if (m) {
    y = +m[1]; mo = +m[2]; d = +m[3]; hh = m[4]; mi = m[5]; ss = m[6]; ap = m[7];
  } else if ((m = str.match(IM_DMY))) {
    const a = +m[1], b = +m[2];
    if (o.dayFirst === false) { mo = a; d = b; } else { d = a; mo = b; }
    y = m[3].length === 2 ? 2000 + +m[3] : +m[3];
    hh = m[4]; mi = m[5]; ss = m[6]; ap = m[7];
  } else {
    return null;
  }
  if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
  let h = hh == null ? null : +hh;
  if (h != null && ap) {
    if (h < 1 || h > 12) return null;
    h = (h % 12) + (/p/i.test(ap) ? 12 : 0);
  }
  const minute = +(mi || 0), second = +(ss || 0);
  if (h != null && (h > 23 || minute > 59 || second > 59)) return null;
  const day = y + '-' + imPad2(mo) + '-' + imPad2(d);
  const time = h == null ? '12:00:00' : imPad2(h) + ':' + imPad2(minute) + ':' + imPad2(second);
  if (zoneMin != null) {
    const asUtc = typeof zonedToUtcISO === 'function' ? zonedToUtcISO(day, time, 'UTC') : null;
    return asUtc ? new Date(Date.parse(asUtc) - zoneMin * 60000).toISOString() : null;
  }
  return imWallToUtc(day, time, o.tz);
}

// ─── Limites de la base ──────────────────────────────────────────
// Contraintes de la table trades (supabase-migration-securite.sql) : au-delà,
// l'écriture est refusée. Contrôlées à la saisie comme à l'import.
const IM_MAX_SYMBOL = 64, IM_MAX_SETUP = 200, IM_MAX_NOTES = 50000;
// Identifiant repris du fichier (external_id) : au-delà, il est remplacé par
// l'empreinte du contenu, le trade reste importé.
const IM_MAX_EXTERNAL_ID = 128;

// Longueur en caractères (et non en unités UTF-16), comme la compte la base.
function imLen(s) {
  const str = String(s == null ? '' : s);
  return str.length <= 1 ? str.length : Array.from(str).length;
}
function imClip(s, max) {
  if (s == null || String(s).length <= max) return s;
  return Array.from(String(s)).slice(0, max).join('');
}

// ─── Empreinte d'un trade sans identifiant ───────────────────────
// 16 caractères hexadécimaux (deux hachages 32 bits indépendants : 32 bits
// seuls donneraient ~1 % de collision à 10 000 trades, et une collision = un
// vrai trade écarté comme doublon). NE JAMAIS MODIFIER ce calcul : les
// empreintes sont enregistrées (external_id) et servent à reconnaître un
// fichier réimporté.
function imHash64Hex(str) {
  let a = 0x811c9dc5, b = 0x9e3779b9;
  for (let i = 0; i < str.length; i++) {
    const c = str.charCodeAt(i);
    a = Math.imul(a ^ c, 0x01000193);
    b = Math.imul(b ^ c, 0x5bd1e995); b ^= b >>> 15;
  }
  const hex = (n) => (n >>> 0).toString(16).padStart(8, '0');
  return hex(a) + hex(b);
}

// Contenu haché : symbole | sens | date-heure telle qu'écrite dans le fichier
// (t._wall, indépendante du fuseau choisi à l'aperçu et de celui du profil) |
// P&L lu | entrée | sortie | quantité. Ni la stratégie, ni les notes, ni le R :
// ce sont des annotations, les compléter dans le tableur puis réimporter ne
// doit pas recréer le trade. Ni l'Id du fichier : un numéro de ligne se décale
// dès qu'on insère une ligne.
function imFingerprint(t) {
  const n = (v) => (v == null ? '' : String(Math.round(v * 1e8) / 1e8));
  return imHash64Hex([
    String(t.symbol).trim().toUpperCase(), t.direction, t._wall || t.executed_at,
    n(t.pnl), n(t.entry), n(t.exit_price), n(t.lots),
  ].join('|'));
}

// ─── Lignes → trades ─────────────────────────────────────────────
// imAnalyzeRows(headers, rows, format, { tz }) →
//   { trades, ignored: { date, pnl, symbol, symbolLong, future },
//     missing: ['Date', 'P&L'…], naive, fileCurrency, rounded }
// Une ligne sans date lisible, sans P&L lisible, sans symbole, au symbole trop
// long ou datée dans le futur est écartée et COMPTÉE, jamais datée
// « maintenant » ni mise à 0. naive = au moins une heure sans fuseau (le choix
// du fuseau à l'aperçu la concerne). fileCurrency = symbole ou code de devise
// porté par la colonne P&L ('$', 'USD'…) ou null. rounded = au moins un montant
// arrondi à l'unité (devise du profil sans décimales).
// Identifiant de chaque trade (external_id, clé de doublon avec imported_from) :
// celui du courtier quand il est fiable (position MetaTrader, exécutions
// Tradovate), sinon une empreinte du contenu « h_… » (imFingerprint).
function imAnalyzeRows(headers, rows, format, opts) {
  const o = opts || {};
  // L'heure des serveurs MetaTrader ne vaut que pour un relevé MetaTrader :
  // les autres fichiers restent alors lus dans le fuseau du profil.
  const isMt5 = format === 'mt5_csv' || format === 'mt5_html';
  const tz = o.tz === 'utc' || (o.tz === 'mt5server' && isMt5) ? o.tz : 'profile';
  const H = headers || [], R = rows || [];
  const out = {
    trades: [], ignored: { date: 0, pnl: 0, symbol: 0, symbolLong: 0, future: 0 },
    missing: [], naive: false, fileCurrency: null, rounded: false,
  };
  const now = Date.now();
  const cell = (r, i) => (i >= 0 && r && r[i] != null ? String(r[i]).trim() : '');
  const round = (n) => Math.round(n * 1e8) / 1e8;
  // Arrondi d'un montant aux décimales de la devise du profil (le montant
  // affiché est le montant enregistré, comme à la saisie manuelle).
  // + 1e-9 × f : absorbe le bruit binaire (1,005 × 100 = 100,4999…) sans
  // toucher un montant du fichier, lu à 8 décimales au plus.
  const curDec = typeof currencyDecimals === 'function'
    ? currencyDecimals((typeof window !== 'undefined' && window.__appCurrency) || 'USD') : 2;
  const roundCur = (n) => {
    const f = Math.pow(10, curDec);
    const v = (n < 0 ? -1 : 1) * Math.round(Math.abs(n) * f + 1e-9 * f) / f;
    return v === 0 ? 0 : v;
  };

  // Convention des nombres (« 1,500 » = 1 500 ou 1,5 ?), décidée une fois par
  // fichier comme l'ordre jour/mois, jamais valeur par valeur. Indices cherchés
  // dans la colonne elle-même ; à défaut, pour les montants et les quantités,
  // dans toutes les colonnes numériques du fichier. Les prix ne suivent que
  // leur propre colonne : « 151.250 » (USD/JPY) à côté d'un P&L « 135,00 » doit
  // rester 151,25. Indices absents ou contradictoires : lecture valeur par valeur.
  let numCols = [];
  const colConv = {};
  let fileConv;
  const convOf = (i, wide) => {
    if (i < 0) return null;
    if (!(i in colConv)) colConv[i] = imNumberConvention(R.map(r => cell(r, i)));
    if (colConv[i] || !wide) return colConv[i];
    if (fileConv === undefined) {
      const vals = [];
      for (const c of numCols) for (const r of R) vals.push(cell(r, c));
      fileConv = imNumberConvention(vals);
    }
    return fileConv;
  };
  const useNumCols = (cols) => { numCols = Array.from(new Set(cols.filter(i => i >= 0))); };
  const readNum = (raw, i, wide) => { const n = imFileNumber(raw, convOf(i, wide)); return typeof n === 'number' ? n : null; };
  const price = (r, i) => readNum(cell(r, i), i, false);
  const money = (r, i) => readNum(cell(r, i), i, true);
  const qty = (r, i) => { const n = readNum(cell(r, i), i, true); return n != null && n !== 0 ? Math.abs(n) : 1; };

  const needCols = (ci) => {
    if (ci.time < 0) out.missing.push('Date');
    if (ci.symbol < 0) out.missing.push('Symbole');
    if (ci.pnl < 0) out.missing.push('P&L');
  };
  // Devise portée par la colonne P&L : unité de l'en-tête (« P&L ($) ») ou
  // symbole des montants (« $450.00 »). La même devise écrite de deux façons
  // (« EUR » et « € ») reste une seule devise ; plusieurs devises différentes :
  // aucune (imSingleCurrency).
  const noteCurrency = (i) => {
    if (i < 0) return;
    const found = new Set();
    const head = imCurrencyToken(H[i]);
    if (head) found.add(head);
    for (const r of R) {
      const t = imCurrencyToken(cell(r, i));
      if (t) found.add(t);
      if (found.size > 2) break; // deux jetons peuvent désigner une devise, trois jamais
    }
    out.fileCurrency = imSingleCurrency(Array.from(found));
  };
  // Date lue → { iso, wall } ou null. iso : l'instant, selon le fuseau choisi.
  // wall : l'heure telle qu'écrite dans le fichier (ou l'instant réel si le
  // fichier porte un fuseau), qui ne dépend d'aucun réglage : elle sert à
  // l'empreinte du trade.
  const readDate = (raw, dOpts) => {
    const iso = parseDateLoose(raw, dOpts);
    if (!iso) return null;
    if (imHasZone(raw)) return { iso, wall: iso };
    out.naive = true;
    return { iso, wall: parseDateLoose(raw, { tz: 'utc', dayFirst: dOpts.dayFirst }) || iso };
  };
  // Même règle qu'à la saisie manuelle : pas de trade au-delà de maintenant + 1 min.
  const isFuture = (iso) => Date.parse(iso) > now + 60000;

  // Passe commune de fin : limites de longueur, identifiant des trades qui
  // n'en ont pas de fiable, arrondi du P&L aux décimales de la devise du profil
  // (roundCur).
  const finish = () => {
    const prints = new Map(); // empreinte → { n, ids: Id du fichier → identifiant }
    const kept = [];
    for (const t of out.trades) {
      if (imLen(String(t.symbol).trim()) > IM_MAX_SYMBOL) { out.ignored.symbolLong++; continue; }
      if (t.setup) t.setup = imClip(t.setup, IM_MAX_SETUP);
      if (t.notes) t.notes = imClip(t.notes, IM_MAX_NOTES);
      // Identifiant du fichier plus long que la limite de la base (colonne mal
      // alignée…) : mesuré ici, sous sa forme finale (préfixe « deal- »,
      // suffixe « -2 » compris), et remplacé par l'empreinte du contenu. Une
      // seule ligne refusée ferait refuser tout le lot.
      if (t.external_id && imLen(t.external_id) > IM_MAX_EXTERNAL_ID) t.external_id = null;
      // Fichier générique : la colonne Id (souvent un numéro de ligne, commun
      // à tous les fichiers) ne sert jamais d'identifiant. Elle distingue
      // seulement, dans un même fichier, une ligne répétée (même Id, même
      // contenu : une seule ligne) de deux trades identiques (Id différents).
      if (t.imported_from === 'generic' || !t.external_id) {
        const h = imFingerprint(t);
        let e = prints.get(h);
        if (!e) { e = { n: 0, ids: new Map() }; prints.set(h, e); }
        const fid = t._fid || '';
        if (fid && e.ids.has(fid)) {
          t.external_id = e.ids.get(fid);
        } else {
          // n-ième trade strictement identique du fichier : « -2 », « -3 »…
          // (ni « # », ni virgule, ni parenthèse : l'identifiant part dans un filtre in.()).
          e.n++;
          t.external_id = 'h_' + h + (e.n > 1 ? '-' + e.n : '');
          if (fid) e.ids.set(fid, t.external_id);
        }
      }
      const rounded = roundCur(t.pnl);
      if (rounded !== t.pnl) {
        if (curDec === 0) out.rounded = true;
        t.pnl = rounded;
      }
      delete t._wall; delete t._fid; delete t._pv; delete t._v;
      kept.push(t);
    }
    out.trades = kept;
    return out;
  };

  if (isMt5) {
    const times = imAllIndexes(H, 'Time', 'Heure');
    const prices = imAllIndexes(H, 'Price', 'Prix');
    const closeTime = findIndexExact(H, 'Close Time', 'CloseTime', 'Heure de clôture');
    const openPrice = findIndexExact(H, 'Open Price', 'OpenPrice', 'Prix d’ouverture');
    const closePrice = findIndexExact(H, 'Close Price', 'ClosePrice', 'Prix de clôture');
    const dealDir = findIndexExact(H, 'Direction');
    // Tableau des transactions (colonne Direction in/out) ou des positions.
    const isDeals = dealDir >= 0 && R.some(r => IM_DEAL_DIR.test(cell(r, dealDir)));
    const posCol = findIndexExact(H, 'Position');
    const ci = {
      time: closeTime >= 0 ? closeTime : (times.length ? times[times.length - 1] : findIndex(H, 'Time', 'Date')),
      symbol: findIndex(H, 'Symbol', 'Symbole'),
      type: findIndexExact(H, 'Type'),
      volume: findIndex(H, 'Volume', 'Lots', 'Size'),
      entry: isDeals ? -1 : (openPrice >= 0 ? openPrice : (prices.length > 1 ? prices[0] : -1)),
      exit: closePrice >= 0 ? closePrice : (prices.length ? prices[prices.length - 1] : -1),
      pnl: findIndex(H, 'Profit', 'P/L', 'Net Profit', 'NetProfit', 'PnL', 'Bénéfice'),
      comm: findIndex(H, 'Commission'),
      fee: findIndexExact(H, 'Fee', 'Fees', 'Frais'),
      swap: findIndex(H, 'Swap', 'Rollover'),
      // Transactions sans colonne Position : le numéro de la transaction.
      id: posCol >= 0 ? posCol
        : isDeals ? findIndexExact(H, 'Deal', 'Transaction', 'Ticket', 'Order', 'Ordre')
          : findIndexExact(H, 'Ticket', 'Deal', 'Transaction', 'Order', 'Ordre'),
    };
    needCols(ci);
    useNumCols([ci.volume, ci.entry, ci.exit, ci.pnl, ci.comm, ci.fee, ci.swap]);
    noteCurrency(ci.pnl);
    const costs = (r) => (money(r, ci.comm) || 0) + (money(r, ci.fee) || 0) + (money(r, ci.swap) || 0);
    const dOpts = { tz, dayFirst: imDayFirst(R.map(r => cell(r, ci.time)), format) };
    // Transactions : ce que l'ouverture a coûté (commission, frais, swap) est
    // compté dans le P&L du trade, comme le fait la synchronisation du
    // courtier. Avec une colonne Position : par position, avec le prix moyen
    // et le sens des entrées. Sans colonne Position : réparti sur les sorties
    // (openCostByExit, plus bas).
    const entries = {}, entryCost = {};
    if (isDeals && posCol >= 0) {
      for (const r of R) {
        if (cell(r, dealDir).toLowerCase() !== 'in') continue;
        const k = cell(r, posCol);
        if (!k) continue;
        entryCost[k] = (entryCost[k] || 0) + costs(r); // avant le test du prix
        const p = price(r, ci.exit);
        if (p == null) continue;
        const v = qty(r, ci.volume);
        const e = entries[k] || (entries[k] = { pv: 0, v: 0, short: /sell|vente/.test(cell(r, ci.type).toLowerCase()) });
        e.pv += p * v; e.v += v;
      }
    }
    // Transactions SANS colonne Position (tableau « Transactions » standard du
    // terminal) : rien ne relie une sortie à son ouverture. Le coût de chaque
    // ouverture est rattaché aux sorties du même symbole et du même sens de
    // position, premier entré premier sorti, au prorata du volume ; la somme
    // des P&L importés reste ainsi celle de la colonne Balance du fichier.
    // Le coût d'une ouverture jamais refermée dans le fichier reste hors
    // import : une position encore ouverte n'est pas un trade.
    const openCostByExit = new Map(); // ligne de sortie → part du coût des ouvertures
    if (isDeals && posCol < 0) {
      const deals = [];
      let datesOk = true;
      R.forEach((r, idx) => {
        // Mêmes lignes que la boucle principale : un symbole, un achat ou une vente.
        if (!cell(r, ci.symbol)) return;
        const type = cell(r, ci.type).toLowerCase();
        if (ci.type >= 0 && !/buy|sell|achat|vente/.test(type)) return;
        const iso = parseDateLoose(cell(r, ci.time), dOpts); // sans effet sur out.naive
        const at = iso ? Date.parse(iso) : NaN;
        if (isNaN(at)) datesOk = false;
        deals.push({ r, idx, at, dir: cell(r, dealDir).toLowerCase(), sell: /sell|vente/.test(type) });
      });
      // Ordre chronologique, quel que soit le tri du fichier (le terminal
      // exporte aussi du plus récent au plus ancien) ; au même instant,
      // l'ouverture d'abord, puis l'ordre du fichier. Une date illisible :
      // l'ordre du fichier, faute de mieux.
      if (datesOk) deals.sort((a, b) => (a.at - b.at) || ((a.dir === 'in' ? 0 : 1) - (b.dir === 'in' ? 0 : 1)) || (a.idx - b.idx));
      // Une file par symbole ET par sens de position : sur un compte en
      // couverture, un achat et une vente du même symbole coexistent. Sans
      // colonne Type, le sens est inconnu : une seule file par symbole.
      const queues = new Map();
      const queueOf = (d, short) => {
        const k = cell(d.r, ci.symbol).toUpperCase() + '|' + (ci.type < 0 ? '' : short ? 'short' : 'long');
        let q = queues.get(k);
        if (!q) { q = []; queues.set(k, q); }
        return q;
      };
      const EPS = 1e-9;
      for (const d of deals) {
        const vol = qty(d.r, ci.volume);
        if (d.dir === 'in') { // un achat ouvre un achat, une vente ouvre une vente
          queueOf(d, d.sell).push({ vol, cost: costs(d.r) });
          continue;
        }
        // Sortie (out, out by, in/out) : une vente ferme un achat, un achat
        // ferme une vente.
        const q = queueOf(d, !d.sell);
        let left = vol, share = 0, matched = 0;
        while (left > EPS && q.length) {
          const lot = q[0];
          if (left >= lot.vol - EPS) {
            // Dernière part de cette ouverture : tout ce qui reste de son coût.
            share += lot.cost; left -= lot.vol; matched += lot.vol;
            q.shift();
          } else {
            // Part arrondie aux décimales de la devise : le reliquat revient
            // à la dernière sortie (trois sorties d'un tiers de 23,00 font
            // 23,00, pas 23,01).
            const part = roundCur(lot.cost * left / lot.vol);
            share += part; matched += left;
            lot.cost = round(lot.cost - part); lot.vol = round(lot.vol - left);
            left = 0;
          }
        }
        share = round(share);
        if (share) openCostByExit.set(d.r, share);
        // Retournement : le volume qui dépasse la position fermée ouvre une
        // position dans le sens de la transaction, sans coût à reporter (le
        // sien est déjà sur cette ligne). Si rien n'a été apparié, la position
        // fermée date d'avant le fichier et ce volume est inconnu : rien
        // n'est ouvert, pour ne pas retenir le coût des ouvertures suivantes.
        if (d.dir === 'in/out' && matched > EPS && left > EPS) queueOf(d, d.sell).push({ vol: round(left), cost: 0 });
      }
    }
    const byPos = {};
    for (const r of R) {
      const symbol = cell(r, ci.symbol);
      if (!symbol) continue; // dépôts, soldes : pas des trades
      const type = cell(r, ci.type).toLowerCase();
      if (ci.type >= 0 && !/buy|sell|achat|vente/.test(type)) continue; // dépôts, crédits…
      const dd = isDeals ? cell(r, dealDir).toLowerCase() : '';
      if (isDeals && dd === 'in') continue; // ouverture : portée par la sortie
      const key = isDeals && posCol >= 0 ? cell(r, posCol) : '';
      let short = /sell|vente/.test(type);
      if (isDeals) short = key && entries[key] ? entries[key].short : !short; // sortie d'un achat = vente
      const d = readDate(cell(r, ci.time), dOpts);
      if (!d) { out.ignored.date++; continue; }
      if (isFuture(d.iso)) { out.ignored.future++; continue; }
      const profit = money(r, ci.pnl);
      if (profit == null) { out.ignored.pnl++; continue; }
      let pnl = round(profit + costs(r) + (openCostByExit.get(r) || 0));
      const exit = price(r, ci.exit);
      const lots = qty(r, ci.volume);
      const prev = key ? byPos[key] : null;
      if (prev) { // clôture partielle : même position, une seule ligne
        prev.pnl = round(prev.pnl + pnl);
        if (exit != null) { prev._pv += exit * lots; prev._v += lots; prev.exit_price = round(prev._pv / prev._v); }
        prev.lots = round(prev.lots + lots);
        if (Date.parse(d.iso) > Date.parse(prev.executed_at)) { prev.executed_at = d.iso; prev._wall = d.wall; }
        continue;
      }
      // Première sortie de la position : le coût de l'ouverture, une seule fois.
      if (key && entryCost[key]) pnl = round(pnl + entryCost[key]);
      const entry = isDeals ? (key && entries[key] && entries[key].v ? round(entries[key].pv / entries[key].v) : null) : price(r, ci.entry);
      const rawId = cell(r, ci.id);
      const t = {
        symbol, direction: short ? 'short' : 'long',
        entry, exit_price: exit, lots, pnl,
        r_multiple: null,
        executed_at: d.iso,
        // Un numéro de transaction est préfixé : il ne doit pas passer pour un
        // ticket de position (clé des trades synchronisés par le courtier).
        external_id: rawId ? (isDeals && posCol < 0 ? 'deal-' + rawId : rawId) : null,
        imported_from: 'mt5',
        setup: null, notes: null,
        _wall: d.wall,
        _pv: exit != null ? exit * lots : 0, _v: exit != null ? lots : 0,
      };
      if (key) byPos[key] = t;
      out.trades.push(t);
    }
    return finish();
  }

  if (format === 'tradovate') {
    const perf = {
      buy: findIndexExact(H, 'buyPrice'), sell: findIndexExact(H, 'sellPrice'),
      bought: findIndexExact(H, 'boughtTimestamp'), sold: findIndexExact(H, 'soldTimestamp'),
    };
    if (perf.buy >= 0 && perf.sell >= 0 && (perf.bought >= 0 || perf.sold >= 0)) {
      // Export « Performance » : une ligne par trade aller-retour.
      const ci = {
        time: perf.sold >= 0 ? perf.sold : perf.bought,
        symbol: findIndex(H, 'symbol', 'Contract'),
        qty: findIndexExact(H, 'qty', 'Quantity'),
        pnl: findIndexExact(H, 'pnl', 'P&L', 'PnL'),
        buyId: findIndexExact(H, 'buyFillId'), sellId: findIndexExact(H, 'sellFillId'),
      };
      needCols(ci);
      useNumCols([ci.qty, ci.pnl, perf.buy, perf.sell]);
      noteCurrency(ci.pnl);
      const dOpts = { tz, dayFirst: imDayFirst(R.flatMap(r => [cell(r, perf.bought), cell(r, perf.sold)]), format) };
      for (const r of R) {
        const symbol = cell(r, ci.symbol);
        if (!symbol) { out.ignored.symbol++; continue; }
        const b = perf.bought >= 0 ? readDate(cell(r, perf.bought), dOpts) : null;
        const s = perf.sold >= 0 ? readDate(cell(r, perf.sold), dOpts) : null;
        if (!b && !s) { out.ignored.date++; continue; }
        const close = b && s ? (Date.parse(b.iso) > Date.parse(s.iso) ? b : s) : (b || s); // clôture
        if (isFuture(close.iso)) { out.ignored.future++; continue; }
        const pnl = money(r, ci.pnl);
        if (pnl == null) { out.ignored.pnl++; continue; }
        const short = !!(b && s && Date.parse(s.iso) < Date.parse(b.iso)); // vendu avant d'acheter
        const buyP = price(r, perf.buy), sellP = price(r, perf.sell);
        const bId = cell(r, ci.buyId), sId = cell(r, ci.sellId);
        out.trades.push({
          symbol, direction: short ? 'short' : 'long',
          entry: short ? sellP : buyP, exit_price: short ? buyP : sellP,
          lots: qty(r, ci.qty), pnl: round(pnl),
          r_multiple: null,
          executed_at: close.iso,
          external_id: bId && sId ? bId + '-' + sId : null,
          imported_from: 'tradovate',
          setup: null, notes: null,
          _wall: close.wall,
        });
      }
      return finish();
    }
    const ci = {
      time: findIndex(H, 'FillTime', 'TradeDate', 'Timestamp', 'Time'),
      symbol: findIndex(H, 'Symbol', 'Contract'),
      side: findIndex(H, 'BuySell', 'Side', 'Direction'),
      qty: findIndex(H, 'Qty', 'Quantity', 'FillQty'),
      price: findIndex(H, 'FillPrice', 'AvgPrice', 'Price'),
      pnl: findIndex(H, 'PnL', 'P&L', 'Profit', 'NetProfit'),
      id: findIndexExact(H, 'OrderId', 'TradeId', 'FillId', 'Id'),
    };
    needCols(ci);
    useNumCols([ci.qty, ci.price, ci.pnl]);
    noteCurrency(ci.pnl);
    const dOpts = { tz, dayFirst: imDayFirst(R.map(r => cell(r, ci.time)), format) };
    // Un ordre exécuté en plusieurs fois donne plusieurs lignes au même
    // identifiant : la 2e exécution (contenu différent) reçoit « -2 », pour ne
    // pas être écartée comme doublon. Même identifiant ET même contenu : une
    // seule et même ligne, répétée. Un suffixe déjà porté par une autre ligne
    // du fichier (un ordre « A-2 » à côté de deux exécutions de « A ») est
    // sauté : deux trades différents ne partagent jamais un identifiant.
    const usedIds = new Set(R.map(r => cell(r, ci.id)).filter(Boolean)); // identifiants du fichier, puis attribués
    const sameId = new Map(); // identifiant du fichier → Map(contenu → identifiant attribué)
    for (const r of R) {
      const symbol = cell(r, ci.symbol);
      if (!symbol) { out.ignored.symbol++; continue; }
      const d = readDate(cell(r, ci.time), dOpts);
      if (!d) { out.ignored.date++; continue; }
      if (isFuture(d.iso)) { out.ignored.future++; continue; }
      const pnl = money(r, ci.pnl);
      if (pnl == null) { out.ignored.pnl++; continue; }
      const t = {
        symbol,
        direction: /sell|short|vente|^v$|^s$/i.test(cell(r, ci.side)) ? 'short' : 'long',
        entry: null, exit_price: price(r, ci.price),
        lots: qty(r, ci.qty), pnl: round(pnl),
        r_multiple: null,
        executed_at: d.iso,
        external_id: cell(r, ci.id) || null,
        imported_from: 'tradovate',
        setup: null, notes: null,
        _wall: d.wall,
      };
      if (t.external_id) {
        const rawId = t.external_id;
        const sig = imFingerprint(t);
        let seen = sameId.get(rawId);
        if (!seen) { seen = new Map(); sameId.set(rawId, seen); }
        if (seen.has(sig)) {
          t.external_id = seen.get(sig); // ligne répétée
        } else {
          if (seen.size > 0) {
            // (ni « # », ni virgule, ni parenthèse : l'identifiant part dans un filtre in.()).
            let n = seen.size + 1;
            while (usedIds.has(rawId + '-' + n)) n++;
            t.external_id = rawId + '-' + n;
            usedIds.add(t.external_id);
          }
          seen.set(sig, t.external_id);
        }
      }
      out.trades.push(t);
    }
    return finish();
  }

  // Fichier générique : en-têtes anglais ou français (dont l'export de Tempo).
  const ci = {
    time: findIndex(H, 'Date', 'Date et heure', 'DateTime', 'ExecutedAt', 'CloseTime', 'TradeDate', 'Timestamp', 'Time', 'Heure'),
    symbol: findIndex(H, 'Symbol', 'Symbole', 'Ticker', 'Pair', 'Paire', 'Instrument', 'Actif', 'Contract', 'Market'),
    side: findIndex(H, 'Sens', 'Direction', 'Side', 'Type', 'BuySell', 'Action'),
    entry: findIndex(H, 'EntryPrice', 'Entry', 'Entrée', 'Prix d’entrée', 'Prix entrée', 'OpenPrice'),
    exit: findIndex(H, 'ExitPrice', 'Exit', 'Sortie', 'Prix de sortie', 'Prix sortie', 'ClosePrice', 'Price', 'Prix'),
    qty: findIndex(H, 'Quantité', 'Qty', 'Quantity', 'Lots', 'Size', 'Volume', 'Contracts'),
    pnl: findIndex(H, 'P&L', 'PnL', 'P/L', 'P&L net', 'Net P&L', 'Profit', 'NetProfit', 'Résultat'),
    r: findIndexExact(H, 'R', 'R multiple', 'RMultiple', 'R-Multiple'),
    setup: findIndex(H, 'Stratégie', 'Strategy', 'Setup', 'Playbook'),
    notes: findIndex(H, 'Notes', 'Note', 'Comment', 'Comments', 'Commentaire', 'Commentaires'),
    id: findIndexExact(H, 'Ticket', 'OrderId', 'TradeId', 'Id'),
  };
  // Une recherche partielle ne doit pas lire deux fois la même colonne
  // (« Price » retrouvé dans « EntryPrice »).
  if (ci.exit >= 0 && ci.exit === ci.entry) ci.exit = -1;
  // Date et heure en deux colonnes (« Date » ; « Heure »).
  const timeCol = findIndexExact(H, 'Heure', 'Time');
  const splitTime = timeCol >= 0 && timeCol !== ci.time;
  needCols(ci);
  useNumCols([ci.entry, ci.exit, ci.qty, ci.pnl]);
  noteCurrency(ci.pnl);
  const rawDateOf = (r) => {
    const d = cell(r, ci.time);
    if (splitTime && d && !/\d{1,2}:\d{2}/.test(d)) { const t = cell(r, timeCol); return t ? d + ' ' + t : d; }
    return d;
  };
  const dOpts = { tz, dayFirst: imDayFirst(R.map(rawDateOf), format) };
  for (const r of R) {
    const symbol = cell(r, ci.symbol);
    if (!symbol) { out.ignored.symbol++; continue; }
    const d = readDate(rawDateOf(r), dOpts);
    if (!d) { out.ignored.date++; continue; }
    if (isFuture(d.iso)) { out.ignored.future++; continue; }
    const pnl = money(r, ci.pnl);
    if (pnl == null) { out.ignored.pnl++; continue; }
    out.trades.push({
      symbol,
      direction: /sell|short|vente|^v$|^s$/i.test(cell(r, ci.side)) ? 'short' : 'long',
      entry: price(r, ci.entry),
      exit_price: price(r, ci.exit),
      lots: qty(r, ci.qty),
      pnl: round(pnl),
      // « 2R », « -1 R », « +0,51 R » (la forme que Tempo affiche) : le R seul.
      r_multiple: readNum(cell(r, ci.r).replace(/\s*R$/i, ''), ci.r, false),
      setup: cell(r, ci.setup) || null,
      notes: cell(r, ci.notes) || null,
      executed_at: d.iso,
      external_id: null, // posé par finish() : empreinte du contenu
      imported_from: 'generic',
      _wall: d.wall, _fid: cell(r, ci.id),
    });
  }
  return finish();
}

// ─── Formats d'affichage ─────────────────────────────────────────
const IM_FORMAT_LABELS = {
  mt5_html: 'Relevé MetaTrader 5 (HTML)',
  mt5_csv: 'MetaTrader 5 (CSV)',
  tradovate: 'Tradovate',
  generic: 'Fichier générique',
};

// Formateur partagé (format-prefs.jsx) : même accord et mêmes milliers partout.
function imCount(n, word) { return fmtCount(n, word); }

function imMoney(v) {
  if (typeof fmtMoney === 'function') return fmtMoney(v, true);
  return fmtMoneyCur(v, (typeof window !== 'undefined' && window.__appCurrency) || 'USD', true);
}

function imSize(bytes) {
  if (!(bytes >= 1024)) return '<' + IM_NB + '1' + IM_NB + 'ko';
  return fmtNum(Math.round(bytes / 1024), 0) + IM_NB + 'ko';
}

function imIgnoredText(ig) {
  if (!ig) return '';
  const parts = [];
  const one = (n, why) => imCount(n, 'ligne') + ' ' + (n > 1 ? 'ignorées' : 'ignorée') + IM_NB + ': ' + why;
  if (ig.date) parts.push(one(ig.date, 'date illisible'));
  if (ig.pnl) parts.push(one(ig.pnl, 'P&L illisible'));
  if (ig.symbol) parts.push(one(ig.symbol, 'symbole manquant'));
  if (ig.symbolLong) parts.push(one(ig.symbolLong, 'symbole de plus de ' + IM_MAX_SYMBOL + ' caractères'));
  if (ig.future) parts.push(one(ig.future, 'date dans le futur (vérifie le fuseau du fichier et l’ordre jour/mois)'));
  return parts.join(' · ');
}

// Ce que l'aperçu doit dire d'un fichier en plus des lignes ignorées : devise
// différente de celle du profil, montants arrondis (devise sans décimales).
function imFileNotes(f) {
  const notes = [];
  const cur = imCurrencyMismatchText(f && f.fileCurrency);
  if (cur) notes.push(cur);
  if (f && f.rounded) {
    const code = String((typeof window !== 'undefined' && window.__appCurrency) || '').toUpperCase();
    notes.push('Montants arrondis à l’unité' + IM_NB + ': ' + (code === 'JPY' ? 'le yen n’a' : 'la devise de ton profil n’a') + ' pas de décimales.');
  }
  return notes;
}

// Enregistrement terminé alors que la fenêtre a été fermée (ou rouverte)
// entre-temps : rien à afficher, mais la liste des trades doit refléter
// l'écriture (la fermeture n'a pas demandé de rechargement).
// Un trade saisi à la main : sa ligne est appliquée à la liste en mémoire
// (imApplyAdded) ; un import de fichier relit tout l'historique.
function imAfterClosedSave(res, state, what) {
  const ok = !!res && !res.error;
  if (!ok) console.warn('[Tempo] ' + what + ' (fenêtre fermée)', res && res.error);
  else if (what === 'addTrade') imApplyAdded(state, res.data);
  else if (state && typeof state.refreshTrades === 'function') state.refreshTrades();
  return ok;
}

// Après l'ajout d'un seul trade : la ligne renvoyée par addTrade est insérée à
// sa place dans la liste en mémoire (state.applyTradeChange, state.jsx, qui
// relit tout de lui-même si la liste n'est pas sûre), au lieu de retélécharger
// tout l'historique — une requête par millier de trades. Repli sur la relecture
// complète si la ligne écrite n'est pas connue.
function imApplyAdded(state, row) {
  if (!state) return;
  if (row && row.id != null && typeof state.applyTradeChange === 'function') {
    Promise.resolve(state.applyTradeChange({ upsert: row })).then(
      (applied) => { if (applied === false && typeof state.refreshTrades === 'function') state.refreshTrades(); },
      (e) => { console.warn('[Tempo] mise à jour de la liste des trades', e); if (typeof state.refreshTrades === 'function') state.refreshTrades(); }
    );
    return;
  }
  if (typeof state.refreshTrades === 'function') state.refreshTrades();
}

function imNormTab(t, defaultDate) {
  if (t === 'csv' || t === 'file') return 'file';
  if (t === 'manual' || t === 'broker') return t;
  return defaultDate ? 'manual' : 'file';
}

const IM_LABEL = { fontSize: 11.5, fontWeight: 600, color: 'var(--fg-2)', display: 'block', marginBottom: 5 };
const IM_HINT = { fontSize: 11, color: 'var(--fg-3)', marginTop: 4, lineHeight: 1.4 };
const IM_FIELD_ERR = { fontSize: 11.5, color: 'var(--red-text)', marginTop: 5, lineHeight: 1.4 };

// ─── Saisie manuelle ─────────────────────────────────────────────
const IM_NUM_FIELDS = [
  { key: 'entry',      err: 'Nombre invalide, par exemple 5' + IM_NNB + '278,50.' },
  { key: 'exit_price', err: 'Nombre invalide, par exemple 5' + IM_NNB + '284,00.' },
  { key: 'lots',       err: 'Nombre invalide, par exemple 1 ou 0,5.' },
  { key: 'pnl',        err: 'Montant invalide, par exemple +120 ou ' + IM_MINUS + '50.' },
  { key: 'r_multiple', err: 'Nombre invalide, par exemple +1,5 ou ' + IM_MINUS + '1.' },
];
// Message « nombre invalide » d'un champ numérique du trade, commun à l'ajout
// et à l'édition (pages-2.jsx) : même faute de frappe, même message (charte §5).
function tradeNumFieldError(key) {
  const f = IM_NUM_FIELDS.find(x => x.key === key);
  return f ? f.err : 'Nombre invalide.';
}

// Clé de comparaison de deux noms de stratégie : la même que celle de l'écran
// Stratégies (stratKey, strategy.jsx), sans espaces de bord ni casse.
function imStratKey(name) { return String(name == null ? '' : name).trim().toLowerCase(); }

// Noms proposés dans le champ Stratégie : ceux de la table strategies (dont une
// stratégie tout juste créée, encore sans trade), puis ceux que portent déjà
// des trades. Sans doublon à la casse et aux espaces de bord près (la graphie
// de la table l'emporte), triés en français.
function mergeTradeStrategyNames(tableNames, trades) {
  const byKey = new Map();
  const add = (name) => {
    const k = imStratKey(name);
    if (k && !byKey.has(k)) byKey.set(k, String(name));
  };
  (Array.isArray(tableNames) ? tableNames : []).forEach(add);
  (Array.isArray(trades) ? trades : []).forEach(t => { if (t && t.setup) add(t.setup); });
  return Array.from(byKey.values()).sort((a, b) => a.localeCompare(b, 'fr'));
}

// useTradeStrategyOptions(open, trades) → noms proposés dans le champ Stratégie
// d'un trade, à l'ajout comme à l'édition. La table strategies est relue à
// chaque ouverture. Échec de lecture ou table absente : avertissement console
// et repli sur les noms des trades, jamais de message à l'écran.
function useTradeStrategyOptions(open, trades) {
  const [tableNames, setTableNames] = React.useState([]);
  React.useEffect(() => {
    if (!open || typeof window === 'undefined' || !window.sb) return undefined;
    let live = true;
    (async () => {
      let res = null;
      try { res = await window.sb.from('strategies').select('name'); }
      catch (e) { res = { data: null, error: e }; }
      if (!live) return;
      if (!res || res.error) {
        console.warn('[Tempo] suggestions de stratégies : lecture impossible, noms des trades seuls.', res && res.error);
        return;
      }
      setTableNames((res.data || []).map(r => r && r.name).filter(n => typeof n === 'string' && n.trim() !== ''));
    })();
    return () => { live = false; };
  }, [open]);
  return React.useMemo(() => mergeTradeStrategyNames(tableNames, trades), [tableNames, trades]);
}

// Graphie à enregistrer pour une stratégie saisie : celle qui existe déjà quand
// seuls la casse ou les espaces de bord diffèrent (le lien trade ↔ stratégie se
// fait par le nom exact), sinon la saisie sans espaces de bord. Vide → ''.
function tradeStrategyCanonical(value, options) {
  const v = String(value == null ? '' : value).trim();
  if (!v) return '';
  const k = imStratKey(v);
  const hit = (Array.isArray(options) ? options : []).find(o => imStratKey(o) === k);
  return hit != null ? String(hit) : v;
}

// Limites de longueur de la base, COMMUNES à l'ajout et à l'édition : renvoie
// { field, msg } pour le premier champ trop long, sinon null.
function tradeTextLimitError(v) {
  const o = v || {};
  if (imLen(String(o.symbol == null ? '' : o.symbol).trim()) > IM_MAX_SYMBOL) {
    return { field: 'symbol', msg: 'Le symbole fait au plus ' + IM_MAX_SYMBOL + ' caractères.' };
  }
  if (imLen(String(o.setup == null ? '' : o.setup).trim()) > IM_MAX_SETUP) {
    return { field: 'setup', msg: 'La stratégie fait au plus ' + IM_MAX_SETUP + ' caractères.' };
  }
  if (imLen(o.notes) > IM_MAX_NOTES) {
    return { field: 'notes', msg: 'Les notes font au plus ' + fmtNum(IM_MAX_NOTES, 0) + ' caractères.' };
  }
  return null;
}

// Messages COMMUNS à l'ajout et à l'édition (charte §5).
const TRADE_MSG_PNL_REQUIRED = 'Le P&L est obligatoire.';
const TRADE_MSG_TIME_REQUIRED = 'Indique l’heure du trade.';

// Validation commune du P&L saisi : une devise sans décimales (yen) n'accepte
// qu'un montant entier, sinon le montant affiché (arrondi) différerait de la
// valeur enregistrée. Renvoie un message d'erreur ou null.
function tradePnlDecimalsError(v) {
  if (v == null || !Number.isFinite(v)) return null;
  const cur = (typeof window !== 'undefined' && window.__appCurrency) || 'USD';
  const dec = typeof currencyDecimals === 'function' ? currencyDecimals(cur) : 2;
  const factor = Math.pow(10, dec);
  if (Math.abs(Math.round(v * factor) - v * factor) < 1e-6) return null;
  if (dec === 0) return 'Cette devise ne prend pas de décimales' + IM_NB + ': saisis un montant entier.';
  return 'Le P&L prend au plus ' + dec + ' décimale' + (dec > 1 ? 's' : '') + '.';
}

// Champs d'un trade, COMMUNS à l'ajout (Saisie manuelle) et à l'édition
// (détail d'un trade) : même ordre, mêmes libellés, mêmes aides, même liste de
// stratégies, date et heure séparées avec le fuseau du profil (charte §5).
// `form` : { symbol, direction, entry, exit_price, lots, pnl, trade_date,
// trade_time, r_multiple, duration, setup, notes } (chaînes) ; `onField(k)` :
// gestionnaire onChange ; `err` : { field, msg } ou null (field peut valoir
// 'trade_time' : message et focus sur le champ Heure) ; `refFor(k)` : ref
// optionnelle (focus du champ en erreur) ; `idPrefix` : préfixe des id ;
// `strategies` : noms proposés (les 60 premiers).
function TradeFields({ idPrefix, form, onField, err, refFor, strategies, isMobile, autoFocus }) {
  const today = todayISO();
  const errId = (k) => idPrefix + 'err-' + k;
  const bad = (k) => !!(err && err.field === k);
  const fp = (k) => ({
    id: idPrefix + k, ref: refFor ? refFor(k) : undefined, value: form[k] == null ? '' : form[k], onChange: onField(k),
    'aria-invalid': bad(k) ? 'true' : undefined,
    'aria-describedby': bad(k) ? errId(k) : undefined,
    style: bad(k) ? { borderColor: 'var(--red)' } : undefined,
  });
  const errLine = (k) => bad(k) ? <div id={errId(k)} role="alert" style={IM_FIELD_ERR}>{err.msg}</div> : null;
  const label = (k, text) => <label htmlFor={idPrefix + k} style={IM_LABEL}>{text}</label>;
  const tzHint = (() => {
    const h = typeof tzHourLabel === 'function' ? tzHourLabel() : '';
    return h ? h.charAt(0).toUpperCase() + h.slice(1) : '';
  })();
  const curSym = typeof currencySymbol === 'function'
    ? currencySymbol((typeof window !== 'undefined' && window.__appCurrency) || 'USD') : '';
  const listId = idPrefix + 'strategies';
  const hasList = Array.isArray(strategies) && strategies.length > 0;
  const shownStrategies = hasList ? strategies.slice(0, 60) : [];
  const badWhen = bad('trade_date') || bad('trade_time');
  const gap = isMobile ? 10 : 12;
  return (
    <div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap }}>
      <div>
        {label('symbol', 'Symbole')}
        <input className="input" {...fp('symbol')} data-autofocus={autoFocus ? '' : undefined} placeholder="ES, NQ, EURUSD…" maxLength={IM_MAX_SYMBOL} autoComplete="off" autoCapitalize="characters" spellCheck={false}
          style={{ textTransform: 'uppercase', ...(bad('symbol') ? { borderColor: 'var(--red)' } : null) }}/>
        {errLine('symbol')}
      </div>
      <div>
        {label('direction', 'Sens')}
        <select className="input" id={idPrefix + 'direction'} value={form.direction} onChange={onField('direction')} style={{ fontFamily: 'inherit' }}>
          <option value="long">Achat</option>
          <option value="short">Vente</option>
        </select>
      </div>
      <div>
        {label('entry', 'Prix d’entrée')}
        <input className="input" {...fp('entry')} placeholder={'5' + IM_NNB + '278,50'} inputMode="decimal" autoComplete="off"/>
        {errLine('entry')}
      </div>
      <div>
        {label('exit_price', 'Prix de sortie')}
        <input className="input" {...fp('exit_price')} placeholder={'5' + IM_NNB + '284,00'} inputMode="decimal" autoComplete="off"/>
        {errLine('exit_price')}
      </div>
      <div>
        {label('lots', 'Quantité (lots)')}
        <input className="input" {...fp('lots')} placeholder="1" inputMode="decimal" autoComplete="off"/>
        {errLine('lots')}
      </div>
      <div>
        {label('pnl', 'P&L (' + curSym + ')')}
        <input className="input" {...fp('pnl')} placeholder={'+120 ou ' + IM_MINUS + '50'} inputMode="decimal" autoComplete="off"/>
        {errLine('pnl')}
      </div>
      <div style={{ gridColumn: '1 / -1' }}>
        {label('trade_date', 'Date et heure')}
        <div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1.4fr) minmax(0, 1fr)', gap, alignItems: 'start' }}>
          <input className="input" type="date" {...fp('trade_date')} max={today}/>
          <div style={{ minWidth: 0 }}>
            <input className="input" type="time" id={idPrefix + 'trade_time'} ref={refFor ? refFor('trade_time') : undefined}
              value={form.trade_time == null ? '' : form.trade_time} onChange={onField('trade_time')}
              aria-label="Heure" aria-invalid={badWhen ? 'true' : undefined}
              aria-describedby={[bad('trade_date') ? errId('trade_date') : '', bad('trade_time') ? errId('trade_time') : '', tzHint ? idPrefix + 'tz-hint' : ''].filter(Boolean).join(' ') || undefined}
              style={badWhen ? { borderColor: 'var(--red)' } : undefined}/>
            {tzHint && <div id={idPrefix + 'tz-hint'} style={IM_HINT}>{tzHint}</div>}
          </div>
        </div>
        {errLine('trade_date')}
        {errLine('trade_time')}
      </div>
      <div>
        {label('r_multiple', 'R multiple')}
        <input className="input" {...fp('r_multiple')} placeholder={'+1,5 ou ' + IM_MINUS + '1'} inputMode="decimal" autoComplete="off"/>
        {errLine('r_multiple')}
      </div>
      <div>
        {label('duration', 'Durée (min)')}
        <input className="input" {...fp('duration')} placeholder={'90 ou 1' + IM_NB + 'h' + IM_NB + '30'} autoComplete="off"/>
        {errLine('duration')}
      </div>
      <div style={{ gridColumn: '1 / -1' }}>
        {label('setup', 'Stratégie')}
        <input className="input" {...fp('setup')} list={hasList ? listId : undefined} placeholder={hasList ? shownStrategies[0] : 'Breakout VWAP, Range Asie…'} maxLength={IM_MAX_SETUP} autoComplete="off"/>
        {hasList && <datalist id={listId}>{shownStrategies.map(s => <option key={s} value={s}/>)}</datalist>}
        {errLine('setup')}
      </div>
      <div style={{ gridColumn: '1 / -1' }}>
        {label('notes', 'Notes')}
        <textarea className="input" {...fp('notes')} maxLength={IM_MAX_NOTES} style={{ minHeight: 80, resize: 'vertical', fontFamily: 'inherit', ...(bad('notes') ? { borderColor: 'var(--red)' } : null) }} placeholder={'Pourquoi ce trade' + IM_NNB + '? Contexte, émotions…'}/>
        {errLine('notes')}
      </div>
    </div>
  );
}

// Formulaire sans bouton d'envoi : le pied de la fenêtre porte « Ajouter le
// trade » (form="manual-trade-form"), Entrée valide. onSubmit renvoie une
// promesse { ok } ; la garde sendingRef empêche tout double envoi.
function ManualTradeForm({ onSubmit, submitting, locked, defaultDate, onDirtyChange, isMobile, strategies }) {
  const seedDay = () => {
    const today = todayISO();
    const d = typeof defaultDate === 'string' ? defaultDate.slice(0, 10) : '';
    if (/^\d{4}-\d{2}-\d{2}$/.test(d)) return d > today ? today : d;
    return today;
  };
  const nowTime = () => (typeof tzNow === 'function' ? tzNow() : '');
  const [form, setForm] = React.useState(() => ({
    symbol: '', direction: 'long',
    entry: '', exit_price: '',
    lots: '1', pnl: '', r_multiple: '',
    duration: '',
    trade_date: seedDay(),
    trade_time: nowTime(),
    setup: '', notes: '',
  }));
  React.useEffect(() => {
    setForm(f => ({ ...f, trade_date: seedDay() }));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [defaultDate]);

  const [err, setErr] = React.useState(null); // { field, msg }
  const refs = React.useRef({});
  const sendingRef = React.useRef(false);
  const reg = (k) => (el) => { refs.current[k] = el; };

  const dirty = !!(form.symbol.trim() || form.entry.trim() || form.exit_price.trim() || form.pnl.trim()
    || form.r_multiple.trim() || form.duration.trim() || form.setup.trim() || form.notes.trim()
    || form.lots.trim() !== '1');
  React.useEffect(() => { if (onDirtyChange) onDirtyChange(dirty); }, [dirty]);

  const upd = (k) => (e) => {
    const v = e.target.value;
    setForm(f => ({ ...f, [k]: v }));
    if (err && (err.field === k || (k === 'trade_time' && err.field === 'trade_date'))) setErr(null);
  };

  const today = todayISO();
  const shiftTradeDate = (delta) => {
    setForm(f => {
      const t = todayISO();
      const dp = /^\d{4}-\d{2}-\d{2}$/.test(f.trade_date || '') ? f.trade_date : t;
      const [y, m, d] = dp.split('-').map(Number);
      let next = new Date(Date.UTC(y, m - 1, d + delta)).toISOString().slice(0, 10);
      if (next > t) next = t;
      return { ...f, trade_date: next };
    });
    if (err && err.field === 'trade_date') setErr(null);
  };

  const fail = (field, msg) => {
    setErr({ field, msg });
    requestAnimationFrame(() => {
      const el = refs.current[field];
      if (el && typeof el.focus === 'function') el.focus();
    });
    return null;
  };

  const validate = () => {
    const symbol = form.symbol.trim();
    if (!symbol) return fail('symbol', 'Indique le symbole, par exemple ES, NQ ou EURUSD.');
    const parsed = {};
    for (const f of IM_NUM_FIELDS) {
      const v = parseDecimalFr(form[f.key]);
      if (v === INVALID_NUM) return fail(f.key, f.err);
      parsed[f.key] = v;
    }
    if (parsed.lots != null && parsed.lots <= 0) return fail('lots', 'La quantité doit être supérieure à 0.');
    // Même règle qu'à l'édition : un P&L oublié n'est jamais enregistré à 0
    // (un 0 saisi reste un trade nul voulu).
    if (parsed.pnl == null) return fail('pnl', TRADE_MSG_PNL_REQUIRED);
    const pnlDecErr = tradePnlDecimalsError(parsed.pnl);
    if (pnlDecErr) return fail('pnl', pnlDecErr);
    const dayStr = (form.trade_date || '').trim();
    const timeStr = (form.trade_time || '').trim();
    if (!/^\d{4}-\d{2}-\d{2}$/.test(dayStr)) return fail('trade_date', 'Date ou heure invalide.');
    // Une heure vide n'est pas remplacée par midi à l'insu de l'utilisateur.
    if (!timeStr) return fail('trade_time', TRADE_MSG_TIME_REQUIRED);
    // near : pendant l'heure répétée du retour à l'heure d'hiver, une saisie
    // faite « maintenant » désigne le passage en cours, pas le premier (sinon
    // le trade serait daté une heure trop tôt).
    const executedAt = zonedToUtcISO(dayStr, timeStr, undefined, { near: Date.now() + 60000 });
    if (!executedAt) return fail('trade_date', 'Date ou heure invalide.');
    if (dayStr > todayISO() || Date.parse(executedAt) > Date.now() + 60000) {
      return fail('trade_date', 'Ce trade est dans le futur' + IM_NB + ': vérifie la date et l’heure.');
    }
    const durationMin = (typeof parseDurationMin === 'function') ? parseDurationMin(form.duration) : null;
    if (Number.isNaN(durationMin)) return fail('duration', 'Durée invalide' + IM_NB + ': écris des minutes (90) ou 1' + IM_NB + 'h' + IM_NB + '30.');
    if (durationMin != null && durationMin <= 0) return fail('duration', 'La durée doit être supérieure à 0.');
    // Stratégie ramenée à la graphie existante (casse, espaces de bord), puis
    // limites de longueur de la base (valeur posée autrement qu'à la frappe).
    const setup = tradeStrategyCanonical(form.setup, strategies);
    const tooLong = tradeTextLimitError({ symbol, setup, notes: form.notes.trim() });
    if (tooLong) return fail(tooLong.field, tooLong.msg);
    return {
      symbol,
      direction: form.direction,
      entry: parsed.entry,
      exit_price: parsed.exit_price,
      lots: parsed.lots,
      pnl: parsed.pnl,
      r_multiple: parsed.r_multiple,
      executed_at: executedAt,
      setup: setup || null,
      notes: form.notes.trim() || null,
      // Envoyée seulement si renseignée (payload inchangé sinon).
      ...(durationMin != null ? { duration_min: durationMin } : {}),
    };
  };

  const submit = async (e) => {
    if (e) e.preventDefault();
    if (sendingRef.current || submitting || locked) return;
    const payload = validate();
    if (!payload) return;
    setErr(null);
    sendingRef.current = true;
    let res = null;
    try { res = await onSubmit(payload); } catch (ex) { res = null; }
    if (!res || !res.ok) sendingRef.current = false;
  };

  const dayLabel = /^\d{4}-\d{2}-\d{2}$/.test(form.trade_date || '') ? fmtDayLong(form.trade_date) : '';
  const atToday = !!form.trade_date && form.trade_date >= today;
  const arrowBtn = (disabled) => ({
    width: 28, height: 28, borderRadius: 7, border: '1px solid var(--blue-border)', background: 'var(--bg-card)',
    display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
    cursor: disabled ? 'not-allowed' : 'pointer', color: 'var(--blue-600)', opacity: disabled ? .45 : 1, flexShrink: 0,
  });

  return (
    <form id="manual-trade-form" onSubmit={submit} noValidate>
      <div style={{ fontSize: 12.5, color: 'var(--fg-2)', marginBottom: 14 }}>
        Ajoute un trade à la main, par exemple un trade qui ne vient pas d’un courtier connecté.
      </div>
      {dayLabel && (
        <div style={{
          display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8,
          marginBottom: 14, padding: '7px 9px 7px 12px', borderRadius: 8,
          background: 'var(--blue-soft)', border: '1px solid var(--blue-border)',
          color: 'var(--blue-600)', fontSize: 12.5, fontWeight: 600,
        }}>
          <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
            <span style={{ display: 'inline-flex', flexShrink: 0 }}>{React.cloneElement(Ico.daily || Ico.data, { width: 15, height: 15 })}</span>
            <span style={{ lineHeight: 1.35 }}>Trade du {dayLabel}</span>
          </span>
          <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
            <button type="button" onClick={() => shiftTradeDate(-1)} aria-label="Jour précédent" title="Jour précédent" className="tap" style={arrowBtn(false)}>{Ico.arrL}</button>
            <button type="button" onClick={() => shiftTradeDate(1)} disabled={atToday} aria-label="Jour suivant" title="Jour suivant" className="tap" style={arrowBtn(atToday)}>{Ico.arrR}</button>
          </span>
        </div>
      )}
      <TradeFields idPrefix="im-f-" form={form} onField={upd} err={err} refFor={reg}
        strategies={strategies} isMobile={isMobile} autoFocus/>
    </form>
  );
}

// ─── Fenêtre « Ajouter des trades » ──────────────────────────────
const IM_TABS = [
  { id: 'file',   label: 'Importer un fichier',   ico: 'upload' },
  { id: 'manual', label: 'Saisie manuelle',       ico: 'plus' },
  { id: 'broker', label: 'Connecter un courtier', ico: 'sync' },
];

function ImportModal({ open, onClose, state, defaultDate, initialTab }) {
  const isMobile = typeof useIsMobile === 'function' ? useIsMobile() : false;
  const [tab, setTab] = React.useState(() => imNormTab(initialTab, defaultDate));
  const [dragOver, setDragOver] = React.useState(false);
  // { id, key, name, size, status, problem, headers, rows, format, trades, ignored, missing, naive, fileCurrency, rounded }
  const [files, setFiles] = React.useState([]);
  const [submitting, setSubmitting] = React.useState(false);
  // done : succès alors que plus rien n'est en attente. L'action reste
  // désactivée et la fenêtre se ferme seule (charte §8). Un succès qui laisse
  // du travail en attente dans un autre onglet ne pose PAS ce verrou.
  const [done, setDone] = React.useState(false);
  // Dernière action lancée ('import' | 'manual') : chaque onglet n'annonce que
  // son propre envoi et son propre succès.
  const [lastAction, setLastAction] = React.useState(null);
  const [importMsg, setImportMsg] = React.useState(null); // { kind: 'info' | 'ok' | 'err', text, reload? } — reload : bouton « Recharger » dans le message
  const [manualMsg, setManualMsg] = React.useState(null);
  const [manualDirty, setManualDirty] = React.useState(false);
  const [brokerDirty, setBrokerDirty] = React.useState(false); // saisie dans le panneau courtier
  // Le panneau courtier n'est monté qu'à la première visite de son onglet (il
  // charge la liste des connexions), puis reste monté, masqué, comme la saisie
  // manuelle : changer d'onglet ne fait pas perdre ce qui y est saisi.
  const [brokerSeen, setBrokerSeen] = React.useState(() => imNormTab(initialTab, defaultDate) === 'broker');
  // Incrémentée après un ajout réussi qui laisse la fenêtre ouverte : remonte le
  // formulaire de saisie (champs vides, garde anti double envoi remise à zéro).
  const [manualKey, setManualKey] = React.useState(0);
  const [fileTz, setFileTz] = React.useState('profile');
  const inputRef = React.useRef(null);
  const doneRef = React.useRef(false); // un import de fichier a réussi et la liste n'a pas encore été relue : la fermeture recharge les trades
  const importingRef = React.useRef(false);
  const sendingRef = React.useRef(false); // un envoi (import ou ajout) est en cours
  const manualDirtyRef = React.useRef(false);
  manualDirtyRef.current = manualDirty;
  const brokerDirtyRef = React.useRef(false);
  brokerDirtyRef.current = brokerDirty;
  const closeTimer = React.useRef(null);
  const sessionRef = React.useRef(0);
  const filesRef = React.useRef(files);
  filesRef.current = files;
  const fileTzRef = React.useRef(fileTz);
  fileTzRef.current = fileTz;

  // Réinitialisation à chaque ouverture, pendant le rendu (pas d'éclair de
  // l'onglet précédent) : onglet demandé, fichiers et messages vidés.
  const [prevOpen, setPrevOpen] = React.useState(open);
  if (open !== prevOpen) {
    setPrevOpen(open);
    sessionRef.current += 1; // invalide une analyse de fichier encore en cours
    clearTimeout(closeTimer.current);
    if (open) {
      setTab(imNormTab(initialTab, defaultDate));
      setBrokerSeen(imNormTab(initialTab, defaultDate) === 'broker');
      setFiles([]); setImportMsg(null); setManualMsg(null); setDragOver(false);
      setSubmitting(false); setDone(false); setManualDirty(false); setBrokerDirty(false); setFileTz('profile');
      setManualKey(0); setLastAction(null);
      doneRef.current = false; importingRef.current = false; sendingRef.current = false;
    }
  }
  React.useEffect(() => () => clearTimeout(closeTimer.current), []);

  const selectTab = (t) => {
    if (t === 'broker') setBrokerSeen(true);
    setTab(t);
  };

  // Pendant un envoi, la fenêtre ne se ferme pas (Échap, croix, Fermer, fond) :
  // un échec doit pouvoir s'afficher ici, saisie intacte, au lieu de perdre le
  // trade en silence. Le succès suit son chemin normal.
  const closeNow = () => {
    if (sendingRef.current) return;
    clearTimeout(closeTimer.current);
    if (onClose) onClose(doneRef.current);
  };
  const imFilesPending = (list) => list.some(f => f.status === 'parsing' || (f.status === 'ready' && f.trades && f.trades.length > 0));
  const filesPending = imFilesPending(files);
  const isDirty = !done && !submitting && (manualDirty || filesPending || brokerDirty);
  const modal = useModalBehavior({ open, onRequestClose: closeNow, isDirty, labelledBy: 'import-modal-title' });

  // Focus au premier champ de l'onglet (ordinateur) ; sur écran tactile, le
  // hook commun garde le focus sur la fenêtre pour ne pas ouvrir le clavier.
  React.useEffect(() => {
    if (!open) return;
    let coarse = false;
    try { coarse = window.matchMedia('(pointer: coarse)').matches; } catch (e) { /* ancien moteur */ }
    if (coarse) return;
    const box = modal.dialogRef && modal.dialogRef.current;
    const el = box && box.querySelector('[data-panel="' + tab + '"] [data-autofocus]');
    if (el && !box.contains(document.activeElement)) el.focus({ preventScroll: true });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [open]);

  // Formulaire de saisie remonté après un ajout (la fenêtre reste ouverte) : le
  // champ qui portait le focus a disparu, on le rend au premier champ du
  // nouveau formulaire (à la fenêtre sur écran tactile, sans ouvrir le clavier).
  React.useEffect(() => {
    if (!open || manualKey === 0) return;
    const box = modal.dialogRef && modal.dialogRef.current;
    if (!box) return;
    const cur = document.activeElement;
    if (cur && cur !== document.body && cur.isConnected && box.contains(cur)) return;
    let coarse = false;
    try { coarse = window.matchMedia('(pointer: coarse)').matches; } catch (e) { /* ancien moteur */ }
    const el = coarse || tab !== 'manual' ? null : box.querySelector('[data-panel="manual"] [data-autofocus]');
    (el || box).focus({ preventScroll: true });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [manualKey]);

  // Barre d'onglets défilante (mobile) : l'onglet actif reste entièrement visible,
  // y compris quand la fenêtre s'ouvre directement sur « Connecter un courtier ».
  const tabsBarRef = React.useRef(null);
  React.useEffect(() => {
    if (!open) return;
    const bar = tabsBarRef.current;
    const el = bar && bar.querySelector('#im-tab-' + tab);
    if (!el) return;
    const b = bar.getBoundingClientRect(), r = el.getBoundingClientRect();
    const cs = window.getComputedStyle(bar);
    const left = b.left + (parseFloat(cs.paddingLeft) || 0);
    const right = b.right - (parseFloat(cs.paddingRight) || 0);
    if (r.left < left) bar.scrollLeft -= left - r.left;
    else if (r.right > right) bar.scrollLeft += r.right - right;
  }, [open, tab]);

  const strategies = useTradeStrategyOptions(open, state && state.trades);

  if (!open) return null;

  // ── Import de fichier ──
  const handleFiles = async (list) => {
    if (submitting || done) return;
    const session = sessionRef.current;
    const known = new Set(filesRef.current.map(x => x.key));
    for (const f of Array.from(list || [])) {
      const key = f.name + '|' + f.size + '|' + (f.lastModified || 0);
      if (known.has(key)) continue; // même fichier déposé deux fois
      known.add(key);
      const id = Math.random().toString(36).slice(2);
      setFiles(prev => [...prev, { id, key, name: f.name, size: f.size, status: 'parsing', trades: [] }]);
      setImportMsg(null);
      try {
        const text = await readFileAsText(f);
        if (session !== sessionRef.current) return;
        const problem = imFileProblem(text, f.size);
        if (problem) { // rien à analyser : la vraie raison, pas « colonnes introuvables »
          setFiles(prev => prev.map(x => x.id === id ? { ...x, status: 'ready', problem, trades: [] } : x));
          continue;
        }
        const isHtml = /\.html?$/i.test(f.name) || /<table/i.test(text.slice(0, 4000));
        let parsed, format;
        if (isHtml) {
          parsed = parseMT5Html(text);
          format = 'mt5_html';
        } else {
          parsed = parseCSV(text);
          format = detectFormat(parsed.headers, text, parsed.rows);
        }
        const a = imAnalyzeRows(parsed.headers, parsed.rows, format, { tz: fileTzRef.current });
        setFiles(prev => prev.map(x => x.id === id ? { ...x, status: 'ready', headers: parsed.headers, rows: parsed.rows, format, ...a } : x));
      } catch (e) {
        console.warn('[Tempo] import parse', e);
        if (session !== sessionRef.current) return;
        setFiles(prev => prev.map(x => x.id === id ? { ...x, status: 'error' } : x));
      }
    }
  };

  const changeFileTz = (v) => {
    setFileTz(v);
    setFiles(prev => prev.map(f => f.status === 'ready' && !f.problem
      ? { ...f, ...imAnalyzeRows(f.headers, f.rows, f.format, { tz: v }) } : f));
  };

  const readyFiles = files.filter(f => f.status === 'ready');
  const shownFiles = files.filter(f => f.status === 'ready' || f.status === 'done');
  const totalParsed = shownFiles.reduce((s, f) => s + (f.trades ? f.trades.length : 0), 0);
  const readyCount = readyFiles.reduce((s, f) => s + f.trades.length, 0);
  const parsing = files.some(f => f.status === 'parsing');
  const canImport = !parsing && readyCount > 0 && !submitting && !done;
  const hasNaive = readyFiles.some(f => f.naive);
  const hasMT5 = readyFiles.some(f => /^mt5/.test(f.format || ''));
  const hasOtherNaive = readyFiles.some(f => f.naive && !/^mt5/.test(f.format || ''));
  const previewRows = shownFiles.flatMap(f => f.trades || []).slice(0, 6);

  const startImport = async () => {
    if (importingRef.current || !canImport) return;
    const all = readyFiles.flatMap(f => f.trades);
    // Un même identifiant ne part qu'une fois (clôtures en double dans le fichier).
    const seen = new Set();
    const unique = [];
    let dupInFile = 0;
    for (const t of all) {
      if (t.external_id) {
        const k = (t.imported_from || '') + '::' + t.external_id;
        if (seen.has(k)) { dupInFile++; continue; }
        seen.add(k);
      }
      unique.push(t);
    }
    importingRef.current = true;
    sendingRef.current = true;
    setSubmitting(true);
    setLastAction('import');
    setImportMsg({ kind: 'info', text: 'Import en cours…' });
    const session = sessionRef.current;
    // Onglet resté ouvert pendant une mise en ligne : il importerait avec les
    // anciennes règles. La version en ligne est relue avant d'écrire
    // (appVersionCheck, app.jsx) ; si elle a changé, rien ne part et le message
    // demande de recharger. Contrôle impossible (hors ligne) : l'import suit
    // son cours, bulkInsertTrades dira lui-même si l'écriture échoue.
    let stale = false;
    try { stale = typeof window.appVersionCheck === 'function' ? !!(await window.appVersionCheck(true)) : false; }
    catch (e) { stale = false; }
    if (session !== sessionRef.current) return; // fenêtre fermée pendant le contrôle : rien n'est parti
    if (stale) {
      importingRef.current = false;
      sendingRef.current = false;
      setSubmitting(false);
      setImportMsg({ kind: 'err', reload: true, text: window.APP_VERSION_IMPORT_TEXT || 'Une nouvelle version de Tempo est en ligne\u00A0: recharge la page, puis relance l’import.' });
      return;
    }
    let res = null;
    try {
      res = await bulkInsertTrades(unique); // gère lui-même l'absence de connexion
    } catch (e) { res = { error: e }; }
    if (session !== sessionRef.current) { // fenêtre fermée par l'application pendant l'envoi
      imAfterClosedSave(res, state, 'import');
      return;
    }
    sendingRef.current = false;
    if (!res || res.error) {
      console.warn('[Tempo] import', res && res.error);
      importingRef.current = false;
      setSubmitting(false);
      setImportMsg({ kind: 'err', text: sbErrorMessage((res && res.error) || 'import failed') });
      return;
    }
    const inserted = (res.data || []).length;
    const dup = (res.skipped || 0) + dupInFile;
    // Une saisie attend dans un autre onglet (trade à la main, identifiants du
    // courtier) : la fenêtre reste ouverte et rien n'est verrouillé.
    const keepOpen = manualDirtyRef.current || brokerDirtyRef.current;
    if (keepOpen) importingRef.current = false;
    else setDone(true);
    setSubmitting(false);
    setImportMsg({
      kind: 'ok',
      text: 'Import terminé' + IM_NB + ': ' + imCount(inserted, 'trade') + ' ' + (inserted > 1 ? 'ajoutés' : 'ajouté')
        + (dup ? ', ' + imCount(dup, 'doublon') + ' ' + (dup > 1 ? 'ignorés' : 'ignoré') : '') + '.',
    });
    // Seuls les fichiers qui ont fourni des trades sont cochés « importés ».
    setFiles(prev => prev.map(f => f.status === 'ready' && f.trades && f.trades.length > 0 ? { ...f, status: 'done' } : f));
    if (keepOpen) { // pas de fermeture automatique : la liste est rechargée tout de suite
      if (state && typeof state.refreshTrades === 'function') state.refreshTrades();
      return;
    }
    // closeImport(true) (app.jsx) recharge les trades, que la fenêtre se ferme
    // d'elle-même ou que l'utilisateur la ferme avant.
    doneRef.current = true;
    closeTimer.current = setTimeout(() => { if (onClose) onClose(true); }, 1500);
  };

  // ── Saisie manuelle ──
  const submitManual = async (payload) => {
    if (sendingRef.current || submitting || done) return { ok: false };
    sendingRef.current = true;
    setSubmitting(true);
    setLastAction('manual');
    setManualMsg(null);
    const session = sessionRef.current;
    let res = null;
    try {
      res = await addTrade(payload); // gère lui-même l'absence de connexion
    } catch (e) { res = { error: e }; }
    if (session !== sessionRef.current) { // fenêtre fermée par l'application pendant l'envoi
      return { ok: imAfterClosedSave(res, state, 'addTrade') };
    }
    sendingRef.current = false;
    if (!res || res.error) {
      console.warn('[Tempo] addTrade', res && res.error);
      setSubmitting(false);
      setManualMsg({ kind: 'err', text: sbErrorMessage((res && res.error) || 'save failed') });
      return { ok: false };
    }
    setManualMsg({ kind: 'ok', text: 'Trade ajouté.' });
    // La liste des trades reçoit tout de suite la ligne écrite, sans relire
    // l'historique : la fermeture n'a donc rien à recharger pour ce trade.
    imApplyAdded(state, res.data);
    // Des fichiers analysés ou des identifiants de courtier attendent dans un
    // autre onglet : la fenêtre reste ouverte et le formulaire repart à neuf
    // (remontage).
    if (imFilesPending(filesRef.current) || brokerDirtyRef.current) {
      setSubmitting(false);
      setManualKey(k => k + 1);
      return { ok: true };
    }
    // Sinon : le bouton reste désactivé jusqu'à la fermeture automatique.
    // doneRef : vrai seulement si un import de fichier attend encore sa relecture.
    setDone(true);
    closeTimer.current = setTimeout(() => { if (onClose) onClose(doneRef.current); }, 700);
    return { ok: true };
  };

  // Le message « Trade ajouté. » s'efface dès qu'une nouvelle saisie commence.
  const onManualDirty = (d) => {
    setManualDirty(d);
    if (d) setManualMsg(m => (m && m.kind === 'ok' ? null : m));
  };

  const removeFile = (id) => setFiles(prev => prev.filter(f => f.id !== id));
  const openPicker = () => { if (!submitting && !done && inputRef.current) inputRef.current.click(); };

  const BrokerPanelC = typeof window !== 'undefined' && typeof window.BrokerPanelCompact === 'function' ? window.BrokerPanelCompact : null;
  // Marge latérale : celle des écrans mobiles (--m-gutter, 16 px), 24 px sur ordinateur.
  const gutter = isMobile ? 'var(--m-gutter, 16px)' : '24px';
  const msg = tab === 'manual' ? manualMsg : tab === 'file' ? importMsg : null;
  // Ce qui serait perdu : la saisie de l'onglet affiché d'abord, sinon celle
  // d'un autre onglet.
  const pendingByTab = { manual: manualDirty, file: filesPending, broker: brokerDirty };
  const discardTab = pendingByTab[tab] ? tab : (['manual', 'file', 'broker'].find(k => pendingByTab[k]) || tab);
  const discardDetail = discardTab === 'broker' ? 'Les informations de connexion saisies seront perdues.'
    : discardTab === 'manual' ? 'Le trade saisi ne sera pas enregistré.'
      : 'Les fichiers analysés ne seront pas importés.';

  const tzProfileLabel = typeof tzLabelWithOffset === 'function' ? tzLabelWithOffset() : '';
  const profileIsUtc = typeof tzLabel === 'function' && tzLabel() === 'UTC';

  // Aperçu : Date, Symbole, Sens, (Entrée, Sortie, Stratégie), P&L. Seuls le
  // symbole et la stratégie peuvent être abrégés ; ni la date ni le montant.
  const previewHead = isMobile
    ? ['Date', 'Symbole', 'Sens', 'P&L']
    : ['Date', 'Symbole', 'Sens', 'Entrée', 'Sortie', 'Stratégie', 'P&L'];
  const previewCols = isMobile
    ? 'auto minmax(56px, 1fr) auto auto'
    : 'auto minmax(56px, auto) auto auto auto minmax(0, 1fr) auto';
  const pvCell = (j, n, head) => ({
    display: 'flex', alignItems: 'center', minWidth: 0,
    justifyContent: j === n - 1 ? 'flex-end' : 'flex-start',
    padding: '8px ' + (j === n - 1 ? 10 : 6) + 'px 8px ' + (j === 0 ? 10 : 6) + 'px',
    borderTop: head ? 'none' : '1px solid var(--line)',
  });
  const cellTxt = { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 };

  let primary = null;
  if (tab === 'manual') {
    const off = submitting || done;
    primary = (
      <button type="submit" form="manual-trade-form" className="btn btn-blue tap" disabled={off}
        style={{ color: '#fff', opacity: off ? .55 : 1, cursor: off ? 'not-allowed' : 'pointer', flex: isMobile ? 1 : 'none', justifyContent: 'center', whiteSpace: 'nowrap' }}>
        {done && lastAction === 'manual' ? 'Trade ajouté' : submitting && lastAction === 'manual' ? 'Enregistrement…' : <React.Fragment>{Ico.plus} Ajouter le trade</React.Fragment>}
      </button>
    );
  } else if (tab === 'file') {
    primary = (
      <button type="button" onClick={startImport} className="btn btn-blue tap" disabled={!canImport}
        style={{ color: '#fff', opacity: canImport ? 1 : .55, cursor: canImport ? 'pointer' : 'not-allowed', flex: isMobile ? 1 : 'none', justifyContent: 'center', whiteSpace: 'nowrap' }}>
        {submitting && lastAction === 'import' ? 'Import en cours…' : done && lastAction === 'import' ? 'Import terminé' : readyCount > 0 ? 'Importer ' + imCount(readyCount, 'trade') : 'Importer'}
      </button>
    );
  }

  const footNote = tab === 'file' && readyCount > 0 && !done
    ? imCount(readyCount, 'trade') + ' ' + (readyCount > 1 ? 'prêts' : 'prêt') + ' à importer'
    : 'Tes données restent privées et sont stockées de manière sécurisée.';

  return (
    <div {...modal.backdropProps}
      // Un fichier lâché à côté de la zone de dépôt (fond ou reste de la
      // fenêtre) n'est ni importé par surprise ni ouvert par le navigateur à la
      // place de Tempo. La zone de dépôt arrête elle-même ces événements ; un
      // texte glissé d'un champ à l'autre n'est pas concerné.
      onDragOver={e => { if (imDragHasFiles(e)) { e.preventDefault(); e.dataTransfer.dropEffect = 'none'; } }}
      onDrop={e => { if (imDragHasFiles(e)) e.preventDefault(); }}
      style={{
      position: 'fixed', inset: 0, zIndex: 210,
      background: 'rgba(15,23,42,0.40)',
      backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)',
      // Ordinateur : fenêtre ancrée en haut (et non centrée) pour que l'en-tête et
      // les onglets restent à la même place quand la hauteur change d'un onglet à l'autre.
      display: 'flex', alignItems: isMobile ? 'flex-end' : 'flex-start', justifyContent: 'center',
      padding: isMobile ? 0 : 'max(24px, 6vh) 24px 24px', animation: 'fade-in .25s var(--ease)',
    }}>
      <div {...modal.dialogProps}
        style={{
          width: '100%', maxWidth: isMobile ? 'none' : 760,
          maxHeight: isMobile ? '100dvh' : 'calc(100dvh - max(24px, 6vh) - 24px)',
          background: 'var(--bg-card)', borderRadius: isMobile ? '16px 16px 0 0' : 16,
          overflow: 'hidden', display: 'flex', flexDirection: 'column',
          boxShadow: '0 28px 56px rgba(0,0,0,.24)', outline: 'none',
          animation: 'fade-up .35s var(--ease)',
        }}>
        {/* En-tête */}
        <div style={{ padding: isMobile ? '12px ' + gutter + ' 10px' : '20px 24px 14px', borderBottom: '1px solid var(--line)', display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 }}>
          <div style={{ minWidth: 0 }}>
            <h2 id="import-modal-title" style={{ margin: 0, fontSize: 17, fontWeight: 700, letterSpacing: '-.015em', color: 'var(--fg)' }}>Ajouter des trades</h2>
            <div style={{ fontSize: 12, color: 'var(--fg-2)', marginTop: 4, lineHeight: 1.4 }}>Dépose l’export CSV ou HTML de ton courtier, ou saisis un trade à la main.</div>
          </div>
          <button type="button" onClick={modal.requestClose} disabled={submitting} aria-label="Fermer" title={submitting ? (lastAction === 'import' ? 'Import en cours…' : 'Enregistrement en cours…') : 'Fermer'} className="tap tap-44" style={{ width: 32, height: 32, flexShrink: 0, borderRadius: 8, border: 'none', background: 'transparent', cursor: submitting ? 'not-allowed' : 'pointer', opacity: submitting ? .45 : 1, color: 'var(--fg-2)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>{Ico.x}</button>
        </div>

        {/* Onglets */}
        <div ref={tabsBarRef} className="scroll" style={{ padding: isMobile ? '10px ' + gutter + ' 0' : '12px 24px 0', overflowX: 'auto', overflowY: 'hidden', scrollbarWidth: 'none', flexShrink: 0 }}>
          <SegmentedTabs ariaLabel="Façon d’ajouter des trades" idPrefix="im" value={tab} onChange={selectTab}
            options={IM_TABS.map(t => ({ id: t.id, label: t.label, ico: Ico[t.ico] }))}
            style={{ display: 'inline-flex', flexWrap: 'nowrap', maxWidth: 'none' }} />
        </div>

        {/* Corps */}
        <div className="scroll" style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: isMobile ? '12px ' + gutter + ' 16px' : '18px 24px 22px' }}>
          {/* Les trois panneaux existent toujours (les onglets les désignent par
              aria-controls) ; un panneau inactif est masqué. Le contenu de
              « Importer un fichier » n'est rendu que sur son onglet, celui du
              courtier dès sa première visite, la saisie manuelle toujours. */}
          <div data-panel="file" role="tabpanel" id="im-panel-file" aria-labelledby="im-tab-file"
            hidden={tab !== 'file'} style={{ display: tab === 'file' ? 'block' : 'none' }}>
          {tab === 'file' && (
            <React.Fragment>
              <div
                role="button" tabIndex={0} data-autofocus=""
                aria-label="Importer un fichier" aria-describedby="im-formats"
                aria-disabled={submitting || done ? 'true' : undefined}
                onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPicker(); } }}
                onDragOver={e => {
                  e.preventDefault(); e.stopPropagation();
                  const ok = !submitting && !done;
                  if (e.dataTransfer) e.dataTransfer.dropEffect = ok ? 'copy' : 'none';
                  if (ok) setDragOver(true);
                }}
                onDragLeave={() => setDragOver(false)}
                onDrop={e => {
                  e.preventDefault(); e.stopPropagation(); setDragOver(false);
                  if (e.dataTransfer && e.dataTransfer.files) handleFiles(Array.from(e.dataTransfer.files));
                }}
                onClick={openPicker}
                style={{
                  border: '1.5px dashed ' + (dragOver ? 'var(--blue)' : 'var(--line-2)'),
                  background: dragOver ? 'var(--blue-soft)' : 'var(--bg-soft)',
                  borderRadius: 12, padding: isMobile ? '26px 16px' : '40px 20px', textAlign: 'center',
                  cursor: submitting || done ? 'default' : 'pointer', transition: 'all .15s var(--ease)',
                }}>
                <div style={{ display: 'flex', justifyContent: 'center', marginBottom: 12, color: dragOver ? 'var(--blue-600)' : 'var(--fg-2)' }}>
                  {React.cloneElement(Ico.upload, { width: 32, height: 32 })}
                </div>
                <div style={{ fontSize: 14, fontWeight: 600, color: 'var(--fg)', marginBottom: 4 }}>
                  {dragOver ? 'Relâche pour ajouter' : isMobile ? 'Choisis un fichier' : 'Glisse tes fichiers ici'}
                </div>
                <div style={{ fontSize: 12, color: 'var(--fg-2)' }}>
                  {isMobile ? 'CSV, HTML ou TSV · plusieurs fichiers possibles' : 'ou clique pour parcourir · CSV, HTML ou TSV'}
                </div>
              </div>
              <input ref={inputRef} type="file" accept=".csv,.tsv,.txt,.htm,.html" multiple tabIndex={-1} aria-hidden="true" style={{ display: 'none' }}
                onChange={e => { const list = Array.from(e.target.files || []); e.target.value = ''; handleFiles(list); }}/>

              <div id="im-formats" style={{ fontSize: 11.5, color: 'var(--fg-3)', marginTop: 10, lineHeight: 1.5 }}>
                Formats reconnus{IM_NB}: relevé MetaTrader 5 (HTML ou CSV), Tradovate (CSV) et fichier générique avec une ligne d’en-têtes (Date, Symbole, Sens, P&L…).
              </div>

              {files.length > 0 && (
                <div style={{ marginTop: 18 }}>
                  <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--fg-2)', marginBottom: 8 }}>
                    {imCount(files.length, 'fichier')} · {imCount(totalParsed, 'trade')} {totalParsed > 1 ? 'reconnus' : 'reconnu'}
                  </div>
                  <div style={{ border: '1px solid var(--line)', borderRadius: 10, overflow: 'hidden' }}>
                    {files.map((f, i) => {
                      const n = f.trades ? f.trades.length : 0;
                      const isReady = f.status === 'ready' || f.status === 'done';
                      const ignored = isReady ? imIgnoredText(f.ignored) : '';
                      const notes = isReady && n > 0 ? imFileNotes(f) : [];
                      const missing = f.problem ? IM_FILE_PROBLEMS[f.problem]
                        : isReady && n === 0 && f.missing && f.missing.length
                        ? (f.missing.length > 1 ? 'Colonnes introuvables' : 'Colonne introuvable') + IM_NB + ': ' + f.missing.join(', ') + '.'
                        : '';
                      return (
                        <div key={f.id} style={{
                          padding: '11px 12px 11px 14px', display: 'grid', gridTemplateColumns: '22px minmax(0, 1fr) auto 32px', gap: 10, alignItems: 'center',
                          borderTop: i ? '1px solid var(--line)' : 'none',
                          background: f.status === 'done' ? 'var(--green-soft)' : f.status === 'error' ? 'var(--red-soft)' : 'var(--bg-card)',
                        }}>
                          <div style={{ display: 'flex', color: f.status === 'done' ? 'var(--green)' : f.status === 'error' ? 'var(--red)' : 'var(--fg-2)' }}>
                            {f.status === 'done' ? (
                              <span style={{ display: 'inline-flex', width: 18, height: 18, borderRadius: 999, background: 'var(--green)', alignItems: 'center', justifyContent: 'center', color: '#fff' }}>{Ico.check}</span>
                            ) : f.status === 'error' ? (
                              React.cloneElement(Ico.x, { width: 18, height: 18 })
                            ) : (
                              React.cloneElement(Ico.data, { width: 16, height: 16 })
                            )}
                          </div>
                          <div style={{ minWidth: 0 }}>
                            <div style={{ fontSize: 13, fontWeight: 500, color: 'var(--fg)', ...cellTxt }}>{f.name}</div>
                            <div style={{ fontSize: 11, color: f.status === 'error' ? 'var(--red-text)' : 'var(--fg-3)', marginTop: 2, lineHeight: 1.4 }}>
                              {f.status === 'parsing' ? 'Analyse…'
                                : f.status === 'error' ? 'Fichier illisible. Vérifie qu’il s’agit d’un export CSV ou HTML.'
                                : f.problem ? imSize(f.size)
                                : (IM_FORMAT_LABELS[f.format] || IM_FORMAT_LABELS.generic) + ' · ' + imSize(f.size)}
                            </div>
                            {isReady && n === 0 && (
                              <div style={{ fontSize: 11, color: 'var(--red-text)', marginTop: 2, lineHeight: 1.4 }}>
                                Aucun trade reconnu dans ce fichier.{missing ? ' ' + missing : ''}
                              </div>
                            )}
                            {ignored && <div style={{ fontSize: 11, color: 'var(--fg-2)', marginTop: 2, lineHeight: 1.4 }}>{ignored}</div>}
                            {notes.map((t, k) => <div key={k} style={{ fontSize: 11, color: 'var(--fg-2)', marginTop: 2, lineHeight: 1.4 }}>{t}</div>)}
                          </div>
                          <span style={{ fontSize: 11.5, fontWeight: 600, whiteSpace: 'nowrap', color: n ? 'var(--blue-600)' : 'var(--fg-3)' }}>
                            {n ? imCount(n, 'trade') : '—'}
                          </span>
                          <button type="button" onClick={() => removeFile(f.id)} disabled={submitting || done}
                            aria-label={'Retirer ' + f.name} title={'Retirer ' + f.name} className="tap"
                            style={{ background: 'transparent', border: 'none', cursor: submitting || done ? 'default' : 'pointer', color: 'var(--fg-3)', padding: 6, display: 'flex', opacity: submitting || done ? .4 : 1 }}>{Ico.x}</button>
                        </div>
                      );
                    })}
                  </div>

                  {hasNaive && !done && (
                    <div style={{ marginTop: 14 }}>
                      <label htmlFor="im-file-tz" style={IM_LABEL}>Fuseau des heures du fichier</label>
                      <select id="im-file-tz" className="input" value={fileTz} onChange={e => changeFileTz(e.target.value)} disabled={submitting}
                        style={{ fontFamily: 'inherit', maxWidth: 440 }}>
                        <option value="profile">{tzProfileLabel ? tzProfileLabel + ' · fuseau de ton profil' : 'Fuseau de ton profil'}</option>
                        {!profileIsUtc && <option value="utc">UTC</option>}
                        {(hasMT5 || fileTz === 'mt5server') && <option value="mt5server">Heure du serveur MetaTrader (UTC+2, UTC+3 en été)</option>}
                      </select>
                      <div style={IM_HINT}>
                        Les heures du fichier qui n’indiquent pas de fuseau sont lues dans celui-ci.
                        {fileTz === 'mt5server' && hasOtherNaive ? ' Les fichiers qui ne viennent pas de MetaTrader restent lus dans le fuseau de ton profil.' : ''}
                      </div>
                    </div>
                  )}

                  {previewRows.length > 0 && (
                    <div style={{ marginTop: 16 }}>
                      <div id="im-preview-title" style={{ fontSize: 11, fontWeight: 600, color: 'var(--fg-3)', letterSpacing: '.06em', textTransform: 'uppercase', marginBottom: 6 }}>Aperçu (premières lignes)</div>
                      {/* Une seule grille : les colonnes s'alignent d'une ligne à l'autre
                          et s'ajustent au contenu (date avec l'année, montant entier). */}
                      <div role="table" aria-labelledby="im-preview-title" style={{
                        display: 'grid', gridTemplateColumns: previewCols,
                        border: '1px solid var(--line)', borderRadius: 8, overflow: 'hidden', fontSize: 11.5,
                      }}>
                        <div role="row" style={{ display: 'contents' }}>
                          {previewHead.map((h, j) => (
                            <div key={h} role="columnheader" style={{
                              ...pvCell(j, previewHead.length, true),
                              background: 'var(--bg-soft)', fontWeight: 600, color: 'var(--fg-3)',
                              letterSpacing: '.04em', textTransform: 'uppercase', fontSize: 10.5, whiteSpace: 'nowrap',
                            }}>{h}</div>
                          ))}
                        </div>
                        {previewRows.map((t, i) => {
                          const long = t.direction !== 'short';
                          const pnl = Number(t.pnl) || 0;
                          const digits = priceDigitsOf(t.entry, t.exit_price);
                          const cells = [
                            <span style={{ color: 'var(--fg-2)', lineHeight: 1.35, whiteSpace: isMobile ? 'normal' : 'nowrap' }}>{fmtDateTime(t.executed_at)}</span>,
                            <span style={{ fontWeight: 600, color: 'var(--fg)', ...cellTxt }}>{t.symbol}</span>,
                            <span title={long ? 'Achat' : 'Vente'} aria-label={long ? 'Achat' : 'Vente'} style={{
                              display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 20, height: 18, borderRadius: 5,
                              fontSize: 10.5, fontWeight: 700,
                              background: long ? 'var(--green-soft)' : 'var(--red-soft)', color: long ? 'var(--green-text)' : 'var(--red-text)',
                            }}>{long ? 'A' : 'V'}</span>,
                          ];
                          if (!isMobile) {
                            cells.push(
                              <span className="mono" style={{ color: 'var(--fg-2)', whiteSpace: 'nowrap' }}>{fmtPrice(t.entry, digits)}</span>,
                              <span className="mono" style={{ color: 'var(--fg-2)', whiteSpace: 'nowrap' }}>{fmtPrice(t.exit_price, digits)}</span>,
                              <span style={{ color: 'var(--fg-3)', ...cellTxt }}>{t.setup || '—'}</span>,
                            );
                          }
                          cells.push(
                            <span className="mono" style={{ whiteSpace: 'nowrap', fontWeight: 600, color: pnl > 0 ? 'var(--green)' : pnl < 0 ? 'var(--red)' : 'var(--fg)' }}>
                              {imMoney(pnl)}
                            </span>
                          );
                          return (
                            <div key={i} role="row" style={{ display: 'contents' }}>
                              {cells.map((c, j) => <div key={j} role="cell" style={pvCell(j, cells.length, false)}>{c}</div>)}
                            </div>
                          );
                        })}
                      </div>
                    </div>
                  )}
                </div>
              )}
            </React.Fragment>
          )}
          </div>

          <div data-panel="manual" role="tabpanel" id="im-panel-manual" aria-labelledby="im-tab-manual"
            hidden={tab !== 'manual'} style={{ display: tab === 'manual' ? 'block' : 'none' }}>
            <ManualTradeForm key={manualKey} onSubmit={submitManual} submitting={submitting} locked={done} defaultDate={defaultDate}
              onDirtyChange={onManualDirty} isMobile={isMobile} strategies={strategies}/>
          </div>

          <div data-panel="broker" role="tabpanel" id="im-panel-broker" aria-labelledby="im-tab-broker"
            hidden={tab !== 'broker'} style={{ display: tab === 'broker' ? 'block' : 'none' }}>
          {(tab === 'broker' || brokerSeen) && (
            <React.Fragment>
              {/* Le panneau de Réglages (connexion MetaTrader 5 réelle) porte déjà
                  la carte « Importer un fichier » et la note sur les autres
                  plateformes : pas de seconde phrase « bientôt disponible ». */}
              {BrokerPanelC
                ? <BrokerPanelC inModal onSynced={state && state.refreshTrades} openImport={() => setTab('file')} onDirtyChange={setBrokerDirty}/>
                : (
                  <div style={{ fontSize: 12.5, color: 'var(--fg-2)', lineHeight: 1.5 }}>
                    <div>La connexion d’un courtier est momentanément indisponible. Réessaie dans un instant.</div>
                    <div style={{ marginTop: 10 }}>
                      Synchronisation directe bientôt disponible pour les autres plateformes. En attendant, exporte un fichier depuis ta plateforme puis utilise l’onglet «{IM_NB}Importer un fichier{IM_NB}».
                    </div>
                  </div>
                )}
            </React.Fragment>
          )}
          </div>
        </div>

        {/* Messages et confirmation : toujours visibles, au-dessus du pied */}
        {(msg || modal.confirmDiscard) && (
          <div style={{ padding: '10px ' + gutter + ' 0', display: 'flex', flexDirection: 'column', gap: 8, flexShrink: 0 }}>
            {msg && (
              <div role={msg.kind === 'err' ? 'alert' : 'status'} style={{
                padding: '9px 12px', borderRadius: 8, fontSize: 12.5, lineHeight: 1.45,
                background: msg.kind === 'err' ? 'var(--red-soft)' : msg.kind === 'ok' ? 'var(--green-soft)' : 'var(--bg-soft)',
                border: '1px solid ' + (msg.kind === 'err' ? 'var(--red)' : 'var(--line)'),
                color: msg.kind === 'err' ? 'var(--red-text)' : msg.kind === 'ok' ? 'var(--green-text)' : 'var(--fg-2)',
                // Message qui demande de recharger la page : le bouton est dans le
                // message (une application installée n'a pas de bouton du navigateur).
                display: msg.reload ? 'flex' : undefined, alignItems: 'center', flexWrap: 'wrap', gap: '8px 12px',
              }}>
                {msg.reload ? <span style={{ flex: '1 1 200px', minWidth: 0 }}>{msg.text}</span> : msg.text}
                {msg.reload && (
                  <button type="button" onClick={() => window.location.reload()} className="btn tap" style={{ fontSize: 12, color: 'var(--fg)', flexShrink: 0 }}>Recharger</button>
                )}
              </div>
            )}
            <ModalDiscardConfirm modal={modal} detail={discardDetail}/>
          </div>
        )}

        {/* Pied : action principale de l'onglet, Fermer en secondaire */}
        <div style={{
          padding: isMobile ? '10px ' + gutter + ' calc(10px + env(safe-area-inset-bottom))' : '14px 24px',
          marginTop: (msg || modal.confirmDiscard) ? 10 : 0,
          borderTop: '1px solid var(--line)', display: 'flex', alignItems: 'center', gap: 12,
          background: 'var(--bg-soft)', flexShrink: 0,
        }}>
          {!isMobile && <span style={{ flex: 1, minWidth: 0, fontSize: 11.5, color: 'var(--fg-3)' }}>{footNote}</span>}
          <div style={{ display: 'flex', gap: 10, flex: isMobile ? 1 : 'none', justifyContent: 'flex-end' }}>
            <button type="button" onClick={modal.requestClose} disabled={submitting} className="btn tap" style={{ color: 'var(--fg)', flex: isMobile ? 1 : 'none', justifyContent: 'center', opacity: submitting ? .55 : 1, cursor: submitting ? 'not-allowed' : 'pointer' }}>Fermer</button>
            {primary}
          </div>
        </div>
      </div>
    </div>
  );
}

Object.assign(window, {
  ImportModal, TradeFields, tradePnlDecimalsError,
  // Communs à l'ajout et à l'édition d'un trade (pages-2.jsx) :
  useTradeStrategyOptions, tradeStrategyCanonical, tradeTextLimitError, tradeNumFieldError,
  TRADE_MSG_PNL_REQUIRED, TRADE_MSG_TIME_REQUIRED,
});
