// AXLU — central store. Loaded right after data.jsx (which seeds window.AxluData).
//
// Every mutation goes through window.AxluStore.dispatch(action, payload).
// Subscribers are notified via a version counter. React components read from
// window.AxluData and call useAxluStore() to re-render on change.
//
// State is persisted to localStorage on every mutation; on next launch the
// persisted state is merged on top of the seed (the seed acts as defaults
// for fields that didn't exist when the user last saved).

(function () {
  const STORAGE_KEY = 'axlu.state.v3';
  const SESSION_KEY = 'axlu.session.v2';
  const subscribers = new Set();
  let version = 0;
  let _currentUser = null;
  let _rememberMe = false;

  function notify() {
    version++;
    persist(); // debounced — see persist()
    subscribers.forEach((fn) => { try { fn(version); } catch (e) { console.error(e); } });
  }

  const PERSISTED_KEYS = [
    'PRODUCTS', 'SUPPLIERS', 'CATEGORIES_AXLU', 'CATEGORIES_PF',
    'SYNCS', 'AUDIT_LOG', 'USERS',
    'MARKING_GRIDS', 'MODIFIERS_CATEGORY', 'MODIFIERS_CLIENT',
    'MODIFIERS_ORDER', 'PRICING_COSTS', 'SHIPPING_TIERS',
    'PF_ATTRIBUTES', 'PF_PRINT_PRICES', 'MARKING_COEFS',
    'PUBLICATIONS_QUEUE', 'PUBLICATIONS_ERRORS', 'LAST_IMPORT',
    'SHOPIFY_PUB_CHANNELS', 'SHOPIFY_LAST_SYNC',
  ];

  // Disk-backed persistence. A full PF catalogue (18k+ products) is ~100 MB —
  // far past localStorage's 5-10 MB quota — so state is written to a JSON file
  // via Electron. Writes are debounced (serializing 100 MB on every mutation
  // would freeze the UI) and flushed synchronously on window close.
  const hasFileStore = !!(window.axlu && window.axlu.state);
  // WEB lift-and-shift (Phase 1). Source unique de vérité du « mode web » : la
  // coquille servie pose window.__AXLU_WEB__. En mode web, les données sont
  // chargées depuis l'API REST APRÈS le login (jamais lues du disque), et la
  // persistance du blob complet est neutralisée (les écritures granulaires vers
  // l'API arrivent en Phase 2). Electron ne pose rien ici → isWeb faux → tous
  // les chemins disque ci-dessous sont inchangés.
  const isWeb = !!window.__AXLU_WEB__;
  // Catalogue/clés entité vidés avant la réponse de l'API pour qu'aucune donnée
  // de démo (seed) ne clignote ; les enums de référence du seed sont conservés.
  const WEB_EMPTY_KEYS = [
    'PRODUCTS', 'SUPPLIERS', 'CATEGORIES_AXLU', 'CATEGORIES_PF',
    'SYNCS', 'AUDIT_LOG', 'USERS',
    'MARKING_GRIDS', 'MODIFIERS_CATEGORY', 'MODIFIERS_CLIENT',
    'MODIFIERS_ORDER', 'SHIPPING_TIERS', 'PF_ATTRIBUTES',
    'PF_PRINT_PRICES', 'PUBLICATIONS_QUEUE', 'PUBLICATIONS_ERRORS',
  ];
  let _persistTimer = null;
  let _persistDirty = false;

  function serializeState() {
    const out = {};
    PERSISTED_KEYS.forEach((k) => { out[k] = window.AxluData[k]; });
    out._v = 2;
    return JSON.stringify(out);
  }

  function doPersist(sync) {
    _persistDirty = false;
    // WEB (Phase 1) : pas de sauvegarde du blob complet. Sérialiser tout le
    // catalogue à chaque mutation gèlerait l'UI et saturerait localStorage ; le
    // store en mémoire a déjà muté window.AxluData et notify() a déjà tourné,
    // donc l'UI est à jour. Les écritures durables passeront par des endpoints
    // API granulaires en Phase 2. Ici : no-op pur.
    if (isWeb) return;
    try {
      const json = serializeState();
      if (hasFileStore) {
        if (sync) window.axlu.state.saveSync(json);
        else window.axlu.state.save(json);
      } else {
        // Fallback (browser / no Electron): localStorage, may hit quota.
        localStorage.setItem(STORAGE_KEY, json);
      }
    } catch (e) {
      console.warn('[AXLU] persist failed', e);
    }
  }

  function persist() {
    _persistDirty = true;
    if (_persistTimer) return;
    _persistTimer = setTimeout(() => {
      _persistTimer = null;
      if (_persistDirty) doPersist(false);
    }, 1200);
  }

  // Flush synchronously before the window closes. ALWAYS writes (no dirty
  // guard): an async debounced save may have already cleared _persistDirty
  // but not finished writing — closing then lost the edit. An unconditional
  // sync write guarantees the current in-memory state reaches disk on close.
  function flushPersist() {
    if (_persistTimer) { clearTimeout(_persistTimer); _persistTimer = null; }
    doPersist(true);
  }
  window.addEventListener('beforeunload', flushPersist);

  function loadPersisted() {
    try {
      let raw = null;
      if (hasFileStore) {
        raw = window.axlu.state.loadSync();
        // One-time migration: if no file yet but localStorage has old state.
        if (!raw) {
          const legacy = localStorage.getItem(STORAGE_KEY);
          if (legacy) { raw = legacy; }
        }
      } else {
        raw = localStorage.getItem(STORAGE_KEY);
      }
      if (!raw) return null;
      return JSON.parse(raw);
    } catch (e) {
      console.warn('[AXLU] loadPersisted failed', e);
      return null;
    }
  }

  function resetToSeed() {
    if (hasFileStore && window.axlu.state.clear) {
      try { window.axlu.state.clear(); } catch (e) {}
    }
    try { localStorage.removeItem(STORAGE_KEY); } catch (e) {}
    try { localStorage.removeItem(SESSION_KEY); } catch (e) {}
    location.reload();
  }

  // ─── Session ─────────────────────────────────────────────────────────
  function loadSession() {
    try {
      const raw = localStorage.getItem(SESSION_KEY);
      if (!raw) return null;
      return JSON.parse(raw);
    } catch (e) { return null; }
  }
  function saveSession(s) {
    try {
      if (s) localStorage.setItem(SESSION_KEY, JSON.stringify(s));
      else localStorage.removeItem(SESSION_KEY);
    } catch (e) { /* ignore */ }
  }

  // ─── Audit & toast helpers ───────────────────────────────────────────
  function audit(action, entity, target, user) {
    const entry = {
      ts: new Date().toISOString(),
      user: user || (_currentUser && _currentUser.name) || 'Système',
      action, entity, target,
    };
    window.AxluData.AUDIT_LOG = [entry, ...window.AxluData.AUDIT_LOG];
  }

  function toast(message, opts) {
    if (typeof window.__axluToast === 'function') window.__axluToast(message, opts || {});
  }

  // ─── Écritures durables (web) : optimiste + réconciliation ───────────────
  // En mode web, après la mutation en mémoire, on persiste via l'API. Succès →
  // on réconcilie le produit avec la version autoritaire du serveur. Échec →
  // revert + toast. NO-OP sur Electron (disque/IPC inchangés).
  function apiFetch(path, method, body) {
    // Verrou collaboratif : si un AUTRE édite la page/fiche où je suis, je suis en LECTURE SEULE →
    // toute écriture est bloquée (la présence /api/presence passe par fetch() brut, jamais bloquée).
    if (method && method !== 'GET' && typeof amIReadOnly === 'function' && amIReadOnly()) {
      return Promise.resolve({ ok: false, status: 423, error: 'Élément en cours d\'utilisation par un autre — lecture seule.' });
    }
    return fetch(path, {
      method: method, credentials: 'same-origin',
      headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
      body: body == null ? undefined : JSON.stringify(body),
    }).then(async (res) => {
      const b = await res.json().catch(() => null);
      if (res.ok) return b || { ok: true };
      return { ok: false, status: res.status, error: b && (b.error || b.message) };
    });
  }
  function replaceProduct(prod) {
    if (!prod || !prod.id) return;
    const list = window.AxluData.PRODUCTS;
    const i = list.findIndex((x) => x.id === prod.id);
    if (i >= 0) window.AxluData.PRODUCTS = [...list.slice(0, i), prod, ...list.slice(i + 1)];
  }
  function replaceSupplier(sup) {
    if (!sup || !sup.id) return;
    const list = window.AxluData.SUPPLIERS;
    const i = list.findIndex((x) => x.id === sup.id);
    if (i >= 0) window.AxluData.SUPPLIERS = [...list.slice(0, i), sup, ...list.slice(i + 1)];
  }
  function replaceUser(u) {
    if (!u || !u.id) return;
    window.AxluData.USERS = window.AxluData.USERS.map((x) => x.id === u.id ? { ...x, ...u } : x);
  }
  // Persiste une grille de marquage ENTIÈRE (les ops de paliers re-PATCHent tout).
  function persistGrid(id) {
    const g = (window.AxluData.MARKING_GRIDS || []).find((x) => x.id === id);
    if (!g) return;
    apiWrite(() => apiFetch('/api/marking-grids/' + encodeURIComponent(id), 'PATCH', { markingType: g.markingType, format: g.format, enabled: g.enabled, tiers: g.tiers }),
      'Échec de l\'enregistrement de la grille de marquage', null, null);
  }
  // Persiste TOUT le tableau des frais de port (positionnels, sans id).
  function persistShipping() {
    apiWrite(() => apiFetch('/api/shipping-tiers', 'PUT', { tiers: window.AxluData.SHIPPING_TIERS || [] }),
      'Échec de l\'enregistrement des frais de port', null, null);
  }
  // fnLazy = () => Promise (construite seulement sur web). onError = revert,
  // onOk(res) = réconciliation. Jamais awaité → l'UI reste instantanée.
  function apiWrite(fnLazy, errMsg, onError, onOk) {
    if (!isWeb) return;
    let p;
    try { p = fnLazy(); } catch (e) { console.warn('[AXLU] apiWrite build', e); }
    if (!p || typeof p.then !== 'function') return;
    p.then((res) => {
      if (res && res.ok === false) { toast(errMsg, { tone: 'danger' }); if (onError) { onError(); notify(); } return; }
      if (onOk) { onOk(res); notify(); }
    }).catch((e) => { console.warn('[AXLU] apiWrite', e); toast(errMsg, { tone: 'danger' }); if (onError) { onError(); notify(); } });
  }

  // ─── Helpers ─────────────────────────────────────────────────────────
  function nextId(prefix) {
    return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
  }

  // Recompute a product's pricing fields. The sell price is the "bare" unit
  // price — buy price × base coefficient — unless a manual price override is
  // set. Margin / margin% follow from it.
  function recomputeProduct(p) {
    const costs = window.AxluData.PRICING_COSTS || {};
    const baseCoef = costs.baseCoef || 2.5;
    const buy = Number(p.buyPrice) || 0;
    // Parité avec le serveur : arrondi 4 décimales + on préserve le prix existant
    // quand l'achat est 0 (au lieu de le mettre à 0). Identique côté Electron.
    const computed = buy > 0 ? +(buy * baseCoef).toFixed(4) : (Number(p.sellPrice) || 0);
    const sellPrice = p.manualPrice != null ? p.manualPrice : computed;
    const margin = sellPrice - buy;
    const marginPct = sellPrice > 0 ? (margin / sellPrice) * 100 : 0;
    return { ...p, sellPrice, margin, marginPct };
  }

  function patchProduct(id, patch) {
    const list = window.AxluData.PRODUCTS;
    const idx = list.findIndex((x) => x.id === id);
    if (idx < 0) return null;
    const updated = recomputeProduct({ ...list[idx], ...patch });
    // Live quality-rule check: any DATA edit re-evaluates the supplier
    // review rules, so a product flips to « à vérifier » instantly (e.g.
    // setting a sell price below prix d'achat ÷ 0,7). A status-only change
    // is the human's explicit decision and is never auto-re-flagged.
    const keys = Object.keys(patch || {});
    const statusOnly = keys.length > 0 && keys.every((k) => k === 'status');
    if (!statusOnly) applyReviewRules(updated, null);
    window.AxluData.PRODUCTS = [...list.slice(0, idx), updated, ...list.slice(idx + 1)];
    return updated;
  }

  // CSV
  function toCSV(rows, columns) {
    const esc = (v) => {
      if (v == null) return '';
      const s = String(v).replace(/"/g, '""');
      return /[",\n;]/.test(s) ? `"${s}"` : s;
    };
    const header = columns.map((c) => esc(c.label || c.key)).join(',');
    const body = rows.map((r) => columns.map((c) => {
      const val = typeof c.value === 'function' ? c.value(r) : r[c.key];
      return esc(val);
    }).join(',')).join('\n');
    return header + '\n' + body;
  }

  function download(content, filename, mime) {
    const blob = (content instanceof Blob) ? content : new Blob([content], { type: mime || 'application/octet-stream' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url; a.download = filename;
    document.body.appendChild(a); a.click(); document.body.removeChild(a);
    setTimeout(() => URL.revokeObjectURL(url), 1000);
  }

  // ─── Action registry ─────────────────────────────────────────────────
  const A = {};

  // products
  A['product.update'] = ({ id, patch, manual }) => {
    const before = window.AxluData.PRODUCTS.find((p) => p.id === id);
    let finalPatch = patch;
    // Manual edits LOCK the fields that actually changed: they are added to
    // product.overrides so feed re-imports never overwrite them (see
    // product.import). Compared by value (JSON) so object/array fields work.
    // (The sell-price override is handled separately via manualPrice.)
    if (manual && before && patch) {
      const changed = Object.keys(patch).filter((k) =>
        k !== 'overrides' && JSON.stringify(before[k]) !== JSON.stringify(patch[k]));
      if (changed.length) {
        const prev = Array.isArray(before.overrides) ? before.overrides : [];
        finalPatch = { ...patch, overrides: Array.from(new Set([...prev, ...changed])) };
      }
    }
    const after = patchProduct(id, finalPatch);
    if (after) {
      const changedKeys = Object.keys(patch || {}).filter((k) => before && before[k] !== after[k]);
      audit('product.update', 'product', `${after.sku} — ${changedKeys.join(', ')}`);
      const snap = before;
      apiWrite(() => apiFetch(`/api/products/${encodeURIComponent(id)}`, 'PATCH', finalPatch),
        `Échec de l'enregistrement de ${after.sku}`,
        () => { if (snap) replaceProduct(snap); },
        (res) => { if (res && res.product) replaceProduct(res.product); });
    }
  };

  A['product.setStatus'] = ({ id, status, _bulk }) => {
    const p = patchProduct(id, { status });
    if (!p) return;
    const verb = ({ approved: 'approve', blocked: 'block', archived: 'archive', to_review: 'review' }[status]) || 'status';
    audit(`product.${verb}`, 'product', `${p.sku} — ${p.title}`);
    // Persistance web (sauf en lot : une seule requête /bulk gère toute la sélection).
    if (!_bulk) {
      apiWrite(() => apiFetch(`/api/products/${encodeURIComponent(p.id)}`, 'PATCH', { status }),
        `Échec du statut de ${p.sku}`, null,
        (res) => { if (res && res.product) replaceProduct(res.product); });
    }
  };

  A['product.bulkSetStatus'] = ({ ids, status }) => {
    ids.forEach((id) => A['product.setStatus']({ id, status, _bulk: true }));
    apiWrite(() => apiFetch('/api/products/bulk', 'POST', { ids, op: 'status', value: status }),
      `Échec du statut (${ids.length} produits)`, null, null);
  };

  A['product.bulkSetCategory'] = ({ ids, categoryId }) => {
    const cat = window.AxluData.CATEGORIES_AXLU.find((c) => c.id === categoryId);
    ids.forEach((id) => patchProduct(id, { categoryId, categoryIds: categoryId ? [categoryId] : [], categoryLabel: cat ? cat.name : '—' }));
    audit('product.bulkSetCategory', 'product', `${ids.length} produits → ${cat ? cat.name : categoryId}`);
    apiWrite(() => apiFetch('/api/products/bulk', 'POST', { ids, op: 'category', value: categoryId }),
      `Échec de la catégorie (${ids.length} produits)`, null, null);
  };

  A['product.bulkPublish'] = ({ ids, mode }) => {
    // mode = 'publish' | 'republish' | 'unpublish' | 'activate' | 'deactivate'
    if (mode === 'unpublish') { A['product.bulkSetStatus']({ ids, status: 'approved' }); return { published: 0, skipped: 0 }; }
    // Deactivate: send each live product back to DRAFT (shopify_pub='draft').
    // No completeness gate — deactivating is always safe.
    if (mode === 'deactivate') { (ids || []).forEach((id) => { shopifyDeactivate(id); }); return { published: 0, skipped: 0 }; }
    // publish / republish / activate: same completeness gate as shopifyPublish.
    // A product that is not yet live and is incomplete goes to "à vérifier"
    // instead of being published/activated. (Already-live products are left to
    // the per-product path, which gates + takes them down if they degraded.)
    const clean = [], skipped = [];
    (ids || []).forEach((id) => {
      const p = window.AxluData.PRODUCTS.find((x) => x.id === id);
      const miss = p ? shopifyPublishBlockers(p) : ['produit'];
      const isLive = !!(p && p.shopifyId && (p.shopifyPub === 'draft' || p.shopifyPub === 'active'));
      if (miss.length && !isLive) skipped.push({ id, miss }); else clean.push(id);
    });
    if (clean.length) {
      // WEB : on agit RÉELLEMENT sur chacun via le serveur (qui persiste) ; Electron : statut groupé.
      if (mode === 'activate') clean.forEach((id) => { shopifyActivate(id); });
      else if (isWeb) clean.forEach((id) => { shopifyPublish(id); });
      else A['product.bulkSetStatus']({ ids: clean, status: 'approved' });
    }
    skipped.forEach(({ id, miss }) => {
      patchProduct(id, { shopifyPub: 'error', shopifyError: 'Produit incomplet — manque : ' + miss.join(', ') + '.' });
      A['product.setStatus']({ id, status: 'to_review' });
    });
    if (skipped.length) audit('product.bulkPublish.skipped', 'product', skipped.length + ' incomplet(s) → à vérifier');
    return { published: clean.length, skipped: skipped.length };
  };

  A['product.bulkReprice'] = ({ ids }) => {
    // Clearing the manual override lets recomputeProduct derive the sell price
    // from buy price × base coefficient (no transport — see refonte 0.13).
    ids.forEach((id) => patchProduct(id, { manualPrice: null }));
    const baseCoef = (window.AxluData.PRICING_COSTS || {}).baseCoef || 2.5;
    audit('product.bulkReprice', 'pricing', `${ids.length} produits — coefficient de base ${baseCoef}`);
    apiWrite(() => apiFetch('/api/products/bulk', 'POST', { ids, op: 'reprice' }),
      `Échec du re-tarif (${ids.length} produits)`, null, null);
  };

  A['product.delete'] = ({ id }) => {
    const p = window.AxluData.PRODUCTS.find((x) => x.id === id);
    if (!p) return;
    window.AxluData.PRODUCTS = window.AxluData.PRODUCTS.filter((x) => x.id !== id);
    audit('product.delete', 'product', `${p.sku} — ${p.title}`);
    const snap = p;
    apiWrite(() => apiFetch(`/api/products/${encodeURIComponent(id)}`, 'DELETE'),
      `Échec de la suppression de ${p.sku}`,
      () => { window.AxluData.PRODUCTS = [snap, ...window.AxluData.PRODUCTS]; }, null);
  };

  A['product.bulkDelete'] = ({ ids }) => {
    if (!Array.isArray(ids) || ids.length === 0) return;
    const idSet = new Set(ids);
    const removed = window.AxluData.PRODUCTS.filter((p) => idSet.has(p.id));
    window.AxluData.PRODUCTS = window.AxluData.PRODUCTS.filter((p) => !idSet.has(p.id));
    audit('product.bulkDelete', 'product', `${removed.length} produit(s) supprimé(s)`);
    apiWrite(() => apiFetch('/api/products/bulk', 'POST', { ids, op: 'delete' }),
      `Échec de la suppression (${removed.length} produits)`,
      () => { window.AxluData.PRODUCTS = [...removed, ...window.AxluData.PRODUCTS]; }, null);
  };

  A['product.duplicate'] = ({ id }) => {
    const p = window.AxluData.PRODUCTS.find((x) => x.id === id);
    if (!p) return;
    const tmpId = nextId('prod');
    const copy = { ...p, id: tmpId, sku: `${p.sku}-COPY`, title: `${p.title} (copie)`, status: 'to_review', shopifyId: null, shopifyPub: 'offline', lastPublishedAt: null, __pending: isWeb };
    window.AxluData.PRODUCTS = [copy, ...window.AxluData.PRODUCTS];
    audit('product.duplicate', 'product', `${copy.sku} (depuis ${p.sku})`);
    // WEB : l'id réel vient du serveur ; on remplace la copie optimiste à la réponse,
    // et on réécrit l'URL si on est déjà sur sa fiche (l'id temporaire n'existe pas en base).
    apiWrite(() => apiFetch(`/api/products/${encodeURIComponent(id)}/duplicate`, 'POST'),
      `Échec de la duplication de ${p.sku}`,
      () => { window.AxluData.PRODUCTS = window.AxluData.PRODUCTS.filter((x) => x.id !== tmpId); },
      (res) => {
        if (res && res.product) {
          window.AxluData.PRODUCTS = [res.product, ...window.AxluData.PRODUCTS.filter((x) => x.id !== tmpId)];
          if (window.location.hash.indexOf(tmpId) >= 0) window.location.hash = `#/products/${res.product.id}`;
        }
      });
    return copy;
  };

  // Helper: aggregate variant-level stock/price into product-level fields.
  function aggregateProductFromVariants(p) {
    const vs = p.variants || [];
    const totalStock = vs.reduce((s, v) => s + (Number(v.stock) || 0), 0);
    const positivePrices = vs.map((v) => Number(v.buyPrice) || 0).filter((x) => x > 0);
    const minBuyPrice = positivePrices.length > 0 ? Math.min(...positivePrices) : 0;
    // Minimum order quantity = lowest MOQ among variants that declare one.
    const moqs = vs.map((v) => Number(v.moq) || 0).filter((x) => x > 0);
    const moq = moqs.length > 0 ? Math.min(...moqs) : 0;
    // "Available, not counted" — true only when EVERY variant is in that
    // state (a mixed product still shows its real summed stock).
    const stockUncounted = vs.length > 0 && vs.every((v) => v && v.stockUncounted);
    return { ...p, stock: totalStock, buyPrice: minBuyPrice, moq, stockUncounted };
  }

  // ─── Import quality rules ────────────────────────────────────────────
  // Resolve the AXLU supplier record for an incoming feed item. The feed
  // parser tags items with a slug (supplier:'pf-concept') and a label
  // (supplierName:'PF Concept'); the user-created supplier has its own
  // generated id (sup-xxx) and a free-text name. Match leniently, and fall
  // back to the only supplier when there is exactly one.
  function findSupplierForItem(suppliers, it) {
    if (!Array.isArray(suppliers) || suppliers.length === 0) return null;
    if (it && it.supplier) {
      const byId = suppliers.find((s) => s.id === it.supplier);
      if (byId) return byId;
    }
    const name = String((it && it.supplierName) || '').trim().toLowerCase();
    if (name) {
      const byName = suppliers.find((s) => String(s.name || '').trim().toLowerCase() === name);
      if (byName) return byName;
    }
    return suppliers.length === 1 ? suppliers[0] : null;
  }

  // Levenshtein edit distance — measures how much a product title changed
  // between two feed imports.
  function levenshtein(a, b) {
    a = String(a || ''); b = String(b || '');
    if (a === b) return 0;
    if (!a.length) return b.length;
    if (!b.length) return a.length;
    let prev = [];
    for (let j = 0; j <= b.length; j++) prev[j] = j;
    for (let i = 1; i <= a.length; i++) {
      const cur = [i];
      for (let j = 1; j <= b.length; j++) {
        const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
        cur[j] = Math.min(cur[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
      }
      prev = cur;
    }
    return prev[b.length];
  }

  // Fraction of the title that changed (0 = identical, 1 = fully different).
  function titleChangeRatio(oldTitle, newTitle) {
    const a = String(oldTitle || '').trim();
    const b = String(newTitle || '').trim();
    if (!a && !b) return 0;
    const maxLen = Math.max(a.length, b.length);
    if (maxLen === 0) return 0;
    return levenshtein(a, b) / maxLen;
  }

  // Is a stock-location string a European location?
  //   true  → recognised European country / region
  //   false → a known location that is NOT European
  //   null  → empty / unknown (no stock data yet)
  // Detection covers EU/EEA/UK/CH country names + ISO codes + "Europe"/"EU".
  function isEuropeanStock(loc) {
    const s = String(loc || '').trim().toLowerCase();
    if (!s) return null;
    const NAMES = [
      'europe', 'european', 'européen', 'eurozone', 'union européenne',
      'france', 'allemagne', 'germany', 'deutschland', 'pays-bas', 'netherlands', 'holland', 'nederland',
      'pologne', 'poland', 'polska', 'belgique', 'belgium', 'belgie', 'espagne', 'spain', 'espana',
      'italie', 'italy', 'italia', 'portugal', 'autriche', 'austria', 'osterreich',
      'suisse', 'switzerland', 'schweiz', 'royaume-uni', 'united kingdom', 'great britain', 'angleterre', 'england',
      'irlande', 'ireland', 'danemark', 'denmark', 'suede', 'suède', 'sweden', 'finlande', 'finland',
      'norvege', 'norvège', 'norway', 'luxembourg', 'tcheque', 'tchèque', 'tchequie', 'czech', 'czechia',
      'slovaquie', 'slovakia', 'hongrie', 'hungary', 'roumanie', 'romania', 'bulgarie', 'bulgaria',
      'grece', 'grèce', 'greece', 'croatie', 'croatia', 'slovenie', 'slovénie', 'slovenia',
      'estonie', 'estonia', 'lettonie', 'latvia', 'lituanie', 'lithuania', 'chypre', 'cyprus',
      'malte', 'malta', 'islande', 'iceland',
    ];
    for (let i = 0; i < NAMES.length; i++) { if (s.indexOf(NAMES[i]) !== -1) return true; }
    const CODES = new Set([
      'eu', 'ue', 'eea', 'eee', 'at', 'be', 'bg', 'hr', 'cy', 'cz', 'dk', 'ee', 'fi', 'fr', 'de',
      'gr', 'el', 'hu', 'ie', 'it', 'lv', 'lt', 'lu', 'mt', 'nl', 'pl', 'pt', 'ro', 'sk', 'si',
      'es', 'se', 'gb', 'uk', 'ch', 'no', 'is', 'li',
    ]);
    const tokens = s.split(/[^a-z]+/).filter(Boolean);
    for (let i = 0; i < tokens.length; i++) { if (CODES.has(tokens[i])) return true; }
    return false;
  }

  // Decide whether an imported product must be flagged « à vérifier ».
  // `rules` = supplier.rules; each review* rule is ON unless explicitly set
  // to false (the UI shows them all on by default). `prevProd` is the
  // product as it existed before this import (null for new products) —
  // only used for the title-change rule. Returns an array of short French
  // reasons (empty = nothing to review).
  function importReviewReasons(prod, rules, prevProd) {
    rules = rules || {};
    const on = (k) => rules[k] !== false;
    const reasons = [];
    if (on('reviewNoEan')) {
      const hasEan = !!String(prod.ean || '').trim()
        || (prod.variants || []).some((v) => v && String(v.ean || '').trim());
      if (!hasEan) reasons.push('sans code EAN');
    }
    if (on('reviewNoImage')) {
      const hasImage = !!prod.hasImage || !!prod.imageUrl
        || (Array.isArray(prod.imageGallery) && prod.imageGallery.length > 0);
      if (!hasImage) reasons.push('sans image');
    }
    if (on('reviewNoCategory')) {
      if (!prod.categoryId) reasons.push('aucune catégorie AXLU');
    }
    if (on('reviewStockOutsideEu')) {
      // Flag when a variant has a KNOWN non-European stock location.
      // Empty / unknown locations never trigger the rule.
      const outside = (prod.variants || []).some((v) => v && isEuropeanStock(v.stockLocation) === false);
      if (outside) reasons.push('stock hors Europe');
    }
    if (on('reviewLowPrice')) {
      const sell = Number(prod.sellPrice) || 0;
      const buy = Number(prod.buyPrice) || 0;
      if (buy > 0 && sell > 0 && sell < buy / 0.7) reasons.push('prix de vente trop bas');
    }
    if (on('reviewTitleChange') && prevProd) {
      if (titleChangeRatio(prevProd.title, prod.title) > 0.3) reasons.push('titre modifié (>30 %)');
    }
    if (on('reviewPriceUp') && prevProd) {
      // New buy price more than 10 % above the previous one.
      const oldBuy = Number(prevProd.buyPrice) || 0;
      const newBuy = Number(prod.buyPrice) || 0;
      if (oldBuy > 0 && newBuy > oldBuy * 1.10) reasons.push('prix d\'achat en hausse (>10 %)');
    }
    return reasons;
  }

  // Re-evaluate the supplier quality rules for a product after a feed
  // touched it (import OR enrichment) and apply the « à vérifier » flag in
  // place. Needed because some signals only arrive via enrichment feeds:
  // the stock location comes from the Stocks feed, the buy price from the
  // Prices feed. `prevProduct` is the product before this feed ran (used by
  // the change-based rules). Returns true if the product ends up flagged.
  function applyReviewRules(product, prevProduct) {
    const suppliers = window.AxluData.SUPPLIERS || [];
    const sup = findSupplierForItem(suppliers, product);
    const rules = (sup && sup.rules) || {};
    const reasons = importReviewReasons(product, rules, prevProduct || null);
    product.reviewReasons = reasons.length > 0 ? reasons : null;
    if (reasons.length > 0) product.status = 'to_review';
    return reasons.length > 0;
  }

  // product.import: dedup by sku (= modelCode in new shape). On update we
  // refresh feed-driven product fields and refresh variants, while preserving
  // stock/price/lifecycle data per-variant from previous enrichments.
  A['product.import'] = ({ items, feedType }) => {
    if (!Array.isArray(items)) return { ok: false, error: 'items must be an array' };

    const suppliers = window.AxluData.SUPPLIERS || [];
    let skipped = { excludedCategory: 0 };
    let flaggedForReview = 0;

    // Only « catégories exclues » removes an item outright. The quality
    // rules (sans EAN, sans image, …) never drop a product — they flag it
    // « à vérifier » so a human checks it (see importReviewReasons above).
    const filtered = items.filter((it) => {
      const sup = findSupplierForItem(suppliers, it);
      const excluded = (sup && sup.excludedCategories) || [];
      if (it.categoryId && excluded.includes(it.categoryId)) { skipped.excludedCategory++; return false; }
      return true;
    });

    const products = [...(window.AxluData.PRODUCTS || [])];
    const indexBySku = new Map();
    products.forEach((p, i) => { if (p.sku) indexBySku.set(p.sku, i); });

    let added = 0, updated = 0;
    const adds = [];

    filtered.forEach((it, i) => {
      const sku = it.sku || `IMP-${Date.now()}-${i}`;
      const incomingVariants = Array.isArray(it.variants) ? it.variants : [];
      const itemSupplier = findSupplierForItem(suppliers, it);
      const itemRules = (itemSupplier && itemSupplier.rules) || {};

      const feedDriven = {
        sku,
        title: it.title || it.name || 'Produit importé',
        description: it.description || '',
        descriptionShort: it.descriptionShort || '',
        keywords: it.keywords || '',
        productComments: it.productComments || '',
        attributes: Array.isArray(it.attributes) ? it.attributes : [],
        brand: it.brand || '—',
        supplier: it.supplier || '',
        supplierName: it.supplierName || '',
        modelCode: it.modelCode || sku,
        pfGroupCode: it.pfGroupCode || null,
        pfGroupDesc: it.pfGroupDesc || null,
        pfCatCode: it.pfCatCode || null,
        pfCatDesc: it.pfCatDesc || null,
        weight: Number(it.weight) || 0,
        dimensions: it.dimensions || { l: 0, w: 0, h: 0 },
        material: it.material || '',
        countryOfOrigin: it.countryOfOrigin || '',
        hsCode: it.hsCode || '',
        ean: it.ean || '',
        hasImage: !!(it.hasImage || it.imageUrl),
        imageUrl: it.imageUrl || null,
        imageUrlLarge: it.imageUrlLarge || null,
        imageGallery: it.imageGallery || [],
        videoUrl: it.videoUrl || null,
        marking: it.marking || { method: '', location: '', maxColors: 0 },
        primaryColor: it.primaryColor || '',
        primaryColorHex: it.primaryColorHex || '',
        variantCount: incomingVariants.length,
        greenPoints: it.greenPoints || null,
        isDiscontinued: !!it.isDiscontinued,
      };

      const existingIdx = indexBySku.get(sku);
      if (existingIdx != null) {
        const cur = products[existingIdx];
        // Merge variants: preserve stock/price enrichments from existing variants
        // when their SKU matches an incoming variant.
        const oldVariantsBySku = new Map();
        (cur.variants || []).forEach((v) => { if (v.sku) oldVariantsBySku.set(v.sku, v); });
        const mergedVariants = incomingVariants.map((nv) => {
          const ov = oldVariantsBySku.get(nv.sku);
          if (!ov) return nv;
          // Preserve every enrichment a previous feed import added to this
          // variant, so re-importing Produits never wipes stock / price /
          // print-data work. Import order then only requires Produits first.
          return {
            ...nv,
            stock: ov.stock || 0,
            stockUncounted: !!ov.stockUncounted,
            stockNextPo: ov.stockNextPo || 0,
            stockDateNextPo: ov.stockDateNextPo || null,
            stockFuture: ov.stockFuture || 0,
            stockLocation: ov.stockLocation || '',
            buyPrice: ov.buyPrice || 0,
            priceTiers: ov.priceTiers || [],
            currency: ov.currency || '',
            minDecoQty: ov.minDecoQty || 0,
            moq: ov.moq || 0,
            decoCharge: ov.decoCharge || '',
            printMethods: ov.printMethods || [],
          };
        });

        const merged = recomputeProduct(aggregateProductFromVariants({
          ...cur,
          ...feedDriven,
          variants: mergedVariants,
          // Tag the feed this product came from — scopes the discontinued check.
          sourceFeed: feedType || cur.sourceFeed || null,
          // Preserve user-owned fields:
          id: cur.id,
          categoryId: cur.categoryId,
          categoryLabel: cur.categoryLabel,
          manualPrice: cur.manualPrice,
          status: cur.status,
          shopifyId: cur.shopifyId,
          shopifyPub: cur.shopifyPub,
          shopifyError: cur.shopifyError,
          lastPublishedAt: cur.lastPublishedAt,
          // Preserve product-level enrichment that was promoted from variants:
          priceTiers: cur.priceTiers || [],
        }));
        // Manual overrides win: any field the user edited by hand (recorded in
        // cur.overrides) is re-applied on top of the feed values, so a re-import
        // never clobbers manual edits (title, description, brand, weight,
        // dimensions, category…). manualPrice is already preserved above.
        const _ovr = Array.isArray(cur.overrides) ? cur.overrides : [];
        _ovr.forEach((k) => {
          if (k !== 'overrides' && Object.prototype.hasOwnProperty.call(cur, k)) merged[k] = cur[k];
        });
        if (_ovr.length) merged.overrides = _ovr;
        // Quality rules: a re-imported product that now fails a check is
        // sent back to « à vérifier ». `cur` is the pre-import version,
        // used for the title-change comparison.
        const mergedReasons = importReviewReasons(merged, itemRules, cur);
        merged.reviewReasons = mergedReasons.length > 0 ? mergedReasons : null;
        if (mergedReasons.length > 0) {
          merged.status = 'to_review';
          if (cur.status !== 'to_review') flaggedForReview++;
        }
        products[existingIdx] = merged;
        updated++;
      } else {
        const fresh = recomputeProduct(aggregateProductFromVariants({
          ...feedDriven,
          sourceFeed: feedType || null,
          id: nextId('prod'),
          categoryId: it.categoryId || null,
          categoryLabel: (window.AxluData.CATEGORIES_AXLU.find((c) => c.id === it.categoryId) || {}).name || '—',
          variants: incomingVariants,
          sellPrice: 0,
          manualPrice: null,
          status: 'to_review',
          duplicate: false,
          shopifyId: null,
          shopifyPub: 'offline',
          shopifyError: null,
          lastPublishedAt: null,
          priceTiers: [],
        }));
        // Quality rules: flag a brand-new product « à vérifier » when it
        // fails a check (no title-change check — there is no previous one).
        const freshReasons = importReviewReasons(fresh, itemRules, null);
        if (freshReasons.length > 0) {
          fresh.status = 'to_review';
          fresh.reviewReasons = freshReasons;
          flaggedForReview++;
        }
        adds.push(fresh);
        added++;
      }
    });

    window.AxluData.PRODUCTS = [...adds, ...products];
    // File freshly-added products into the AXLU category tree (creates new
    // PF categories if the import introduced any). Existing products keep
    // their category — manual assignments are preserved.
    const categorized = fileUncategorizedProducts();

    // Discontinued-product guard: a product previously imported from THIS
    // feed but now absent from it — if still published — is sent back to
    // « à vérifier » so a human decides (unpublish / archive). Scoped by
    // sourceFeed so importing one product feed never touches another's.
    let reverted = 0;
    if (feedType && filtered.length > 0) {
      const ABSENT_REASON = 'Absent du dernier import fournisseur';
      const importedSkus = new Set(filtered.map((it) => it.sku).filter(Boolean));
      window.AxluData.PRODUCTS = window.AxluData.PRODUCTS.map((p) => {
        if (p.sourceFeed === feedType && (p.shopifyPub === 'active' || p.shopifyPub === 'draft') && p.sku && !importedSkus.has(p.sku)) {
          reverted++;
          const reasons = Array.isArray(p.reviewReasons) ? p.reviewReasons.slice() : [];
          if (reasons.indexOf(ABSENT_REASON) === -1) reasons.push(ABSENT_REASON);
          return { ...p, status: 'to_review', reviewReasons: reasons };
        }
        return p;
      });
    }

    // Record the import delta for the dashboard alert. Deltas accumulate
    // within a 15-min window so one multi-feed sync reports a single figure.
    const nowTs = Date.now();
    const prevLI = window.AxluData.LAST_IMPORT;
    const recentLI = prevLI && prevLI.at && (nowTs - new Date(prevLI.at).getTime() < 15 * 60 * 1000);
    const newSkus = adds.map((p) => p.sku).filter(Boolean);
    window.AxluData.LAST_IMPORT = {
      added: (recentLI ? (prevLI.added || 0) : 0) + added,
      reverted: (recentLI ? (prevLI.reverted || 0) : 0) + reverted,
      // Exact SKUs added — used by the dashboard's "Voir" link to filter the
      // catalog precisely to the new products instead of all "imported".
      addedSkus: (recentLI && Array.isArray(prevLI.addedSkus) ? prevLI.addedSkus : []).concat(newSkus),
      at: new Date(nowTs).toISOString(),
    };

    const skippedTotal = skipped.excludedCategory;
    if (skippedTotal > 0) {
      audit('product.import.skipped', 'product', `${skippedTotal} ignorés (catégorie exclue)`);
    }
    audit('product.import', 'product',
      `${added} ajoutés · ${updated} mis à jour`
      + (flaggedForReview > 0 ? ` · ${flaggedForReview} à vérifier` : '')
      + (reverted > 0 ? ` · ${reverted} absents → à vérifier` : '')
      + (categorized > 0 ? ` · ${categorized} rangés` : '')
      + (skippedTotal > 0 ? ` · ${skippedTotal} ignorés` : ''));
    return { ok: true, added, updated, skipped: skippedTotal, flagged: flaggedForReview, reverted, categorized, skippedBreakdown: skipped };
  };

  // product.enrichStock: PF stock entries are per-variant (itemCode).
  // Look up the variant by SKU across all products, update it, recompute
  // the parent product's aggregate stock.
  A['product.enrichStock'] = ({ items }) => {
    if (!Array.isArray(items)) return { ok: false, error: 'items must be an array' };
    const products = [...(window.AxluData.PRODUCTS || [])];
    // Snapshot products before enrichment — change-based review rules need it.
    const beforeById = new Map();
    products.forEach((p) => { if (p && p.id) beforeById.set(p.id, p); });

    // Build variant index: variantSku → { pIdx, vIdx }.
    const variantIndex = new Map();
    products.forEach((p, pIdx) => {
      (p.variants || []).forEach((v, vIdx) => {
        if (v.sku) variantIndex.set(v.sku, { pIdx, vIdx });
      });
    });

    const touchedProducts = new Set();
    let matched = 0, missing = 0;
    items.forEach((s) => {
      if (!s.sku) return;
      const loc = variantIndex.get(s.sku);
      if (!loc) { missing++; return; }
      const p = products[loc.pIdx];
      const variants = [...(p.variants || [])];
      variants[loc.vIdx] = {
        ...variants[loc.vIdx],
        // Sentinel stock (PF "disponible") carries no real count → store 0
        // and flag it; the UI shows "Disponible" instead of a fake number.
        stock: s.stockUncounted ? 0 : (Number(s.stockDirect) || 0),
        stockUncounted: !!s.stockUncounted,
        stockNextPo: Number(s.stockNextPo) || 0,
        stockDateNextPo: s.stockDateNextPo || null,
        stockFuture: Number(s.stockFuture) || 0,
        stockLocation: s.stockLocation || '',
      };
      products[loc.pIdx] = { ...p, variants };
      touchedProducts.add(loc.pIdx);
      matched++;
    });

    // Recompute aggregated stock per touched product, then re-check the
    // supplier quality rules — the « stock hors Europe » rule depends on the
    // stock location this feed just set.
    let flagged = 0;
    touchedProducts.forEach((pIdx) => {
      const prev = beforeById.get(products[pIdx].id);
      products[pIdx] = recomputeProduct(aggregateProductFromVariants(products[pIdx]));
      const wasReview = prev && prev.status === 'to_review';
      if (applyReviewRules(products[pIdx], prev) && !wasReview) flagged++;
    });

    window.AxluData.PRODUCTS = products;
    audit('product.enrichStock', 'product',
      `${matched} variantes · ${missing} sans correspondance` + (flagged ? ` · ${flagged} à vérifier` : ''));
    return { ok: true, matched, missing, productsTouched: touchedProducts.size, flagged };
  };

  // product.enrichPrices: PF price entries are per-variant (itemCode).
  // Each variant gets its own priceTiers and headline buyPrice. The product
  // aggregate buyPrice = min across variants.
  A['product.enrichPrices'] = ({ items }) => {
    if (!Array.isArray(items)) return { ok: false, error: 'items must be an array' };
    const products = [...(window.AxluData.PRODUCTS || [])];
    // Snapshot products before enrichment — the « prix d'achat en hausse »
    // rule compares the new buy price against this previous one.
    const beforeById = new Map();
    products.forEach((p) => { if (p && p.id) beforeById.set(p.id, p); });

    const variantIndex = new Map();
    products.forEach((p, pIdx) => {
      (p.variants || []).forEach((v, vIdx) => {
        if (v.sku) variantIndex.set(v.sku, { pIdx, vIdx });
      });
    });

    const touchedProducts = new Set();
    let matched = 0, missing = 0;
    items.forEach((pr) => {
      if (!pr.sku) return;
      const loc = variantIndex.get(pr.sku);
      if (!loc) { missing++; return; }
      const tiers = Array.isArray(pr.tiers) ? pr.tiers : [];
      const headlinePrice = tiers.length > 0 ? Number(tiers[0].netPrice) || 0 : 0;
      const p = products[loc.pIdx];
      const variants = [...(p.variants || [])];
      variants[loc.vIdx] = {
        ...variants[loc.vIdx],
        buyPrice: headlinePrice,
        priceTiers: tiers,
        currency: pr.currency || variants[loc.vIdx].currency || 'EUR',
        minDecoQty: Number(pr.minDecoQty) || 0,
        moq: Number(pr.moq) || 0,
        decoCharge: pr.decoCharge || '',
      };
      products[loc.pIdx] = { ...p, variants };
      touchedProducts.add(loc.pIdx);
      matched++;
    });

    let flagged = 0;
    touchedProducts.forEach((pIdx) => {
      const p = products[pIdx];
      const prev = beforeById.get(p.id);
      // Propagate currency to product-level (use most common from variants).
      const currencies = (p.variants || []).map((v) => v.currency).filter(Boolean);
      const currency = currencies.length > 0 ? currencies[0] : (p.currency || 'EUR');
      // Use the longest tier list as product-level priceTiers headline.
      const longestTiers = (p.variants || []).reduce((best, v) => {
        return (v.priceTiers && v.priceTiers.length > (best || []).length) ? v.priceTiers : best;
      }, []);
      products[pIdx] = recomputeProduct(aggregateProductFromVariants({
        ...p,
        currency,
        priceTiers: longestTiers,
      }));
      // Re-check the supplier quality rules — the « prix d'achat en hausse »
      // and « prix de vente trop bas » rules depend on this new buy price.
      const wasReview = prev && prev.status === 'to_review';
      if (applyReviewRules(products[pIdx], prev) && !wasReview) flagged++;
    });

    window.AxluData.PRODUCTS = products;
    audit('product.enrichPrices', 'product',
      `${matched} variantes · ${missing} sans correspondance` + (flagged ? ` · ${flagged} à vérifier` : ''));
    return { ok: true, matched, missing, productsTouched: touchedProducts.size, flagged };
  };

  // product.enrichPrintData: PF print-data entries are per-variant (itemCode).
  // Each variant gets a printMethods array (available decoration techniques).
  A['product.enrichPrintData'] = ({ items }) => {
    if (!Array.isArray(items)) return { ok: false, error: 'items must be an array' };
    const products = [...(window.AxluData.PRODUCTS || [])];
    const variantIndex = new Map();
    products.forEach((p, pIdx) => {
      (p.variants || []).forEach((v, vIdx) => {
        if (v.sku) variantIndex.set(v.sku, { pIdx, vIdx });
      });
    });
    const touched = new Set();
    let matched = 0, missing = 0;
    items.forEach((it) => {
      if (!it.sku) return;
      const loc = variantIndex.get(it.sku);
      if (!loc) { missing++; return; }
      const p = products[loc.pIdx];
      const variants = [...(p.variants || [])];
      variants[loc.vIdx] = { ...variants[loc.vIdx], printMethods: it.printMethods || [] };
      products[loc.pIdx] = { ...p, variants };
      touched.add(loc.pIdx);
      matched++;
    });
    window.AxluData.PRODUCTS = products;
    audit('product.enrichPrintData', 'product', `${matched} variantes · ${missing} sans correspondance`);
    return { ok: true, matched, missing, productsTouched: touched.size };
  };

  // pf.setAttributes / pf.setPrintPrices: reference tables replaced wholesale
  // on each import (not per-record dedup — these are full snapshots).
  // Merge attributes by their PF code: an existing attribute is updated in
  // place, a new one is added — the dictionary is never wiped and rebuilt.
  A['pf.setAttributes'] = ({ items }) => {
    const existing = window.AxluData.PF_ATTRIBUTES || [];
    const byCode = new Map();
    existing.forEach((a) => { if (a && a.code) byCode.set(a.code, a); });
    let added = 0, updated = 0;
    (Array.isArray(items) ? items : []).forEach((a) => {
      if (!a || !a.code) return;
      if (byCode.has(a.code)) updated++; else added++;
      byCode.set(a.code, a);
    });
    window.AxluData.PF_ATTRIBUTES = [...byCode.values()];
    audit('pf.setAttributes', 'pricing', `${added} ajoutés · ${updated} mis à jour · total ${window.AxluData.PF_ATTRIBUTES.length} attributs`);
    return { ok: true, count: window.AxluData.PF_ATTRIBUTES.length, added, updated };
  };

  // Merge print-price codes by printCode. The stock and USB/WS print-price
  // feeds use distinct code namespaces (MR*/EMB*/DTF* vs WS*); merging keeps
  // both alive instead of the last import wiping the previous one.
  A['pf.setPrintPrices'] = ({ items }) => {
    const existing = window.AxluData.PF_PRINT_PRICES || [];
    const byCode = new Map();
    existing.forEach((p) => { if (p && p.printCode) byCode.set(p.printCode, p); });
    let added = 0, updated = 0;
    (Array.isArray(items) ? items : []).forEach((p) => {
      if (!p || !p.printCode) return;
      if (byCode.has(p.printCode)) updated++; else added++;
      byCode.set(p.printCode, p);
    });
    window.AxluData.PF_PRINT_PRICES = [...byCode.values()];
    audit('pf.setPrintPrices', 'pricing', `${added} ajoutés · ${updated} mis à jour · total ${window.AxluData.PF_PRINT_PRICES.length} codes`);
    return { ok: true, count: window.AxluData.PF_PRINT_PRICES.length, added, updated };
  };

  // markingCoef.setTech: per-technique marking coefficient override.
  // Passing coef = null/'' removes the override (falls back to global coef).
  A['markingCoef.setTech'] = ({ printCode, coef }) => {
    if (!printCode) return;
    const m = { ...(window.AxluData.MARKING_COEFS || {}) };
    const n = parseFloat(coef);
    if (coef == null || coef === '' || !Number.isFinite(n)) delete m[printCode];
    else m[printCode] = n;
    window.AxluData.MARKING_COEFS = m;
    audit('markingCoef.setTech', 'pricing', `${printCode} → ${m[printCode] != null ? m[printCode] : 'défaut'}`);
    apiWrite(() => apiFetch('/api/marking-coefs/' + encodeURIComponent(printCode), 'PUT', { coef }),
      'Échec du coefficient de marquage', null, null);
  };

  // product.setMarkingPrices: per-product manual override of a technique's
  // marking sell prices. `matrix` mirrors the priceMatrix but with sell values
  // (setupSell + tiers[].priceSell). Passing matrix = null removes the override.
  A['product.setMarkingPrices'] = ({ id, printCode, matrix }) => {
    const list = window.AxluData.PRODUCTS;
    const idx = list.findIndex((p) => p.id === id);
    if (idx < 0 || !printCode) return;
    const cur = list[idx];
    const overrides = { ...(cur.markingPriceOverrides || {}) };
    if (matrix == null) delete overrides[printCode];
    else overrides[printCode] = matrix;
    window.AxluData.PRODUCTS = [
      ...list.slice(0, idx),
      { ...cur, markingPriceOverrides: overrides },
      ...list.slice(idx + 1),
    ];
    audit('product.setMarkingPrices', 'product', `${cur.sku} — marquage ${printCode}`);
    apiWrite(() => apiFetch('/api/products/' + encodeURIComponent(id), 'PATCH', { markingPriceOverrides: overrides }),
      'Échec des prix de marquage', () => { window.AxluData.PRODUCTS = list; },
      (res) => { if (res && res.product) replaceProduct(res.product); });
  };

  // suppliers
  A['supplier.update'] = ({ id, patch }) => {
    const list = window.AxluData.SUPPLIERS;
    const idx = list.findIndex((x) => x.id === id);
    if (idx < 0) return;
    const snap = list[idx];
    const updated = { ...list[idx], ...patch };
    window.AxluData.SUPPLIERS = [...list.slice(0, idx), updated, ...list.slice(idx + 1)];
    audit('supplier.update', 'supplier', `${updated.name} — ${Object.keys(patch).join(', ')}`);
    apiWrite(() => apiFetch('/api/suppliers/' + encodeURIComponent(id), 'PATCH', patch),
      'Échec de l\'enregistrement du fournisseur', () => replaceSupplier(snap),
      (res) => { if (res && res.supplier) replaceSupplier(res.supplier); });
  };

  A['supplier.create'] = ({ data }) => {
    const id = nextId('sup');
    const sup = {
      id, name: data.name || 'Nouveau fournisseur', emoji: '🔌',
      type: data.type || 'API REST', country: data.country || 'FR', flag: '🇫🇷',
      status: 'disconnected', lastSync: null, nextSync: null,
      productsCount: 0, errorsCount: 0,
      // feeds = Array of { type, url, schedule, enabled, lastRun, lastStatus, lastError, lastRowsRead }
      feeds: [],
      rules: {},
    };
    window.AxluData.SUPPLIERS = [...window.AxluData.SUPPLIERS, sup];
    audit('supplier.create', 'supplier', sup.name);
    apiWrite(() => apiFetch('/api/suppliers', 'POST', sup),
      'Échec de la création du fournisseur',
      () => { window.AxluData.SUPPLIERS = window.AxluData.SUPPLIERS.filter((x) => x.id !== id); },
      (res) => { if (res && res.supplier) replaceSupplier(res.supplier); });
    return id;
  };

  // Normalize legacy feed shape (string[]) to object[] on read.
  function normalizeFeeds(supplier) {
    const f = supplier && supplier.feeds;
    if (!Array.isArray(f)) return [];
    return f.map((entry) => {
      if (typeof entry === 'string') {
        return { type: entry, url: '', schedule: 'manual', enabled: true, lastRun: null, lastStatus: null, lastError: null, lastRowsRead: null };
      }
      return entry;
    });
  }

  A['supplier.addFeed'] = ({ id, feed }) => {
    const list = window.AxluData.SUPPLIERS;
    const idx = list.findIndex((x) => x.id === id);
    if (idx < 0 || !feed || !feed.type) return;
    const cur = list[idx];
    const existing = normalizeFeeds(cur);
    if (existing.find((f) => f.type === feed.type)) return;
    const newFeed = {
      type: feed.type,
      url: feed.url || '',
      schedule: feed.schedule || 'manual',
      enabled: feed.enabled !== false,
      lastRun: null,
      lastStatus: null,
      lastError: null,
      lastRowsRead: null,
    };
    const updated = { ...cur, feeds: [...existing, newFeed] };
    window.AxluData.SUPPLIERS = [...list.slice(0, idx), updated, ...list.slice(idx + 1)];
    audit('supplier.addFeed', 'supplier', `${cur.name} — ${feed.type}`);
    apiWrite(() => apiFetch('/api/suppliers/' + encodeURIComponent(id) + '/feeds', 'POST', newFeed),
      'Échec de l\'ajout du flux', () => replaceSupplier(cur),
      (res) => { if (res && res.supplier) replaceSupplier(res.supplier); });
  };

  A['supplier.updateFeed'] = ({ id, type, patch }) => {
    const list = window.AxluData.SUPPLIERS;
    const idx = list.findIndex((x) => x.id === id);
    if (idx < 0) return;
    const cur = list[idx];
    const feeds = normalizeFeeds(cur);
    const fIdx = feeds.findIndex((f) => f.type === type);
    if (fIdx < 0) return;
    feeds[fIdx] = { ...feeds[fIdx], ...patch };
    const updated = { ...cur, feeds };
    window.AxluData.SUPPLIERS = [...list.slice(0, idx), updated, ...list.slice(idx + 1)];
    audit('supplier.updateFeed', 'supplier', `${cur.name} — ${type}: ${Object.keys(patch).join(', ')}`);
    apiWrite(() => apiFetch('/api/suppliers/' + encodeURIComponent(id) + '/feeds/' + encodeURIComponent(type), 'PATCH', patch),
      'Échec de l\'enregistrement du flux', () => replaceSupplier(cur),
      (res) => { if (res && res.supplier) replaceSupplier(res.supplier); });
  };

  A['supplier.toggleFeedEnabled'] = ({ id, type }) => {
    const sup = window.AxluData.SUPPLIERS.find((x) => x.id === id);
    if (!sup) return;
    const feeds = normalizeFeeds(sup);
    const f = feeds.find((x) => x.type === type);
    if (!f) return;
    A['supplier.updateFeed']({ id, type, patch: { enabled: !f.enabled } });
  };

  A['supplier.removeFeed'] = ({ id, type }) => {
    const list = window.AxluData.SUPPLIERS;
    const idx = list.findIndex((x) => x.id === id);
    if (idx < 0) return;
    const cur = list[idx];
    const feeds = normalizeFeeds(cur).filter((f) => f.type !== type);
    const updated = { ...cur, feeds };
    window.AxluData.SUPPLIERS = [...list.slice(0, idx), updated, ...list.slice(idx + 1)];
    audit('supplier.removeFeed', 'supplier', `${cur.name} — ${type}`);
    apiWrite(() => apiFetch('/api/suppliers/' + encodeURIComponent(id) + '/feeds/' + encodeURIComponent(type), 'DELETE'),
      'Échec de la suppression du flux', () => replaceSupplier(cur),
      (res) => { if (res && res.supplier) replaceSupplier(res.supplier); });
  };


  A['supplier.delete'] = ({ id }) => {
    const s = window.AxluData.SUPPLIERS.find((x) => x.id === id);
    if (!s) return;
    const snapSyncs = window.AxluData.SYNCS.filter((x) => x.supplierId === id);
    window.AxluData.SUPPLIERS = window.AxluData.SUPPLIERS.filter((x) => x.id !== id);
    window.AxluData.SYNCS = window.AxluData.SYNCS.filter((x) => x.supplierId !== id);
    audit('supplier.delete', 'supplier', s.name);
    apiWrite(() => apiFetch('/api/suppliers/' + encodeURIComponent(id), 'DELETE'),
      'Échec de la suppression du fournisseur',
      () => { window.AxluData.SUPPLIERS = [s, ...window.AxluData.SUPPLIERS]; window.AxluData.SYNCS = [...snapSyncs, ...window.AxluData.SYNCS]; }, null);
  };

  A['supplier.toggleEnabled'] = ({ id }) => {
    const s = window.AxluData.SUPPLIERS.find((x) => x.id === id);
    if (!s) return;
    // The UI reads `supplier.enabled` everywhere — toggle that field so the
    // button label and the import guard both reflect the real state.
    A['supplier.update']({ id, patch: { enabled: s.enabled === false } });
  };

  A['supplier.testConnection'] = ({ id }) => {
    // Simulated async test, returns nothing — caller awaits the promise from dispatch.
    const s = window.AxluData.SUPPLIERS.find((x) => x.id === id);
    if (!s) return;
    audit('supplier.test', 'supplier', `${s.name} — test connexion`);
  };

  // syncs
  A['sync.start'] = ({ supplierId, type }) => {
    const sup = window.AxluData.SUPPLIERS.find((x) => x.id === supplierId);
    if (!sup) return;
    const id = nextId('sync');
    // Guard: `type` must be a feed-type string — never a feed object.
    const safeType = (type && typeof type === 'object') ? (type.type || 'products') : (type || 'products');
    const sync = {
      id, supplierId, type: safeType,
      status: 'running',
      startedAt: new Date().toISOString(),
      duration: null,
      rowsRead: 0,
      rowsCreated: 0, rowsUpdated: 0, errors: 0,
    };
    window.AxluData.SYNCS = [sync, ...window.AxluData.SYNCS];
    audit('sync.started', 'sync', `${sup.name} / ${sync.type}`);
    // Simulate progress + completion
    const total = Math.min(sup.productsCount || 200, 1000) + 200;
    const t0 = Date.now();
    let progressed = 0;
    const tick = setInterval(() => {
      const cur = window.AxluData.SYNCS.find((s) => s.id === id);
      if (!cur || cur.status !== 'running') { clearInterval(tick); return; }
      const inc = Math.max(50, Math.floor(total / 12));
      progressed = Math.min(total, progressed + inc);
      const next = { ...cur, rowsRead: progressed };
      window.AxluData.SYNCS = window.AxluData.SYNCS.map((s) => s.id === id ? next : s);
      notify();
      if (progressed >= total) {
        clearInterval(tick);
        const finalErrors = sup.status === 'warning' ? Math.max(0, Math.round(sup.errorsCount * (Math.random() * .3 + .7))) : 0;
        const finalStatus = finalErrors > 0 ? 'partial_success' : 'success';
        const created = Math.floor(Math.random() * 12);
        const updated = Math.floor(progressed * (Math.random() * .15 + .05));
        const dur = Math.round((Date.now() - t0) / 1000);
        const completed = { ...next, status: finalStatus, duration: dur, rowsCreated: created, rowsUpdated: updated, errors: finalErrors };
        window.AxluData.SYNCS = window.AxluData.SYNCS.map((s) => s.id === id ? completed : s);
        // Update supplier last sync
        A['supplier.update']({ id: supplierId, patch: { lastSync: completed.startedAt } });
        audit('sync.completed', 'sync', `${id} (${sup.name} / ${sync.type})`);
        notify();
      }
    }, 600);
    return id;
  };

  A['sync.retry'] = ({ id }) => {
    const s = window.AxluData.SYNCS.find((x) => x.id === id);
    if (!s) return;
    return A['sync.start']({ supplierId: s.supplierId, type: s.type });
  };

  A['sync.cancel'] = ({ id }) => {
    const s = window.AxluData.SYNCS.find((x) => x.id === id);
    if (!s || s.status !== 'running') return;
    window.AxluData.SYNCS = window.AxluData.SYNCS.map((x) => x.id === id ? { ...x, status: 'failed', errorMsg: 'Annulé par l\'utilisateur', duration: Math.round((Date.now() - new Date(x.startedAt).getTime()) / 1000) } : x);
    audit('sync.cancelled', 'sync', id);
  };

  // sync.record: append a COMPLETED sync entry produced by a REAL feed
  // import (unlike sync.start, which simulates progress). Used by the
  // "Lancer toutes les synchros" action so the Historique tab, the
  // supplier stats and the dashboard reflect the real runs.
  A['sync.record'] = ({ supplierId, type, status, rowsRead, rowsCreated, rowsUpdated, errors, duration, startedAt, errorMsg, message, missing, flagged }) => {
    const sup = window.AxluData.SUPPLIERS.find((x) => x.id === supplierId);
    const safeType = (type && typeof type === 'object') ? (type.type || 'products') : (type || 'products');
    const sync = {
      id: nextId('sync'),
      supplierId,
      type: safeType,
      status: status || 'success',
      startedAt: startedAt || new Date().toISOString(),
      duration: Number(duration) || 0,
      rowsRead: Number(rowsRead) || 0,
      rowsCreated: Number(rowsCreated) || 0,
      rowsUpdated: Number(rowsUpdated) || 0,
      errors: Number(errors) || 0,
      errorMsg: errorMsg || null,
      // Human-readable breakdown of what the run did, plus the two counts that
      // matter most for diagnosing a feed: prices/stocks with no matching
      // product (missing), and products re-flagged « à vérifier » (flagged).
      message: message || null,
      missing: Number(missing) || 0,
      flagged: Number(flagged) || 0,
    };
    window.AxluData.SYNCS = [sync, ...window.AxluData.SYNCS];
    if (sup) A['supplier.update']({ id: supplierId, patch: { lastSync: sync.startedAt } });
    audit('sync.completed', 'sync', `${(sup && sup.name) || supplierId} / ${safeType}`);
    return sync.id;
  };

  // categories
  // Recount every AXLU category from the products actually filed under it
  // (leaf = its own product count, group = own + sum of children).
  function recountAxluCategories() {
    const direct = new Map();
    (window.AxluData.PRODUCTS || []).forEach((p) => {
      const ids = (Array.isArray(p.categoryIds) && p.categoryIds.length)
        ? p.categoryIds : (p.categoryId ? [p.categoryId] : []);
      ids.forEach((id) => direct.set(id, (direct.get(id) || 0) + 1));
    });
    const cats = (window.AxluData.CATEGORIES_AXLU || []).map((c) => ({ ...c, count: direct.get(c.id) || 0 }));
    const childSum = new Map();
    cats.forEach((c) => { if (c.parent) childSum.set(c.parent, (childSum.get(c.parent) || 0) + c.count); });
    window.AxluData.CATEGORIES_AXLU = cats.map((c) =>
      c.parent ? c : { ...c, count: (direct.get(c.id) || 0) + (childSum.get(c.id) || 0) });
  }

  // Assign a categoryId to every product that carries PF category codes but
  // is not yet filed (categoryId == null). Creates the AXLU + PF category
  // entries on the way, using the same id scheme as category.buildFromPf so
  // a product joins the existing tree. NON-DESTRUCTIVE: an already-filed
  // product is never touched, so manual category assignments survive. Used
  // after a product import so newly-added products land in the catalogue.
  function fileUncategorizedProducts() {
    const products = window.AxluData.PRODUCTS || [];
    const slug = (s) => String(s || '').trim().toLowerCase()
      .replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
    const axlu = (window.AxluData.CATEGORIES_AXLU || []).slice();
    const axluById = new Map(axlu.map((c) => [c.id, c]));
    const pf = (window.AxluData.CATEGORIES_PF || []).slice();
    const pfById = new Map(pf.map((c) => [c.id, c]));
    const ensureAxlu = (id, name, parent) => {
      if (!axluById.has(id)) {
        const c = { id, name: name || id, parent: parent || null, count: 0, source: 'pf' };
        axlu.push(c); axluById.set(id, c);
      }
      return axluById.get(id);
    };
    let filed = 0;
    const updated = products.map((p) => {
      if (p.categoryId) return p; // already filed — preserve (incl. manual)
      const gCode = (p.pfGroupCode || '').trim();
      const gDesc = (p.pfGroupDesc || '').trim();
      const cCode = (p.pfCatCode || '').trim();
      const cDesc = (p.pfCatDesc || '').trim();
      if (!gCode && !gDesc) return p; // no PF category info — leave as-is
      const gId = 'cat-pf-' + (gCode || slug(gDesc) || 'x');
      const sId = (cCode || cDesc) ? gId + '--' + (cCode || slug(cDesc)) : null;
      ensureAxlu(gId, gDesc || gCode, null);
      if (sId) ensureAxlu(sId, cDesc || cCode, gId);
      let pfId = null, leafId = sId || gId;
      if (sId) {
        pfId = 'pf-' + (gCode || slug(gDesc)) + '-' + (cCode || slug(cDesc));
        const existing = pfById.get(pfId);
        if (existing) {
          if (existing.mappedTo) leafId = existing.mappedTo; // follow a prior mapping
        } else {
          const npf = { id: pfId, name: cDesc || cCode, group: gDesc || gCode, count: 0, mappedTo: sId };
          pf.push(npf); pfById.set(pfId, npf);
        }
      }
      filed++;
      const leaf = axluById.get(leafId);
      return { ...p, categoryId: leafId, categoryIds: [leafId], categoryLabel: leaf ? leaf.name : '—', pfCategoryId: pfId };
    });
    if (filed > 0) {
      window.AxluData.PRODUCTS = updated;
      window.AxluData.CATEGORIES_AXLU = axlu;
      window.AxluData.CATEGORIES_PF = pf;
      recountAxluCategories();
    }
    return filed;
  }

  // category.map: (un)map a PF supplier category to an AXLU category. The
  // products of that PF category follow the mapping — unmapping removes them
  // from the AXLU category, so its product count drops accordingly.
  A['category.map'] = ({ pfId, axluId }) => {
    const list = window.AxluData.CATEGORIES_PF || [];
    const pf = list.find((c) => c.id === pfId);
    if (!pf) return;
    const oldAxluId = pf.mappedTo || null;
    const newAxluId = axluId || null;
    window.AxluData.CATEGORIES_PF = list.map((c) => c.id === pfId ? { ...c, mappedTo: newAxluId } : c);

    // Move this PF category's products from the old AXLU category to the new.
    window.AxluData.PRODUCTS = (window.AxluData.PRODUCTS || []).map((p) => {
      if (p.pfCategoryId !== pfId) return p;
      let ids = Array.isArray(p.categoryIds) ? p.categoryIds.slice() : (p.categoryId ? [p.categoryId] : []);
      if (oldAxluId) ids = ids.filter((x) => x !== oldAxluId);
      if (newAxluId && !ids.includes(newAxluId)) ids = [newAxluId, ...ids];
      const primary = ids[0] || null;
      const leaf = (window.AxluData.CATEGORIES_AXLU || []).find((c) => c.id === primary);
      return { ...p, categoryIds: ids, categoryId: primary, categoryLabel: leaf ? leaf.name : '—' };
    });

    recountAxluCategories();
    const axlu = newAxluId ? window.AxluData.CATEGORIES_AXLU.find((c) => c.id === newAxluId) : null;
    audit('category.map', 'category', `PF / ${pf.name} → AXLU / ${axlu ? axlu.name : '∅'}`);
    apiWrite(() => apiFetch('/api/pf-categories/' + encodeURIComponent(pfId), 'PATCH', { mappedTo: newAxluId }),
      'Échec du mapping de catégorie',
      () => { window.AxluData.CATEGORIES_PF = list; recountAxluCategories(); }, null);
  };

  A['category.create'] = ({ data }) => {
    const id = data.id || nextId('cat');
    window.AxluData.CATEGORIES_AXLU = [...window.AxluData.CATEGORIES_AXLU, { id, name: data.name, parent: data.parent || null, count: 0 }];
    audit('category.create', 'category', data.name);
    apiWrite(() => apiFetch('/api/categories', 'POST', { id, name: data.name, parent: data.parent || null }),
      'Échec de la création de la catégorie',
      () => { window.AxluData.CATEGORIES_AXLU = window.AxluData.CATEGORIES_AXLU.filter((c) => c.id !== id); },
      (res) => { if (res && res.category) window.AxluData.CATEGORIES_AXLU = window.AxluData.CATEGORIES_AXLU.map((c) => c.id === id ? { ...c, ...res.category } : c); });
  };

  A['category.update'] = ({ id, patch }) => {
    const snap = window.AxluData.CATEGORIES_AXLU.find((c) => c.id === id);
    window.AxluData.CATEGORIES_AXLU = window.AxluData.CATEGORIES_AXLU.map((c) => c.id === id ? { ...c, ...patch } : c);
    // WEB : si le nom change, re-libelle les produits de cette catégorie (comme le serveur).
    if (isWeb && patch.name !== undefined) {
      window.AxluData.PRODUCTS = window.AxluData.PRODUCTS.map((p) => p.categoryId === id ? { ...p, categoryLabel: patch.name } : p);
    }
    audit('category.update', 'category', id);
    apiWrite(() => apiFetch('/api/categories/' + encodeURIComponent(id), 'PATCH', patch),
      'Échec de l\'enregistrement de la catégorie',
      () => { if (snap) window.AxluData.CATEGORIES_AXLU = window.AxluData.CATEGORIES_AXLU.map((c) => c.id === id ? snap : c); }, null);
  };
  // Shopify sales channels (publications) AXLU publishes products to. Empty
  // array = publish to ALL channels (default). Persisted.
  A['settings.setPubChannels'] = ({ ids }) => {
    window.AxluData.SHOPIFY_PUB_CHANNELS = Array.isArray(ids) ? ids.slice() : [];
    audit('shopify.pubChannels', 'shopify', (window.AxluData.SHOPIFY_PUB_CHANNELS.length || 'tous') + ' canaux');
    apiWrite(() => apiFetch('/api/settings/shopify_pub_channels', 'PUT', { value: window.AxluData.SHOPIFY_PUB_CHANNELS }),
      'Échec des canaux de publication', null, null);
  };

  A['category.delete'] = ({ id }) => {
    const c = window.AxluData.CATEGORIES_AXLU.find((x) => x.id === id);
    if (!c) return;
    const removedCats = window.AxluData.CATEGORIES_AXLU.filter((x) => x.id === id || x.parent === id);
    // Unmap PF cats pointing to this id, prevent dangling mappings
    window.AxluData.CATEGORIES_AXLU = window.AxluData.CATEGORIES_AXLU.filter((x) => x.id !== id && x.parent !== id);
    window.AxluData.CATEGORIES_PF = window.AxluData.CATEGORIES_PF.map((p) => p.mappedTo === id ? { ...p, mappedTo: null } : p);
    if (isWeb) { // déclasse les produits des catégories supprimées (comme le serveur)
      const delIds = new Set(removedCats.map((x) => x.id));
      window.AxluData.PRODUCTS = window.AxluData.PRODUCTS.map((p) => delIds.has(p.categoryId) ? { ...p, categoryId: null, categoryIds: [], categoryLabel: '—' } : p);
    }
    audit('category.delete', 'category', c.name);
    apiWrite(() => apiFetch('/api/categories/' + encodeURIComponent(id), 'DELETE'),
      'Échec de la suppression de la catégorie',
      () => { window.AxluData.CATEGORIES_AXLU = [...removedCats, ...window.AxluData.CATEGORIES_AXLU]; }, null);
  };

  // category.buildFromPf: rebuild the AXLU category tree from each product's
  // PF Concept group / sub-category, then file every product into its leaf.
  // Manually-created categories (source !== 'pf') are preserved. Idempotent —
  // category ids derive from the PF codes, so re-running never duplicates.
  A['category.buildFromPf'] = () => {
    const products = window.AxluData.PRODUCTS || [];
    const slug = (s) => String(s || '').trim().toLowerCase()
      .replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');

    const cats = new Map(); // id -> { id, name, parent, count, source }
    const ensure = (id, name, parent) => {
      if (!cats.has(id)) cats.set(id, { id, name: name || id, parent: parent || null, count: 0, source: 'pf' });
      return cats.get(id);
    };
    const pfCats = new Map(); // PF supplier-category mirror: id -> { id, name, group, count, mappedTo }

    // Assign each product to its PF leaf category, creating categories on the way.
    let ranged = 0;
    const updated = products.map((p) => {
      const gCode = (p.pfGroupCode || '').trim();
      const gDesc = (p.pfGroupDesc || '').trim();
      const cCode = (p.pfCatCode || '').trim();
      const cDesc = (p.pfCatDesc || '').trim();
      if (!gCode && !gDesc) return p; // no PF category — leave as-is
      const gId = 'cat-pf-' + (gCode || slug(gDesc) || 'x');
      const sId = (cCode || cDesc) ? gId + '--' + (cCode || slug(cDesc)) : null;
      ensure(gId, gDesc || gCode, null);
      if (sId) ensure(sId, cDesc || cCode, gId);
      const leafId = sId || gId;
      // PF supplier-category mirror (leaf level), mapped 1:1 to its AXLU twin.
      let pfId = null;
      if (sId) {
        pfId = 'pf-' + (gCode || slug(gDesc)) + '-' + (cCode || slug(cDesc));
        if (!pfCats.has(pfId)) {
          pfCats.set(pfId, { id: pfId, name: cDesc || cCode, group: gDesc || gCode, count: 0, mappedTo: sId });
        }
        pfCats.get(pfId).count++;
      }
      ranged++;
      return { ...p, categoryId: leafId, categoryIds: [leafId], categoryLabel: cats.get(leafId).name, pfCategoryId: pfId };
    });

    // Order: groups alphabetically, each immediately followed by its children.
    const byName = (a, b) => String(a.name).localeCompare(String(b.name), 'fr');
    const groups = [...cats.values()].filter((c) => !c.parent).sort(byName);
    const ordered = [];
    groups.forEach((g) => {
      ordered.push(g);
      [...cats.values()].filter((c) => c.parent === g.id).sort(byName).forEach((c) => ordered.push(c));
    });

    // Preserve user-set Shopify-taxonomy mappings across rebuilds: PF category
    // ids are stable, so re-attach any shopifyTaxId the user mapped before.
    const prevById = new Map((window.AxluData.CATEGORIES_AXLU || []).map((c) => [c.id, c]));
    ordered.forEach((c) => {
      const prev = prevById.get(c.id);
      if (prev && prev.shopifyTaxId) { c.shopifyTaxId = prev.shopifyTaxId; c.shopifyTaxName = prev.shopifyTaxName || null; }
    });
    const manual = (window.AxluData.CATEGORIES_AXLU || []).filter((c) => c.source !== 'pf');
    window.AxluData.CATEGORIES_AXLU = [...manual, ...ordered];
    window.AxluData.CATEGORIES_PF = [...pfCats.values()].sort((a, b) => {
      const g = String(a.group).localeCompare(String(b.group), 'fr');
      return g !== 0 ? g : String(a.name).localeCompare(String(b.name), 'fr');
    });
    window.AxluData.PRODUCTS = updated;
    recountAxluCategories();
    audit('category.buildFromPf', 'category',
      `${ordered.length} catégories AXLU · ${pfCats.size} catégories PF · ${ranged} produits rangés`);
    return { ok: true, categories: ordered.length, groups: groups.length, pfCategories: pfCats.size, products: ranged };
  };

  // ─── Pricing — marking grids ───────────────────────────────────────
  A['markingGrid.create'] = ({ data } = {}) => {
    const id = nextId('grid');
    const g = {
      id,
      markingType: (data && data.markingType) || '',
      format: (data && data.format) || '',
      enabled: true,
      tiers: [{ from: 1, to: null, unitPrice: 0 }],
    };
    window.AxluData.MARKING_GRIDS = [...window.AxluData.MARKING_GRIDS, g];
    audit('markingGrid.create', 'pricing', `${g.markingType || '—'} / ${g.format || '—'}`);
    apiWrite(() => apiFetch('/api/marking-grids', 'POST', { id: g.id, markingType: g.markingType, format: g.format, enabled: g.enabled, tiers: g.tiers }),
      'Échec de la grille de marquage', () => { window.AxluData.MARKING_GRIDS = window.AxluData.MARKING_GRIDS.filter((x) => x.id !== id); }, null);
    return id;
  };

  A['markingGrid.update'] = ({ id, patch }) => {
    window.AxluData.MARKING_GRIDS = window.AxluData.MARKING_GRIDS.map((g) => g.id === id ? { ...g, ...patch } : g);
    audit('markingGrid.update', 'pricing', `${id} — ${Object.keys(patch).join(', ')}`);
    persistGrid(id);
  };

  A['markingGrid.toggleEnabled'] = ({ id }) => {
    const g = window.AxluData.MARKING_GRIDS.find((x) => x.id === id);
    if (!g) return;
    A['markingGrid.update']({ id, patch: { enabled: !g.enabled } });
  };

  A['markingGrid.duplicate'] = ({ id }) => {
    const g = window.AxluData.MARKING_GRIDS.find((x) => x.id === id);
    if (!g) return;
    const copy = {
      ...g, id: nextId('grid'), enabled: false,
      markingType: g.markingType ? `${g.markingType} (copie)` : 'Copie',
      tiers: g.tiers.map((t) => ({ ...t })),
    };
    window.AxluData.MARKING_GRIDS = [...window.AxluData.MARKING_GRIDS, copy];
    audit('markingGrid.duplicate', 'pricing', `${copy.markingType} / ${copy.format}`);
    apiWrite(() => apiFetch('/api/marking-grids', 'POST', { id: copy.id, markingType: copy.markingType, format: copy.format, enabled: copy.enabled, tiers: copy.tiers }),
      'Échec de la duplication de grille', () => { window.AxluData.MARKING_GRIDS = window.AxluData.MARKING_GRIDS.filter((x) => x.id !== copy.id); }, null);
    return copy.id;
  };

  A['markingGrid.delete'] = ({ id }) => {
    const g = window.AxluData.MARKING_GRIDS.find((x) => x.id === id);
    if (!g) return;
    window.AxluData.MARKING_GRIDS = window.AxluData.MARKING_GRIDS.filter((x) => x.id !== id);
    audit('markingGrid.delete', 'pricing', `${g.markingType} / ${g.format}`);
    apiWrite(() => apiFetch('/api/marking-grids/' + encodeURIComponent(id), 'DELETE'),
      'Échec de la suppression de grille', () => { window.AxluData.MARKING_GRIDS = [g, ...window.AxluData.MARKING_GRIDS]; }, null);
  };

  A['markingGrid.addTier'] = ({ id }) => {
    window.AxluData.MARKING_GRIDS = window.AxluData.MARKING_GRIDS.map((g) => {
      if (g.id !== id) return g;
      const last = g.tiers[g.tiers.length - 1];
      const newFrom = last && last.to != null ? last.to + 1 : (last ? (last.from || 1) + 5 : 1);
      const newTier = { from: newFrom, to: null, unitPrice: last ? Math.max(0, (last.unitPrice || 0) - 1) : 0 };
      const updated = [...g.tiers];
      if (last && last.to == null) updated[updated.length - 1] = { ...last, to: newFrom - 1 };
      updated.push(newTier);
      return { ...g, tiers: updated };
    });
    persistGrid(id);
  };

  A['markingGrid.updateTier'] = ({ id, idx, patch }) => {
    window.AxluData.MARKING_GRIDS = window.AxluData.MARKING_GRIDS.map((g) => {
      if (g.id !== id) return g;
      const tiers = g.tiers.map((t, i) => i === idx ? { ...t, ...patch } : t);
      return { ...g, tiers };
    });
    persistGrid(id);
  };

  A['markingGrid.removeTier'] = ({ id, idx }) => {
    window.AxluData.MARKING_GRIDS = window.AxluData.MARKING_GRIDS.map((g) => g.id === id ? { ...g, tiers: g.tiers.filter((_, i) => i !== idx) } : g);
    persistGrid(id);
  };

  // ─── Pricing — modifiers (category / client / order) ───────────────
  function modifierKey(scope) {
    return scope === 'category' ? 'MODIFIERS_CATEGORY'
         : scope === 'client'   ? 'MODIFIERS_CLIENT'
         : scope === 'order'    ? 'MODIFIERS_ORDER'
         : null;
  }

  A['modifier.create'] = ({ scope, data } = {}) => {
    const k = modifierKey(scope);
    if (!k) return null;
    const id = nextId('mod');
    const base = { id, name: '', enabled: true, coef: 1 };
    const m = scope === 'order'
      ? { ...base, from: 0, to: null }
      : { ...base, key: '' };
    if (data) Object.assign(m, data);
    window.AxluData[k] = [...window.AxluData[k], m];
    audit('modifier.create', 'pricing', `${scope} — ${m.name || m.key || m.from}`);
    apiWrite(() => apiFetch('/api/modifiers', 'POST', { id, scope, name: m.name, key: m.key, from: m.from, to: m.to, coef: m.coef, enabled: m.enabled }),
      'Échec du modificateur', () => { window.AxluData[k] = window.AxluData[k].filter((x) => x.id !== id); }, null);
    return id;
  };

  A['modifier.update'] = ({ scope, id, patch }) => {
    const k = modifierKey(scope);
    if (!k) return;
    window.AxluData[k] = window.AxluData[k].map((m) => m.id === id ? { ...m, ...patch } : m);
    audit('modifier.update', 'pricing', `${scope}/${id} — ${Object.keys(patch).join(', ')}`);
    apiWrite(() => apiFetch('/api/modifiers/' + encodeURIComponent(id), 'PATCH', patch), 'Échec du modificateur', null, null);
  };

  A['modifier.toggleEnabled'] = ({ scope, id }) => {
    const k = modifierKey(scope);
    if (!k) return;
    const m = window.AxluData[k].find((x) => x.id === id);
    if (!m) return;
    A['modifier.update']({ scope, id, patch: { enabled: !m.enabled } });
  };

  A['modifier.delete'] = ({ scope, id }) => {
    const k = modifierKey(scope);
    if (!k) return;
    const m = window.AxluData[k].find((x) => x.id === id);
    if (!m) return;
    window.AxluData[k] = window.AxluData[k].filter((x) => x.id !== id);
    audit('modifier.delete', 'pricing', `${scope} — ${m.name || m.key || m.from}`);
    apiWrite(() => apiFetch('/api/modifiers/' + encodeURIComponent(id), 'DELETE'),
      'Échec de la suppression du modificateur', () => { window.AxluData[k] = [m, ...window.AxluData[k]]; }, null);
  };

  // ─── Pricing — global cost & margin parameters ─────────────────────
  A['pricingCosts.update'] = ({ patch }) => {
    window.AxluData.PRICING_COSTS = { ...(window.AxluData.PRICING_COSTS || {}), ...patch };
    audit('pricingCosts.update', 'pricing', Object.keys(patch).join(', '));
    apiWrite(() => apiFetch('/api/settings/pricing_costs', 'PUT', { patch }),
      'Échec des réglages de prix', null,
      (res) => { if (res && res.value) window.AxluData.PRICING_COSTS = res.value; });
  };

  // ─── Pricing — cart-amount shipping fee tiers ──────────────────────
  A['shippingTier.add'] = () => {
    const tiers = window.AxluData.SHIPPING_TIERS || [];
    if (tiers.length === 0) {
      window.AxluData.SHIPPING_TIERS = [{ from: 0, to: null, fee: 0 }];
    } else {
      const last = tiers[tiers.length - 1];
      const boundary = last.to != null ? last.to : (last.from || 0) + 50;
      const updated = tiers.map((t) => ({ ...t }));
      // Close the previously open-ended tier so the new one extends the range.
      if (last.to == null) updated[updated.length - 1] = { ...last, to: boundary };
      updated.push({ from: boundary, to: null, fee: 0 });
      window.AxluData.SHIPPING_TIERS = updated;
    }
    audit('shippingTier.add', 'pricing', 'frais de port');
    persistShipping();
  };

  A['shippingTier.update'] = ({ idx, patch }) => {
    window.AxluData.SHIPPING_TIERS = (window.AxluData.SHIPPING_TIERS || []).map((t, i) => i === idx ? { ...t, ...patch } : t);
    persistShipping();
  };

  A['shippingTier.remove'] = ({ idx }) => {
    window.AxluData.SHIPPING_TIERS = (window.AxluData.SHIPPING_TIERS || []).filter((_, i) => i !== idx);
    audit('shippingTier.remove', 'pricing', 'frais de port');
    persistShipping();
  };

  // users
  A['user.create'] = ({ data }) => {
    const id = nextId('u');
    const u = {
      id, name: data.name || 'Nouvel utilisateur',
      email: data.email || '', role: data.role || 'operator',
      password: null,
      requiresPasswordReset: true,
      pendingKey: null,
      lastLogin: null, active: true,
    };
    window.AxluData.USERS = [...window.AxluData.USERS, u];
    audit('user.create', 'user', `${u.email} (${u.role})`);
    apiWrite(() => apiFetch('/api/users', 'POST', { name: u.name, email: u.email, role: u.role }),
      'Échec de la création de l\'utilisateur',
      () => { window.AxluData.USERS = window.AxluData.USERS.filter((x) => x.id !== id); },
      (res) => { if (res && res.user) window.AxluData.USERS = window.AxluData.USERS.map((x) => x.id === id ? res.user : x); });
    return id;
  };

  A['user.update'] = ({ id, patch, _web }) => {
    const snap = window.AxluData.USERS.find((u) => u.id === id);
    window.AxluData.USERS = window.AxluData.USERS.map((u) => u.id === id ? { ...u, ...patch } : u);
    audit('user.update', 'user', `${id} — ${Object.keys(patch).join(', ')}`);
    // Persistance web UNIQUEMENT pour les éditions admin (name/email/role/active).
    // Les appels internes (login lastLogin, changePassword, generateKey) ne re-persistent pas.
    if (_web) {
      apiWrite(() => apiFetch('/api/users/' + encodeURIComponent(id), 'PATCH', patch),
        'Échec de l\'enregistrement de l\'utilisateur', () => { if (snap) replaceUser(snap); },
        (res) => { if (res && res.user) replaceUser(res.user); });
    }
  };

  A['user.toggleActive'] = ({ id }) => {
    const u = window.AxluData.USERS.find((x) => x.id === id);
    if (!u) return;
    A['user.update']({ id, patch: { active: !u.active }, _web: true });
  };

  A['user.delete'] = ({ id }) => {
    const u = window.AxluData.USERS.find((x) => x.id === id);
    if (!u) return;
    window.AxluData.USERS = window.AxluData.USERS.filter((x) => x.id !== id);
    audit('user.delete', 'user', u.email);
    apiWrite(() => apiFetch('/api/users/' + encodeURIComponent(id), 'DELETE'),
      'Échec de la suppression de l\'utilisateur',
      () => { window.AxluData.USERS = [u, ...window.AxluData.USERS]; }, null);
  };

  A['user.resendInvite'] = ({ id }) => {
    const u = window.AxluData.USERS.find((x) => x.id === id);
    if (!u) return;
    audit('user.resendInvite', 'user', u.email);
  };

  // ─── Auth & connection keys ──────────────────────────────────────────
  function genKey() {
    const alpha = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
    let s = '';
    for (let i = 0; i < 12; i++) s += alpha[Math.floor(Math.random() * alpha.length)];
    return s.match(/.{1,4}/g).join('-');
  }

  // Password policy: ≥ 8 chars, with at least one uppercase, one digit
  // and one special character among @ ! . , ; : /
  const PASSWORD_RULES = {
    minLen: 8,
    upper:   /[A-Z]/,
    digit:   /[0-9]/,
    special: /[@!.,;:\/]/,
    specialChars: '@ ! . , ; : /',
  };
  function checkPassword(pwd) {
    const s = String(pwd || '');
    return {
      length:  s.length >= PASSWORD_RULES.minLen,
      upper:   PASSWORD_RULES.upper.test(s),
      digit:   PASSWORD_RULES.digit.test(s),
      special: PASSWORD_RULES.special.test(s),
    };
  }
  function validatePassword(pwd) {
    const c = checkPassword(pwd);
    if (!c.length)  return { ok: false, error: 'Au moins 8 caractères' };
    if (!c.upper)   return { ok: false, error: 'Au moins une lettre majuscule' };
    if (!c.digit)   return { ok: false, error: 'Au moins un chiffre' };
    if (!c.special) return { ok: false, error: 'Au moins un caractère spécial (' + PASSWORD_RULES.specialChars + ')' };
    return { ok: true };
  }

  A['auth.login'] = async ({ email, password, rememberMe }) => {
    // ── WEB : vérification serveur (bcrypt) + cookie de session, puis on tire
    //    les données. L'UI du login est identique. ──
    if (isWeb) {
      let res;
      try {
        res = await fetch('/api/auth/login', {
          method: 'POST', credentials: 'same-origin',
          headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
          body: JSON.stringify({ email: String(email || '').trim(), password }),
        });
      } catch (e) { return { ok: false, error: 'Serveur injoignable' }; }
      let body = {}; try { body = await res.json(); } catch (e) {}
      if (!res.ok || !body.user) return { ok: false, error: body.error || 'Identifiants invalides' };
      setCurrentUser(body.user, !!rememberMe); // bascule la porte de l'app (_currentUser)
      try { await loadFromApi(); } catch (e) { console.warn('[AXLU] loadFromApi failed', e); }
      audit('auth.login', 'auth', body.user.email, body.user.name);
      notify();
      return { ok: true, user: body.user };
    }
    // ── ELECTRON / disque (inchangé) ──
    const u = window.AxluData.USERS.find((x) => x.email && x.email.toLowerCase() === String(email || '').toLowerCase());
    if (!u) return { ok: false, error: 'Identifiants invalides' };
    if (!u.active) return { ok: false, error: 'Compte désactivé par un administrateur' };
    if (!u.password) return { ok: false, error: 'Aucun mot de passe défini. Utilisez votre clé de connexion.' };
    if (u.password !== password) return { ok: false, error: 'Identifiants invalides' };
    A['user.update']({ id: u.id, patch: { lastLogin: new Date().toISOString() } });
    const fresh = window.AxluData.USERS.find((x) => x.id === u.id);
    setCurrentUser(fresh, !!rememberMe);
    audit('auth.login', 'auth', u.email, u.name);
    return { ok: true, user: fresh };
  };

  A['auth.loginWithKey'] = async ({ email, key, rememberMe }) => {
    // WEB : le serveur valide la clé (hash bcrypt, expiration, usage unique),
    // ouvre la session, et force la définition d'un mot de passe.
    if (isWeb) {
      let res;
      try {
        res = await fetch('/api/auth/login', {
          method: 'POST', credentials: 'same-origin',
          headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
          body: JSON.stringify({ email: String(email || '').trim(), key: String(key || '').trim().toUpperCase() }),
        });
      } catch (e) { return { ok: false, error: 'Serveur injoignable' }; }
      let body = {}; try { body = await res.json(); } catch (e) {}
      if (!res.ok || !body.user) return { ok: false, error: body.error || 'Clé invalide' };
      setCurrentUser({ ...body.user, requiresPasswordReset: true }, !!rememberMe);
      try { await loadFromApi(); } catch (e) { console.warn('[AXLU] loadFromApi failed', e); }
      audit('auth.loginWithKey', 'auth', body.user.email, body.user.name);
      notify();
      return { ok: true, user: body.user };
    }
    const u = window.AxluData.USERS.find((x) => x.email && x.email.toLowerCase() === String(email || '').toLowerCase());
    if (!u) return { ok: false, error: 'Identifiants invalides' };
    if (!u.active) return { ok: false, error: 'Compte désactivé par un administrateur' };
    const pk = u.pendingKey;
    if (!pk || pk.key !== String(key || '').trim().toUpperCase()) return { ok: false, error: 'Clé invalide' };
    if (pk.expiresAt && Date.now() > pk.expiresAt) return { ok: false, error: 'Clé expirée' };
    // Consume the key and force a password reset on this account.
    A['user.update']({ id: u.id, patch: {
      pendingKey: null,
      lastLogin: new Date().toISOString(),
      requiresPasswordReset: true,
    } });
    const fresh = window.AxluData.USERS.find((x) => x.id === u.id);
    setCurrentUser(fresh, !!rememberMe);
    audit('auth.loginWithKey', 'auth', u.email, u.name);
    return { ok: true, user: fresh };
  };

  // changePassword serves two flows:
  //  - forced reset after key login (no currentPassword needed)
  //  - own change from the sidebar (currentPassword required & verified)
  A['auth.changePassword'] = async ({ id, password, currentPassword }) => {
    // WEB : le serveur vérifie l'ancien mot de passe (bcrypt) + écrit le nouveau hash.
    if (isWeb) {
      const res = await apiFetch('/api/auth/change-password', 'POST', { password, currentPassword });
      if (res && res.ok) {
        if (res.user) { replaceUser(res.user); if (_currentUser && _currentUser.id === res.user.id) _currentUser = { ..._currentUser, ...res.user }; }
        notify();
        return { ok: true };
      }
      return { ok: false, error: (res && res.error) || 'Échec du changement de mot de passe' };
    }
    const v = validatePassword(password);
    if (!v.ok) return { ok: false, error: v.error };
    if (currentPassword !== undefined) {
      const u = window.AxluData.USERS.find((x) => x.id === id);
      if (!u || u.password !== currentPassword) return { ok: false, error: 'Mot de passe actuel incorrect' };
      if (u.password === password) return { ok: false, error: 'Choisissez un mot de passe différent de l\'actuel' };
    }
    A['user.update']({ id, patch: { password, requiresPasswordReset: false } });
    if (_currentUser && _currentUser.id === id) {
      const fresh = window.AxluData.USERS.find((x) => x.id === id);
      _currentUser = { ...fresh };
      if (_rememberMe) saveSession({ userId: id, rememberMe: true });
    }
    audit('auth.changePassword', 'auth', id);
    return { ok: true };
  };

  A['auth.logout'] = async () => {
    if (_currentUser) audit('auth.logout', 'auth', _currentUser.email, _currentUser.name);
    if (isWeb) { try { await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' }); } catch (e) {} }
    setCurrentUser(null, false);
    notify();
  };

  A['auth.generateKey'] = async ({ targetUserId, expiresInHours = 24 }) => {
    // WEB : le serveur génère la clé (crypto), n'en stocke que le hash, et la
    // renvoie en clair une seule fois. Le mot de passe actuel est invalidé.
    if (isWeb) {
      try {
        const res = await apiFetch('/api/users/' + encodeURIComponent(targetUserId) + '/generate-key', 'POST', { expiresInHours });
        if (res && res.ok && res.key) { audit('auth.generateKey', 'auth', targetUserId); return { ok: true, key: res.key, expiresAt: res.expiresAt }; }
        return { ok: false, error: (res && res.error) || 'Échec de la génération de clé' };
      } catch (e) { return { ok: false, error: 'Serveur injoignable' }; }
    }
    const u = window.AxluData.USERS.find((x) => x.id === targetUserId);
    if (!u) return { ok: false, error: 'Utilisateur introuvable' };
    if (!_currentUser || _currentUser.role !== 'admin') return { ok: false, error: 'Action réservée aux administrateurs' };
    const key = genKey();
    const expiresAt = Date.now() + expiresInHours * 3600 * 1000;
    // Issuing a key invalidates the existing password — the user MUST set a
    // new one on next login. This is also the right behaviour for a
    // "forgotten password" flow (the old password is no longer valid).
    A['user.update']({ id: u.id, patch: {
      pendingKey: { key, expiresAt, issuedAt: Date.now(), issuedBy: _currentUser.id },
      password: null,
      requiresPasswordReset: true,
    } });
    audit('auth.generateKey', 'auth', u.email);
    return { ok: true, key, expiresAt };
  };

  // publications
  A['publication.queueRemove'] = ({ id }) => {
    window.AxluData.PUBLICATIONS_QUEUE = window.AxluData.PUBLICATIONS_QUEUE.filter((q) => q.id !== id);
    audit('publication.queueRemove', 'publication', id);
  };

  A['publication.errorRetry'] = ({ sku }) => {
    window.AxluData.PUBLICATIONS_ERRORS = window.AxluData.PUBLICATIONS_ERRORS.filter((e) => e.sku !== sku);
    const p = window.AxluData.PRODUCTS.find((x) => x.sku === sku);
    if (p) {
      window.AxluData.PUBLICATIONS_QUEUE = [
        { id: nextId('pub-q'), productSku: sku, title: p.title, status: 'queued', queuedAt: new Date().toISOString() },
        ...(window.AxluData.PUBLICATIONS_QUEUE || []),
      ];
    }
    audit('publication.retry', 'publication', sku);
  };

  // ─── dispatch ────────────────────────────────────────────────────────
  // Actions allowed without being authenticated (login flow).
  const PUBLIC_ACTIONS = new Set(['auth.login', 'auth.loginWithKey']);
  // Read-style actions that everyone (incl. viewer) may run if logged in.
  const READ_ACTIONS = new Set(['auth.logout', 'auth.changePassword']);

  function isAllowed(action) {
    if (PUBLIC_ACTIONS.has(action)) return true;
    // Headless background sync (Windows scheduled task) runs with no logged-in
    // user — it is a trusted, app-internal process, so the role gate is lifted.
    if (window.__AXLU_HEADLESS__) return true;
    if (!_currentUser) return false;
    if (READ_ACTIONS.has(action)) return true;

    // Viewer can do nothing else.
    if (_currentUser.role === 'viewer') return false;

    // User management — admins only.
    if (action.startsWith('user.') || action === 'auth.generateKey') {
      return can('manageUsers');
    }

    // Hard deletes — admins only (including bulk variants).
    if (action.endsWith('.delete') || action === 'product.bulkDelete') {
      return can('delete');
    }

    // Operator+admin can do the rest (write actions).
    return true;
  }

  function dispatch(action, payload) {
    const fn = A[action];
    if (!fn) {
      console.warn('[AxluStore] unknown action:', action);
      return;
    }
    if (!isAllowed(action)) {
      toast('Action non autorisée pour votre rôle', { tone: 'danger' });
      audit('permission.denied', 'auth', action);
      notify();
      return { ok: false, error: 'forbidden' };
    }
    const result = fn(payload || {});
    notify();
    return result;
  }

  function subscribe(fn) {
    subscribers.add(fn);
    return () => { subscribers.delete(fn); };
  }

  function setCurrentUser(user, rememberMe) {
    _currentUser = user ? { ...user } : null;
    _rememberMe = !!rememberMe;
    if (user && rememberMe) saveSession({ userId: user.id, rememberMe: true });
    else saveSession(null);
  }

  // ─── Role-based permissions ──────────────────────────────────────────
  const ROLE_CAPS = {
    admin:    { nav: ['dashboard','catalog','products','suppliers','syncs','categories','pricing','shopify','logs','admin'],
                read: true, write: true, delete: true, manageUsers: true },
    operator: { nav: ['dashboard','catalog','products','suppliers','syncs','categories','pricing','shopify'],
                read: true, write: true, delete: false, manageUsers: false },
    viewer:   { nav: ['dashboard'],
                read: true, write: false, delete: false, manageUsers: false },
  };

  function can(action) {
    const role = _currentUser ? _currentUser.role : null;
    if (!role) return false;
    const caps = ROLE_CAPS[role] || ROLE_CAPS.viewer;
    if (action.startsWith('nav:')) {
      const route = action.slice(4);
      return caps.nav.includes(route);
    }
    if (action === 'delete')      return !!caps.delete;
    if (action === 'write')       return !!caps.write;
    if (action === 'read')        return !!caps.read;
    if (action === 'manageUsers') return !!caps.manageUsers;
    // Default-deny for unknown actions if not admin.
    return role === 'admin';
  }

  // ─── React hooks (UMD-ish) ───────────────────────────────────────────
  function useAxluStore() {
    const [v, setV] = React.useState(version);
    React.useEffect(() => subscribe(setV), []);
    return v;
  }

  function useAuth() {
    const v = useAxluStore();
    const mustChangePassword = !!(_currentUser && _currentUser.requiresPasswordReset);
    return { user: _currentUser, rememberMe: _rememberMe, mustChangePassword, version: v };
  }

  function useCan(action) {
    useAxluStore();
    return can(action);
  }

  // ─── Init: hydrate from localStorage ─────────────────────────────────
  // Réparations/migrations post-chargement, factorisées pour tourner après un
  // chargement disque (Electron) ET après un chargement API (web). En mode web,
  // les produits et catégories sont déjà calculés/construits côté serveur, donc
  // on ne rejoue ni le recalcul des prix ni la (re)construction des catégories
  // (autoritatifs côté serveur) — sinon buildFromPf/fileUncategorized se
  // déclencheraient à tort sur le catalogue allégé.
  function applyMigrations() {
    // Migration: recompute sell price / margin for every product, and repair
    // older saved states — derive the MOQ from existing price tiers, and flag
    // the PF "disponible" stock sentinel (stockDirect 10000, no location).
    if (!isWeb && Array.isArray(window.AxluData.PRODUCTS)) {
      window.AxluData.PRODUCTS = window.AxluData.PRODUCTS.map((p) => {
        if (!p || !Array.isArray(p.variants) || !p.variants.length) return recomputeProduct(p);
        let touched = false;
        const vs = p.variants.map((v) => {
          if (!v) return v;
          let nv = v;
          // MOQ from existing price tiers (states saved before MOQ existed).
          if ((Number(nv.moq) || 0) === 0) {
            const qtys = (Array.isArray(nv.priceTiers) ? nv.priceTiers : [])
              .map((t) => Number(t.fromQty) || 0).filter((q) => q > 0);
            if (qtys.length) { nv = { ...nv, moq: Math.min.apply(null, qtys) }; touched = true; }
          }
          // Stock sentinel: PF's 10000 + no location = "available, not counted".
          if (!nv.stockUncounted && Number(nv.stock) === 10000 && !String(nv.stockLocation || '').trim()) {
            nv = { ...nv, stock: 0, stockUncounted: true }; touched = true;
          }
          return nv;
        });
        if (!touched) return recomputeProduct(p);
        return recomputeProduct(aggregateProductFromVariants({ ...p, variants: vs }));
      });
    }
    // First-run / upgrade bootstrap: build the PF category tree when it is
    // missing. (Web : l'arbre arrive déjà construit du serveur → on ne touche à rien.)
    if (!isWeb) {
      const prods = window.AxluData.PRODUCTS;
      const hasPfProducts = Array.isArray(prods) && prods.some((p) => p.pfGroupCode || p.pfGroupDesc);
      const noAxluCats = (window.AxluData.CATEGORIES_AXLU || []).length === 0;
      const noPfCats = (window.AxluData.CATEGORIES_PF || []).length === 0;
      const noPfLink = hasPfProducts && !prods.some((p) => p.pfCategoryId);
      if (hasPfProducts && (noAxluCats || noPfCats || noPfLink)) {
        try { A['category.buildFromPf'](); } catch (e) { console.warn('[AXLU] auto buildFromPf failed', e); }
      }
      try {
        const filed = fileUncategorizedProducts();
        if (filed > 0) console.info('[AXLU] ' + filed + ' produit(s) rangé(s) automatiquement au démarrage');
      } catch (e) { console.warn('[AXLU] fileUncategorizedProducts failed', e); }
    }
    // Migration: repair syncs whose `type` was saved as a feed object, and
    // mark stale "running" syncs as interrupted.
    if (Array.isArray(window.AxluData.SYNCS)) {
      window.AxluData.SYNCS = window.AxluData.SYNCS.map((s) => {
        if (!s) return s;
        let n = s;
        if (n.type && typeof n.type === 'object') n = { ...n, type: n.type.type || 'products' };
        if (n.status === 'running') {
          n = { ...n, status: 'interrupted',
            errorMsg: n.errorMsg || 'Synchronisation interrompue (application fermée)',
            duration: n.duration || 0 };
        }
        return n;
      });
    }
  }

  function init() {
    // ── MODE WEB (déménagement) ──────────────────────────────────────────
    // Données chargées depuis l'API APRÈS le login (jamais du disque).
    if (isWeb) {
      WEB_EMPTY_KEYS.forEach((k) => { if (Array.isArray(window.AxluData[k])) window.AxluData[k] = []; });
      // Valide le cookie de session ; si OK → pose l'utilisateur + tire les données.
      // Non authentifié → /api/auth/me 401 → on reste sur l'écran de login (pas fatal).
      fetch('/api/auth/me', { credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
        .then((r) => (r.ok ? r.json() : null))
        .then((b) => { if (b && b.user) { setCurrentUser(b.user, false); return loadFromApi(); } })
        .catch(() => {})
        .finally(() => notify());
      return;
    }

    // ── MODE ELECTRON (inchangé) ─────────────────────────────────────────
    const persisted = loadPersisted();
    if (persisted) {
      PERSISTED_KEYS.forEach((k) => {
        if (persisted[k] != null) window.AxluData[k] = persisted[k];
      });
    }
    applyMigrations();
    const session = loadSession();
    if (session && session.rememberMe && session.userId) {
      const u = window.AxluData.USERS.find((x) => x.id === session.userId && x.active);
      if (u) {
        _currentUser = { ...u };
        _rememberMe = true;
        try { console.info('[AXLU] session restored for', u.email); } catch (e) {}
      } else {
        try { console.warn('[AXLU] session userId', session.userId, 'not found in USERS — clearing session'); } catch (e) {}
        saveSession(null);
      }
    }
  }

  // WEB : récupère les données de référence + le catalogue ALLÉGÉ après login.
  // GET même origine → le cookie HttpOnly part tout seul. Assigne les clés
  // connues, applique les migrations (cosmétiques sur web), puis redessine.
  async function loadFromApi() {
    let res;
    try {
      res = await fetch('/api/bootstrap', { credentials: 'same-origin', headers: { 'Accept': 'application/json' }, cache: 'no-store' });
    } catch (e) { throw new Error('Réseau indisponible'); }
    if (res.status === 401 || res.status === 403) { const err = new Error('not_authenticated'); err.status = res.status; throw err; }
    if (!res.ok) throw new Error('/api/bootstrap HTTP ' + res.status);
    const payload = await res.json();
    PERSISTED_KEYS.forEach((k) => { if (payload[k] != null) window.AxluData[k] = payload[k]; });
    if (payload.currentUser && payload.currentUser.id) { _currentUser = { ...payload.currentUser }; _rememberMe = false; }
    if (payload.serverTime) _lastChangeAt = payload.serverTime;
    applyMigrations();
    notify();
    startAutoRefresh();
    return payload;
  }

  // Base partagée « live » (web) : le serveur POUSSE via SSE dès qu'une donnée change.
  //  • « products » → on ne re-télécharge QUE les produits modifiés (incrémental, léger, sans « sauter »).
  //  • « reload »   → rechargement complet (changements de config — rares).
  //  • « locks »    → mise à jour de la présence/verrous collaboratifs.
  let _autoRefreshTimer = null;
  let _sse = null;
  let _lastChangeAt = null;
  const _myResources = new Set(); // ce sur quoi JE suis présent (fiches/pages) → heartbeat + libération
  let _currentPage = null; // ma page/fiche éditable actuelle (sert au verrou « lecture seule »)
  let _unloadHooked = false;
  async function applyChanges() {
    if (!_lastChangeAt) return loadFromApi();
    let res;
    try { res = await fetch('/api/changes?since=' + encodeURIComponent(_lastChangeAt), { credentials: 'same-origin', cache: 'no-store' }); }
    catch (e) { return; }
    if (!res.ok) return;
    let payload; try { payload = await res.json(); } catch (e) { return; }
    if (payload.serverTime) _lastChangeAt = payload.serverTime;
    const list = Array.isArray(payload.products) ? payload.products : [];
    if (!list.length) return;
    const byId = new Map((window.AxluData.PRODUCTS || []).map((p) => [p.id, p]));
    for (const np of list) byId.set(np.id, { ...(byId.get(np.id) || {}), ...np });
    window.AxluData.PRODUCTS = Array.from(byId.values());
    notify();
  }
  async function loadLocks() {
    if (!isWeb) return;
    try { const r = await fetch('/api/presence', { credentials: 'same-origin', cache: 'no-store' }); if (r.ok) { const j = await r.json(); window.AxluData.LOCKS = Array.isArray(j.presence) ? j.presence : []; notify(); } } catch (e) { /* */ }
  }
  function acquireLock(resource) {
    if (!isWeb || !resource) return;
    _currentPage = resource;
    _myResources.add(resource);
    fetch('/api/presence', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ resource }) }).then(loadLocks).catch(() => {});
  }
  function releaseLock(resource) {
    if (!isWeb || !resource) return;
    if (_currentPage === resource) _currentPage = null;
    _myResources.delete(resource);
    fetch('/api/presence/release', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ resource }) }).then(loadLocks).catch(() => {});
  }
  function lockEditorOf(resource) {
    const here = (window.AxluData.LOCKS || []).filter((l) => l.resource === resource);
    if (!here.length) return null;
    here.sort((a, b) => (String(a.since || '') < String(b.since || '') ? -1 : 1)); // 1er arrivé = éditeur
    return here[0];
  }
  function lockedByOther(resource) {
    const ed = lockEditorOf(resource);
    return (ed && ed.userId !== (_currentUser && _currentUser.id)) ? ed : null;
  }
  function othersOnPage(page) {
    const res = 'page:' + page, me = _currentUser && _currentUser.id;
    return (window.AxluData.LOCKS || []).filter((l) => l.resource === res && l.userId !== me);
  }
  // Suis-je en LECTURE SEULE ? = un AUTRE édite la page/fiche sur laquelle je me trouve.
  function amIReadOnly() { return !!(_currentPage && lockedByOther(_currentPage)); }
  function startAutoRefresh() {
    if (!isWeb) return;
    if (!_sse && typeof EventSource !== 'undefined') {
      try {
        _sse = new EventSource('/api/stream'); // cookie de session envoyé automatiquement (même origine)
        _sse.onmessage = (ev) => {
          try {
            const m = JSON.parse(ev.data);
            if (m && m.type === 'products') applyChanges().catch(() => {});
            else if (m && m.type === 'reload') loadFromApi().catch(() => {});
            else if (m && m.type === 'locks') loadLocks().catch(() => {});
          } catch (e) { /* */ }
        };
        _sse.onopen = () => { applyChanges().catch(() => {}); loadLocks().catch(() => {}); }; // reprise après (re)connexion → jamais de trou
        _sse.onerror = () => { /* EventSource se reconnecte tout seul */ };
      } catch (e) { _sse = null; }
    }
    if (!_autoRefreshTimer) {
      // Heartbeat présence + filet incrémental LÉGER (jamais de rechargement complet → « sans sauter »).
      _autoRefreshTimer = setInterval(() => {
        if (!_currentUser) return;
        if (_myResources.size) fetch('/api/presence/heartbeat', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ resources: [..._myResources] }) }).catch(() => {});
        applyChanges().catch(() => {});
      }, 12000);
    }
    loadLocks();
    if (!_unloadHooked && typeof window !== 'undefined') {
      _unloadHooked = true;
      window.addEventListener('beforeunload', () => {
        if (_myResources.size && navigator.sendBeacon) {
          try { navigator.sendBeacon('/api/presence/release', new Blob([JSON.stringify({ resources: [..._myResources] })], { type: 'application/json' })); } catch (e) { /* */ }
        }
      });
    }
  }

  // Resolve a product's category hierarchy from the AXLU tree:
  //   main = parent category (e.g. "Maison et cuisine"), sub = leaf (e.g.
  //   "Couvertures"). Used for Shopify Type (= sub) + Collections (main+sub).
  function categoryNamesOf(p) {
    const cats = window.AxluData.CATEGORIES_AXLU || [];
    const clean = (s) => { const t = String(s == null ? '' : s).trim(); return (t && t !== '—') ? t : ''; };
    const leafId = p.categoryId || (Array.isArray(p.categoryIds) && p.categoryIds.length ? p.categoryIds[0] : null);
    const leaf = leafId ? cats.find((c) => c.id === leafId) : null;
    if (leaf) {
      const sub = clean(leaf.name);
      const parent = leaf.parent ? cats.find((c) => c.id === leaf.parent) : null;
      const main = parent ? clean(parent.name) : sub;
      return { main: main || sub, sub: sub };
    }
    const sub = clean(p.categoryLabel);
    return { main: sub, sub: sub };
  }
  // Customs: PF gives the country as a French name and a zero-padded HS code;
  // Shopify wants an ISO-2 country code + a 6–10 digit HS code.
  const _COUNTRY_CODES = { 'CHINE':'CN','FRANCE':'FR','ALLEMAGNE':'DE','ITALIE':'IT','ESPAGNE':'ES','PAYS-BAS':'NL','BELGIQUE':'BE','ROYAUME-UNI':'GB','ANGLETERRE':'GB','POLOGNE':'PL','INDE':'IN','VIETNAM':'VN','TAIWAN':'TW','TURQUIE':'TR','ETATS-UNIS':'US','USA':'US','JAPON':'JP','COREE':'KR','COREE DU SUD':'KR','BANGLADESH':'BD','PAKISTAN':'PK','INDONESIE':'ID','THAILANDE':'TH','TCHEQUIE':'CZ','REPUBLIQUE TCHEQUE':'CZ','PORTUGAL':'PT','SUISSE':'CH','AUTRICHE':'AT','HONGRIE':'HU','ROUMANIE':'RO','SUEDE':'SE','DANEMARK':'DK','FINLANDE':'FI','NORVEGE':'NO','IRLANDE':'IE','GRECE':'GR','SLOVAQUIE':'SK','SLOVENIE':'SI','BULGARIE':'BG','CROATIE':'HR','LITUANIE':'LT','LETTONIE':'LV','ESTONIE':'EE','LUXEMBOURG':'LU','MAROC':'MA','TUNISIE':'TN','MEXIQUE':'MX','BRESIL':'BR','CANADA':'CA','HONG KONG':'HK','CAMBODGE':'KH','MALAISIE':'MY','SRI LANKA':'LK','MYANMAR':'MM','BIRMANIE':'MM' };
  function countryNameToCode(name) {
    const k = String(name == null ? '' : name).normalize('NFD').replace(/[̀-ͯ]/g, '').trim().toUpperCase();
    return _COUNTRY_CODES[k] || null;
  }
  function normalizeHsCode(hs) {
    const d = String(hs == null ? '' : hs).replace(/\D/g, '');
    return d.length >= 6 ? d.slice(0, 10) : null;
  }

  // ─── Shopify publish (Stage 2) ───────────────────────────────────────
  // Effective Shopify unit price for one (product, variant). Single source of
  // truth so the publish gate (shopifyPublishBlockers) and the mapper agree on
  // what counts as a sellable price — a manual override wins even when it is 0.
  function shopifyUnitPrice(p, v, baseCoef) {
    baseCoef = baseCoef || (window.AxluData.PRICING_COSTS && window.AxluData.PRICING_COSTS.baseCoef) || 2.5;
    if (p.manualPrice != null) return Number(p.manualPrice) || 0;
    const buy = Number(v && v.buyPrice) || 0;
    if (buy > 0) return buy * baseCoef;
    return Number(p.sellPrice) || 0;
  }
  // Map an AXLU product to a Shopify `productSet` input. Pure function —
  // builds product fields + options (Couleur / Taille) + variants from the
  // AXLU variant array.
  function mapProductToShopifyInput(p, baseCoef) {
    baseCoef = baseCoef || (window.AxluData.PRICING_COSTS && window.AxluData.PRICING_COSTS.baseCoef) || 2.5;
    const trim = (x) => String(x == null ? '' : x).trim();
    const _cat = categoryNamesOf(p);
    const customsCountry = countryNameToCode(p.countryOfOrigin);
    const customsHs = normalizeHsCode(p.hsCode);

    const tags = [];
    [_cat.main, _cat.sub, p.pfGroupDesc, p.pfCatDesc, p.material, p.brand].forEach((t) => {
      const s = trim(t);
      if (s && s !== '—' && tags.indexOf(s) === -1) tags.push(s);
    });

    let variants = (Array.isArray(p.variants) && p.variants.length)
      ? p.variants
      : [{ sku: p.sku, color: '', size: '', buyPrice: p.buyPrice, ean: p.ean }];
    if (variants.length > 100) variants = variants.slice(0, 100); // Shopify cap

    const useColor = variants.some((v) => trim(v.color));
    const useSize = variants.some((v) => trim(v.size));

    const priceFor = (v) => shopifyUnitPrice(p, v, baseCoef);
    const mkVariant = (v, optionValues) => {
      const inv = { sku: trim(v.sku) || trim(p.sku) };
      inv.tracked = true; // every variant is inventory-tracked; uncounted (PF "illimité") get a 9999 sentinel qty (see pushProductInventory)
      if (customsCountry) inv.countryCodeOfOrigin = customsCountry; // native customs field
      if (customsHs) inv.harmonizedSystemCode = customsHs;           // native customs field
      const out = { price: priceFor(v).toFixed(2), optionValues: optionValues, inventoryItem: inv };
      if (trim(v.ean)) out.barcode = trim(v.ean);
      return out;
    };

    let productOptions, sVariants;
    if (!useColor && !useSize) {
      productOptions = [{ name: 'Title', values: [{ name: 'Default Title' }] }];
      sVariants = [mkVariant(variants[0], [{ optionName: 'Title', name: 'Default Title' }])];
    } else {
      const colorVals = [], sizeVals = [], seen = {}, built = [];
      variants.forEach((v) => {
        const c = useColor ? (trim(v.color) || 'Standard') : null;
        const s = useSize ? (trim(v.size) || 'Unique') : null;
        const key = (c || '') + '|||' + (s || '');
        if (seen[key]) return;          // drop duplicate option combos (Shopify rejects them)
        seen[key] = true;
        if (c && colorVals.indexOf(c) === -1) colorVals.push(c);
        if (s && sizeVals.indexOf(s) === -1) sizeVals.push(s);
        const ov = [];
        if (useColor) ov.push({ optionName: 'Couleur', name: c });
        if (useSize) ov.push({ optionName: 'Taille', name: s });
        built.push(mkVariant(v, ov));
      });
      productOptions = [];
      if (useColor) productOptions.push({ name: 'Couleur', values: colorVals.map((n) => ({ name: n })) });
      if (useSize) productOptions.push({ name: 'Taille', values: sizeVals.map((n) => ({ name: n })) });
      sVariants = built;
    }

    const input = {
      // Shopify caps the title at 255 chars; some PF "titles" are long
      // marketing descriptions — truncate so productSet never rejects.
      title: (trim(p.title).slice(0, 255)) || 'Produit',
      descriptionHtml: trim(p.description),
      vendor: trim(p.brand) || trim(p.supplierName),
      productType: _cat.sub,
      tags: tags,
      status: 'DRAFT',
      productOptions: productOptions,
      variants: sVariants,
    };

    // NB: Shopify's STANDARD product category (taxonomy node) is intentionally
    // NOT sent. For a headless store it's useless (tax is by region, the
    // storefront uses AXLU's own categories) and its fuzzy auto-mapping produced
    // wrong nodes. Categorisation lives in the axlu.category / axlu.subcategory
    // metafields + the smart collections instead.
    // ── Rich data → Shopify metafields (namespace "axlu") + product media.
    //    These feed the headless storefront / configurator: specs, colour
    //    swatches (hex), marking methods + zone photos, degressive prices. ──
    const PF_IMG_BASE = 'https://images.pfconcept.com/ProductImages_All/JPG/500x500/';
    const pfImg = (f) => {
      const s = trim(f);
      if (!s) return null;
      return /^https?:\/\//.test(s) ? s : PF_IMG_BASE + s;
    };
    const mf = [];
    const pushMf = (key, type, value) => {
      if (value === null || value === undefined || value === '') return;
      mf.push({ namespace: 'axlu', key: key, type: type, value: String(value) });
    };
    const pushJson = (key, obj) => {
      if (!obj || (Array.isArray(obj) && obj.length === 0)) return;
      mf.push({ namespace: 'axlu', key: key, type: 'json', value: JSON.stringify(obj) });
    };

    pushMf('category', 'single_line_text_field', _cat.main);
    pushMf('subcategory', 'single_line_text_field', _cat.sub);
    pushMf('material', 'single_line_text_field', trim(p.material));
    pushMf('country_of_origin', 'single_line_text_field', trim(p.countryOfOrigin));
    pushMf('hs_code', 'single_line_text_field', trim(p.hsCode));
    pushMf('supplier', 'single_line_text_field', trim(p.supplierName));
    pushMf('supplier_sku', 'single_line_text_field', trim(p.modelCode || p.sku));
    if (Number(p.weight) > 0) pushMf('weight_g', 'number_integer', String(Math.round(Number(p.weight))));
    const _d = p.dimensions || {};
    if (_d.l || _d.w || _d.h) pushMf('dimensions_cm', 'single_line_text_field', (_d.l || 0) + ' × ' + (_d.w || 0) + ' × ' + (_d.h || 0) + ' cm');
    pushMf('ean', 'single_line_text_field', trim(p.ean));
    if (p.greenPoints && p.greenPoints.total) pushMf('green_points', 'number_integer', String(Math.round(p.greenPoints.total)));
    if (p.greenPoints && p.greenPoints.co2) pushMf('co2_kg', 'number_decimal', String(p.greenPoints.co2));

    // Colours (with hex) — for swatches in the storefront.
    const colors = [], _seenC = {};
    variants.forEach((v) => {
      const name = trim(v.color); if (!name || _seenC[name]) return; _seenC[name] = true;
      colors.push({ name: name, code: trim(v.colorCode), hex: trim(v.colorHex), baseColor: trim(v.baseColor) });
    });
    pushJson('colors', colors);

    // Degressive (tiered) SELL prices per variant.
    const ptiers = [];
    variants.forEach((v) => {
      const vt = Array.isArray(v.priceTiers) ? v.priceTiers : [];
      if (!vt.length) return;
      ptiers.push({ sku: trim(v.sku), tiers: vt.map((t) => ({
        fromQty: t.fromQty, sellPrice: Number(((Number(t.netPrice) || 0) * baseCoef).toFixed(2)),
      })) });
    });
    pushJson('price_tiers', ptiers);

    // Marking: default decoration + per-method data + zone photos.
    const _mk = p.marking || {};
    const markingDefault = {
      method: trim(_mk.method), location: trim(_mk.location),
      maxColors: _mk.maxColors || 0, size: trim(_mk.sizeText),
      imprintImage: pfImg(_mk.imprintImage), leadTime: _mk.leadTime || null,
    };
    if (markingDefault.method || markingDefault.imprintImage) pushJson('marking_default', markingDefault);
    const methods = [], _seenM = {};
    variants.forEach((v) => {
      (v.printMethods || []).forEach((m) => {
        const code = trim(m.printCode); if (!code || _seenM[code]) return; _seenM[code] = true;
        methods.push({ printCode: code, method: trim(m.method), location: trim(m.location),
          maxColors: m.maxColors, maxAreaCm2: m.maxAreaCm2, image: pfImg(m.image) });
      });
    });
    pushJson('marking_methods', methods);

    if (mf.length) input.metafields = mf;

    // Product images (full PF URLs, deduped). Each variant's own image is
    // tagged with alt "variant:<sku>" so shopifyPublish can bind it to the
    // right Shopify variant afterwards (Shopify rehosts on its CDN, so URLs
    // can't be matched post-upload — the alt tag is the stable key).
    const imgs = [], _seenImg = {};
    const addImg = (u, alt) => {
      const s = trim(u);
      if (s && /^https?:\/\//.test(s) && !_seenImg[s]) { _seenImg[s] = true; imgs.push({ originalSource: s, contentType: 'IMAGE', alt: alt }); }
    };
    const titleAlt = (trim(p.title) || 'Produit').slice(0, 250);
    variants.forEach((v) => { if (trim(v.sku)) addImg(v.imageUrl, 'variant:' + trim(v.sku)); });
    addImg(p.imageUrl, titleAlt);
    (p.imageGallery || []).forEach((u) => addImg(u, titleAlt));
    variants.forEach((v) => (v.imageGallery || []).forEach((u) => addImg(u, titleAlt)));
    if (imgs.length) input.files = imgs.slice(0, 100);

    if (p.shopifyId) input.id = p.shopifyId; // update in place if already published
    return input;
  }

  const PRODUCT_SET_MUTATION =
    'mutation productSet($input: ProductSetInput!) {' +
    '  productSet(synchronous: true, input: $input) {' +
    '    product {' +
    '      id title handle status' +
    '      variants(first: 100) { nodes { id sku } }' +
    '      media(first: 250) { nodes { id alt } }' +
    '    }' +
    '    userErrors { field message }' +
    '  }' +
    '}';

  const VARIANT_MEDIA_BIND_MUTATION =
    'mutation pvbu($productId: ID!, $variants: [ProductVariantsBulkInput!]!) {' +
    '  productVariantsBulkUpdate(productId: $productId, variants: $variants) {' +
    '    userErrors { field message }' +
    '  }' +
    '}';

  // Completeness gate for Shopify publish (refonte 0.48.1): a product must have
  // a real (mapped) category, a sellable price, and at least one image before
  // it can reach Shopify. Returns the list of missing fields ([] = OK). This is
  // what keeps "Publier" from ever producing an incomplete Shopify product.
  function shopifyPublishBlockers(p) {
    if (!p) return ['produit'];
    const t = (x) => String(x == null ? '' : x).trim();
    const miss = [];
    if (!categoryNamesOf(p).sub) miss.push('catégorie');          // also covers productType (= sub)
    const _pv = (Array.isArray(p.variants) && p.variants.length) ? p.variants : [{ sku: p.sku, buyPrice: p.buyPrice }];
    if (!_pv.some((v) => shopifyUnitPrice(p, v) > 0)) miss.push('prix');  // mirrors priceFor: manualPrice=0 ⇒ not sellable
    const hasImg = !!t(p.imageUrl) ||
      (Array.isArray(p.imageGallery) && p.imageGallery.some((u) => t(u))) ||
      (Array.isArray(p.variants) && p.variants.some((v) => v &&
        (t(v.imageUrl) || (Array.isArray(v.imageGallery) && v.imageGallery.some((u) => t(u))))));
    if (!hasImg) miss.push('image');
    if (!Array.isArray(p.variants) || !p.variants.some((v) => t(v && v.sku))) miss.push('variante');
    if (!t(p.title)) miss.push('titre');
    if (!t(p.description)) miss.push('description');
    return miss;
  }

  // Publish ONE product to Shopify (create, or update if it already has a
  // shopifyId). Updates the product's shopify* fields with the outcome.
  async function shopifyPublish(id) {
    if (!(window.axlu && window.axlu.shopify && window.axlu.shopify.graphql)) {
      return { ok: false, error: 'Module Shopify indisponible (relancez AXLU)' };
    }
    // WEB : le serveur publie de bout en bout (token côté serveur) + persiste
    // shopifyId/shopifyPub/status. On réconcilie le produit depuis sa réponse.
    if (isWeb) {
      let res;
      try { res = await apiFetch('/api/shopify/publish/' + encodeURIComponent(id), 'POST'); }
      catch (e) { res = { ok: false, error: e && e.message ? e.message : String(e) }; }
      if (res && res.ok) {
        if (res.product) replaceProduct(res.product);
        else patchProduct(id, { shopifyId: res.shopifyId, shopifyPub: 'draft', lastPublishedAt: new Date().toISOString() });
      } else {
        // Le serveur a déjà persisté l'état (incomplet → à vérifier, ou erreur).
        patchProduct(id, { shopifyPub: 'error', shopifyError: (res && res.error) || 'Échec' });
        if (res && res.incomplete) patchProduct(id, { status: 'to_review' });
      }
      notify();
      return res || { ok: false, error: 'Échec' };
    }
    const p = (window.AxluData.PRODUCTS || []).find((x) => x.id === id);
    if (!p) return { ok: false, error: 'Produit introuvable' };
    // Guard-rail: never push an incomplete product. Send it back to "à vérifier"
    // (to_review) with a clear reason instead — so a successful publish always
    // means a clean Shopify product (category + price + image present).
    const missing = shopifyPublishBlockers(p);
    if (missing.length) {
      const err = 'Produit incomplet — manque : ' + missing.join(', ') + '. Renvoyé en « à vérifier ».';
      // Already live on Shopify? Take it down (DRAFT) so an incomplete product
      // never keeps serving on the storefront — local and Shopify stay in sync.
      if (p.shopifyId && p.shopifyPub === 'active') {
        try { await window.axlu.shopify.graphql({ query: PRODUCT_UPDATE_STATUS_MUTATION, variables: { input: { id: p.shopifyId, status: 'DRAFT' } } }); }
        catch (e) { /* best-effort */ }
      }
      dispatch('product.update', { id: id, patch: { shopifyPub: 'error', shopifyError: err } });
      dispatch('product.setStatus', { id: id, status: 'to_review' });
      audit('shopify.publish.blocked', 'product', p.sku + ' — manque ' + missing.join(', '));
      return { ok: false, error: err, incomplete: missing };
    }
    let input;
    try { input = mapProductToShopifyInput(p); }
    catch (e) { return { ok: false, error: 'Mapping : ' + (e && e.message ? e.message : String(e)) }; }

    let res;
    try { res = await window.axlu.shopify.graphql({ query: PRODUCT_SET_MUTATION, variables: { input: input } }); }
    catch (e) { res = { ok: false, error: e && e.message ? e.message : String(e) }; }

    if (!res || !res.ok) {
      const err = (res && res.error) || 'Échec de l’appel Shopify';
      dispatch('product.update', { id: id, patch: { shopifyPub: 'error', shopifyError: err } });
      return { ok: false, error: err };
    }
    const ps = res.data && res.data.productSet;
    const ue = (ps && ps.userErrors) || [];
    if (ue.length) {
      const err = ue.map((e) => (e.field ? e.field.join('.') + ' : ' : '') + e.message).join(' · ');
      dispatch('product.update', { id: id, patch: { shopifyPub: 'error', shopifyError: err } });
      return { ok: false, error: err };
    }
    const prod = ps && ps.product;
    const gid = prod && prod.id;
    dispatch('product.update', { id: id, patch: {
      shopifyId: gid, shopifyPub: 'draft', shopifyError: null,
      lastPublishedAt: new Date().toISOString(),
    } });
    audit('shopify.publish', 'product', p.sku + ' → ' + (gid || '?'));

    // Best-effort enrichment (never fails the publish):
    //  1) bind each variant to its own colour image;
    //  2) make sure this product's AXLU category SMART collections exist (the
    //     product then auto-files itself into them via its Type + tags).
    await bindVariantImages(gid, p.variants);
    await ensureProductCategoryCollections(p);
    await ensureProductPublishedOnChannels(gid);
    const stockRes = await pushProductInventory(gid, p.variants);

    return { ok: true, shopifyId: gid, handle: prod && prod.handle, status: prod && prod.status, stock: stockRes };
  }

  // Bind variant images: match the "variant:<sku>" media alt to the Shopify
  // variant of the same SKU, then set that media as the variant's image.
  // Re-queries media with a short retry because media may still be processing
  // right after productSet. Best-effort — never throws.
  async function bindVariantImages(productGid, axluVariants) {
    try {
      if (!productGid) return;
      const Q = 'query($id: ID!) { product(id: $id) { variants(first: 100) { nodes { id sku } } media(first: 250) { nodes { id alt } } } }';
      let mediaNodes = [], varNodes = [];
      for (let attempt = 0; attempt < 3; attempt++) {
        const r = await window.axlu.shopify.graphql({ query: Q, variables: { id: productGid } });
        const pr = r && r.ok && r.data && r.data.product;
        mediaNodes = (pr && pr.media && pr.media.nodes) || [];
        varNodes = (pr && pr.variants && pr.variants.nodes) || [];
        if (mediaNodes.length) break;
        await new Promise((res) => setTimeout(res, 800));
      }
      const mediaByAlt = {}; mediaNodes.forEach((m) => { if (m && m.alt) mediaByAlt[m.alt] = m.id; });
      const varIdBySku = {}; varNodes.forEach((vn) => { if (vn && vn.sku) varIdBySku[vn.sku] = vn.id; });
      const bindings = [];
      (axluVariants || []).forEach((v) => {
        const sku = String(v.sku || '').trim();
        const mediaId = mediaByAlt['variant:' + sku];
        const vid = varIdBySku[sku];
        if (mediaId && vid) bindings.push({ id: vid, mediaId: mediaId });
      });
      if (bindings.length) {
        await window.axlu.shopify.graphql({ query: VARIANT_MEDIA_BIND_MUTATION, variables: { productId: productGid, variants: bindings } });
      }
    } catch (e) { /* best-effort */ }
  }

  // Collections — every AXLU category becomes a Shopify SMART collection that
  // auto-includes its products by rule, so products self-file AND re-file when
  // their category changes (no stale manual memberships to clean up):
  //   • leaf / sub category  → rule TYPE = <name>  (matches the product "Type")
  //   • parent / main category → rule TAG = <name> (matches the category tag)
  // A pre-existing MANUAL collection of the same name is converted (deleted then
  // recreated as smart) so collections from older publishes get upgraded.
  const _smartCollCache = {};
  function categoryRule(name, isMain) {
    return isMain
      ? { column: 'TAG', relation: 'EQUALS', condition: name }
      : { column: 'TYPE', relation: 'EQUALS', condition: name };
  }
  async function ensureSmartCollection(title, rule) {
    const name = String(title == null ? '' : title).trim();
    if (!name || name === '—') return 'skipped';
    const cacheKey = name + '|' + rule.column;
    if (_smartCollCache[cacheKey]) return 'existed';
    const esc = (s) => String(s).replace(/"/g, '\\"');
    const q = await window.axlu.shopify.graphql({
      query: 'query($q: String!) { collections(first: 10, query: $q) { nodes { id title ruleSet { rules { column relation condition } } } } }',
      variables: { q: 'title:"' + esc(name) + '"' },
    });
    const nodes = (q && q.ok && q.data && q.data.collections && q.data.collections.nodes) || [];
    const found = nodes.find((c) => c.title === name);
    if (found && found.ruleSet && found.ruleSet.rules && found.ruleSet.rules.length) {
      _smartCollCache[cacheKey] = found.id; return 'existed'; // already a smart collection
    }
    if (found) { // manual collection of same name — delete it so we can recreate it as smart
      await window.axlu.shopify.graphql({
        query: 'mutation($input: CollectionDeleteInput!) { collectionDelete(input: $input) { deletedCollectionId userErrors { field message } } }',
        variables: { input: { id: found.id } },
      });
    }
    const c = await window.axlu.shopify.graphql({
      query: 'mutation($input: CollectionInput!) { collectionCreate(input: $input) { collection { id } userErrors { field message } } }',
      variables: { input: { title: name, ruleSet: { appliedDisjunctively: false, rules: [rule] } } },
    });
    const cc = c && c.ok && c.data && c.data.collectionCreate;
    const ue = (cc && cc.userErrors) || [];
    if (ue.length) throw new Error(ue.map((e) => e.message).join(', '));
    const id = cc && cc.collection && cc.collection.id;
    if (id) { _smartCollCache[cacheKey] = id; return 'created'; }
    return 'failed';
  }
  // On publish: make sure THIS product's category smart collections exist (the
  // product then auto-joins via its Type + tags). Best-effort — never throws.
  async function ensureProductCategoryCollections(p) {
    try {
      const names = categoryNamesOf(p);
      const main = names.main, sub = names.sub;
      if (sub) await ensureSmartCollection(sub, categoryRule(sub, false));
      if (main && main !== sub) await ensureSmartCollection(main, categoryRule(main, true));
    } catch (e) { /* best-effort */ }
  }

  // Carry the AXLU tree INTO Shopify: each child collection stores a reference
  // to its parent collection in metafield axlu.parent, so the headless nav can
  // rebuild "Maison et cuisine › Couvertures" straight from the Storefront API.
  const METAFIELDS_SET_MUTATION =
    'mutation metafieldsSet($metafields: [MetafieldsSetInput!]!) {' +
    '  metafieldsSet(metafields: $metafields) { userErrors { field message } }' +
    '}';
  async function ensureCollectionParentDefinition() {
    try {
      await window.axlu.shopify.graphql({
        query: METAFIELD_DEF_CREATE_MUTATION,
        variables: { definition: {
          namespace: 'axlu', key: 'parent', name: 'Catégorie parente',
          type: 'collection_reference', ownerType: 'COLLECTION',
          access: { storefront: 'PUBLIC_READ' },
        } },
      });
    } catch (e) { /* best-effort — a TAKEN definition already exists, that's fine */ }
  }

  // Create the AXLU metafield DEFINITIONS in Shopify so the values show on the
  // product page in the admin (and are typed). Idempotent: a definition that
  // already exists (TAKEN) is counted as fine, not an error.
  const AXLU_METAFIELD_DEFS = [
    { key: 'category', name: 'Catégorie principale', type: 'single_line_text_field' },
    { key: 'subcategory', name: 'Sous-catégorie', type: 'single_line_text_field' },
    { key: 'material', name: 'Matière', type: 'single_line_text_field' },
    { key: 'country_of_origin', name: 'Pays d’origine', type: 'single_line_text_field' },
    { key: 'hs_code', name: 'Code HS', type: 'single_line_text_field' },
    { key: 'supplier', name: 'Fournisseur', type: 'single_line_text_field' },
    { key: 'supplier_sku', name: 'Réf. fournisseur', type: 'single_line_text_field' },
    { key: 'weight_g', name: 'Poids (g)', type: 'number_integer' },
    { key: 'dimensions_cm', name: 'Dimensions', type: 'single_line_text_field' },
    { key: 'ean', name: 'EAN', type: 'single_line_text_field' },
    { key: 'green_points', name: 'Éco-points', type: 'number_integer' },
    { key: 'co2_kg', name: 'Empreinte CO₂ (kg)', type: 'number_decimal' },
    { key: 'colors', name: 'Couleurs (hex)', type: 'json' },
    { key: 'price_tiers', name: 'Prix dégressifs', type: 'json' },
    { key: 'marking_default', name: 'Marquage par défaut', type: 'json' },
    { key: 'marking_methods', name: 'Méthodes de marquage', type: 'json' },
  ];
  // Per-VARIANT metafields — incoming/restock info (Shopify's native "incoming"
  // can't be set directly, so the headless storefront reads these instead).
  const AXLU_VARIANT_DEFS = [
    { key: 'incoming_qty', name: 'Réappro entrant (qté)', type: 'number_integer' },
    { key: 'incoming_date', name: 'Réappro attendu le', type: 'date' },
    { key: 'stock_location', name: 'Lieu de stock', type: 'single_line_text_field' },
  ];
  const METAFIELD_DEF_CREATE_MUTATION =
    'mutation defCreate($definition: MetafieldDefinitionInput!) {' +
    '  metafieldDefinitionCreate(definition: $definition) {' +
    '    createdDefinition { id }' +
    '    userErrors { field message code }' +
    '  }' +
    '}';
  const METAFIELD_DEF_UPDATE_MUTATION =
    'mutation defUpdate($definition: MetafieldDefinitionUpdateInput!) {' +
    '  metafieldDefinitionUpdate(definition: $definition) {' +
    '    updatedDefinition { id } userErrors { field message } } }';
  async function shopifyEnsureDefinitions() {
    if (!(window.axlu && window.axlu.shopify && window.axlu.shopify.graphql)) {
      return { ok: false, error: 'Module Shopify indisponible' };
    }
    let created = 0, existed = 0, failed = 0; const errors = [];
    const ACCESS = { storefront: 'PUBLIC_READ' }; // so the headless Storefront API can read them
    const ensureOne = async (d, ownerType) => {
      let res;
      try { res = await window.axlu.shopify.graphql({ query: METAFIELD_DEF_CREATE_MUTATION, variables: { definition: { namespace: 'axlu', key: d.key, name: d.name, type: d.type, ownerType: ownerType, access: ACCESS } } }); }
      catch (e) { res = { ok: false, error: e && e.message ? e.message : String(e) }; }
      if (!res || !res.ok) { failed++; errors.push(d.key + ' : ' + ((res && res.error) || 'échec')); return; }
      const r = res.data && res.data.metafieldDefinitionCreate;
      const ue = (r && r.userErrors) || [];
      if (ue.length) {
        if (ue.some((e) => e.code === 'TAKEN')) {
          existed++;
          // Grant storefront access on the already-existing definition too.
          try { await window.axlu.shopify.graphql({ query: METAFIELD_DEF_UPDATE_MUTATION, variables: { definition: { namespace: 'axlu', key: d.key, ownerType: ownerType, access: ACCESS } } }); } catch (e) { /* best-effort */ }
        } else { failed++; errors.push(d.key + ' : ' + ue.map((e) => e.message).join(', ')); }
      } else if (r && r.createdDefinition) { created++; }
      else { failed++; errors.push(d.key + ' : réponse inattendue'); }
    };
    for (let i = 0; i < AXLU_METAFIELD_DEFS.length; i++) await ensureOne(AXLU_METAFIELD_DEFS[i], 'PRODUCT');
    for (let i = 0; i < AXLU_VARIANT_DEFS.length; i++) await ensureOne(AXLU_VARIANT_DEFS[i], 'PRODUCTVARIANT');
    audit('shopify.definitions', 'shopify', created + ' créées · ' + existed + ' existantes · ' + failed + ' échecs');
    return { ok: failed === 0, created: created, existed: existed, failed: failed, errors: errors };
  }

  // Unpublish: SUPPRIME le produit de Shopify (productDelete) puis remet
  // shopify_pub='offline' et efface shopifyId. Le produit n'existe plus côté
  // Shopify : une republication recréera un nouveau produit.
  const PRODUCT_UPDATE_STATUS_MUTATION =
    'mutation productUpdate($input: ProductInput!) {' +
    '  productUpdate(input: $input) { product { id status } userErrors { field message } }' +
    '}';
  const PRODUCT_DELETE_MUTATION =
    'mutation productDelete($input: ProductDeleteInput!) {' +
    '  productDelete(input: $input) { deletedProductId userErrors { field message } }' +
    '}';
  async function shopifyUnpublish(id) {
    if (!(window.axlu && window.axlu.shopify && window.axlu.shopify.graphql)) {
      return { ok: false, error: 'Module Shopify indisponible' };
    }
    // WEB : le serveur SUPPRIME le produit Shopify + persiste shopify_pub
    // 'offline' et shopifyId=null (renvoie res.product à jour).
    if (isWeb) {
      let res;
      try { res = await apiFetch('/api/shopify/unpublish/' + encodeURIComponent(id), 'POST'); }
      catch (e) { res = { ok: false, error: e && e.message ? e.message : String(e) }; }
      if (res && res.ok) { if (res.product) replaceProduct(res.product); else patchProduct(id, { shopifyPub: 'offline', shopifyId: null }); }
      notify();
      return res || { ok: false, error: 'Échec' };
    }
    const p = (window.AxluData.PRODUCTS || []).find((x) => x.id === id);
    if (!p) return { ok: false, error: 'Produit introuvable' };
    if (!p.shopifyId) return { ok: false, error: 'Ce produit n’est pas publié' };
    let res;
    try { res = await window.axlu.shopify.graphql({ query: PRODUCT_DELETE_MUTATION, variables: { input: { id: p.shopifyId } } }); }
    catch (e) { res = { ok: false, error: e && e.message ? e.message : String(e) }; }
    if (!res || !res.ok) return { ok: false, error: (res && res.error) || 'Échec Shopify' };
    const ue = (res.data && res.data.productDelete && res.data.productDelete.userErrors) || [];
    if (ue.length) return { ok: false, error: ue.map((e) => e.message).join(' · ') };
    dispatch('product.update', { id: id, patch: { shopifyPub: 'offline', shopifyId: null } });
    audit('shopify.unpublish', 'product', p.sku);
    return { ok: true };
  }

  // Activate: set the Shopify product to ACTIVE (visible to customers) and
  // shopify_pub='active'. Never activate an incomplete product (same gate as
  // publish). A product never published yet (no shopifyId) is published first
  // (DRAFT creation) then activated.
  async function shopifyActivate(id) {
    if (!(window.axlu && window.axlu.shopify && window.axlu.shopify.graphql)) {
      return { ok: false, error: 'Module Shopify indisponible' };
    }
    // WEB : le serveur active (ACTIVE) + persiste shopify_pub 'active'.
    if (isWeb) {
      let res;
      try { res = await apiFetch('/api/shopify/activate/' + encodeURIComponent(id), 'POST'); }
      catch (e) { res = { ok: false, error: e && e.message ? e.message : String(e) }; }
      if (res && res.ok) { if (res.product) replaceProduct(res.product); else patchProduct(id, { shopifyPub: 'active' }); }
      notify();
      return res || { ok: false, error: 'Échec' };
    }
    const p = (window.AxluData.PRODUCTS || []).find((x) => x.id === id);
    if (!p) return { ok: false, error: 'Produit introuvable' };
    // Guard-rail: never activate an incomplete product.
    const missing = shopifyPublishBlockers(p);
    if (missing.length) return { ok: false, error: 'Produit incomplet — manque : ' + missing.join(', ') + '.', incomplete: missing };
    // Never published yet? Publish (DRAFT creation) first, then activate.
    if (!p.shopifyId) {
      const pub = await shopifyPublish(id);
      if (!pub || !pub.ok) return pub || { ok: false, error: 'Échec de la publication préalable' };
    }
    // Re-read so we pick up the shopifyId written by shopifyPublish.
    const fresh = (window.AxluData.PRODUCTS || []).find((x) => x.id === id);
    const targetId = (fresh && fresh.shopifyId) || p.shopifyId;
    if (!targetId) return { ok: false, error: 'Ce produit n’est pas publié' };
    let res;
    try { res = await window.axlu.shopify.graphql({ query: PRODUCT_UPDATE_STATUS_MUTATION, variables: { input: { id: targetId, status: 'ACTIVE' } } }); }
    catch (e) { res = { ok: false, error: e && e.message ? e.message : String(e) }; }
    if (!res || !res.ok) return { ok: false, error: (res && res.error) || 'Échec Shopify' };
    const ue = (res.data && res.data.productUpdate && res.data.productUpdate.userErrors) || [];
    if (ue.length) return { ok: false, error: ue.map((e) => e.message).join(' · ') };
    dispatch('product.update', { id: id, patch: { shopifyPub: 'active' } });
    audit('shopify.activate', 'product', p.sku);
    return { ok: true };
  }

  // Deactivate: set the Shopify product back to DRAFT (hides it from customers)
  // and shopify_pub='draft'. Uses productUpdate so other fields are untouched.
  async function shopifyDeactivate(id) {
    if (!(window.axlu && window.axlu.shopify && window.axlu.shopify.graphql)) {
      return { ok: false, error: 'Module Shopify indisponible' };
    }
    // WEB : le serveur désactive (DRAFT) + persiste shopify_pub 'draft'.
    if (isWeb) {
      let res;
      try { res = await apiFetch('/api/shopify/deactivate/' + encodeURIComponent(id), 'POST'); }
      catch (e) { res = { ok: false, error: e && e.message ? e.message : String(e) }; }
      if (res && res.ok) { if (res.product) replaceProduct(res.product); else patchProduct(id, { shopifyPub: 'draft' }); }
      notify();
      return res || { ok: false, error: 'Échec' };
    }
    const p = (window.AxluData.PRODUCTS || []).find((x) => x.id === id);
    if (!p) return { ok: false, error: 'Produit introuvable' };
    if (!p.shopifyId) return { ok: false, error: 'Ce produit n’est pas publié' };
    let res;
    try { res = await window.axlu.shopify.graphql({ query: PRODUCT_UPDATE_STATUS_MUTATION, variables: { input: { id: p.shopifyId, status: 'DRAFT' } } }); }
    catch (e) { res = { ok: false, error: e && e.message ? e.message : String(e) }; }
    if (!res || !res.ok) return { ok: false, error: (res && res.error) || 'Échec Shopify' };
    const ue = (res.data && res.data.productUpdate && res.data.productUpdate.userErrors) || [];
    if (ue.length) return { ok: false, error: ue.map((e) => e.message).join(' · ') };
    dispatch('product.update', { id: id, patch: { shopifyPub: 'draft' } });
    audit('shopify.deactivate', 'product', p.sku);
    return { ok: true };
  }

  // Push the WHOLE AXLU category tree to Shopify as smart collections (even
  // empty ones) so the storefront/admin has the full taxonomy. Parent
  // categories → TAG rule, leaf categories → TYPE rule. Idempotent.
  async function shopifySyncCategories() {
    if (!(window.axlu && window.axlu.shopify && window.axlu.shopify.graphql)) {
      return { ok: false, error: 'Module Shopify indisponible' };
    }
    const cats = (window.AxluData && window.AxluData.CATEGORIES_AXLU) || [];
    if (!cats.length) return { ok: false, error: 'Aucune catégorie AXLU à synchroniser' };
    await ensureCollectionParentDefinition(); // so the parent link is typed + storefront-readable
    const childCount = {};
    cats.forEach((c) => { if (c.parent) childCount[c.parent] = (childCount[c.parent] || 0) + 1; });
    let created = 0, existed = 0, failed = 0; const errors = [];
    const collByCatId = {};
    for (let i = 0; i < cats.length; i++) {
      const c = cats[i];
      const name = String(c.name == null ? '' : c.name).trim();
      if (!name || name === '—') continue;
      const isMain = !!childCount[c.id]; // a category that has children is a parent/main
      try {
        const r = await ensureSmartCollection(name, categoryRule(name, isMain));
        if (r === 'created') created++;
        else if (r === 'existed') existed++;
        else if (r === 'failed') { failed++; errors.push(name + ' : création refusée'); }
        const gid = _smartCollCache[name + '|' + (isMain ? 'TAG' : 'TYPE')];
        if (gid) collByCatId[c.id] = gid;
      } catch (e) {
        failed++; errors.push(name + ' : ' + (e && e.message ? e.message : String(e)));
      }
    }
    // Link each child collection → its parent collection (metafield axlu.parent).
    let parented = 0;
    for (let i = 0; i < cats.length; i++) {
      const c = cats[i];
      if (!c.parent) continue;
      const childCol = collByCatId[c.id], parentCol = collByCatId[c.parent];
      if (!childCol || !parentCol) continue;
      try {
        const r = await window.axlu.shopify.graphql({
          query: METAFIELDS_SET_MUTATION,
          variables: { metafields: [{ ownerId: childCol, namespace: 'axlu', key: 'parent', type: 'collection_reference', value: parentCol }] },
        });
        const ue = (r && r.ok && r.data && r.data.metafieldsSet && r.data.metafieldsSet.userErrors) || [];
        if (!ue.length) parented++;
      } catch (e) { /* best-effort */ }
    }
    audit('shopify.syncCategories', 'shopify', created + ' créées · ' + existed + ' existantes · ' + parented + ' liens parent · ' + failed + ' échecs');
    if (failed === 0) _lastCatSyncSig = categoriesSignature();
    return { ok: failed === 0, created: created, existed: existed, parented: parented, failed: failed, errors: errors };
  }

  // ── Automatic category sync ──────────────────────────────────────────
  // After imports (or on connect) AXLU re-syncs categories to Shopify on its
  // own, but only when the category tree actually changed — so the user never
  // has to click the button.
  let _lastCatSyncSig = null;
  function categoriesSignature() {
    const cats = (window.AxluData && window.AxluData.CATEGORIES_AXLU) || [];
    return cats.map((c) => c.id + ':' + (c.name || '') + ':' + (c.parent || '')).join('|');
  }
  async function shopifyIsConnected() {
    try {
      if (!(window.axlu && window.axlu.shopify && window.axlu.shopify.getConfig)) return false;
      const c = await window.axlu.shopify.getConfig();
      return !!(c && c.hasToken);
    } catch (e) { return false; }
  }
  async function shopifyAutoSyncCategories() {
    if (!(await shopifyIsConnected())) return { ok: false, skipped: 'not-connected' };
    if (categoriesSignature() === _lastCatSyncSig) return { ok: true, skipped: 'unchanged' };
    return await shopifySyncCategories();
  }

  // ── Sales channels (publications) ────────────────────────────────────
  // List the store's sales channels. Needs the read_publications scope.
  let _allPubIdsCache = null;
  async function shopifyListPublications() {
    if (!(window.axlu && window.axlu.shopify && window.axlu.shopify.graphql)) {
      return { ok: false, error: 'Module Shopify indisponible' };
    }
    let res;
    try { res = await window.axlu.shopify.graphql({ query: 'query { publications(first: 50) { nodes { id name } } }' }); }
    catch (e) { return { ok: false, error: e && e.message ? e.message : String(e) }; }
    if (!res || !res.ok) return { ok: false, error: (res && res.error) || 'Échec (scope read_publications manquant ?)' };
    const nodes = (res.data && res.data.publications && res.data.publications.nodes) || [];
    return { ok: true, publications: nodes };
  }
  async function allPublicationIds() {
    if (_allPubIdsCache) return _allPubIdsCache;
    const lp = await shopifyListPublications();
    _allPubIdsCache = (lp && lp.ok ? lp.publications : []).map((p) => p.id);
    return _allPubIdsCache;
  }
  // Publish a product to the configured sales channels (or all if none chosen)
  // so the Storefront API / headless storefront returns it. Needs the
  // write_publications scope. Best-effort — never fails the publish.
  async function ensureProductPublishedOnChannels(productGid) {
    try {
      if (!productGid) return;
      let pubIds = (window.AxluData && window.AxluData.SHOPIFY_PUB_CHANNELS) || [];
      if (!pubIds.length) pubIds = await allPublicationIds();
      if (!pubIds || !pubIds.length) return;
      await window.axlu.shopify.graphql({
        query: 'mutation pub($id: ID!, $input: [PublicationInput!]!) { publishablePublish(id: $id, input: $input) { userErrors { field message } } }',
        variables: { id: productGid, input: pubIds.map((pid) => ({ publicationId: pid })) },
      });
    } catch (e) { /* best-effort — requires write_publications scope */ }
  }

  // ── Stock / inventory push ───────────────────────────────────────────
  // Push AXLU stock quantities to Shopify at the primary location. Counted
  // variants are tracked (see mapProductToShopifyInput); uncounted ones (PF
  // "illimité") stay untracked = always available. Needs write_inventory scope.
  let _locationIdCache = null;
  async function getShopifyLocationId() {
    if (_locationIdCache) return _locationIdCache;
    try {
      const r = await window.axlu.shopify.graphql({ query: 'query { locations(first: 10) { nodes { id name isActive } } }' });
      const nodes = (r && r.ok && r.data && r.data.locations && r.data.locations.nodes) || [];
      const active = nodes.find((n) => n && n.isActive) || nodes[0];
      _locationIdCache = active ? active.id : null;
    } catch (e) { _locationIdCache = null; }
    return _locationIdCache;
  }
  const INVENTORY_SET_MUTATION =
    'mutation invSet($input: InventorySetQuantitiesInput!) {' +
    '  inventorySetQuantities(input: $input) { userErrors { field message } }' +
    '}';
  const INVENTORY_ACTIVATE_MUTATION =
    'mutation invAct($inventoryItemId: ID!, $locationId: ID!, $available: Int) {' +
    '  inventoryActivate(inventoryItemId: $inventoryItemId, locationId: $locationId, available: $available) {' +
    '    inventoryLevel { id } userErrors { field message } } }';
  const UNLIMITED_QTY = 9999; // pushed for uncounted (PF "illimité") variants
  // Normalise a date to ISO YYYY-MM-DD (accepts ISO or DD/MM/YYYY); null if unparseable.
  function toISODate(s) {
    const t = String(s == null ? '' : s).trim();
    if (!t) return null;
    let m = t.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
    if (m) return m[1] + '-' + ('0' + m[2]).slice(-2) + '-' + ('0' + m[3]).slice(-2);
    m = t.match(/^(\d{1,2})[\/.\-](\d{1,2})[\/.\-](\d{4})/);
    if (m) return m[3] + '-' + ('0' + m[2]).slice(-2) + '-' + ('0' + m[1]).slice(-2);
    return null;
  }
  // Push stock for one product: (1) per-variant incoming/restock metafields
  // (qty + date + location), then (2) the available quantity at the primary
  // location. Returns { ok, set, error } for the INVENTORY part — errors are
  // surfaced so the UI can explain what blocked the write.
  async function pushProductInventory(productGid, axluVariants) {
    if (!productGid) return { ok: true, set: 0 };
    const wanted = {}, meta = {};
    (axluVariants || []).forEach((v) => {
      const sku = String((v && v.sku) || '').trim();
      if (!sku) return;
      wanted[sku] = (v && v.stockUncounted) ? UNLIMITED_QTY : Math.max(0, Math.round(Number(v.stock) || 0));
      meta[sku] = { qty: Math.max(0, Math.round(Number(v.stockNextPo) || 0)), date: toISODate(v.stockDateNextPo), loc: String((v && v.stockLocation) || '').trim() };
    });
    if (!Object.keys(wanted).length) return { ok: true, set: 0 };
    let r;
    try { r = await window.axlu.shopify.graphql({ query: 'query($id: ID!) { product(id: $id) { variants(first: 100) { nodes { id sku inventoryItem { id } } } } }', variables: { id: productGid } }); }
    catch (e) { return { ok: false, error: e && e.message ? e.message : String(e) }; }
    if (!r || !r.ok) return { ok: false, error: (r && r.error) || 'Lecture des variantes impossible' };
    const vnodes = (r.data && r.data.product && r.data.product.variants && r.data.product.variants.nodes) || [];
    const quantities = [], metafields = [];
    vnodes.forEach((vn) => {
      const sku = String((vn && vn.sku) || '').trim();
      const invId = vn && vn.inventoryItem && vn.inventoryItem.id;
      const vid = vn && vn.id;
      if (sku && invId && wanted[sku] != null) quantities.push({ inventoryItemId: invId, locationId: null, quantity: wanted[sku] });
      const m = vid ? meta[sku] : null;
      if (m) {
        metafields.push({ ownerId: vid, namespace: 'axlu', key: 'incoming_qty', type: 'number_integer', value: String(m.qty) });
        if (m.date) metafields.push({ ownerId: vid, namespace: 'axlu', key: 'incoming_date', type: 'date', value: m.date });
        if (m.loc) metafields.push({ ownerId: vid, namespace: 'axlu', key: 'stock_location', type: 'single_line_text_field', value: m.loc });
      }
    });
    // (1) incoming/restock metafields — best-effort, needs only write_products.
    for (let i = 0; i < metafields.length; i += 25) {
      try { await window.axlu.shopify.graphql({ query: METAFIELDS_SET_MUTATION, variables: { metafields: metafields.slice(i, i + 25) } }); } catch (e) { /* best-effort */ }
    }
    // (2) available quantity — needs the location + write_inventory.
    if (!quantities.length) return { ok: true, set: 0 };
    const locationId = await getShopifyLocationId();
    if (!locationId) return { ok: false, error: 'Aucun emplacement Shopify trouvé' };
    quantities.forEach((q) => { q.locationId = locationId; });
    let setRes;
    try { setRes = await window.axlu.shopify.graphql({ query: INVENTORY_SET_MUTATION, variables: { input: { name: 'available', reason: 'correction', ignoreCompareQuantity: true, quantities: quantities } } }); }
    catch (e) { setRes = { ok: false, error: e && e.message ? e.message : String(e) }; }
    if (setRes && setRes.ok) {
      const ue = (setRes.data && setRes.data.inventorySetQuantities && setRes.data.inventorySetQuantities.userErrors) || [];
      if (!ue.length) return { ok: true, set: quantities.length };
      // 2) Fallback: some items not stocked at this location → activate (also sets available).
      let activated = 0, lastErr = ue.map((e) => e.message).join(', ');
      for (let i = 0; i < quantities.length; i++) {
        const q = quantities[i];
        try {
          const aRes = await window.axlu.shopify.graphql({ query: INVENTORY_ACTIVATE_MUTATION, variables: { inventoryItemId: q.inventoryItemId, locationId: q.locationId, available: q.quantity } });
          const aue = (aRes && aRes.ok && aRes.data && aRes.data.inventoryActivate && aRes.data.inventoryActivate.userErrors) || [];
          if (aRes && aRes.ok && !aue.length) activated++;
          else if (aue.length) lastErr = aue.map((e) => e.message).join(', ');
          else if (aRes && aRes.error) lastErr = aRes.error;
        } catch (e) { lastErr = e && e.message ? e.message : String(e); }
      }
      return activated ? { ok: true, set: activated } : { ok: false, error: lastErr };
    }
    // Transport-level failure (most often the missing write_inventory scope).
    return { ok: false, error: (setRes && setRes.error) || 'Écriture inventaire refusée (scope write_inventory ?)' };
  }
  // Refresh stock on Shopify for ALL published products (called after a stock
  // import). Sequential + best-effort. Only products already published.
  async function shopifyAutoPushStock() {
    if (!(await shopifyIsConnected())) return { ok: false, skipped: 'not-connected' };
    const published = (window.AxluData.PRODUCTS || []).filter((p) => p && p.shopifyId);
    if (!published.length) return { ok: true, pushed: 0 };
    let pushed = 0, failedN = 0, firstError = null;
    for (let i = 0; i < published.length; i++) {
      let res;
      try { res = await pushProductInventory(published[i].shopifyId, published[i].variants); }
      catch (e) { res = { ok: false, error: e && e.message ? e.message : String(e) }; }
      if (res && res.ok) { if (res.set) pushed++; }
      else { failedN++; if (!firstError) firstError = res && res.error; }
    }
    audit('shopify.pushStock', 'shopify', pushed + ' produits · ' + failedN + ' échecs');
    return { ok: failedN === 0, pushed: pushed, failed: failedN, error: firstError };
  }

  // The access scopes actually granted to the connected app (to warn when a
  // required scope like write_inventory / write_publications is missing).
  async function shopifyGrantedScopes() {
    if (!(window.axlu && window.axlu.shopify && window.axlu.shopify.graphql)) return { ok: false, error: 'Module Shopify indisponible' };
    let r;
    try { r = await window.axlu.shopify.graphql({ query: 'query { currentAppInstallation { accessScopes { handle } } }' }); }
    catch (e) { return { ok: false, error: e && e.message ? e.message : String(e) }; }
    if (!r || !r.ok) return { ok: false, error: (r && r.error) || 'Échec' };
    const scopes = ((r.data && r.data.currentAppInstallation && r.data.currentAppInstallation.accessScopes) || []).map((s) => s && s.handle).filter(Boolean);
    return { ok: true, scopes: scopes };
  }

  window.AxluStore = {
    dispatch, subscribe, audit, useAxluStore, useAuth, useCan,
    mapProductToShopify: mapProductToShopifyInput, shopifyPublish, shopifyPublishBlockers, shopifyUnpublish, shopifyActivate, shopifyDeactivate, shopifyEnsureDefinitions, shopifySyncCategories,
    shopifyAutoSyncCategories, shopifyListPublications, shopifyAutoPushStock, shopifyGrantedScopes,
    toCSV, download, toast,
    resetToSeed, init, loadFromApi, notify,
    acquireLock, releaseLock, lockEditorOf, lockedByOther, othersOnPage, amIReadOnly,
    actions: A,
    nextId, can,
    normalizeFeeds,
    flush: flushPersist,
    checkPassword, validatePassword, PASSWORD_RULES,
    get currentUser() { return _currentUser; },
    get rememberMe() { return _rememberMe; },
    get version() { return version; },
  };

  // Expose hooks globally for screens (avoid imports churn)
  window.useAxluStore = useAxluStore;
  window.useAuth = useAuth;
  window.useCan = useCan;

  // Auto-init now that data.jsx has populated window.AxluData
  init();
})();
