// =============================================================
// App shell — sidebar nav, topbar, route to screens, state
// Boot : charge /api/state. Actions : optimistic UI + fetch backend.
// =============================================================

const { useState, useEffect, useRef, useCallback, useMemo } = React;

// ── theme toggle ──────────────────────────────────────────────────────────────
function pbToggleTheme(){
  var cur = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
  var next = cur === 'dark' ? 'light' : 'dark';
  document.documentElement.setAttribute('data-theme', next);
  try { localStorage.setItem('pb-theme', next); } catch(e){}
  var m = document.querySelector('meta[name="theme-color"]');
  if (m) m.setAttribute('content', next === 'dark' ? '#15181f' : '#f1ede7');
  return next;
}

// ── helpers API ───────────────────────────────────────────────────────────────

/**
 * Appel generique fetch JSON.
 * Renvoie le corps JSON si status 2xx, sinon rejette avec le detail d'erreur.
 * NB: pas de `async/await` ici. Le transform Babel in-browser des fonctions
 * async produit un trampoline (`_apiFetch.apply(this, arguments)`) qui peut
 * recurser a l'infini si le helper `_asyncToGenerator` echoue silencieusement.
 * Promise chain natif = pas de transform, pas de recursion.
 */
function apiFetch(method, path, body) {
  const opts = {
    method,
    headers: { "Content-Type": "application/json" },
  };
  if (body !== undefined) opts.body = JSON.stringify(body);
  return fetch(path, opts).then(res => {
    // 204 No Content (DELETE) : pas de corps
    if (res.status === 204) return null;
    return res.json().catch(() => ({})).then(json => {
      if (!res.ok) throw new Error(json.detail || `HTTP ${res.status}`);
      return json;
    });
  });
}
// expose pour appels ponctuels depuis les screens (ex: drawer receive)
window._apiFetch = apiFetch;

// ── App ───────────────────────────────────────────────────────────────────────

const App = () => {
  // etat global — initialement vide, rempli apres boot
  const [state, setState] = useState({
    categories: [],
    shipments: [],
    topups: [],
    purchases: [],
    sales: [],
    saleLines: [],
    stockSeries: [],
    sealed: [],
    bankMovements: [],
    currentRate: 0,
  });

  // reference globale de la liste complete des colis (pour shipNum, cote shared.jsx)
  if (typeof window !== "undefined") window.__allShipments = state.shipments;

  const [loading, setLoading] = useState(true);

  // theme dark/light
  const [theme, setTheme] = useState(function(){ return document.documentElement.getAttribute('data-theme') || 'light'; });

  // history stack pour undo
  const historyRef = useRef([]);
  const [historyLen, setHistoryLen] = useState(0);

  const [route, setRoute] = useState("dashboard");
  const [routeArgs, setRouteArgs] = useState({});
  const { show, hide, node: toastNode } = useToast();

  // ── boot : charge l'etat depuis l'API ──────────────────────────────────────
  useEffect(() => {
    (async () => {
      try {
        const seed = await window.loadInitialState();
        setState({
          categories:  seed.categories,
          shipments:   seed.shipments,
          topups:      seed.topups,
          purchases:   seed.purchases,
          sales:       seed.sales,
          saleLines:   seed.saleLines || [],
          stockSeries: seed.stockSeries,
          sealed:      seed.sealed,
          bankMovements: seed.bankMovements || [],
          currentRate: seed.currentRate || 0,
        });
      } catch (err) {
        console.warn("LDG: boot error", err);
      } finally {
        setLoading(false);
      }
    })();
  }, []);

  const navigate = (r, args = {}) => { setRoute(r); setRouteArgs(args); window.scrollTo(0, 0); };

  // mutator wrapper — pousse le prev state dans l'historique
  const mutate = useCallback((updater, label) => {
    setState(prev => {
      historyRef.current.push({ state: prev, label });
      if (historyRef.current.length > 30) historyRef.current.shift();
      setHistoryLen(historyRef.current.length);
      return typeof updater === "function" ? updater(prev) : updater;
    });
  }, []);

  const undo = useCallback(() => {
    const last = historyRef.current.pop();
    if (!last) return null;
    setHistoryLen(historyRef.current.length);
    setState(last.state);
    return last.label;
  }, []);

  // raccourci clavier Ctrl+Z / Cmd+Z
  useEffect(() => {
    const onKey = (e) => {
      if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z") {
        e.preventDefault();
        const label = undo();
        if (label) show(`Annule : ${label}`);
      }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [undo, show]);

  // ── helper : rollback + toast erreur apres echec fetch ─────────────────────
  const onFetchError = useCallback((err, label) => {
    console.warn(`LDG: fetch error [${label}]`, err);
    undo();
    show(`Erreur sync : ${err.message || label}. Annulation.`);
  }, [undo, show]);

  // ── helper : remplace l'id temporaire par l'id reel renvoye par le backend ─
  const replaceOptimisticId = useCallback((listKey, tmpId, realId) => {
    setState(prev => ({
      ...prev,
      [listKey]: prev[listKey].map(x => x.id === tmpId ? { ...x, id: realId } : x),
    }));
  }, []);

  // ── actions ───────────────────────────────────────────────────────────────
  const actions = useMemo(() => ({

    // --- achats ---
    addPurchase: (p) => {
      const tmpId = -Date.now();
      const newPurchase = { ...p, id: tmpId };
      mutate(s => ({ ...s, purchases: [...s.purchases, newPurchase] }), `ajout achat`);
      show(`Achat ajoute a la commande #${p.ship}`, undo);

      // corps pour l'API : mapping champs frontend -> schema backend
      const body = {
        shipment_id:       p.ship,
        category_id:       p.cat,
        title:             p.title,
        quantity:          p.qty,
        price_jpy:         p.jpy,
        jp_shipping_jpy:   p.port_jp,
        zm_commission_jpy: p.zm_fee || 0,
        prix_final_eur:    p.eur,
        status:            p.status || "ordered",
        mercari_url:       p.url,
        notes:             p.notes,
      };
      apiFetch("POST", "/api/purchases", body).then(created => {
        if (created?.id) replaceOptimisticId("purchases", tmpId, created.id);
      }).catch(err => onFetchError(err, "addPurchase"));
    },

    // --- suppression achat ---
    deletePurchase: (purchaseId) => {
      mutate(s => ({ ...s, purchases: s.purchases.filter(p => p.id !== purchaseId) }), "suppression achat");
      show("Achat supprime", undo);
      // id negatif = jamais ete persiste, pas besoin d'appel API
      if (purchaseId < 0) return;
      apiFetch("DELETE", `/api/purchases/${purchaseId}`)
        .catch(err => onFetchError(err, "deletePurchase"));
    },

    // --- creation colis depuis achats en attente ---
    // Retourne la promesse pour que le caller (drawer) puisse afficher un overlay
    // pendant la requete et fermer apres.
    createShipmentFromPending: ({ purchase_ids, intl_jpy, method, name }) => {
      return apiFetch("POST", "/api/shipments", {
        purchase_ids,
        intl_shipping_jpy: intl_jpy,
        method,
        status: "created",
        name: name || null,
      })
        .then(() => window.loadInitialState ? window.loadInitialState() : null)
        .then(seed => {
          if (seed) {
            mutate(s => ({
              ...s,
              shipments:   seed.shipments,
              purchases:   seed.purchases,
              topups:      seed.topups,
              stockSeries: seed.stockSeries,
              sealed:      seed.sealed,
              currentRate: seed.currentRate || s.currentRate,
            }), "refresh apres colis");
          }
          show(`Colis cree avec ${purchase_ids.length} achats`);
        })
        .catch(err => {
          console.warn("LDG: createShipmentFromPending error", err);
          show(`Erreur creation colis : ${err.message}`);
          throw err;
        });
    },

    renameShipment: (shipId, newName) => {
      const cleaned = (newName || "").trim() || null;
      mutate(s => ({
        ...s,
        shipments: s.shipments.map(x => x.id === shipId ? { ...x, name: cleaned } : x),
      }), `renommage colis #${shipId}`);
      show(`Colis renomme`, undo);
      apiFetch("PATCH", `/api/shipments/${shipId}`, { name: cleaned })
        .catch(err => onFetchError(err, "renameShipment"));
    },

    updateShipmentStatus: (shipId, newStatus) => {
      mutate(s => ({
        ...s,
        shipments: s.shipments.map(x => x.id === shipId ? { ...x, status: newStatus } : x),
      }), `statut colis #${shipId} -> ${newStatus}`);
      show(`Statut mis a jour`, undo);
      apiFetch("PATCH", `/api/shipments/${shipId}`, { status: newStatus })
        .catch(err => onFetchError(err, "updateShipmentStatus"));
    },

    updateShipmentNotes: (shipId, newNotes) => {
      const cleaned = (newNotes || "").trim() || null;
      mutate(s => ({
        ...s,
        shipments: s.shipments.map(x => x.id === shipId ? { ...x, notes: cleaned } : x),
      }), `note colis #${shipId}`);
      apiFetch("PATCH", `/api/shipments/${shipId}`, { notes: cleaned })
        .catch(err => onFetchError(err, "updateShipmentNotes"));
    },

    updateShipmentDuty: (shipId, dutyEur) => {
      const val = Number(dutyEur) || 0;
      mutate(s => ({
        ...s,
        shipments: s.shipments.map(x => x.id === shipId ? { ...x, duty_eur: val } : x),
      }), `douane colis #${shipId} -> ${val}`);
      apiFetch("PATCH", `/api/shipments/${shipId}`, { duty_eur: val })
        .catch(err => onFetchError(err, "updateShipmentDuty"));
    },

    // --- validation manuelle rechargement colis (recharge_confirmed) ---
    updateShipmentRechargeConfirmed: (shipId, confirmed) => {
      mutate(s => ({
        ...s,
        shipments: s.shipments.map(x => x.id === shipId ? { ...x, recharge_confirmed: confirmed } : x),
      }), `recharge_confirmed colis #${shipId} -> ${confirmed}`);
      apiFetch("PATCH", `/api/shipments/${shipId}`, { recharge_confirmed: confirmed })
        .catch(err => onFetchError(err, "updateShipmentRechargeConfirmed"));
    },

    // --- mute la categorie d'un achat dans le state local (avant receive si correction) ---
    patchPurchaseCategory: (purchaseId, catId) => {
      mutate(s => ({
        ...s,
        purchases: s.purchases.map(p => p.id === purchaseId ? { ...p, cat: catId } : p),
      }), `categorie achat #${purchaseId}`);
    },

    // --- split d'un achat en plusieurs sous-items ---
    splitPurchase: (purchaseId, items) => {
      return apiFetch("POST", `/api/purchases/${purchaseId}/split`, { items })
        .then(result => {
          return window.loadInitialState().then(seed => {
            mutate(s => ({
              ...s,
              purchases: seed.purchases,
              shipments: seed.shipments,
              stockSeries: seed.stockSeries,
              sealed: seed.sealed,
            }), `split achat #${purchaseId}`);
            show("Achat splitte en " + result.new_ids.length + " items", null);
            return result;
          });
        })
        .catch(err => {
          onFetchError(err, "splitPurchase");
          throw err;
        });
    },

    // --- suppression colis ---
    deleteShipment: (shipId) => {
      // mutation optimiste : retire le shipment + detache purchases/topups + rollback stock
      mutate(s => {
        const linkedPurchases = s.purchases.filter(p => p.ship === shipId);
        let stockSeries = [...s.stockSeries];
        let sealed = [...s.sealed];
        // rollback stock pour chaque purchase distribue
        linkedPurchases.forEach(p => {
          const target = p.stock_target;
          const qty = p.qty || 1;
          if (!target) return;
          if (target === "sealed") {
            sealed = sealed.filter(x => x.purchase_id !== p.id);
          } else if (target === "np" || target === "nh" || target === "h") {
            const idx = stockSeries.findIndex(x => x.cat === p.cat);
            if (idx >= 0) {
              const cur = { ...stockSeries[idx] };
              cur[target] = Math.max(0, (cur[target] || 0) - qty);
              stockSeries[idx] = cur;
            }
          }
        });
        return {
          ...s,
          shipments: s.shipments.filter(x => x.id !== shipId),
          purchases: s.purchases.map(p => p.ship === shipId ? {
            ...p,
            ship: null,
            status: "ordered",
            stock_state: "pending",
            stock_target: null,
            eur: null,
          } : p),
          topups: s.topups.map(t => t.shipment === shipId ? { ...t, shipment: null } : t),
          stockSeries,
          sealed,
        };
      }, `suppression colis #${shipId}`);
      show(`Colis #${shipId} supprime · stock recalcule, achats et rechargements detaches`, undo);
      if (shipId < 0) return Promise.resolve();
      return apiFetch("DELETE", `/api/shipments/${shipId}`)
        .then(() => window.loadInitialState())
        .then(seed => {
          setState(prev => ({
            ...prev,
            shipments: seed.shipments,
            purchases: seed.purchases,
            topups: seed.topups,
            stockSeries: seed.stockSeries,
            sealed: seed.sealed,
          }));
        })
        .catch(err => { onFetchError(err, "deleteShipment"); throw err; });
    },

    // --- ventes ---
    addSale: (sale) => {
      const tmpId = -Date.now();
      const newSale = { ...sale, id: tmpId };
      // saleLines optimistic pour que le detail s'affiche tout de suite
      const optimisticLines = (sale.lines || []).map((line, i) => {
        const base = {
          id: tmpId * 10 - i,  // id negatif unique
          sale_id: tmpId,
          kind: line.kind,
          quantity: line.qty,
          unit_cost_eur: line.unit_cost,
        };
        if (line.kind === "series") {
          return {
            ...base,
            category_name: line.series?.name || "?",
            category_short: line.series?.short || "?",
            sub: line.sub || "",
          };
        }
        return {
          ...base,
          sealed_name: line.sealed?.name || "?",
          sealed_state: line.sealed?.state || "",
        };
      });
      mutate(s => {
        let stockSeries = [...s.stockSeries];
        let sealed = [...s.sealed];
        (sale.lines || []).forEach(line => {
          if (line.kind === "series") {
            const idx = stockSeries.findIndex(x => x.short === line.series?.short || x.cat === line.cat);
            if (idx >= 0) {
              const cur = { ...stockSeries[idx] };
              cur[line.sub] = Math.max(0, (cur[line.sub] || 0) - line.qty);
              stockSeries[idx] = cur;
            }
          } else if (line.kind === "sealed") {
            const idx = sealed.findIndex(x => x.id === (line.sealed?.id || line.sealed_id));
            if (idx >= 0) {
              sealed[idx] = { ...sealed[idx], qty: Math.max(0, sealed[idx].qty - line.qty) };
            }
          }
        });
        return {
          ...s,
          sales: [newSale, ...s.sales],
          saleLines: [...(s.saleLines || []), ...optimisticLines],
          stockSeries,
          sealed: sealed.filter(x => x.qty > 0),
        };
      }, `vente`);
      show(`Vente enregistree · stock decremente`, undo);

      // corps pour l'API : mapping champs frontend -> schema backend
      const lines = (sale.lines || []).map(l => ({
        kind:      l.kind,
        cat:       l.cat || l.series?.cat,
        sub:       l.sub,
        sealed_id: l.sealed_id || l.sealed?.id,
        quantity:  l.qty,
        unit_cost: l.unit_cost,
      }));
      const body = {
        date:        sale.date,
        platform:    sale.platform,
        category_id: sale.cat,
        title:       sale.title,
        price_eur:   sale.eur,
        cost_eur:    sale.cost,
        lines,
      };
      apiFetch("POST", "/api/sales", body).then(created => {
        if (created?.id) {
          replaceOptimisticId("sales", tmpId, created.id);
          // remappe les saleLines optimistic vers le vrai sale_id
          mutate(s => ({
            ...s,
            saleLines: (s.saleLines || []).map(l => l.sale_id === tmpId ? { ...l, sale_id: created.id } : l),
          }), "remap saleLines");
        }
      }).catch(err => onFetchError(err, "addSale"));
    },

    // --- scan IMAP Vinted (preview sans insert) ---
    scanVinted: () => {
      return apiFetch("POST", "/api/scrape/vinted/scan")
        .then(result => result.items || [])
        .catch(err => {
          onFetchError(err, "scanVinted");
          return [];
        });
    },

    // --- commit des ventes Vinted selectionnees ---
    commitVintedSales: (items) => {
      return apiFetch("POST", "/api/scrape/vinted/commit", { items })
        .then(result => {
          show(`${result.inserted || 0} vente(s) Vinted importee(s)`);
          return window.loadInitialState().then(seed => {
            setState(prev => ({ ...prev, sales: seed.sales }));
            return result;
          });
        })
        .catch(err => {
          onFetchError(err, "commitVintedSales");
          return null;
        });
    },

    // --- scan IMAP eBay (preview sans insert) ---
    scanEbay: () => {
      return apiFetch("POST", "/api/scrape/ebay/scan")
        .then(result => result.items || [])
        .catch(err => {
          onFetchError(err, "scanEbay");
          return [];
        });
    },

    // --- commit des ventes eBay selectionnees ---
    commitEbaySales: (items) => {
      return apiFetch("POST", "/api/scrape/ebay/commit", { items })
        .then(result => {
          show(`${result.inserted || 0} vente(s) eBay importee(s)`);
          return window.loadInitialState().then(seed => {
            setState(prev => ({ ...prev, sales: seed.sales }));
            return result;
          });
        })
        .catch(err => {
          onFetchError(err, "commitEbaySales");
          return null;
        });
    },

    // --- reload state complet depuis un seed deja charge (utilise par le wizard import) ---
    reloadState: (seed) => {
      if (!seed) return;
      setState(prev => ({
        ...prev,
        sales:       seed.sales       !== undefined ? seed.sales       : prev.sales,
        stockSeries: seed.stockSeries !== undefined ? seed.stockSeries : prev.stockSeries,
        sealed:      seed.sealed      !== undefined ? seed.sealed      : prev.sealed,
      }));
    },

    // --- suppression vente ---
    deleteSale: (saleId) => {
      mutate(s => ({ ...s, sales: s.sales.filter(x => x.id !== saleId) }), `suppression vente #${saleId}`);
      show(`Vente #${saleId} supprimee`, undo);
      // id negatif = jamais ete persiste, pas besoin d'appel API
      if (saleId < 0) return;
      apiFetch("DELETE", `/api/sales/${saleId}`)
        .then(async () => {
          // backend a rollback le stock via sale_lines -> resync UI
          try {
            const seed = await window.loadInitialState();
            setState(prev => ({ ...prev, stockSeries: seed.stockSeries, sealed: seed.sealed }));
          } catch (e) {
            console.warn("LDG: resync stock apres delete echoue", e);
          }
        })
        .catch(err => onFetchError(err, "deleteSale"));
    },

    // --- reception colis ---
    receiveShipment: (shipId, distribution) => {
      // distribution: { purchaseId: { target, quantity?, sealedName?, sealedState? } }
      mutate(s => {
        const ship = s.shipments.find(x => x.id === shipId);
        if (!ship) return s;
        let stockSeries = [...s.stockSeries];
        let sealed = [...s.sealed];
        const newShipments = s.shipments.map(x => x.id === shipId ? { ...x, status: "delivered" } : x);
        const newPurchases = s.purchases.map(p => {
          if (p.ship !== shipId) return p;
          const dist = distribution[p.id];
          if (!dist) return { ...p, status: "delivered", stock_state: "available" };
          const cat = s.categories.find(c => c.id === p.cat);
          const distQty = dist.quantity && dist.quantity > 0 ? dist.quantity : (p.qty || 1);
          const unitCost = (p.eur || 0) / Math.max(1, distQty);
          if (dist.target === "sealed") {
            const newId = -Date.now() - Math.random();
            sealed.push({
              id: newId,
              name: dist.sealedName || p.title,
              state: dist.sealedState || "scelle",
              qty: distQty,
              cost: unitCost,
              cat: p.cat,
            });
          } else {
            const idx = stockSeries.findIndex(x => x.cat === p.cat);
            if (idx >= 0) {
              const cur = { ...stockSeries[idx] };
              cur[dist.target] = (cur[dist.target] || 0) + distQty;
              const oldTot = (cur.np || 0) + (cur.nh || 0) + (cur.h || 0);
              cur.unit_cost = oldTot > 0
                ? ((cur.unit_cost * (oldTot - distQty)) + (unitCost * distQty)) / oldTot
                : unitCost;
              stockSeries[idx] = cur;
            } else {
              stockSeries.push({
                cat: p.cat,
                name: cat?.name || p.title,
                short: cat?.short || p.title.slice(0, 4),
                np: dist.target === "np" ? distQty : 0,
                nh: dist.target === "nh" ? distQty : 0,
                h:  dist.target === "h"  ? distQty : 0,
                unit_cost: unitCost,
              });
            }
          }
          return { ...p, status: "delivered", stock_state: "available" };
        });
        return { ...s, shipments: newShipments, purchases: newPurchases, stockSeries, sealed };
      }, `reception colis #${shipId}`);
      show(`Colis #${shipId} recu · stock mis a jour`, undo);

      if (shipId < 0) return;
      // conversion distribution frontend -> format API
      const apiDist = {};
      Object.entries(distribution).forEach(([pid, d]) => {
        apiDist[pid] = {
          target:       d.target,
          sealed_name:  d.sealedName,
          sealed_state: d.sealedState,
          quantity:     d.quantity || undefined,
        };
      });
      apiFetch("POST", `/api/shipments/${shipId}/receive`, { distribution: apiDist })
        .then(async () => {
          // resync depuis backend pour avoir les vrais unit_cost / quantites calcules
          try {
            const seed = await window.loadInitialState();
            setState(prev => ({
              ...prev,
              stockSeries: seed.stockSeries,
              sealed: seed.sealed,
              purchases: seed.purchases,
              shipments: seed.shipments,
            }));
          } catch (e) {
            console.warn("LDG: resync apres receive echoue", e);
          }
        })
        .catch(err => onFetchError(err, "receiveShipment"));
    },

    // --- pesee ---
    weighBoosters: (catId, fromNP, toNH, toH) => {
      mutate(s => {
        const stockSeries = s.stockSeries.map(x => {
          if (x.cat !== catId) return x;
          return {
            ...x,
            np: Math.max(0, x.np - fromNP),
            nh: x.nh + toNH,
            h:  x.h  + toH,
          };
        });
        return { ...s, stockSeries };
      }, `pesee serie`);
      show(`${fromNP} boosters peses · +${toNH} NH · +${toH} hit`, undo);

      apiFetch("POST", "/api/stock/weigh", {
        cat:     catId,
        from_np: fromNP,
        to_nh:   toNH,
        to_h:    toH,
      }).catch(err => onFetchError(err, "weighBoosters"));
    },

    // --- stock series ---
    addStockSeries: (entry) => {
      const tmpId = entry.cat || -Date.now();
      mutate(s => ({ ...s, stockSeries: [...s.stockSeries, entry] }), `ajout serie stock`);
      show(`Serie « ${entry.name} » ajoutee au stock`, undo);

      apiFetch("POST", "/api/stock/series", {
        category_id:  entry.cat,
        qty_np:       entry.np || 0,
        qty_nh:       entry.nh || 0,
        qty_h:        entry.h  || 0,
        unit_cost_eur: entry.unit_cost,
      }).then(() => window.loadInitialState())
        .then(seed => {
          setState(prev => ({ ...prev, purchases: seed.purchases, stockSeries: seed.stockSeries }));
        })
        .catch(err => onFetchError(err, "addStockSeries"));
    },

    // --- sealed ---
    addSealedItem: (item) => {
      const tmpId = -Date.now();
      mutate(s => {
        return { ...s, sealed: [...s.sealed, { ...item, id: tmpId }] };
      }, `ajout scelle`);
      show(`« ${item.name} » ajoute au stock scelle`, undo);

      apiFetch("POST", "/api/stock/sealed", {
        name:          item.name,
        state:         item.state,
        quantity:      item.qty,
        unit_cost_eur: item.cost,
        category_id:   item.cat,
      }).then(created => {
        if (created?.id) replaceOptimisticId("sealed", tmpId, created.id);
        return window.loadInitialState();
      }).then(seed => {
        setState(prev => ({ ...prev, purchases: seed.purchases }));
      }).catch(err => onFetchError(err, "addSealedItem"));
    },

    // Optimistic + Persist + resync
    updateSealedItem: (id, patch) => {
      mutate(s => ({
        ...s,
        sealed: s.sealed.map(x => x.id === id ? { ...x, ...patch } : x),
      }), `modif scelle #${id}`);
      show(`Item scelle modifie`, undo);
      // id negatif = jamais ete persiste, pas besoin d'appel API
      if (id < 0) return Promise.resolve();
      // Mapping frontend keys -> backend (DB) keys
      // Le frontend utilise qty/cost/state-avec-accent, la DB utilise quantity/unit_cost_eur/state-sans-accent
      const backendPatch = {};
      if (patch.name !== undefined) backendPatch.name = patch.name;
      if (patch.qty !== undefined) backendPatch.quantity = Number(patch.qty);
      if (patch.cost !== undefined) backendPatch.unit_cost_eur = Number(patch.cost);
      if (patch.notes !== undefined) backendPatch.notes = patch.notes;
      if (patch.cat !== undefined) backendPatch.category_id = patch.cat;
      if (patch.state !== undefined) {
        // normalise vers les valeurs autorisees par le CHECK constraint en DB
        const s = String(patch.state).toLowerCase().replace("é", "e").replace(" ", "_");
        backendPatch.state = s === "non_scelle" ? "non_scelle" : "scelle";
      }
      // Persist + resync
      return apiFetch("PATCH", `/api/stock/sealed/${id}`, backendPatch)
        .then(() => window.loadInitialState())
        .then(seed => {
          setState(prev => ({ ...prev, sealed: seed.sealed }));
        })
        .catch(err => { onFetchError(err, "updateSealedItem"); throw err; });
    },

    // Optimistic + Persist + resync
    deleteSealedItem: (id) => {
      // lecture du nom avant mutate pour le toast
      const item = state.sealed.find(x => x.id === id);
      mutate(s => ({ ...s, sealed: s.sealed.filter(x => x.id !== id) }), `suppression scelle #${id}`);
      show(`« ${item?.name || "Item"} » supprime du stock`, undo);
      // id negatif = jamais ete persiste, pas besoin d'appel API
      if (id < 0) return Promise.resolve();
      // Persist + resync
      return apiFetch("DELETE", `/api/stock/sealed/${id}`)
        .then(() => window.loadInitialState())
        .then(seed => {
          setState(prev => ({ ...prev, sealed: seed.sealed }));
        })
        .catch(err => { onFetchError(err, "deleteSealedItem"); throw err; });
    },

    // Optimistic + Persist + resync
    adjustSeriesStock: (catId, target, reason, note) => {
      mutate(s => ({
        ...s,
        stockSeries: s.stockSeries.map(x =>
          x.cat === catId
            ? { ...x, np: Math.max(0, target.np), nh: Math.max(0, target.nh), h: Math.max(0, target.h) }
            : x
        ),
      }), `ajustement stock serie ${catId}`);
      show(`Stock ajuste · ${reason}`, undo);
      // Persist + resync
      return apiFetch("POST", `/api/stock/series/${catId}/adjust`, {
        np: target.np,
        nh: target.nh,
        h:  target.h,
        reason,
        note,
      })
        .then(() => window.loadInitialState())
        .then(seed => {
          setState(prev => ({ ...prev, stockSeries: seed.stockSeries }));
        })
        .catch(err => { onFetchError(err, "adjustSeriesStock"); throw err; });
    },

    // Optimistic + Persist + resync
    deleteStockSeries: (catId) => {
      const item = state.stockSeries.find(x => x.cat === catId);
      mutate(s => ({ ...s, stockSeries: s.stockSeries.filter(x => x.cat !== catId) }), `suppression serie stock`);
      show(`Serie « ${item?.name || ""} » retiree du stock`, undo);
      // Persist + resync
      return apiFetch("DELETE", `/api/stock/series/${catId}`)
        .then(() => window.loadInitialState())
        .then(seed => {
          setState(prev => ({ ...prev, stockSeries: seed.stockSeries }));
        })
        .catch(err => { onFetchError(err, "deleteStockSeries"); throw err; });
    },

    // Optimistic + Persist + resync
    addShipment: (shipment, items) => {
      mutate(s => {
        const newShipId = Math.max(0, ...s.shipments.map(x => x.id)) + 1;
        let nextPurId = Math.max(0, ...s.purchases.map(x => x.id)) + 1;
        const newPurchases = (items || []).map(it => ({
          id: nextPurId++,
          ship: newShipId,
          cat: Number(it.cat),
          title: it.title,
          qty: Number(it.qty),
          jpy: Number(it.jpy || 0),
          port_jp: 0,
          eur: Number(it.eur || 0),
          status: "ordered",
        }));
        return {
          ...s,
          shipments: [{ id: newShipId, ...shipment }, ...s.shipments],
          purchases: [...s.purchases, ...newPurchases],
        };
      }, `nouvelle commande`);
      show(`Commande creee · ${shipment.method}`, undo);
      // Persist + resync (les ids temporaires seront ecrases par le resync)
      return apiFetch("POST", "/api/shipments", {
        method:            shipment.method,
        source:            shipment.source,
        currency:          shipment.currency,
        port_jpy:          shipment.port_jpy          || 0,
        port_eur:          shipment.port_eur          || 0,
        duty_eur:          shipment.duty_eur          || 0,
        platform_fee_eur:  shipment.platform_fee_eur  || 0,
        rate_jpy_per_eur:  shipment.rate,
        status:            shipment.status            || "created",
        name:              shipment.name              || null,
        notes:             shipment.notes             || null,
        date:              shipment.date,
        items:             items,
      })
        .then(() => window.loadInitialState())
        .then(seed => {
          setState(prev => ({ ...prev, shipments: seed.shipments, purchases: seed.purchases }));
        })
        .catch(err => { onFetchError(err, "addShipment"); throw err; });
    },

    // --- rechargements ---
    addTopup: (t) => {
      const tmpId = -Date.now();
      mutate(s => {
        return { ...s, topups: [...s.topups, { ...t, id: tmpId }] };
      }, `ajout rechargement`);
      show("Rechargement enregistre", undo);

      apiFetch("POST", "/api/topups", {
        date:             t.date,
        jpy_credited:     t.jpy,
        eur_paid:         t.eur,
        rate_jpy_per_eur: t.rate,
        shipment_id:      t.shipment,
        source:           t.source || "email",
        notes:            t.notes,
      }).then(created => {
        if (created?.id) replaceOptimisticId("topups", tmpId, created.id);
        return window.loadInitialState();
      }).then(seed => {
        if (seed) setState(prev => ({ ...prev, currentRate: seed.currentRate ?? prev.currentRate }));
      }).catch(err => onFetchError(err, "addTopup"));
    },

    updateTopup: (topupId, patch) => {
      mutate(s => ({
        ...s,
        topups: s.topups.map(t => t.id === topupId ? { ...t, ...patch } : t),
      }), `modif rechargement #${topupId}`);
      show(`Rechargement #${topupId} mis a jour`, undo);
      if (topupId < 0) return;

      // mapping frontend -> backend (snake_case)
      const body = {};
      if ("eur" in patch)      body.eur_paid         = patch.eur;
      if ("rate" in patch)     body.rate_jpy_per_eur = patch.rate;
      if ("shipment" in patch) body.shipment_id      = patch.shipment;
      if ("jpy" in patch)      body.jpy_credited     = patch.jpy;
      if ("date" in patch)     body.date             = patch.date;
      if ("source" in patch)   body.source           = patch.source;

      apiFetch("PATCH", `/api/topups/${topupId}`, body)
        .then(() => window.loadInitialState())
        .then(seed => setState(prev => ({ ...prev, currentRate: seed.currentRate ?? prev.currentRate })))
        .catch(err => onFetchError(err, "updateTopup"));
    },

    deleteTopup: (topupId) => {
      mutate(s => ({ ...s, topups: s.topups.filter(t => t.id !== topupId) }), `suppression rechargement #${topupId}`);
      show(`Rechargement #${topupId} supprime`, undo);
      if (topupId < 0) return Promise.resolve();
      return apiFetch("DELETE", `/api/topups/${topupId}`)
        .then(() => window.loadInitialState())
        .then(seed => {
          // resync topups + currentRate : sans currentRate, le header garde l'ancien
          // taux quand le dernier topup (celui qui defini le rate) est supprime.
          setState(prev => ({ ...prev, topups: seed.topups, currentRate: seed.currentRate ?? prev.currentRate }));
        })
        .catch(err => { onFetchError(err, "deleteTopup"); throw err; });
    },

    // --- scrap Gmail : detecte les nouveaux rechargements depuis les emails ---
    scrapGmail: async () => {
      try {
        const result = await apiFetch("POST", "/api/topups/scrap-gmail");
        // refetch les topups pour avoir les vrais ids generes par le backend
        const seed = await window.loadInitialState();
        setState(prev => ({ ...prev, topups: seed.topups, currentRate: seed.currentRate ?? prev.currentRate }));
        const nb = result?.count ?? (seed.topups.length - 0);
        show(`Gmail scrappe · ${result?.new_count ?? "?"} nouveau(x) rechargement(s) detecte(s)`);
      } catch (err) {
        console.warn("LDG: scrapGmail error", err);
        show(`Erreur scrap Gmail : ${err.message}`);
      }
    },

    // --- completion EUR d'un rechargement (mutation optimiste + PATCH backend) ---
    completeTopupEur: (id, eurValue, shipmentId) => {
      const eurNum = Number(eurValue);
      // mutation optimiste : on lit jpy depuis le state courant via updater
      let jpySnap = 0;
      mutate(s => {
        const topups = s.topups.map(t => {
          if (t.id !== id) return t;
          jpySnap = t.jpy;
          const rate = eurNum > 0 ? Math.round((t.jpy / eurNum) * 100) / 100 : t.rate;
          return {
            ...t,
            eur: eurNum || null,
            rate,
            shipment: shipmentId !== undefined ? (shipmentId || null) : t.shipment,
          };
        });
        return { ...s, topups };
      }, `completion EUR rechargement #${id}`);

      const body = { eur_paid: eurNum || null };
      if (shipmentId !== undefined) body.shipment_id = shipmentId || null;
      if (jpySnap > 0 && eurNum > 0) {
        body.rate_jpy_per_eur = Math.round((jpySnap / eurNum) * 100) / 100;
      }

      apiFetch("PATCH", `/api/topups/${id}`, body)
        .then(() => show(`Rechargement #${id} complete`, undo))
        .then(() => window.loadInitialState())
        .then(seed => setState(prev => ({ ...prev, currentRate: seed.currentRate ?? prev.currentRate })))
        .catch(err => onFetchError(err, "completeTopupEur"));
    },

    // --- assignation d'un topup a un colis (sans changer EUR) ---
    assignTopup: (topupId, shipmentId) => {
      mutate(s => ({
        ...s,
        topups: s.topups.map(t => t.id === topupId ? { ...t, shipment: shipmentId || null } : t),
      }), `assignation rechargement #${topupId}`);
      show(`Rechargement #${topupId} assigne`, undo);
      if (topupId < 0) return;
      apiFetch("PATCH", `/api/topups/${topupId}`, { shipment_id: shipmentId || null })
        .catch(err => onFetchError(err, "assignTopup"));
    },

    // --- renommage categorie (name + short optionnel) ---
    renameCategory: (id, name, short) => {
      const body = { name };
      if (short !== undefined && short !== null) body.short = short;
      return apiFetch("PATCH", `/api/categories/${id}`, body)
        .then(cat => {
          // resync complet : stockSeries, purchases, saleLines contiennent category_name/short joints
          return window.loadInitialState().then(seed => {
            mutate(s => ({
              ...s,
              categories:  seed.categories,
              stockSeries: seed.stockSeries,
              purchases:   seed.purchases,
              sales:       seed.sales,
              saleLines:   seed.saleLines || s.saleLines,
              sealed:      seed.sealed,
            }), `renommage collection #${id}`);
            show(`Collection renommee : « ${cat.name} »`, null);
            return cat;
          });
        })
        .catch(err => {
          onFetchError(err, "renameCategory");
          throw err;
        });
    },

    // --- creation categorie ---
    createCategory: (name, short) => {
      return apiFetch("POST", "/api/categories", { name, short })
        .then(cat => {
          mutate(s => ({ ...s, categories: [...s.categories, cat] }), `nouvelle categorie ${cat.name}`);
          show(`Categorie « ${cat.name} » creee`, null);
          return cat;
        })
        .catch(err => {
          onFetchError(err, "createCategory");
          throw err;
        });
    },

    // --- mouvements bancaires (compte Boursorama : ajout/retrait manuel) ---
    addBankMovement: (kind, amountEur, note) => {
      const tmpId = `tmp-${Date.now()}`;
      const optimistic = { id: tmpId, kind, amount_eur: amountEur, note: note || null, created_at: new Date().toISOString() };
      mutate(s => ({ ...s, bankMovements: [optimistic, ...s.bankMovements] }), kind === "add" ? "ajout compte bourso" : "retrait compte bourso");
      show(kind === "add" ? "Ajout enregistre" : "Retrait enregistre", undo);

      apiFetch("POST", "/api/bank-movements", { kind, amount_eur: amountEur, note: note || null })
        .then(created => {
          // remplace la ligne optimiste par la vraie ligne (id uuid + created_at serveur)
          // sans passer par mutate : c'est une simple synchro, pas une action annulable
          if (created?.id) {
            setState(prev => ({
              ...prev,
              bankMovements: prev.bankMovements.map(m => m.id === tmpId ? created : m),
            }));
          }
        })
        .catch(err => {
          onFetchError(err, "addBankMovement");
          // retire la ligne optimiste : undo() ci-dessus peut reverter une autre action
          // si l'utilisateur a enchaine vite, on nettoie donc explicitement par tmpId
          setState(prev => ({
            ...prev,
            bankMovements: prev.bankMovements.filter(m => m.id !== tmpId),
          }));
        });
    },

    deleteBankMovement: (id) => {
      mutate(s => ({ ...s, bankMovements: s.bankMovements.filter(m => m.id !== id) }), "suppression mouvement bancaire");
      show("Mouvement supprime", undo);
      // id temporaire (jamais persiste) -> pas d'appel API
      if (typeof id === "string" && id.startsWith("tmp-")) return;
      apiFetch("DELETE", `/api/bank-movements/${id}`)
        .catch(err => onFetchError(err, "deleteBankMovement"));
    },

    undo,
  }), [state.purchases, state.sales, state.categories, mutate, show, undo, onFetchError, replaceOptimisticId]);

  // ── nav ──────────────────────────────────────────────────────────────────
  const NAV = [
    { id: "dashboard",  label: "Vue d'ensemble", icon: "home" },
    { id: "shipments",  label: "Commandes",      icon: "truck",   count: state.shipments.length },
    { id: "purchases",  label: "Achats",         icon: "cart",    count: state.purchases.length },
    { id: "stock",      label: "Stock",          icon: "package", count: state.stockSeries.reduce((a,s)=>a+s.np+s.nh+s.h,0) + state.sealed.reduce((a,x)=>a+x.qty,0) },
    { id: "sales",      label: "Ventes",         icon: "tag",     count: state.sales.length },
    { id: "topups",     label: "Rechargements",  icon: "wallet",  count: state.topups.length },
  ];

  // ── splash loader ─────────────────────────────────────────────────────────
  if (loading) {
    return (
      <div className="pb-splash">
        <div className="pb-splash-inner">
          <svg className="pb-ball" viewBox="0 0 200 200" width="108" height="108" xmlns="http://www.w3.org/2000/svg">
            <defs>
              <linearGradient id="pbRed" x1="0" y1="0" x2="0" y2="1">
                <stop offset="0" stopColor="#1a1c22" /><stop offset="1" stopColor="#0d0e12" />
              </linearGradient>
              <clipPath id="pbClip"><circle cx="100" cy="100" r="92" /></clipPath>
            </defs>
            <g clipPath="url(#pbClip)">
              <rect x="0" y="0" width="200" height="96" fill="url(#pbRed)" />
              <rect x="0" y="104" width="200" height="96" fill="#f4f6fa" />
              <rect x="0" y="92" width="200" height="16" fill="#0a0e1a" />
            </g>
            <circle cx="100" cy="100" r="92" fill="none" stroke="#0a0e1a" strokeWidth="8" />
            <circle cx="100" cy="100" r="30" fill="#0a0e1a" />
            <circle cx="100" cy="100" r="22" fill="#ffffff" />
          </svg>
          <div className="pb-wordmark">Pok<span>é</span>Bank</div>
          <div className="pb-sub">Chargement…</div>
        </div>
      </div>
    );
  }

  const Screen = {
    dashboard: <Dashboard state={state} navigate={navigate} actions={actions} />,
    shipments: <ShipmentsScreen state={state} actions={actions} navigate={navigate} />,
    purchases: <PurchasesScreen state={state} actions={actions} />,
    stock: <StockScreen state={state} actions={actions} />,
    sales: <SalesScreen state={state} actions={actions} openCompose={routeArgs.compose} />,
    topups: <TopupsScreen state={state} actions={actions} />,
  }[route];

  const currentNav = NAV.find(n => n.id === route);

  return (
    <div className="app">
      <aside className="sidebar">
        <div className="brand">
          <div className="brand-mark"><img src="/icons/icon-192.svg" alt="PokéBank" style={{width:'100%',height:'100%',borderRadius:'inherit',display:'block'}} /></div>
          <div className="brand-name">
            PokéBank
            <small>Compta · v2</small>
          </div>
        </div>

        <div className="nav-section">Activite</div>
        {NAV.slice(0, 1).map(n => (
          <button key={n.id} className={`nav-item ${route === n.id ? "active" : ""}`} onClick={() => navigate(n.id)}>
            <span className="nav-icon"><Icon name={n.icon} /></span>
            <span>{n.label}</span>
          </button>
        ))}

        <div className="nav-section">Flux d'entree</div>
        {NAV.slice(1, 4).map(n => (
          <button key={n.id} className={`nav-item ${route === n.id ? "active" : ""}`} onClick={() => navigate(n.id)}>
            <span className="nav-icon"><Icon name={n.icon} /></span>
            <span>{n.label}</span>
            <span className="nav-count">{n.count}</span>
          </button>
        ))}

        <div className="nav-section">Sortie</div>
        {NAV.slice(4, 5).map(n => (
          <button key={n.id} className={`nav-item ${route === n.id ? "active" : ""}`} onClick={() => navigate(n.id)}>
            <span className="nav-icon"><Icon name={n.icon} /></span>
            <span>{n.label}</span>
            <span className="nav-count">{n.count}</span>
          </button>
        ))}

        <div className="nav-section">Tresorerie</div>
        {NAV.slice(5).map(n => (
          <button key={n.id} className={`nav-item ${route === n.id ? "active" : ""}`} onClick={() => navigate(n.id)}>
            <span className="nav-icon"><Icon name={n.icon} /></span>
            <span>{n.label}</span>
            <span className="nav-count">{n.count}</span>
          </button>
        ))}

        <button
          onClick={() => setTheme(pbToggleTheme())}
          title={theme === 'dark' ? 'Passer en mode clair' : 'Passer en mode sombre'}
          style={{ display:'flex', alignItems:'center', gap:8, background:'transparent', border:'none', color:'var(--ink-2)', padding:'8px 12px', borderRadius:'var(--r-md)', cursor:'pointer', width:'100%', marginBottom:4 }}
          onMouseEnter={function(e){ e.currentTarget.style.background='var(--surface-2)'; }}
          onMouseLeave={function(e){ e.currentTarget.style.background='transparent'; }}
        >
          {theme === 'dark'
            ? <svg width={18} height={18} viewBox="0 0 24 24"><circle cx="12" cy="12" r="4" fill="currentColor"/><g stroke="currentColor" strokeWidth="2" strokeLinecap="round"><line x1="12" y1="2" x2="12" y2="5"/><line x1="12" y1="19" x2="12" y2="22"/><line x1="2" y1="12" x2="5" y2="12"/><line x1="19" y1="12" x2="22" y2="12"/><line x1="4.9" y1="4.9" x2="7" y2="7"/><line x1="17" y1="17" x2="19.1" y2="19.1"/><line x1="4.9" y1="19.1" x2="7" y2="17"/><line x1="17" y1="7" x2="19.1" y2="4.9"/></g></svg>
            : <svg width={18} height={18} viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8Z" fill="currentColor"/></svg>
          }
          <span style={{ fontSize:13 }}>{theme === 'dark' ? 'Mode clair' : 'Mode sombre'}</span>
        </button>

        <div className="sidebar-foot">
          Vincent · mono-utilisateur<br />
          DB Supabase · sync Gmail
        </div>
      </aside>

      <main>
        <div className="topbar">
          <div className="crumbs">
            <span style={{ color: "var(--ink-3)" }}>PokéBank</span>
            <span className="sep">/</span>
            <span className="now">{currentNav?.label || route}</span>
          </div>
          <div className="topbar-spacer"></div>
          <button
            className="btn btn-sm btn-ghost"
            disabled={historyLen === 0}
            onClick={() => { const label = undo(); if (label) show(`Annule : ${label}`); }}
            style={{ opacity: historyLen === 0 ? 0.4 : 1 }}
            title="Annuler la derniere action (Cmd+Z)"
          >
            <Icon name="refresh" /> Annuler <span className="kbd" style={{ marginLeft: 4 }}>⌘Z</span>
          </button>
          <div className="rate-pill">
            <span className="dot"></span>
            <span className="lab">JPY/EUR</span>
            <span className="val">{state.currentRate || "—"}</span>
            <span className="lab">· taux</span>
          </div>
        </div>
        <div data-screen-label={currentNav?.label || route}>{Screen}</div>
      </main>

      {toastNode}
    </div>
  );
};

ReactDOM.createRoot(document.getElementById("root")).render(<App />);
