// Tempo — page Stratégies
// Modèle hybride : l'utilisateur crée des stratégies nommées (table Supabase
// `strategies`). Les statistiques d'une stratégie portent sur les trades dont
// le champ Stratégie (colonne `setup` en base) porte exactement son nom.
// Renommer une stratégie renomme donc aussi ce champ sur ses trades.
// Expose : StrategyPage({ state }), StrategyMobile({ state }).

// ─── Libellés et typographie (charte §5 et §6) ──────────────────────
const STRAT_NBSP = ' ';  // espace insécable : intérieur des guillemets
const STRAT_NNBSP = ' '; // espace fine insécable : avant « ? »
const STRAT_NAME_REQUIRED = 'Donne un nom à ta stratégie.';
// Texte de la charte §7 pour un échec de chargement des trades (le même que sur
// le Bilan hebdo et les Statistiques).
const STRAT_TRADES_ERROR = 'Impossible de charger tes trades. Vérifie ta connexion.';
// Renommage : échec de l'écriture des trades, retour arrière réussi.
const STRAT_RENAME_FAILED = 'Le nouveau nom n’a pas pu être appliqué à tes trades.';
// Renommage : échec de l'écriture des trades ET du retour arrière.
const STRAT_RENAME_PENDING = 'Le nom a changé, mais tes trades portent encore l’ancien nom. Clique sur Enregistrer pour réessayer.';

// Renommage en suspens, fin de la phrase de la notification envoyée quand l'échec
// arrive alors que la page Stratégies n'est plus affichée (« Clique sur
// Enregistrer » n'y aurait aucun sens).
const STRAT_RENAME_PENDING_AWAY = 'ses trades portent encore l’ancien nom. Reviens sur Stratégies pour réessayer.';

function stratQuote(s) { return '«' + STRAT_NBSP + s + STRAT_NBSP + '»'; }
// Clé de comparaison des noms : sans espaces de bord ni casse (doublons refusés).
function stratKey(name) { return String(name || '').trim().toLowerCase(); }
function stratDupMessage(name) { return 'Tu as déjà une stratégie ' + stratQuote(String(name || '').trim()) + '.'; }

// Titre de l'écran : table unique NAV_LABELS (core.jsx).
function stratNavTitle() {
  const n = (typeof NAV_LABELS !== 'undefined' && NAV_LABELS && NAV_LABELS.strategy) || null;
  return (n && n.titre) || 'Stratégies';
}

// Sous-titre commun ordinateur / mobile : « 4 stratégies · 46 trades associés ».
// nTrades null : les trades ne sont pas connus (premier chargement en cours ou
// en échec) → « 4 stratégies », sans annoncer un faux « 0 trade associé ».
function strategySubtitle(nStrategies, nTrades) {
  if (!nStrategies) return 'Suis la performance de chacune de tes approches.';
  const s = nStrategies > 1 ? 's' : '';
  const head = `${fmtNum(nStrategies, 0)} stratégie${s}`;
  if (nTrades == null) return head;
  const t = nTrades > 1 ? 's' : '';
  return `${head} · ${fmtNum(nTrades, 0)} trade${t} associé${t}`;
}

// État des trades vu par la page (charte §7 : une erreur de chargement n'est pas
// un compte vide). failed : le dernier chargement a échoué ; loading : un
// chargement est en cours ; unknown : aucun trade à l'écran ET on ne sait pas
// s'il y en a → les cartes affichent « — » au lieu de « 0 ».
function stratTradesState(st, trades) {
  const failed = !!(st && st.tradesError);
  const loading = !!(st && st.tradesLoading);
  return { failed, loading, unknown: (trades || []).length === 0 && (failed || loading) };
}

// Couleur d'un montant signé (charte §2) : vert pour un gain, rouge pour une
// perte, neutre (classe vide) pour ce qui s'affiche « 0,00 € ».
function stratMoneyTone(v) {
  const sign = moneySign(v); // règle unique, format-prefs.jsx
  return sign > 0 ? 'pos' : sign < 0 ? 'neg' : '';
}

// ─── Renommages en suspens : mémoire de l'appareil ──────────────────
// Un renommage « en suspens » (la stratégie porte le nouveau nom, ses trades
// encore l'ancien) doit survivre à un changement d'écran, à un rechargement et à
// la bascule ordinateur / mobile : sans lui, plus aucun Enregistrer ne saurait
// quel nom portent les trades à rattacher. Il est donc écrit dans le stockage
// local, par compte.
//   clé     'tempo:stratPendingRename:' + id du compte — le préfixe « tempo: »
//           la fait effacer à la déconnexion (purgeLocalTempoData, app.jsx)
//   valeur  { idStratégie: { from, rules } }
//           from   nom que portent encore les trades
//           rules  règles d'AVANT la toute première tentative (retour arrière)
// Limite assumée : le suspens est propre à l'appareil ; un autre appareil ne le
// voit pas. Seule une fonction SQL atomique (renommer la stratégie et ses trades
// d'un bloc) supprimerait le cas ; c'est une décision séparée.
const STRAT_PENDING_KEY = 'tempo:stratPendingRename:';

// Une entrée lisible, ou null : toute valeur mal formée est ignorée.
function stratPendingEntry(v) {
  if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
  if (typeof v.from !== 'string' || !v.from.trim()) return null;
  if (!('rules' in v) || !(v.rules === null || typeof v.rules === 'string')) return null;
  return { from: v.from, rules: v.rules };
}

function stratPendingRead(uid) {
  const out = {};
  if (!uid) return out;
  let raw = null;
  try { raw = JSON.parse(localStorage.getItem(STRAT_PENDING_KEY + uid) || 'null'); }
  catch (e) { return out; } // stockage indisponible ou contenu illisible
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
  for (const k of Object.keys(raw)) {
    const entry = stratPendingEntry(raw[k]);
    if (entry) out[k] = entry;
  }
  return out;
}

// Écrit (ou retire, entry = null) UNE entrée, en repartant du contenu stocké :
// un autre onglet peut y avoir ajouté les siennes. Stockage indisponible : le
// suspens ne vit qu'en mémoire, comme avant.
function stratPendingWrite(uid, id, entry) {
  if (!uid) return;
  try {
    const all = stratPendingRead(uid);
    if (entry) all[id] = { from: entry.from, rules: entry.rules == null ? null : entry.rules };
    else delete all[id];
    if (Object.keys(all).length) localStorage.setItem(STRAT_PENDING_KEY + uid, JSON.stringify(all));
    else localStorage.removeItem(STRAT_PENDING_KEY + uid);
  } catch (e) { /* stockage indisponible ou plein */ }
}

// ─── Écriture terminée en échec après le départ de la page ──────────
// La route démonte la page pendant qu'une écriture lente est encore en vol : son
// message d'erreur local n'a plus d'écran. L'échec part alors vers le canal
// commun de l'application (state.notifyWriteError, affiché sur tous les écrans).
function stratNotifyLost(state, text) {
  if (state && typeof state.notifyWriteError === 'function') { state.notifyWriteError(text); return; }
  console.warn('[Tempo] ' + text);
}

// Texte de la notification : il nomme la stratégie, puis donne la cause.
//   kind  'create' | 'edit' | 'delete' | 'retry'
function stratLostText(kind, name, error) {
  const q = stratQuote(String(name || '').trim());
  const msg = (error && error.message) || '';
  if (error && error.pending) {
    return ('Le renommage de la stratégie ' + q + ' n’a pas abouti : ' + STRAT_RENAME_PENDING_AWAY + ' ' + (error.detail || '')).trim();
  }
  let head;
  if (kind === 'create') head = 'La stratégie ' + q + ' n’a pas été créée.';
  else if (kind === 'delete') head = 'La stratégie ' + q + ' n’a pas été supprimée.';
  else if (kind === 'retry') head = 'Le renommage de la stratégie ' + q + ' n’a pas abouti.';
  else head = 'Les modifications de la stratégie ' + q + ' n’ont pas été enregistrées.';
  return (head + ' ' + (kind === 'delete' ? (error && error.detail) || msg : msg)).trim();
}

// Brouillon d'une création qui a échoué après le départ de la page : la saisie
// (nom et règles) n'est pas perdue, le formulaire la retrouve au retour — ou
// tout de suite si la page est déjà revenue à l'écran. Un seul brouillon, lié au
// compte, rendu une seule fois.
const STRAT_DRAFT = { value: null, subs: new Set(), n: 0 };
function stratDraftPut(uid, draft) {
  STRAT_DRAFT.value = { uid: uid || '', name: draft.name || '', rules: draft.rules || '', error: draft.error || '' };
  Array.from(STRAT_DRAFT.subs).forEach(fn => { try { fn(); } catch (e) { console.warn('[Tempo] strategy draft', e); } });
}
function stratDraftTake(uid) {
  const v = STRAT_DRAFT.value;
  if (!v) return null;
  if (v.uid !== (uid || '')) {
    // Autre compte : le brouillon ne le regarde pas. Compte pas encore connu : on attend.
    if (uid) STRAT_DRAFT.value = null;
    return null;
  }
  STRAT_DRAFT.value = null;
  return v;
}

// Création en échec après le départ de la page : la saisie devient un brouillon
// (rouvert au retour) et l'échec est annoncé sur l'écran où se trouve l'utilisateur.
function stratCreateLost(state, uid, sent, error) {
  const cause = (error && error.message) || '';
  stratDraftPut(uid, {
    name: sent.name, rules: sent.rules,
    error: ('Cette stratégie n’a pas été créée. ' + cause).trim() + ' Ta saisie a été conservée.',
  });
  stratNotifyLost(state, stratLostText('create', sent.name, error));
}

// Brouillon à rouvrir, commun à l'ordinateur et au mobile.
//   canReplaceRef  () → bool : faux si un formulaire de création est ouvert avec
//                  une AUTRE saisie en cours (on ne l'écrase pas)
//   openRef        () → ouvre le formulaire de création
// Renvoie [seed, clearSeed] ; seed = { name, rules, error, key } ou null.
function useStratDraftSeed(uid, canReplaceRef, openRef) {
  const [seed, setSeed] = React.useState(null);
  React.useEffect(() => {
    const take = () => {
      const d = stratDraftTake(uid);
      if (!d) return;
      if (canReplaceRef.current && !canReplaceRef.current()) return;
      setSeed({ name: d.name, rules: d.rules, error: d.error, key: ++STRAT_DRAFT.n });
      if (openRef.current) openRef.current();
    };
    take();
    STRAT_DRAFT.subs.add(take);
    return () => { STRAT_DRAFT.subs.delete(take); };
  }, [uid, canReplaceRef, openRef]);
  const clearSeed = React.useCallback(() => setSeed(null), []);
  return [seed, clearSeed];
}

// ─── Données : stratégies + statistiques par stratégie ──────────────
function useStrategies(state) {
  const [rows, setRows] = React.useState([]);
  const [loading, setLoading] = React.useState(true);
  // '' = OK · 'missing' = table absente · sinon message français (sbErrorMessage).
  const [error, setError] = React.useState('');
  // Dernière liste connue, lue par les contrôles de doublon.
  const rowsRef = React.useRef(rows);
  rowsRef.current = rows;
  // Renommages restés en suspens : { id de la stratégie → { from, rules } }.
  //   from   nom que portent encore ses trades
  //   rules  règles d'avant la toute première tentative (pour le retour arrière)
  // Arrive quand l'écriture des trades ET le retour arrière ont échoué (coupure
  // réseau au milieu d'un renommage). La référence sert aux écritures (lecture
  // immédiate), l'état à l'affichage de l'avertissement ; le tout est recopié
  // dans le stockage local (voir STRAT_PENDING_KEY) pour survivre à un
  // changement d'écran ou à un rechargement.
  const uid = (state && state.user && state.user.id) || '';
  const uidRef = React.useRef(uid);
  uidRef.current = uid;
  const pendingRef = React.useRef({});
  const [pendingRename, setPendingRename] = React.useState({});
  const publishPending = (next) => { pendingRef.current = next; setPendingRename(next); };
  // from vide = le suspens est levé. Un suspens déjà connu garde ses règles
  // d'origine : celles d'un second essai sont déjà les nouvelles.
  const setPending = (id, from, rules) => {
    const key = String(id);
    const cur = pendingRef.current;
    const had = cur[key];
    if (!from) {
      if (!had) return;
      const next = { ...cur };
      delete next[key];
      publishPending(next);
      stratPendingWrite(uidRef.current, key, null);
      return;
    }
    if (had && had.from === from) return;
    const entry = { from, rules: had ? had.rules : (rules == null ? null : rules) };
    publishPending({ ...cur, [key]: entry });
    stratPendingWrite(uidRef.current, key, entry);
  };
  // Une autre stratégie prend le nom que portent ces trades : ils lui reviennent,
  // il n'y a plus rien à reprendre.
  const dropPendingNamed = (name, exceptId) => {
    const cur = pendingRef.current;
    for (const k of Object.keys(cur)) {
      if (k !== String(exceptId) && cur[k].from === name) setPending(k, null);
    }
  };
  // Ménage, à partir d'une liste FRAÎCHEMENT relue (jamais après une erreur de
  // chargement) : un suspens n'a plus lieu d'être si sa stratégie n'existe plus,
  // si elle a repris le nom de ses trades (retour arrière fait ailleurs), ou si
  // ce nom est maintenant celui d'une autre stratégie.
  const prunePending = (list) => {
    const cur = pendingRef.current;
    for (const k of Object.keys(cur)) {
      const from = cur[k].from;
      const row = list.find(r => String(r.id) === k);
      const taken = list.some(r => String(r.id) !== k && (r.name || '').trim() === from);
      if (!row || (row.name || '').trim() === from || taken) setPending(k, null);
    }
  };
  const pruneRef = React.useRef(prunePending);
  pruneRef.current = prunePending;
  // Vrai dès qu'un chargement a réussi : la liste à l'écran est digne de foi.
  const loadedOkRef = React.useRef(false);

  // Relecture du stockage local dès que le compte est connu (il peut ne pas
  // l'être au premier rendu), fusionnée avec ce qui est déjà en mémoire.
  const hydratedUidRef = React.useRef('');
  React.useEffect(() => {
    if (!uid) return;
    const sameAccount = !hydratedUidRef.current || hydratedUidRef.current === uid;
    hydratedUidRef.current = uid;
    // Autre compte : rien de l'ancien ne le concerne.
    if (!sameAccount) loadedOkRef.current = false;
    const mem = sameAccount ? pendingRef.current : {};
    const stored = stratPendingRead(uid);
    for (const k of Object.keys(mem)) {
      const had = stored[k];
      if (!had || had.from !== mem[k].from || had.rules !== mem[k].rules) stratPendingWrite(uid, k, mem[k]);
    }
    const merged = { ...stored, ...mem };
    if (Object.keys(merged).length || Object.keys(pendingRef.current).length) publishPending(merged);
    if (loadedOkRef.current) pruneRef.current(rowsRef.current);
  }, [uid]);

  // Seul le DERNIER chargement lancé publie son résultat : une réponse partie
  // avant une écriture ne doit ni remettre l'ancienne liste à l'écran, ni faire
  // croire au ménage qu'un renommage en suspens a été annulé.
  const loadSeqRef = React.useRef(0);
  const load = React.useCallback(async () => {
    if (!window.sb) { setLoading(false); return; }
    const seq = ++loadSeqRef.current;
    setLoading(true);
    let res;
    try {
      res = await window.sb.from('strategies')
        .select('*').order('created_at', { ascending: false });
    } catch (e) {
      res = { data: null, error: e };
    }
    if (seq !== loadSeqRef.current) return;
    const { data, error } = res || {};
    if (error) {
      if (error.code === 'PGRST205' || error.code === '42P01') {
        // Table absente : fonction dégradée, sans jargon technique à l'écran.
        console.warn('[Tempo] strategies : table absente, exécuter supabase-migration-strategies.sql', error);
        setError('missing');
      } else {
        // Erreur de chargement ≠ compte vide : les stratégies déjà chargées restent.
        console.warn('[Tempo] strategies load:', error);
        setError(sbErrorMessage(error));
      }
      setLoading(false);
      return;
    }
    setError(''); setRows(data || []); setLoading(false);
    loadedOkRef.current = true;
    pruneRef.current(data || []);
  }, []);

  React.useEffect(() => { load(); }, [load]);

  async function currentUserId() {
    let uid = state?.user?.id;
    if (!uid && window.sb) {
      try { const { data } = await window.sb.auth.getUser(); uid = data?.user?.id; } catch (e) {}
    }
    return uid;
  }

  async function addStrategy({ name, rules }) {
    const trimmed = (name || '').trim();
    if (!trimmed) return { error: { message: STRAT_NAME_REQUIRED } };
    const dup = rowsRef.current.find(r => stratKey(r.name) === stratKey(trimmed));
    if (dup) return { error: { message: stratDupMessage(dup.name) } };
    const uid = await currentUserId();
    if (!uid) return { error: { message: 'Tu dois être connecté.' } };
    const row = { user_id: uid, name: trimmed, rules: (rules || '').trim() || null };
    const { data, error } = await window.sb.from('strategies').insert(row).select().single();
    if (error) {
      console.warn('[Tempo] addStrategy', error);
      // 23505 : index unique de supabase-migration-strategies-unique.sql (stratégie
      // créée ailleurs, par exemple dans un autre onglet) → on resynchronise la liste.
      if (error.code === '23505') { load(); return { error: { message: stratDupMessage(trimmed) } }; }
      return { error: { message: sbErrorMessage(error) } };
    }
    setRows(prev => [data, ...prev]);
    dropPendingNamed(trimmed, null);
    return { data };
  }

  // Renommer une stratégie = deux écritures (la stratégie, puis ses trades) que
  // Supabase ne sait pas faire d'un bloc depuis le navigateur. Garanties :
  //  · échec côté trades → retour arrière complet de la stratégie : le nom que
  //    portent ses trades ET ses règles d'origine, c'est-à-dire celles d'avant la
  //    toute première tentative quand un renommage était déjà en suspens (les
  //    règles alors en base sont déjà les nouvelles) ;
  //  · retour arrière en échec lui aussi → le renommage reste « en suspens » :
  //    on le dit, et le prochain Enregistrer (ou « Réessayer » sur la carte)
  //    renomme les trades restés sur l'ancien nom.
  // L'ordre (stratégie d'abord) est voulu : l'ordre inverse obligerait un retour
  // arrière sur les trades par le nouveau nom, qui emporterait des trades portant
  // déjà ce nom en texte libre.
  //   opts.keepRules  le retour arrière ne touche qu'au nom. Pour « Réessayer »
  //                   sur la carte : aucun formulaire n'est ouvert, rendre les
  //                   règles d'origine effacerait sans le dire celles que la
  //                   carte affiche.
  async function updateStrategy(id, { name, rules }, opts) {
    const keepRules = !!(opts && opts.keepRules);
    const trimmed = (name || '').trim();
    if (!trimmed) return { error: { message: STRAT_NAME_REQUIRED } };
    const current = rowsRef.current.find(r => r.id === id);
    if (!current) return { error: { message: 'Cette stratégie n’existe plus.' } };
    const dup = rowsRef.current.find(r => r.id !== id && stratKey(r.name) === stratKey(trimmed));
    if (dup) return { error: { message: stratDupMessage(dup.name) } };
    const oldName = (current.name || '').trim();
    // Nom que portent réellement les trades : celui d'un renommage en suspens, sinon le nom actuel.
    const pending = pendingRef.current[String(id)] || null;
    const pendingFrom = pending ? pending.from : '';
    const from = pendingFrom || oldName;
    // Règles d'avant toute tentative : celles du suspens s'il y en a un.
    const rulesBefore = pending ? pending.rules : (current.rules ?? null);
    const renamed = from !== trimmed;
    const patch = { name: trimmed, rules: (rules || '').trim() || null };
    let res;
    try {
      res = await window.sb.from('strategies').update(patch).eq('id', id).select().single();
    } catch (e) { res = { data: null, error: e }; }
    const { data, error } = res || {};
    if (error) {
      console.warn('[Tempo] updateStrategy', error);
      return { error: { message: error.code === '23505' ? stratDupMessage(trimmed) : sbErrorMessage(error) } };
    }
    // Le lien stratégie ↔ trades passe par le nom : les trades suivent le renommage.
    if (renamed && from) {
      let tErr = null;
      try {
        const tRes = await window.sb.from('trades').update({ setup: trimmed }).eq('setup', from);
        tErr = tRes && tRes.error;
      } catch (e) { tErr = e; }
      if (tErr) {
        console.warn('[Tempo] updateStrategy trades', tErr);
        const detail = sbErrorMessage(tErr);
        // Retour arrière complet : la stratégie reprend le nom que portent ses
        // trades, et ses règles d'origine (rien n'est enregistré à moitié).
        let backErr = null;
        try {
          const bRes = await window.sb.from('strategies')
            .update({ name: pendingFrom || current.name, rules: keepRules ? (current.rules ?? null) : rulesBefore }).eq('id', id);
          backErr = bRes && bRes.error;
        } catch (e) { backErr = e; }
        if (backErr) {
          console.warn('[Tempo] updateStrategy rollback', backErr);
          setPending(id, from, rulesBefore);
          load();
          return { error: { message: STRAT_RENAME_PENDING + ' ' + detail, pending: true, detail } };
        }
        setPending(id, null);
        load();
        return { error: { message: STRAT_RENAME_FAILED + ' ' + detail } };
      }
    }
    setPending(id, null);
    dropPendingNamed(trimmed, id);
    setRows(prev => prev.map(r => (r.id === id ? { ...r, ...(data || patch) } : r)));
    if (renamed && state && typeof state.refreshTrades === 'function') {
      try { await state.refreshTrades(); } catch (e) { console.warn('[Tempo] refreshTrades', e); }
    }
    return { data };
  }

  async function removeStrategy(id) {
    const { error } = await window.sb.from('strategies').delete().eq('id', id);
    if (error) {
      console.warn('[Tempo] removeStrategy', error);
      const detail = sbErrorMessage(error);
      return { error: { message: 'La stratégie n’a pas été supprimée. ' + detail, detail } };
    }
    setRows(prev => prev.filter(r => r.id !== id));
    setPending(id, null);
    return {};
  }

  return { rows, loading, error, addStrategy, updateStrategy, removeStrategy, reload: load, pendingRename };
}

// Statistiques par stratégie, calculées sur les trades déjà chargés dans l'état.
// Un trade appartient à une stratégie quand t.setup === strategy.name.
function useStrategyStats(strategies, trades) {
  return React.useMemo(() => {
    const bySetup = {};
    for (const t of (trades || [])) {
      const key = (t.setup || '').trim();
      if (!key) continue;
      (bySetup[key] = bySetup[key] || []).push(t);
    }
    const map = {};
    for (const s of strategies) {
      const subset = bySetup[(s.name || '').trim()] || [];
      map[s.id] = computeStats(subset);
    }
    return map;
  }, [strategies, trades]);
}

// Ordre d'affichage des cartes : P&L net décroissant (pertes en bas), puis les
// stratégies sans trade dans leur ordre de création. Même ordre que « Performance
// par stratégie » dans les Statistiques.
function useSortedStrategies(rows, statsById) {
  return React.useMemo(() => {
    const idx = new Map(rows.map((r, i) => [r.id, i]));
    return [...rows].sort((a, b) => {
      const sa = statsById[a.id], sb = statsById[b.id];
      const na = sa && sa.count ? 1 : 0, nb = sb && sb.count ? 1 : 0;
      if (na !== nb) return nb - na;
      if (na && nb && sb.netPnl !== sa.netPnl) return sb.netPnl - sa.netPnl;
      return idx.get(a.id) - idx.get(b.id);
    });
  }, [rows, statsById]);
}

// Nombre de trades associés à au moins une stratégie (chaque trade compté une
// fois, même si d'anciens doublons de nom subsistent).
function useStrategyTotals(strategies, trades) {
  return React.useMemo(() => {
    const names = new Set(strategies.map(s => (s.name || '').trim()).filter(Boolean));
    let count = 0;
    for (const t of (trades || [])) if (names.has((t.setup || '').trim())) count++;
    return { count };
  }, [strategies, trades]);
}

// ─── Anneau de progression (taux de réussite au centre) ─────────────
function ProgressRing({ value = 0, size = 92, stroke = 8, count = 0 }) {
  const r = (size - stroke) / 2;
  const c = 2 * Math.PI * r;
  const pct = Math.max(0, Math.min(100, Number(value) || 0));
  const dash = (pct / 100) * c;
  // Couleur de l'anneau : l'échelle unique du taux de réussite, celle de la
  // jauge du Tableau de bord (dashZoneWinRate, dashboard-widgets.jsx, chargé
  // avant ce fichier) : seuils comparés à la valeur affichée, une décimale.
  const ringColor = dashZoneWinRate(pct).color;
  return (
    <div style={{ position: 'relative', width: size, height: size, flexShrink: 0 }}>
      <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ transform: 'rotate(-90deg)' }} aria-hidden="true">
        <circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="var(--line-2)" strokeWidth={stroke} />
        {/* Sans trade ou à 0 %, pas d'arc : l'extrémité arrondie dessinerait un point. */}
        {count > 0 && dash > 0 && (
          <circle
            cx={size / 2} cy={size / 2} r={r} fill="none"
            stroke={ringColor} strokeWidth={stroke} strokeLinecap="round"
            strokeDasharray={`${dash} ${c}`}
            style={{ transition: 'stroke-dasharray .6s var(--ease)' }}
          />
        )}
      </svg>
      <div style={{
        position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column',
        alignItems: 'center', justifyContent: 'center',
      }}>
        <span className="num" style={{ fontSize: size * 0.19, fontWeight: 700, letterSpacing: '-.02em', lineHeight: 1, whiteSpace: 'nowrap' }}>
          {count === 0 ? '—' : fmtPct(pct, 1)}
        </span>
        <span style={{ fontSize: 9.5, color: 'var(--fg-3)', fontWeight: 500, marginTop: 2, maxWidth: size - 2 * stroke - 12, textAlign: 'center', lineHeight: 1.15 }}>
          Taux de réussite
        </span>
      </div>
    </div>
  );
}

// ─── Mini ligne de stat (Gagnants / Perdants / Nuls / Trades) ───────
function StatLine({ label, value, tone }) {
  const color = tone === 'green' ? 'var(--green-text)' : tone === 'red' ? 'var(--red-text)' : 'var(--fg)';
  return (
    <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '6px 0' }}>
      <span style={{ fontSize: 12, color: 'var(--fg-3)' }}>{label}</span>
      <span className="num" style={{ fontSize: 13, fontWeight: 600, color, whiteSpace: 'nowrap' }}>{value}</span>
    </div>
  );
}

// ─── Formulaire d'une stratégie (création ou modification) ──────────
// initial        valeurs de départ (modification) ; absent = création
// draft          création seulement : saisie retrouvée après un échec survenu
//                hors de la page, { name, rules, error } ; elle reste « non
//                enregistrée » (rien n'est en base)
// onSubmit       async ({ name, rules }) → { error? } ; en cas de succès, le
//                parent ferme le formulaire (le bouton reste désactivé jusque-là)
// onDirtyChange  (bool) saisie non enregistrée, pour la feuille mobile
// onBusyChange   (bool) envoi en cours : le parent ne ferme rien pendant ce temps
// onLostError    ({ name, rules }, error) l'envoi a échoué alors que le
//                formulaire n'est plus à l'écran (page quittée entre-temps)
function StrategyForm({ initial, draft, onSubmit, onCancel, onDirtyChange, onBusyChange, onLostError, autoFocus = true }) {
  const editing = !!initial;
  const initName = (initial && initial.name) || '';
  const initRules = (initial && initial.rules) || '';
  const [name, setName] = React.useState(draft ? (draft.name || '') : initName);
  const [rules, setRules] = React.useState(draft ? (draft.rules || '') : initRules);
  const [err, setErr] = React.useState((draft && draft.error) || '');
  const [busy, setBusy] = React.useState(false);
  const sendingRef = React.useRef(false);
  const mountedRef = React.useRef(true);
  React.useEffect(() => {
    mountedRef.current = true;
    return () => { mountedRef.current = false; };
  }, []);

  const dirty = name.trim() !== initName.trim() || rules.trim() !== initRules.trim();
  React.useEffect(() => { if (onDirtyChange) onDirtyChange(dirty); }, [dirty, onDirtyChange]);

  const submit = async (e) => {
    if (e) e.preventDefault();
    if (sendingRef.current) return; // anti double envoi
    if (!name.trim()) { setErr(STRAT_NAME_REQUIRED); return; }
    sendingRef.current = true;
    setErr(''); setBusy(true);
    if (onBusyChange) onBusyChange(true);
    const sent = { name, rules };
    let res;
    try { res = await onSubmit(sent); }
    catch (ex) {
      console.warn('[Tempo] strategy submit', ex);
      res = { error: { message: sbErrorMessage(ex) } };
    }
    if (!mountedRef.current) {
      // Page quittée pendant l'envoi : un échec ne passe pas inaperçu.
      if (res && res.error && onLostError) onLostError(sent, res.error);
      return;
    }
    if (res && res.error) {
      sendingRef.current = false;
      setBusy(false);
      if (onBusyChange) onBusyChange(false);
      setErr(res.error.message || sbErrorMessage(res.error));
    }
  };

  const labelStyle = { fontSize: 11.5, fontWeight: 600, color: 'var(--fg-2)', display: 'block', marginBottom: 6 };
  return (
    <form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
      <label style={{ display: 'block' }}>
        <span style={labelStyle}>Nom de la stratégie</span>
        <input
          autoFocus={autoFocus} value={name} onChange={e => { setName(e.target.value); if (err) setErr(''); }}
          placeholder="Ex. ORB 5 min, Breakout NQ…" className="input" maxLength={120}
          style={{ width: '100%' }}
        />
      </label>
      <label style={{ display: 'block' }}>
        <span style={labelStyle}>Règles (optionnel)</span>
        <textarea
          value={rules} onChange={e => setRules(e.target.value)}
          placeholder="Conditions d’entrée, gestion du risque, sortie…" className="input"
          rows={3} style={{ width: '100%', resize: 'vertical', lineHeight: 1.5, fontFamily: 'inherit' }}
        />
      </label>
      <div style={{ fontSize: 11.5, color: 'var(--fg-2)', background: 'var(--blue-soft)', border: '1px solid var(--blue-border)', borderRadius: 8, padding: '8px 10px', lineHeight: 1.45 }}>
        Les statistiques portent sur les trades dont le champ Stratégie porte exactement ce nom.
        {editing && ' Les trades associés suivent le nouveau nom.'}
      </div>
      {err && <div role="alert" style={{ fontSize: 12, color: 'var(--red-text)' }}>{err}</div>}
      <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
        <button type="button" onClick={onCancel} disabled={busy} className="btn tap" style={{ color: 'var(--fg-2)' }}>Annuler</button>
        <button type="submit" disabled={busy} className="btn btn-blue tap" style={{ color: '#fff' }}>
          {editing ? (busy ? 'Enregistrement…' : 'Enregistrer') : (busy ? 'Création…' : 'Créer la stratégie')}
        </button>
      </div>
    </form>
  );
}

// ─── Carte d'une stratégie ──────────────────────────────────────────
const STRAT_KPI_LABEL = { fontSize: 10, color: 'var(--fg-3)', letterSpacing: '.04em', fontWeight: 600, textTransform: 'uppercase' };
const STRAT_SMALL_BTN = { height: 28, padding: '0 10px', borderRadius: 7, border: '1px solid var(--line)', background: 'var(--bg-card)', color: 'var(--fg-2)', fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' };

// tradesUnknown  les trades ne sont pas connus (chargement en cours ou en échec,
//                rien à l'écran) : « — » partout, jamais un faux « 0 »
// pendingFrom    renommage en suspens : nom que portent encore les trades
// onLostError    (texte) une écriture a échoué alors que la carte n'est plus à
//                l'écran (page quittée pendant l'envoi)
function StrategyCard({ s, st, onDelete, onUpdate, onLostError, compact, tradesUnknown = false, pendingFrom = '' }) {
  // null | 'confirmDelete' | 'edit'
  const [mode, setMode] = React.useState(null);
  const [busy, setBusy] = React.useState(false);
  const [msg, setMsg] = React.useState('');
  const deletingRef = React.useRef(false);
  const [retrying, setRetrying] = React.useState(false);
  const retryingRef = React.useRef(false);
  const mountedRef = React.useRef(true);
  React.useEffect(() => {
    mountedRef.current = true;
    return () => { mountedRef.current = false; };
  }, []);

  // Retour du focus clavier : à la fermeture de la confirmation de suppression
  // (Annuler, Échap) ou du formulaire (Annuler, Enregistrer), l'élément qui avait
  // le focus a disparu de la page. On le rend au bouton d'origine, sauf si
  // l'utilisateur a déjà visé autre chose.
  const editBtnRef = React.useRef(null);
  const delBtnRef = React.useRef(null);
  const prevModeRef = React.useRef(null);
  React.useEffect(() => {
    const prev = prevModeRef.current;
    prevModeRef.current = mode;
    if (mode !== null || !prev) return;
    const target = prev === 'edit' ? editBtnRef.current : delBtnRef.current;
    const active = document.activeElement;
    if (target && (!active || active === document.body)) {
      try { target.focus({ preventScroll: true }); } catch (e) {}
    }
  }, [mode]);
  const total = st.count || 0;
  const wins = st.wins || 0;
  const losses = st.losses || 0;
  const nuls = Math.max(0, total - wins - losses);
  const tone = total > 0 ? stratMoneyTone(st.netPnl) : '';
  const name = s.name || '';
  // L'avertissement n'a de sens que si la carte porte déjà le nouveau nom (si la
  // liste n'a pas pu être relue après l'échec, elle affiche encore l'ancien).
  const pendingShown = pendingFrom && pendingFrom !== name.trim() ? pendingFrom : '';

  const doDelete = async () => {
    if (deletingRef.current) return; // anti double envoi
    deletingRef.current = true;
    setBusy(true); setMsg('');
    let res;
    try { res = await onDelete(s.id); }
    catch (ex) {
      console.warn('[Tempo] strategy delete', ex);
      const detail = sbErrorMessage(ex);
      res = { error: { message: 'La stratégie n’a pas été supprimée. ' + detail, detail } };
    }
    if (!mountedRef.current) {
      // Succès : la carte a quitté la liste. Échec après le départ de la page : on le dit.
      if (res && res.error && onLostError) onLostError(stratLostText('delete', name, res.error));
      return;
    }
    // Échec : la carte reste, avec le message.
    if (res && res.error) {
      deletingRef.current = false;
      setBusy(false);
      setMsg(res.error.message);
    }
  };

  // Renommage en suspens : nouvelle tentative avec le nom et les règles actuels.
  // keepRules : si les trades refusent encore le nom et que la stratégie reprend
  // l'ancien, les règles affichées sur la carte restent (aucun formulaire n'est
  // ouvert pour les retrouver).
  const retryRename = async () => {
    if (retryingRef.current) return; // anti double envoi
    retryingRef.current = true;
    setRetrying(true); setMsg('');
    let res;
    try { res = await onUpdate(s.id, { name, rules: s.rules || '' }, { keepRules: true }); }
    catch (ex) {
      console.warn('[Tempo] strategy retry rename', ex);
      res = { error: { message: sbErrorMessage(ex) } };
    }
    retryingRef.current = false;
    if (!mountedRef.current) {
      if (res && res.error && onLostError) onLostError(stratLostText('retry', name, res.error));
      return;
    }
    setRetrying(false);
    // Toujours en suspens : l'avertissement reste, on n'ajoute que la cause.
    if (res && res.error) setMsg(res.error.pending ? (res.error.detail || '') : res.error.message);
  };

  const pad = compact ? 16 : 18;

  if (mode === 'edit') {
    return (
      <div className="card" style={{ padding: pad }}>
        <CardTitle style={{ fontSize: 14, letterSpacing: 'normal', marginBottom: 14 }}>Modifier la stratégie</CardTitle>
        <StrategyForm
          initial={{ name, rules: s.rules || '' }}
          onSubmit={async (payload) => {
            const res = await onUpdate(s.id, payload);
            if (!(res && res.error) && mountedRef.current) setMode(null);
            return res;
          }}
          onCancel={() => setMode(null)}
          onLostError={(sent, error) => {
            // En suspens, la stratégie porte déjà le nouveau nom ; sinon elle a gardé l'ancien.
            if (onLostError) onLostError(stratLostText('edit', error && error.pending ? sent.name : name, error));
          }}
        />
      </div>
    );
  }

  return (
    <div className="card lift" style={{ padding: pad, display: 'flex', flexDirection: 'column', gap: 14 }}>
      {/* En-tête : nom + actions */}
      <div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 10 }}>
        <div style={{ minWidth: 0 }}>
          <div title={name} style={{ fontSize: 15, fontWeight: 700, letterSpacing: '-.015em', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{name}</div>
          {s.rules
            ? <div style={{ fontSize: 11.5, color: 'var(--fg-3)', marginTop: 3, lineHeight: 1.4, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{s.rules}</div>
            : <div style={{ fontSize: 11.5, color: 'var(--fg-3)', marginTop: 3 }}>Aucune règle définie</div>}
        </div>
        {mode !== 'confirmDelete' && (
          <div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
            <button type="button" ref={editBtnRef} onClick={() => { setMsg(''); setMode('edit'); }} className="tap tap-44"
              aria-label={'Modifier la stratégie ' + stratQuote(name)} style={STRAT_SMALL_BTN}>Modifier</button>
            <button type="button" ref={delBtnRef} onClick={() => { setMsg(''); setMode('confirmDelete'); }} className="tap tap-44"
              title="Supprimer la stratégie" aria-label={'Supprimer la stratégie ' + stratQuote(name)}
              style={{ ...STRAT_SMALL_BTN, width: 28, padding: 0, color: 'var(--fg-3)' }}>
              <svg className="ico" width="14" height="14" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 4h10M6.5 4V2.5h3V4M5 4l.6 9h4.8L11 4"/></svg>
            </button>
          </div>
        )}
      </div>

      {/* Confirmation de suppression en ligne (modèle du backtest, charte §8) */}
      {mode === 'confirmDelete' && (
        <div role="group" aria-label="Confirmer la suppression"
          onKeyDown={e => { if (e.key === 'Escape' && !busy) { e.stopPropagation(); setMode(null); } }}
          style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', padding: '10px 12px', borderRadius: 8, background: 'var(--red-soft)' }}>
          <span style={{ flex: '1 1 180px', minWidth: 0, fontSize: 12, color: 'var(--fg)', lineHeight: 1.45 }}>
            {`Supprimer ${stratQuote(name)}${STRAT_NNBSP}? Les trades associés sont conservés.`}
          </span>
          <div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
            <button type="button" onClick={doDelete} disabled={busy} className="tap"
              style={{ ...STRAT_SMALL_BTN, background: 'var(--sell-bg, #dc2626)', borderColor: 'var(--sell-bg, #dc2626)', color: '#fff' }}>
              {busy ? 'Suppression…' : 'Supprimer'}
            </button>
            <button type="button" autoFocus onClick={() => { setMsg(''); setMode(null); }} disabled={busy} className="tap" style={STRAT_SMALL_BTN}>Annuler</button>
          </div>
        </div>
      )}

      {/* Renommage en suspens : la stratégie a changé de nom, pas encore ses trades */}
      {pendingShown && (
        <div role="alert"
          style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', padding: '10px 12px', borderRadius: 8, background: 'var(--red-soft)' }}>
          <span style={{ flex: '1 1 180px', minWidth: 0, fontSize: 12, color: 'var(--fg)', lineHeight: 1.45, overflowWrap: 'anywhere' }}>
            {`Tes trades portent encore l’ancien nom ${stratQuote(pendingShown)}.`}
          </span>
          <button type="button" onClick={retryRename} disabled={retrying || busy} className="tap" style={{ ...STRAT_SMALL_BTN, flexShrink: 0 }}>
            {retrying ? 'Enregistrement…' : 'Réessayer'}
          </button>
        </div>
      )}
      {msg && <div role="alert" style={{ fontSize: 12, color: 'var(--red-text)', marginTop: -6 }}>{msg}</div>}

      {/* Corps : anneau + lignes de stats */}
      <div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
        <ProgressRing value={st.winRate} count={total} size={compact ? 84 : 92} />
        <div style={{ flex: 1, minWidth: 0 }}>
          <StatLine label="Gagnants" value={total === 0 ? '—' : fmtNum(wins, 0)} tone={wins > 0 ? 'green' : null} />
          <div style={{ height: 1, background: 'var(--line)' }} />
          <StatLine label="Perdants" value={total === 0 ? '—' : fmtNum(losses, 0)} tone={losses > 0 ? 'red' : null} />
          <div style={{ height: 1, background: 'var(--line)' }} />
          {nuls > 0 && (
            <React.Fragment>
              <StatLine label="Nuls" value={fmtNum(nuls, 0)} />
              <div style={{ height: 1, background: 'var(--line)' }} />
            </React.Fragment>
          )}
          <StatLine label="Trades" value={tradesUnknown ? '—' : fmtNum(total, 0)} />
        </div>
      </div>

      {/* Pied : Profit factor + P&L net */}
      <div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: 12, paddingTop: 12, borderTop: '1px solid var(--line)' }}>
        <div style={{ minWidth: 0 }}>
          <div style={STRAT_KPI_LABEL}>Profit factor</div>
          <div className="num" style={{ fontSize: 14, fontWeight: 600, marginTop: 2, whiteSpace: 'nowrap', color: 'var(--fg)' }}>
            {fmtPF(st.profitFactor, wins + losses)}
          </div>
        </div>
        <div style={{ textAlign: 'right', minWidth: 0 }}>
          <div style={STRAT_KPI_LABEL}>P&amp;L net</div>
          <div className={'num' + (tone ? ' ' + tone : '')} style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-.02em', marginTop: 2, whiteSpace: 'nowrap', color: tone ? undefined : 'var(--fg)' }}>
            {total === 0 ? '—' : fmtMoney(st.netPnl, true)}
          </div>
        </div>
      </div>
    </div>
  );
}

// ─── État vide ──────────────────────────────────────────────────────
function StrategyEmpty({ onCreate, compact }) {
  return (
    <div className="card">
      <EmptyState icon={Ico.strategy} title="Aucune stratégie pour l’instant" compact={compact}
        text="Crée une stratégie pour suivre ses performances. Associe-lui tes trades avec le champ Stratégie."
        primary={{ label: 'Nouvelle stratégie', onClick: onCreate, ico: Ico.plus }}/>
    </div>
  );
}

// ─── Stratégies indisponibles (table absente) ───────────────────────
function StrategyUnavailableNote({ onRetry, busy }) {
  return (
    <div className="card" style={{ padding: '24px', textAlign: 'center' }}>
      <CardTitle style={{ fontSize: 14, letterSpacing: 'normal', marginBottom: 6 }}>Stratégies indisponibles</CardTitle>
      <div style={{ fontSize: 12.5, color: 'var(--fg-3)', lineHeight: 1.5, marginBottom: 14 }}>
        Les stratégies sont momentanément indisponibles. Réessaie plus tard.
      </div>
      <button type="button" onClick={onRetry} disabled={busy} className="btn tap" style={{ color: 'var(--fg)' }}>
        {busy ? 'Chargement…' : 'Réessayer'}
      </button>
    </div>
  );
}

// ─── Bandeaux d'erreur de chargement (charte §7) ────────────────────
// Un seul gabarit : le texte, puis « Réessayer » (désactivé pendant la relance).
function StrategyAlertBanner({ children, onRetry, busy }) {
  return (
    <div role="alert" className="card" style={{
      display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12,
      padding: '12px 16px', marginBottom: 16, background: 'var(--red-soft)', borderColor: 'var(--red)',
    }}>
      <span style={{ flex: '1 1 220px', minWidth: 0, fontSize: 12.5, fontWeight: 500, color: 'var(--red-text)', lineHeight: 1.45 }}>
        {children}
      </span>
      {onRetry && (
        <button type="button" onClick={onRetry} disabled={busy} className="btn tap" style={{ color: 'var(--fg)' }}>
          {busy ? 'Chargement…' : 'Réessayer'}
        </button>
      )}
    </div>
  );
}

// Les stratégies n'ont pas pu être chargées.
function StrategyLoadError({ message, onRetry, busy }) {
  return <StrategyAlertBanner onRetry={onRetry} busy={busy}>Impossible de charger tes stratégies. {message}</StrategyAlertBanner>;
}

// Les trades n'ont pas pu être chargés : les chiffres des cartes sont absents
// (« — ») ou datent du dernier chargement réussi.
function StrategyTradesError({ onRetry, busy }) {
  return <StrategyAlertBanner onRetry={onRetry} busy={busy}>{STRAT_TRADES_ERROR}</StrategyAlertBanner>;
}

// Bandeaux et relances communs à l'ordinateur et au mobile. Jamais deux
// bandeaux ni deux « Réessayer » : si les stratégies ET les trades ont échoué
// (réseau coupé), seul le bandeau des stratégies s'affiche et son bouton
// relance les deux. Le bandeau des trades n'apparaît que s'il y a des cartes,
// donc des chiffres concernés.
function stratRetryState(st, trades, { rows, loading, reload, loadError, missing }) {
  const tradesState = stratTradesState(st, trades);
  const refreshTrades = st && typeof st.refreshTrades === 'function' ? st.refreshTrades : null;
  const retryTrades = refreshTrades ? () => { refreshTrades(); } : null;
  const retryAll = () => { reload(); if (tradesState.failed && refreshTrades) refreshTrades(); };
  return {
    tradesUnknown: tradesState.unknown,
    retryAll,
    retryBusy: loading || (tradesState.failed && tradesState.loading),
    tradesBanner: (!missing && !loadError && tradesState.failed && rows.length > 0)
      ? <StrategyTradesError onRetry={retryTrades} busy={tradesState.loading} />
      : null,
  };
}

// ─── Ordinateur ─────────────────────────────────────────────────────
function StrategyPage({ state }) {
  const st = state || {};
  const trades = st.trades || [];
  const { rows, loading, error, addStrategy, updateStrategy, removeStrategy, reload, pendingRename } = useStrategies(st);
  const statsById = useStrategyStats(rows, trades);
  const sortedRows = useSortedStrategies(rows, statsById);
  const totals = useStrategyTotals(rows, trades);
  const [showForm, setShowForm] = React.useState(false);
  const uid = (st.user && st.user.id) || '';
  const notifyLost = (text) => stratNotifyLost(st, text);

  // Création qui a échoué après le départ de la page : le formulaire se rouvre
  // avec la saisie, sauf si une autre saisie y est déjà en cours.
  const showFormRef = React.useRef(showForm);
  showFormRef.current = showForm;
  const formDirtyRef = React.useRef(false);
  const onFormDirty = React.useCallback((d) => { formDirtyRef.current = !!d; }, []);
  const canReplaceRef = React.useRef(null);
  canReplaceRef.current = () => !(showFormRef.current && formDirtyRef.current);
  const openFormRef = React.useRef(null);
  openFormRef.current = () => setShowForm(true);
  const [seed, clearSeed] = useStratDraftSeed(uid, canReplaceRef, openFormRef);
  const closeForm = () => { setShowForm(false); formDirtyRef.current = false; clearSeed(); };

  const handleCreate = async (payload) => {
    const res = await addStrategy(payload);
    if (!res?.error) closeForm();
    return res;
  };
  const handleCreateLost = (sent, error) => stratCreateLost(st, uid, sent, error);

  const missing = error === 'missing';
  const loadError = error && !missing ? error : '';
  // Échec de chargement sans rien d'affiché : une seule action, Réessayer.
  const canCreate = !missing && !(loadError && rows.length === 0);
  const { tradesUnknown, retryAll, retryBusy, tradesBanner } = stratRetryState(st, trades, { rows, loading, reload, loadError, missing });

  return (
    <div className="scroll" style={{ width: '100%', height: '100%', overflow: 'auto' }}>
      <PageHeader title={stratNavTitle()} />
      <div style={{ ...PAGE_BODY, padding: '20px 24px 40px' }}>
        {/* Sous-titre + action (le titre est dans l'en-tête) */}
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 12, marginBottom: 18, minHeight: 32 }}>
          <div style={{ fontSize: 12.5, color: 'var(--fg-3)' }}>
            {strategySubtitle(rows.length, tradesUnknown ? null : totals.count)}
          </div>
          {/* Compte sans stratégie : l'état vide porte seul l'action (charte §7). */}
          {!showForm && canCreate && rows.length > 0 && (
            <button type="button" onClick={() => setShowForm(true)} className="btn btn-blue tap" style={{ color: '#fff' }}>{Ico.plus} Nouvelle stratégie</button>
          )}
        </div>

        {/* Formulaire en ligne */}
        {showForm && !missing && (
          <div className="card stagger" style={{ padding: 18, marginBottom: 18, maxWidth: 520 }}>
            <CardTitle style={{ fontSize: 14, letterSpacing: 'normal', marginBottom: 14 }}>Nouvelle stratégie</CardTitle>
            <StrategyForm key={seed ? seed.key : 'new'} draft={seed} onSubmit={handleCreate} onCancel={closeForm}
              onDirtyChange={onFormDirty} onLostError={handleCreateLost} />
          </div>
        )}

        {/* Contenu */}
        {missing ? (
          <StrategyUnavailableNote onRetry={retryAll} busy={retryBusy} />
        ) : (
          <React.Fragment>
            {loadError && <StrategyLoadError message={loadError} onRetry={retryAll} busy={retryBusy} />}
            {tradesBanner}
            {rows.length > 0 ? (
              <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: 16 }}>
                {sortedRows.map(s => (
                  <StrategyCard key={s.id} s={s} st={statsById[s.id] || computeStats([])} onDelete={removeStrategy} onUpdate={updateStrategy}
                    onLostError={notifyLost}
                    tradesUnknown={tradesUnknown} pendingFrom={(pendingRename[s.id] || {}).from || ''} />
                ))}
              </div>
            ) : loading ? (
              <div className="card" style={{ padding: '32px 20px', textAlign: 'center', color: 'var(--fg-3)', fontSize: 13 }}>Chargement des stratégies…</div>
            ) : (!loadError && !showForm) ? (
              <StrategyEmpty onCreate={() => setShowForm(true)} />
            ) : null}
          </React.Fragment>
        )}
      </div>
    </div>
  );
}

// ─── Mobile ─────────────────────────────────────────────────────────
function StrategyMobile({ state }) {
  const st = state || {};
  const trades = st.trades || [];
  const { rows, loading, error, addStrategy, updateStrategy, removeStrategy, reload, pendingRename } = useStrategies(st);
  const statsById = useStrategyStats(rows, trades);
  const sortedRows = useSortedStrategies(rows, statsById);
  const totals = useStrategyTotals(rows, trades);
  const [sheet, setSheet] = React.useState(false);
  const [sheetDirty, setSheetDirty] = React.useState(false);
  // Envoi en cours : la référence sert aux fermetures (lecture immédiate),
  // l'état à l'affichage.
  const [sheetBusy, setSheetBusy] = React.useState(false);
  const sheetBusyRef = React.useRef(false);
  const onSheetBusy = React.useCallback((b) => { sheetBusyRef.current = !!b; setSheetBusy(!!b); }, []);
  const uid = (st.user && st.user.id) || '';
  const notifyLost = (text) => stratNotifyLost(st, text);

  // Création qui a échoué après le départ de la page : la feuille se rouvre avec
  // la saisie, sauf si une autre saisie y est déjà en cours.
  const sheetRef = React.useRef(sheet);
  sheetRef.current = sheet;
  const sheetDirtyRef = React.useRef(sheetDirty);
  sheetDirtyRef.current = sheetDirty;
  const canReplaceRef = React.useRef(null);
  canReplaceRef.current = () => !(sheetRef.current && (sheetDirtyRef.current || sheetBusyRef.current));
  const openSheetRef = React.useRef(null);
  openSheetRef.current = () => setSheet(true);
  const [seed, clearSeed] = useStratDraftSeed(uid, canReplaceRef, openSheetRef);

  const closeSheet = React.useCallback(() => {
    sheetBusyRef.current = false;
    setSheet(false); setSheetDirty(false); setSheetBusy(false); clearSeed();
  }, [clearSeed]);
  // Pendant un envoi, la feuille ne se ferme pas (Échap, croix, fond, Annuler) :
  // un échec doit pouvoir s'y afficher, saisie intacte, et une création en vol
  // ne doit pas aboutir après un « Abandonner ». Le succès ferme directement.
  const closeIfIdle = React.useCallback(() => { if (!sheetBusyRef.current) closeSheet(); }, [closeSheet]);
  // Feuille « Nouvelle stratégie » : comportement commun des modales (charte §8).
  const modal = useModalBehavior({ open: sheet, onRequestClose: closeIfIdle, isDirty: sheetDirty && !sheetBusy, labelledBy: 'strat-sheet-title' });
  const openSheet = () => { setSheetDirty(false); setSheet(true); };

  const handleCreate = async (payload) => {
    const res = await addStrategy(payload);
    // Enregistré : plus rien à abandonner, fermeture directe.
    if (!res?.error) closeSheet();
    return res;
  };
  const handleCreateLost = (sent, error) => stratCreateLost(st, uid, sent, error);

  const missing = error === 'missing';
  const loadError = error && !missing ? error : '';
  const canCreate = !missing && !(loadError && rows.length === 0);
  const { tradesUnknown, retryAll, retryBusy, tradesBanner } = stratRetryState(st, trades, { rows, loading, reload, loadError, missing });

  return (
    <div style={{ padding: '14px var(--m-gutter, 16px) calc(24px + env(safe-area-inset-bottom))', position: 'relative' }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 4 }}>
        <h1 style={{ fontSize: 24, fontWeight: 700, letterSpacing: '-.025em', margin: 0 }}>{stratNavTitle()}</h1>
        {canCreate && rows.length > 0 && (
          <button type="button" onClick={openSheet} className="btn btn-blue tap" style={{ fontSize: 12, color: '#fff', flexShrink: 0 }}>{Ico.plus} Nouvelle stratégie</button>
        )}
      </div>
      <div style={{ fontSize: 12.5, color: 'var(--fg-3)', marginBottom: 16 }}>
        {strategySubtitle(rows.length, tradesUnknown ? null : totals.count)}
      </div>

      {missing ? (
        <StrategyUnavailableNote onRetry={retryAll} busy={retryBusy} />
      ) : (
        <React.Fragment>
          {loadError && <StrategyLoadError message={loadError} onRetry={retryAll} busy={retryBusy} />}
          {tradesBanner}
          {rows.length > 0 ? (
            <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
              {sortedRows.map(s => (
                <StrategyCard key={s.id} s={s} st={statsById[s.id] || computeStats([])} onDelete={removeStrategy} onUpdate={updateStrategy} compact
                  onLostError={notifyLost}
                  tradesUnknown={tradesUnknown} pendingFrom={(pendingRename[s.id] || {}).from || ''} />
              ))}
            </div>
          ) : loading ? (
            <div className="card" style={{ padding: '28px 16px', textAlign: 'center', color: 'var(--fg-3)', fontSize: 13 }}>Chargement des stratégies…</div>
          ) : !loadError ? (
            <StrategyEmpty onCreate={openSheet} compact />
          ) : null}
        </React.Fragment>
      )}

      {/* Feuille du bas « Nouvelle stratégie » */}
      {sheet && (
        <div
          {...modal.backdropProps}
          style={{ position: 'fixed', inset: 0, zIndex: 50, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'flex-end' }}
        >
          <div
            {...modal.dialogProps}
            className="stagger"
            style={{
              width: '100%', background: 'var(--bg-card)', borderTopLeftRadius: 18, borderTopRightRadius: 18,
              borderTop: '1px solid var(--line)', padding: '10px var(--m-gutter, 16px) calc(20px + env(safe-area-inset-bottom))',
              maxHeight: '85vh', overflowY: 'auto', outline: 'none',
            }}
          >
            <div aria-hidden="true" style={{ width: 36, height: 4, borderRadius: 2, background: 'var(--line-2)', margin: '0 auto 10px' }} />
            <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: 14 }}>
              <div id="strat-sheet-title" style={{ fontSize: 16, fontWeight: 700 }}>Nouvelle stratégie</div>
              <button type="button" onClick={modal.requestClose} disabled={sheetBusy} className="tap tap-44" aria-label="Fermer"
                style={{ width: 32, height: 32, borderRadius: 8, border: '1px solid var(--line)', background: 'var(--bg-card)', color: 'var(--fg-2)', cursor: sheetBusy ? 'default' : 'pointer', opacity: sheetBusy ? 0.5 : 1, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
                {Ico.x}
              </button>
            </div>
            <ModalDiscardConfirm modal={modal} style={{ marginBottom: 14 }} />
            <StrategyForm key={seed ? seed.key : 'new'} draft={seed} onSubmit={handleCreate} onCancel={modal.requestClose}
              onDirtyChange={setSheetDirty} onBusyChange={onSheetBusy} onLostError={handleCreateLost} autoFocus={false} />
          </div>
        </div>
      )}
    </div>
  );
}

Object.assign(window, { StrategyPage, StrategyMobile });
