/* global React */
// ─── FloodsFlow — vue d'ensemble ──────────────────────────────────────────────────────
//
// Un CONCEPT regroupe des comptes Instagram et leur banque de contenu. On y voit son
// avancement — combien de comptes attribués, connectés à l'API, encore en attente — et
// surtout : COMBIEN DE JOURS DE CONTENU IL RESTE.
//
// Ce dernier chiffre est le vrai sujet. À trois publications par jour et par compte, une
// banque fond vite : 50 comptes consomment 150 contenus par jour. Sans ce calcul affiché,
// on découvre la pénurie le matin où les publications ne partent pas.

const RYTHME_DEFAUT = 3;      // publications par jour et par compte
const COUVERTURE_MIN = 3;     // en dessous de 3 jours d'avance, on alerte

// Combien de jours cette banque tient-elle, et que manque-t-il pour tenir le minimum ?
//
// LA BANQUE N'EST PAS UN STOCK PARTAGÉ. Chaque compte reçoit sa PROPRE copie de tout le
// contenu — 28 vidéos et 3 comptes, ce sont 28 vidéos dans la file de chacun, pas 28
// divisées en trois. La durée de couverture ne dépend donc PAS du nombre de comptes :
// 28 vidéos à 3 publications par jour tiennent 9,3 jours, qu'il y ait 3 comptes ou 30.
//
// L'écran divisait par le nombre de comptes et annonçait 3,1 jours au lieu de 9,3 : il
// aurait fait réapprovisionner trois fois trop tôt. Le backend, lui, calculait juste
// (`jours_couverture` = file du compte le plus démuni ÷ rythme) — c'est sa valeur qu'on
// affiche maintenant, et `provision` ne sert plus qu'à l'habillage.
function provision(joursBackend, banque, connectes, rythme = RYTHME_DEFAUT, minJours = COUVERTURE_MIN) {
  const parJour = Math.max(1, rythme);            // par COMPTE, pas pour le parc entier
  const jours = connectes > 0 ? (joursBackend || 0) : (banque > 0 ? Infinity : 0);
  const requis = parJour * minJours;              // ce qu'il faut dans la file de CHAQUE compte
  return {
    parJour, jours, requis,
    manque: Math.max(0, requis - banque),
    // Vert : on tient le minimum. Orange : moins d'un jour d'avance au-delà. Rouge : sous
    // la journée — les publications de demain ne sont pas couvertes.
    ton: connectes === 0 ? "neutre" : jours >= minJours ? "ok" : jours >= 1 ? "bas" : "vide",
  };
}

const COULEUR = {
  ok: "var(--accent)", bas: "var(--busy, #f0a020)",
  vide: "var(--danger, #ff5c6c)", neutre: "var(--faint)",
};

// ── Fiabilité des catégories ──────────────────────────────────────────────────────────
// Deux catégories qui se ressemblent sont presque toujours un doublon créé par accident.
// Vécu : « Compte en attente » et « Compte en attente API » coexistaient, les 59 comptes
// étaient dans la première, on les cherchait dans la seconde. Rien ne le signalait.
//
// La liste DÉCLARÉE des catégories vit dans le cloud (Backend.getCategories) ; celle des
// catégories UTILISÉES vient du parc. Les deux diffèrent — une catégorie déclarée et vide
// n'apparaît que dans la première — et c'est exactement l'écart qui trompe.
function nomNu(s) {
  return String(s || "").normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase()
    .replace(/[^a-z0-9]+/g, " ").trim();
}

function voisines(a, b) {
  const x = nomNu(a), y = nomNu(b);
  if (!x || !y || a === b) return false;
  if (x === y) return true;                        // même nom, décoration différente
  const ma = x.split(" "), mb = y.split(" ");
  const [court, long] = ma.length <= mb.length ? [ma, mb] : [mb, ma];
  // Un seul mot d'écart, et le court est le début du long. Deux mots, ce sont deux
  // catégories différentes — « Compte Ban » et « Compte en attente » ne doivent pas s'alerter.
  return long.length - court.length === 1 && long.slice(0, court.length).join(" ") === court.join(" ");
}

// Toutes les catégories : celles qui portent des comptes ET celles seulement déclarées.
// Une catégorie déclarée mais vide est le piège principal, il faut donc la VOIR.
async function catalogueCategories(duParc) {
  const parc = duParc || [];
  let declarees = [];
  try {
    if (window.Backend && window.Backend.getCategories) {
      declarees = (await window.Backend.getCategories()) || [];
    }
  } catch (e) {}
  const par = {};
  parc.forEach((c) => { par[c.nom] = { nom: c.nom, comptes: c.comptes, archives: c.archives || 0 }; });
  declarees.forEach((d) => {
    const nom = (d && (d.label || d)) || "";
    if (nom && !par[nom]) par[nom] = { nom: nom, comptes: 0, archives: 0 };
  });
  const liste = Object.keys(par).map((k) => par[k])
    .sort((a, b) => b.comptes - a.comptes || a.nom.localeCompare(b.nom));
  liste.forEach((c) => { c.voisines = liste.filter((o) => voisines(c.nom, o.nom)).map((o) => o.nom); });
  return liste;
}

// ─── Le parc que FloodsFlow voit n'est pas celui que le Stockage affiche ───────────────
//
// LE DÉFAUT, ET IL EXPLIQUE « ça ne se met pas à jour ». L'écran des comptes lit le CLOUD
// (`AUTH_BASE` = phone-labs.pages.dev) et garde sa copie dans localStorage. Le backend local,
// lui, n'a qu'un miroir — et `syncFromBackend` ne le réalimente QUE si une modification locale
// vient d'avoir lieu (`localWins`, account_creation.jsx:268). Sans édition récente, un compte
// créé ailleurs arrive dans l'écran et n'atteint JAMAIS le backend.
//
// Or FloodsFlow lit le backend. D'où un concept qui ignore un compte pourtant visible dans le
// Stockage, et un « ça ne se met pas à jour » parfaitement fondé.
//
// On tire donc le cloud, puis on RECOPIE dans le backend local. Le sens compte : jamais
// l'inverse. Repousser le miroir vers le cloud ressusciterait des fiches périmées, et le cloud
// est la source de vérité — pas nous.
async function ffAlignerLeParc() {
  const magasin = window.AccountCreationStore;
  const base = (window.Backend && window.Backend.base) || "";
  if (!magasin || !magasin.list) return { ok: false, raison: "Stockage non chargé" };
  try {
    if (magasin.syncFromBackend) await magasin.syncFromBackend();   // cloud -> localStorage
  } catch (e) { /* hors ligne : on travaille avec ce qu'on a */ }

  const local = magasin.list() || [];
  if (!local.length) return { ok: false, raison: "aucun compte en local" };
  try {
    const r = await fetch(base + "/api/igaccounts", { cache: "no-store" });
    const d = await r.json();
    const backend = (d && (d.accounts || d)) || [];
    // On ne réécrit que si le contenu diffère : cette fonction tourne à chaque rechargement
    // du panneau, et réécrire 405 fiches pour rien coûte une sérialisation à chaque fois.
    if (JSON.stringify(backend) === JSON.stringify(local)) {
      return { ok: true, aligne: false, comptes: local.length };
    }
    await fetch(base + "/api/igaccounts", {
      method: "PUT", headers: { "content-type": "application/json" },
      body: JSON.stringify({ accounts: local }),
    });
    return { ok: true, aligne: true, comptes: local.length,
             ecart: local.length - (backend.length || 0) };
  } catch (e) {
    return { ok: false, raison: e.message };
  }
}

function Pastille({ ton = "neutre", children, titre }) {
  const c = COULEUR[ton] || COULEUR.neutre;
  return (
    <span title={titre} style={{
      display: "inline-flex", alignItems: "center", gap: 5, padding: "3px 9px",
      borderRadius: 99, fontSize: 11, fontWeight: 700, whiteSpace: "nowrap",
      background: ton === "neutre" ? "var(--surface-2)" : "color-mix(in srgb, " + c + " 12%, transparent)",
      color: ton === "neutre" ? "var(--muted)" : c,
      border: "1px solid " + (ton === "neutre" ? "var(--border)" : "color-mix(in srgb, " + c + " 30%, transparent)"),
    }}>{children}</span>
  );
}

// La jauge de couverture. Le repère à 3 jours est dessiné : sans lui, « 4,2 jours » ne dit
// pas si c'est confortable ou juste.
function JaugeCouverture({ p }) {
  const pleine = p.jours === Infinity ? 100 : Math.min(100, (p.jours / (COUVERTURE_MIN * 2)) * 100);
  const c = COULEUR[p.ton];
  return (
    <div style={{ marginTop: 10 }}>
      <div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", marginBottom: 5 }}>
        <span style={{ fontSize: 11.5, color: "var(--muted)" }}>
          {p.jours !== 0 || p.parJour
            ? <>chaque compte consomme <b style={{ color: "var(--text)" }}>{p.parJour}</b>/jour</>
            : "aucun compte connecté"}
        </span>
        <span className="mono" style={{ fontSize: 12.5, fontWeight: 800, color: c }}>
          {p.ton === "neutre" ? "—"
            : p.jours === Infinity ? "∞"
            : p.jours >= 100 ? "99+ jours"
            : p.jours.toFixed(1) + " jour" + (p.jours >= 2 ? "s" : "")}
        </span>
      </div>
      <div style={{ position: "relative", height: 7, borderRadius: 99, background: "var(--surface-2)", overflow: "hidden" }}>
        <div style={{ width: pleine + "%", height: "100%", background: c, transition: "width .25s" }} />
        {/* le seuil des 3 jours, à la moitié de l'échelle */}
        <div title={"minimum conseillé : " + COUVERTURE_MIN + " jours"} style={{
          position: "absolute", left: "50%", top: -2, bottom: -2, width: 2,
          background: "var(--border-2)", opacity: .9 }} />
      </div>
      {/* On ne parle QUE quand il y a quelque chose a dire — mais alors on parle VRAIMENT.
          « Chaque compte tient au moins 3 jours » repetait en toutes lettres ce que la jauge
          verte montrait deja, sur chaque carte : du bruit. En revanche la condition d'avant
          (`p.manque > 0`) se taisait exactement quand la jauge passait au ROUGE : `manque`
          compare le requis a la BANQUE du concept, alors que le ton vient de la file du compte
          le plus demuni. Un compte branche APRES un import a une file vide -- jauge a 0,0 jour
          en rouge, banque pleine, donc `manque` a zero, donc aucune explication. La ligne
          existait pile pour le cas ou elle ne s'affichait pas. */}
      {p.ton !== "ok" && (
        <div style={{ marginTop: 6, fontSize: 11,
          color: p.ton === "neutre" ? "var(--faint)" : COULEUR[p.ton] }}>
          {p.ton === "neutre"
            ? "Connecte des comptes pour estimer la couverture."
            : p.manque > 0
              ? <>Il manque <b>{p.manque}</b> contenu{p.manque > 1 ? "s" : ""} dans la banque pour tenir {COUVERTURE_MIN} jours.</>
              : <>La banque est fournie, mais <b>un compte au moins a une file vide ou courte</b> — relance l'étape 2 pour lui distribuer du contenu.</>}
        </div>
      )}
    </div>
  );
}

// ---------------------------------------------------------------------------
// La cle API bundle.social.
//
// Elle n'avait aucun champ dans PhoneLabs : le magasin de cles ne connaissait que fsim,
// gemini, getatext et herosms. On la saisit donc ici, la ou elle sert.
//
// DEUX PRECAUTIONS :
//
// 1. On LIT le magasin, on FUSIONNE, on reecrit. La route PUT /api/apikeys remplace la
//    totalite du magasin : envoyer seulement { bundlesocial } effacerait les quatre autres
//    cles, dont celle qui achete les numeros de telephone.
//
// 2. On n'affiche jamais la valeur, meme enregistree -- seulement sa presence et sa
//    longueur. Une cle lisible a l'ecran est une cle qui finit dans une capture d'ecran.
// ---------------------------------------------------------------------------
function CleAPI({ surAvis }) {
  const { useState, useEffect } = React;
  const [etat, setEtat] = useState(null);       // {presente, longueur}
  const [saisie, setSaisie] = useState("");
  const [ouvert, setOuvert] = useState(false);
  const [occupe, setOccupe] = useState(false);

  const base = () => (window.Backend && window.Backend.base) || "";
  const lire = async () => {
    try {
      const r = await fetch(base() + "/api/apikeys", { cache: "no-store" });
      const d = await r.json();
      const k = (d && (d.keys || d)) || {};
      const v = k.bundlesocial || "";
      setEtat({ presente: !!v, longueur: String(v).length });
    } catch (e) { setEtat({ presente: false, longueur: 0 }); }
  };
  useEffect(() => { lire(); }, []);

  const enregistrer = async () => {
    const v = saisie.trim();
    if (!v || occupe) return;
    setOccupe(true);
    try {
      const r = await fetch(base() + "/api/apikeys", { cache: "no-store" });
      const d = await r.json();
      const keys = Object.assign({}, (d && (d.keys || d)) || {});   // fusion, jamais remplacement
      keys.bundlesocial = v;
      const p = await fetch(base() + "/api/apikeys", {
        method: "PUT", headers: { "content-type": "application/json" },
        body: JSON.stringify({ keys: keys }),
      });
      if (!p.ok) throw new Error("le backend a répondu " + p.status);
      setSaisie(""); setOuvert(false);
      await lire();
      surAvis && surAvis({ ton: "ok", texte: "Plug enregistré. Les quatre autres clés du parc sont intactes." });
    } catch (e) {
      surAvis && surAvis({ ton: "ko", texte: "enregistrement impossible — " + e.message });
    }
    setOccupe(false);
  };

  const verifier = async () => {
    setOccupe(true);
    try {
      const r = await fetch(base() + "/api/floodsflow/verifier-cle", { method: "POST" });
      const d = await r.json();
      surAvis && surAvis(d.ok
        ? { ton: "ok", texte: "Plug actif — organisation « " + d.organisation + " », "
            + d.teams + " team(s) existante(s)." }
        : { ton: "ko", texte: d.error || "la vérification a échoué" });
    } catch (e) {
      surAvis && surAvis({ ton: "ko", texte: "vérification impossible — " + e.message });
    }
    setOccupe(false);
  };

  if (!etat) return null;
  return (
    <div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap",
      padding: "10px 14px", marginBottom: 13, borderRadius: 10, fontSize: 12.5,
      background: "var(--surface)", border: "1px solid " + (etat.presente ? "var(--border)" : "rgba(240,160,32,.35)") }}>
      <span style={{ color: etat.presente ? "var(--accent)" : "var(--busy, #f0a020)" }}>
        {etat.presente ? "●" : "○"}
      </span>
      <span style={{ flex: 1, minWidth: 200, color: "var(--muted)" }}>
        🔌 Plug Reverse API Insta {etat.presente
          ? <span style={{ color: "var(--faint)" }}>— enregistrée ({etat.longueur} caractères, jamais réaffichée)</span>
          : <span style={{ color: "var(--busy, #f0a020)" }}>— absente : rien ne peut être connecté ni publié</span>}
      </span>
      {ouvert ? (
        <React.Fragment>
          <input type="password" value={saisie} autoFocus
            onChange={(e) => setSaisie(e.target.value)}
            onKeyDown={(e) => { if (e.key === "Enter") enregistrer(); }}
            placeholder="colle la clé du plug ici"
            style={{ flex: "1 1 260px", padding: "7px 10px", borderRadius: 8, fontSize: 13,
              background: "var(--bg, #0b1220)", border: "1px solid var(--border)",
              color: "var(--text)", outline: "none", font: "inherit" }} />
          <button onClick={enregistrer} disabled={!saisie.trim() || occupe}
            style={{ ...btn(), background: saisie.trim() ? "var(--accent)" : "var(--border)",
              color: saisie.trim() ? "#06281c" : "var(--faint)", borderColor: "transparent" }}>
            {occupe ? "…" : "Enregistrer"}
          </button>
          <button onClick={() => { setOuvert(false); setSaisie(""); }} style={btn()}>Annuler</button>
        </React.Fragment>
      ) : (
        <React.Fragment>
          {etat.presente && (
            // LECTURE SEULE : ne cree rien chez le fournisseur. A faire avant de generer
            // 59 liens sur une cle fausse ou revoquee.
            <button onClick={verifier} disabled={occupe} style={btn()}
              title="Interroge bundle.social sans rien créer">
              {occupe ? "…" : "Vérifier"}
            </button>
          )}
          <button onClick={() => setOuvert(true)} style={btn()}>
            {etat.presente ? "Remplacer" : "Brancher"}
          </button>
        </React.Fragment>
      )}
    </div>
  );
}

// ---------------------------------------------------------------------------
// Le planning d'un concept.
//
// CE QU'IL ÉTAIT. Une liste à plat, triée par heure, où le 30 août côtoyait le 1er septembre
// sans qu'on sache lequel partirait. Reproche de l'opérateur, mot pour mot : « on voit le
// 30 août alors qu'on est le 31, on ne sait pas ce qui est en cours, ni ce qu'on peut
// annuler ». Les trois griefs disent la même chose : la liste montrait des données, pas un
// état.
//
// CE QU'IL EST. Trois blocs, dans l'ordre où ils demandent une décision :
//
//   1. LE PASSÉ QUI NE PARTIRA PLUS — regroupé en une seule ligne, avec le geste qui le
//      règle. Une publication dont l'heure est écoulée n'est jamais remise au fournisseur
//      (ce serait publier tout le retard d'un coup) : elle encombre, et rien d'autre.
//   2. CE QUI PARTIRA — par jour, « aujourd'hui » et « demain » nommés en toutes lettres,
//      avec le compte, l'heure locale, le contenu, et un bouton pour annuler CETTE ligne.
//   3. CE QUI EST BLOQUÉ — le fournisseur n'a pas répondu, un humain doit aller voir.
//
// ANNULER VEUT DIRE DEUX CHOSES, et le bouton le dit avant d'agir : une ligne prévue n'existe
// que chez nous, une ligne partie est un post réel chez bundle.social qu'il faut supprimer
// LÀ-BAS — sinon elle publie quand même et on a cessé de la surveiller.
//
// Les heures sont converties dans le fuseau de la machine : le backend raisonne en UTC
// (fenêtre 13h–23h UTC), on ne demande pas à un opérateur de faire la conversion.
// ---------------------------------------------------------------------------
function VuePlanning({ c, surAvis }) {
  const { useState, useEffect } = React;
  const [lignes, setLignes] = useState(null);
  const [occupe, setOccupe] = useState(false);

  const base = () => (window.Backend && window.Backend.base) || "";
  const charger = async () => {
    try {
      const r = await fetch(base() + "/api/floodsflow/concepts/" + c.id + "/planning",
        { cache: "no-store" });
      const d = await r.json();
      setLignes(d.planning || []);
    } catch (e) { setLignes([]); }
  };
  // On se recharge aussi quand un envoi part : c'est ce qui fait passer les lignes grises
  // « ne partira pas » au vert « remis au fournisseur », sans quoi la liste continue de dire
  // le contraire de ce qui vient de se produire.
  useEffect(() => {
    charger();
    const surEnvoi = (ev) => { if (!ev.detail || ev.detail.id === c.id) setTimeout(charger, 1200); };
    window.addEventListener("floodsflow:envoi", surEnvoi);
    return () => window.removeEventListener("floodsflow:envoi", surEnvoi);
  }, [c.id]);

  const [dialogue, setDialogue] = useState(false);

  // REPLIE PAR DEFAUT. Deploye, le planning occupait toute la carte -- trente lignes pour dix
  // jours -- alors qu'on ne le lit que pour verifier un detail. Le choix est retenu par concept.
  const cle = "ff-planning-" + c.id;
  const [ouvert, setOuvert] = useState(() => {
    try { return localStorage.getItem(cle) === "1"; } catch (e) { return false; }
  });
  const basculer = () => setOuvert((v) => {
    try { localStorage.setItem(cle, v ? "0" : "1"); } catch (e) {}
    return !v;
  });

  const heure = (iso) => {
    try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); }
    catch (e) { return iso.slice(11, 16); }
  };
  const jourLisible = (iso) => {
    const j = new Date(iso);
    const auj = new Date();
    const demain = new Date(auj.getTime() + 86400000);
    const meme = (a, b) => a.toDateString() === b.toDateString();
    const nom = j.toLocaleDateString([], { weekday: "long", day: "numeric", month: "long" });
    if (meme(j, auj)) return "Aujourd'hui · " + nom;
    if (meme(j, demain)) return "Demain · " + nom;
    return nom.charAt(0).toUpperCase() + nom.slice(1);
  };

  const maintenant = new Date().toISOString();
  const toutes = lignes || [];
  const bloquees = toutes.filter((l) => l.statut === "ENVOI");
  const perimees = toutes.filter((l) => l.statut === "PREVU" && l.quand <= maintenant);
  const avenir = toutes.filter((l) => l.statut !== "ENVOI" && l.quand > maintenant);

  const parJour = {};
  avenir.forEach((l) => { (parJour[l.quand.slice(0, 10)] = parJour[l.quand.slice(0, 10)] || []).push(l); });
  const jours = Object.keys(parJour).sort();
  const partiront = avenir.filter((l) => l.statut === "PROGRAMME" || l.statut === "BROUILLON").length;

  // On DEMANDE, on ne devine pas : seul l'opérateur peut aller regarder chez le fournisseur
  // si la publication existe. Les deux réponses mènent à des états opposés, et se tromper
  // coûte soit un doublon, soit un créneau muet.
  const debloquer = async (l) => {
    const parti = confirm(
      "Cette publication est restée bloquée : bundle.social n'a pas répondu, on ignore si "
      + "elle a été créée.\n\n@" + l.compte + " — " + heure(l.quand) + " — " + l.contenu
      + "\n\nVa voir dans bundle.social, puis choisis :\n\n"
      + "  OK       → elle EST là-bas : je la marque comme partie (elle ne sera pas renvoyée)\n"
      + "  Annuler  → elle n'y est PAS : je la remets en attente pour un nouvel essai");
    try {
      const r = await fetch(base() + "/api/floodsflow/concepts/" + c.id + "/planning", {
        method: "PATCH", headers: { "content-type": "application/json" },
        body: JSON.stringify({ compte: l.compte, quand: l.quand, deja_parti: parti }),
      });
      const d = await r.json();
      surAvis && surAvis(d.ok
        ? { ton: "ok", texte: "@" + l.compte + " · " + heure(l.quand) + " → " + d.statut }
        : { ton: "ko", texte: d.error || "déblocage impossible" });
      charger();
    } catch (e) {
      surAvis && surAvis({ ton: "ko", texte: "déblocage impossible — " + e.message });
    }
  };

  const appeler = async (corps, question) => {
    if (question && !confirm(question)) return;
    setOccupe(true);
    try {
      const r = await fetch(base() + "/api/floodsflow/concepts/" + c.id + "/annuler", {
        method: "POST", headers: { "content-type": "application/json" },
        body: JSON.stringify(corps),
      });
      const d = await r.json();
      const notes = (d.echecs || []).length ? " · échecs : " + d.echecs.join(" · ") : "";
      surAvis && surAvis(d.ok && !(d.echecs || []).length
        ? { ton: "ok", texte: d.annulees + " publication(s) annulée(s)" }
        : { ton: "ko", texte: (d.annulees || 0) + " annulée(s), " + (d.restent || 0)
            + " n'ont pas pu l'être" + notes });
      await charger();
    } catch (e) {
      surAvis && surAvis({ ton: "ko", texte: "annulation impossible — " + e.message });
    }
    setOccupe(false);
  };

  const annuler = (l) => appeler({ lignes: [{ compte: l.compte, quand: l.quand }] },
    l.statut === "PROGRAMME" || l.statut === "BROUILLON"
      ? "Cette publication est DÉJÀ chez bundle.social.\n\n@" + l.compte + " — "
        + jourLisible(l.quand) + " à " + heure(l.quand) + "\n" + l.contenu
        + "\n\nElle sera supprimée là-bas, donc elle ne publiera pas. Confirmer ?"
      : "Retirer cette publication du planning ?\n\n@" + l.compte + " — "
        + jourLisible(l.quand) + " à " + heure(l.quand)
        + "\n\nElle n'a jamais été envoyée : rien à défaire chez bundle.social.");

  // ── un jour, et ce qu'on peut en faire ────────────────────────────────────────────
  const LigneJour = ({ l }) => {
    const parti = l.statut === "PROGRAMME" || l.statut === "BROUILLON";
    return (
      <div style={{ display: "flex", gap: 9, padding: "5px 11px", fontSize: 12.5,
        alignItems: "center" }}>
        <span title={parti ? "remise à bundle.social — elle partira toute seule"
          : "programmée ici seulement — elle ne partira pas tant qu'on ne l'a pas envoyée"}
          style={{ width: 10, fontWeight: 800,
            color: parti ? "var(--accent)" : "var(--faint)" }}>{parti ? "✓" : "·"}</span>
        <span style={{ fontVariantNumeric: "tabular-nums", color: "var(--accent)",
          minWidth: 44 }}>{heure(l.quand)}</span>
        <span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis",
          whiteSpace: "nowrap" }}>{l.compte}</span>
        <span style={{ color: "var(--faint)", maxWidth: "38%", overflow: "hidden",
          textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{l.contenu}</span>
        <button onClick={() => annuler(l)} disabled={occupe} style={FF_LIEN_MINI}
          title={parti ? "Supprime la publication chez bundle.social"
            : "Retire la ligne du planning"}>annuler</button>
      </div>
    );
  };

  return (
    <div style={{ border: "1px solid var(--border)", borderRadius: 11, overflow: "hidden" }}>
      <div onClick={basculer} title={ouvert ? "Replier le planning" : "Déplier le planning"}
        style={{ display: "flex", alignItems: "center", gap: 8, padding: "9px 11px",
          cursor: "pointer", background: "var(--surface-2, rgba(255,255,255,.03))" }}>
        <span style={{ fontSize: 10, color: "var(--faint)", width: 10,
          display: "inline-block", transition: "transform .15s",
          transform: ouvert ? "rotate(90deg)" : "none" }}>▶</span>
        <span style={{ fontSize: 11.5, fontWeight: 700, letterSpacing: ".06em",
          textTransform: "uppercase", color: "var(--muted)" }}>Planning</span>
        {/* LE RÉSUMÉ REPLIÉ DIT CE QUI VA SE PASSER, pas combien de lignes il y a. « 9
            publications » ne disait pas si elles partiraient. */}
        <span style={{ flex: 1, minWidth: 0, fontSize: 11.5, overflow: "hidden",
          textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
          {lignes === null ? <span style={{ color: "var(--faint)" }}>…</span>
            : !toutes.length ? <span style={{ color: "var(--faint)" }}>rien de programmé</span>
            : (
              <React.Fragment>
                <b style={{ color: partiront ? "var(--accent)" : "var(--faint)" }}>{partiront}</b>
                <span style={{ color: "var(--muted)" }}> partiront</span>
                {avenir.length - partiront > 0 && (
                  <span style={{ color: "var(--faint)" }}> · {avenir.length - partiront} à envoyer</span>
                )}
                {perimees.length > 0 && (
                  <span style={{ color: "var(--busy, #f0a020)" }}> · {perimees.length} périmées</span>
                )}
                {bloquees.length > 0 && (
                  <span style={{ color: "var(--danger, #ff5c6c)" }}> · {bloquees.length} bloquées</span>
                )}
              </React.Fragment>
            )}
        </span>
        <button onClick={(e) => { e.stopPropagation(); setDialogue(true); }}
          style={{ ...btn(), padding: "5px 10px", fontSize: 12 }}
          title="Choisir les comptes, la durée et le nombre de publications par jour">
          🗓 Programmer…
        </button>
      </div>

      {dialogue && window.DialoguePlanning && (
        <window.DialoguePlanning concept={c} mode="programmer" surAvis={surAvis || (() => {})}
          surFermer={(fait) => { setDialogue(false); if (fait) charger(); }} />
      )}

      {!ouvert ? null : lignes === null ? (
        <div style={{ padding: "11px", fontSize: 12, color: "var(--faint)" }}>chargement…</div>
      ) : (
        <React.Fragment>
          {/* ── LE PASSÉ, GROUPÉ ET ÉCARTÉ ─────────────────────────────────────────── */}
          {perimees.length > 0 && (
            <div style={{ display: "flex", alignItems: "center", gap: 9, padding: "9px 11px",
              borderTop: "1px solid var(--border)", fontSize: 12,
              background: "rgba(240,160,32,.06)", color: "var(--busy, #f0a020)" }}>
              <span style={{ flex: 1 }}>
                <b>{perimees.length}</b> publication(s) dont l'heure est passée — elles ne
                partiront plus.
              </span>
              <button onClick={() => appeler({ perimees: true },
                "Retirer les " + perimees.length + " publications dont l'heure est passée ?"
                + "\n\nAucune n'a été envoyée à bundle.social : il n'y a rien à défaire là-bas.")}
                disabled={occupe} style={{ ...btn(), padding: "3px 9px", fontSize: 11 }}>
                Nettoyer
              </button>
            </div>
          )}

          {/* ── CE QUI PARTIRA ─────────────────────────────────────────────────────── */}
          {!jours.length ? (
            <div style={{ padding: "11px", fontSize: 12, color: "var(--faint)" }}>
              {perimees.length || bloquees.length
                ? "Rien à venir. Programme de nouveaux jours."
                : "Rien de programmé. La montée en charge démarre à 1 publication par jour et "
                  + "par compte la première semaine, puis monte jusqu'à "
                  + (c.rythme || RYTHME_DEFAUT) + "."}
            </div>
          ) : jours.map((j) => (
            <div key={j} style={{ borderTop: "1px solid var(--border)" }}>
              <div style={{ display: "flex", gap: 8, padding: "6px 11px", fontSize: 11,
                color: "var(--faint)", background: "rgba(255,255,255,.02)" }}>
                <span style={{ flex: 1 }}>{jourLisible(parJour[j][0].quand)}</span>
                <span>{parJour[j].length} publication{parJour[j].length > 1 ? "s" : ""}</span>
              </div>
              {parJour[j].map((l, i) => <LigneJour key={i} l={l} />)}
            </div>
          ))}

          {/* ── CE QUI EST BLOQUÉ ──────────────────────────────────────────────────── */}
          {bloquees.length > 0 && (
            <div style={{ borderTop: "1px solid var(--border)" }}>
              <div style={{ padding: "6px 11px", fontSize: 11, color: "var(--danger, #ff5c6c)",
                background: "rgba(255,92,108,.06)" }}>
                ⚠ {bloquees.length} envoi(s) interrompu(s) sans réponse — va voir chez
                bundle.social si la publication existe, puis tranche.
              </div>
              {bloquees.map((l, i) => (
                <div key={i} style={{ display: "flex", gap: 9, padding: "5px 11px",
                  fontSize: 12.5, alignItems: "center" }}>
                  <span style={{ width: 10, color: "var(--danger, #ff5c6c)", fontWeight: 800 }}>⚠</span>
                  <span style={{ fontVariantNumeric: "tabular-nums", color: "var(--muted)",
                    minWidth: 44 }}>{heure(l.quand)}</span>
                  <span style={{ flex: 1, minWidth: 0, overflow: "hidden",
                    textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{l.compte}</span>
                  <button onClick={() => debloquer(l)} style={FF_LIEN_MINI}>débloquer</button>
                </div>
              ))}
            </div>
          )}
        </React.Fragment>
      )}
    </div>
  );
}

const FF_LIEN_MINI = {
  background: "none", border: "1px solid var(--border-2)", borderRadius: 6,
  padding: "1px 7px", font: "inherit", fontSize: 11, fontWeight: 600,
  color: "var(--muted)", cursor: "pointer", flexShrink: 0,
};

// ─── La carte d'un concept ────────────────────────────────────────────────────────────
//
// CE QU'ELLE ÉTAIT. Huit boutons pleine largeur empilés, dont DEUX verts qui se disputaient
// l'attention — « Lancer la publication » et « Lancer le spoof et distribuer » — plus une
// ligne de stock, un panneau de planning déplié, et trois boutons secondaires du même poids
// visuel que les principaux. Rien ne disait ce qu'il restait à faire, ni dans quel ordre.
//
// CE QU'ELLE EST. Le travail est une CHAÎNE, et elle va toujours dans le même sens :
//
//     ① approvisionner la banque  →  ② spoofer et distribuer  →  ③ programmer et publier
//
// Chaque étape a besoin de la précédente : sans contenu, rien à spoofer ; sans fichier
// spoofé, rien à publier. La carte montre donc les trois d'un coup, avec leur état réel, et
// n'accentue QUE celle qui est jouable maintenant. Le reste — dossier, contenus, suppression —
// descend dans un pied de page discret : ce sont des gestes de vérification, pas de travail.
function EtapeLigne({ n, titre, etat, sousTitre, action, libelle, actif, bloque, premiere }) {
  return (
    <div style={{
      display: "flex", alignItems: "center", gap: 11, padding: "10px 12px",
      borderTop: premiere ? "none" : "1px solid var(--border)",
      background: actif ? "color-mix(in srgb, var(--accent) 6%, transparent)" : "transparent",
      opacity: bloque ? .5 : 1,
    }}>
      <span style={{
        width: 20, height: 20, borderRadius: 99, flexShrink: 0, fontSize: 11, fontWeight: 800,
        display: "grid", placeItems: "center",
        background: actif ? "var(--accent)" : "var(--surface-2, rgba(255,255,255,.06))",
        color: actif ? "#06281c" : "var(--faint)",
      }}>{n}</span>
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ fontSize: 12.5, fontWeight: 600 }}>{titre}</div>
        <div style={{ fontSize: 11, color: "var(--faint)", overflow: "hidden",
          textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{sousTitre}</div>
      </div>
      {etat}
      <button onClick={bloque ? undefined : action} disabled={!!bloque}
        style={{ ...btn(), padding: "6px 12px", fontSize: 12, flexShrink: 0,
          cursor: bloque ? "default" : "pointer",
          background: actif ? "var(--accent)" : "var(--surface-2)",
          color: actif ? "#06281c" : "var(--text)",
          borderColor: actif ? "var(--accent)" : "var(--border-2)" }}>{libelle}</button>
    </div>
  );
}

function CarteConcept({ c, onLiens, onSync, onDossiers, onModifier, onContenu, onDistribuer, onVoir, onSupprimer, onRecharger, surAvis }) {
  const { useState } = React;
  // Le panneau de publication : QUOI, POUR QUI, COMBIEN PAR JOUR, PENDANT COMBIEN DE JOURS.
  // Le bouton lançait autrefois un appel direct qui répondait « envoi reel pas encore
  // branche » — rien n'était offert à l'opérateur, et rien ne partait.
  const [panneau, setPanneau] = useState(false);
  const [sante, setSante] = useState(false);
  const p = provision(c.jours_couverture, c.banque, c.connectes, c.rythme || RYTHME_DEFAUT);
  const enAttente = Math.max(0, c.comptes - c.connectes);
  const et = c.etapes || {};
  const stock = c.stock || {};

  // QUELLE ÉTAPE EST JOUABLE ? Une seule à la fois est mise en avant : c'est ce qui remplace
  // la lecture des huit boutons. L'ordre suit la dépendance réelle, pas une préférence.
  const aDuContenu = (et.banque || c.banque || 0) > 0;
  const aDesFichiers = (et.spoofes || 0) > 0 && (et.spoofes || 0) >= (et.distribues || 0);
  const etape = !aDuContenu ? 1 : !aDesFichiers ? 2 : 3;

  return (
    <div style={{
      background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 14,
      padding: 17, display: "flex", flexDirection: "column", gap: 12,
    }}>
      <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
        <h3 style={{ margin: 0, fontSize: 16, letterSpacing: "-.01em", flex: 1 }}>{c.nom}</h3>
        <Pastille ton={c.actif ? "ok" : "neutre"}>{c.actif ? "● ACTIF" : "○ EN PAUSE"}</Pastille>
      </div>

      {/* D'où viennent ces comptes. Sans cette ligne, deux concepts de 8 comptes sont
          indiscernables : on ne sait plus lequel travaille sur quelle catégorie du parc.
          La ligne est CLIQUABLE : se tromper de catégorie à la création est l'erreur la plus
          facile à faire, et il fallait sinon supprimer le concept — donc perdre sa banque et
          son planning — pour la corriger. */}
      <div onClick={() => onModifier && onModifier(c)}
        title="Changer les catégories, le nom ou le rythme de ce concept"
        style={{ display: "flex", gap: 6, flexWrap: "wrap", alignItems: "center",
          marginTop: -6, fontSize: 11.5, color: "var(--faint)", cursor: "pointer" }}>
        <span>comptes de</span>
        {(c.categories || []).length ? c.categories.map((cat) => (
          <span key={cat} style={{ padding: "2px 8px", borderRadius: 999, fontSize: 11.5,
            background: "var(--surface-2, rgba(255,255,255,.05))",
            border: "1px solid var(--border)", color: "var(--text)" }}>{cat}</span>
        )) : (
          <span style={{ fontStyle: "italic" }}>tout le parc</span>
        )}
        <span style={{ opacity: .6 }}>✎</span>
      </div>

      {/* L'état du parc en UNE ligne. Trois pastilles séparées disaient la même chose en
          prenant trois fois la place. */}
      <div style={{ display: "flex", gap: 7, flexWrap: "wrap", alignItems: "center",
        fontSize: 12, color: "var(--muted)" }}>
        <b style={{ color: "var(--text)" }}>{c.comptes}</b> comptes
        <span style={{ color: "var(--border-2)" }}>·</span>
        <b style={{ color: c.connectes ? "var(--accent)" : "var(--faint)" }}>{c.connectes}</b> connectés
        {enAttente > 0 && (
          <React.Fragment>
            <span style={{ color: "var(--border-2)" }}>·</span>
            <b style={{ color: "var(--busy, #f0a020)" }}>{enAttente}</b> en attente
          </React.Fragment>
        )}
      </div>

      <JaugeCouverture p={p} />

      {/* AUTHENTIFICATION — une bande à part, et seulement quand il y a quelque chose à faire.
          Un lien vaut pour un compte et meurt au bout de 48 h. On distingue « il en manque »
          de « il y en a de morts » : ce n'est pas le même geste, et un lien mort ressemble à
          un lien bon jusqu'à ce qu'un VA y perde dix minutes. */}
      {/* TOUJOURS VISIBLE, ET C'EST UNE CORRECTION. Cette bande n'apparaissait que si un compte
          était en attente : dès que tout était branché, « Générer les liens » disparaissait —
          exactement au moment où l'on veut ajouter un compte et où on la cherche. Un bouton
          caché est un bouton qu'on ne trouve pas. Elle reste donc là, et change de ton. */}
      {c.comptes > 0 && (
        <div style={{ borderRadius: 11, padding: "10px 12px",
          border: "1px solid " + (enAttente ? "rgba(240,160,32,.28)" : "var(--border)"),
          background: enAttente ? "rgba(240,160,32,.06)" : "var(--surface-2, rgba(255,255,255,.02))",
          display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
          <span style={{ flex: 1, minWidth: 120, fontSize: 12,
            color: enAttente ? "var(--busy, #f0a020)" : "var(--faint)" }}>
            {enAttente
              ? enAttente + " compte(s) à brancher à l'API"
              : "Tous branchés — ajoute un compte à « " + ((c.categories || [])[0] || "la catégorie")
                + " » pour en brancher un de plus"}
            {c.liens_valides > 0 ? " · " + c.liens_valides + " liens prêts" : ""}
            {c.liens_perimes > 0 ? " · " + c.liens_perimes + " périmés" : ""}
          </span>
          <button onClick={() => onLiens && onLiens(c)} style={{ ...btn(), padding: "5px 10px", fontSize: 12 }}
            title="Un lien par compte en attente, valable 48 h. Les liens encore valides ne sont pas retouchés.">
            ↧ Générer les liens
          </button>
          <button onClick={() => onSync && onSync(c)} style={{ ...btn(), padding: "5px 10px", fontSize: 12 }}
            title="Demande à bundle.social quels comptes sont réellement connectés">
            ⟳ Vérifier
          </button>
        </div>
      )}

      {/* LA CHAÎNE. Trois lignes, un seul bouton accentué : celui qu'on peut jouer. */}
      <div style={{ border: "1px solid var(--border)", borderRadius: 11, overflow: "hidden" }}>
        <EtapeLigne premiere n="1" titre="Contenu source" actif={etape === 1}
          sousTitre={FF_RESUME_STOCK(stock)}
          libelle="Approvisionner" action={() => onContenu && onContenu(c)} />
        <EtapeLigne n="2" titre="Spoof et distribution" actif={etape === 2}
          bloque={!aDuContenu || !c.connectes}
          sousTitre={!c.connectes ? "aucun compte branché"
            : !aDuContenu ? "la banque est vide"
            : (et.spoofes || 0) + " fichier(s) spoofé(s) sur " + (et.distribues || 0) + " distribué(s)"}
          libelle="Lancer" action={() => onDistribuer && onDistribuer(c)} />
        <EtapeLigne n="3" titre="Publication" actif={etape === 3}
          bloque={!c.connectes}
          sousTitre={!c.connectes ? "aucun compte branché"
            : (et.partis || 0) + " remise(s) à bundle.social · " + (et.prevus || 0) + " programmée(s) ici"
              // « ENVOI » n'est ni parti ni prévu : le fournisseur n'a pas répondu et le post
              // a peut-être été créé. C'est le seul état qui demande un œil humain — l'omettre
              // le faisait disparaître de la carte, planning replié par-dessus.
              + ((et.bloques || 0) ? " · ⚠ " + et.bloques + " à vérifier chez bundle.social" : "")}
          libelle="Publier…" action={() => setPanneau(true)} />
      </div>

      {/* ON RECHARGE APRES LE SEUL GESTE IRREVERSIBLE DE L'ECRAN. Sans cela, l'envoi partait,
          la barre montait a 21/21 -- et tout le reste continuait d'affirmer le contraire : le
          sous-titre de l'etape 3 gardait ses anciens chiffres, chaque ligne du planning gardait
          son point gris « ne partira pas », et le bandeau orange du tableau de bord reclamait
          un envoi deja fait. L'operateur relance, la reservation l'en empeche, et il ne
          comprend toujours pas. */}
      {panneau && window.DialoguePlanning && (
        <window.DialoguePlanning concept={c} surAvis={surAvis || (() => {})}
          surFermer={(fait) => { setPanneau(false); if (fait && onRecharger) onRecharger(); }} />
      )}

      {/* Les deux travaux de fond. Ils restent distincts à dessein : le spoof ne fait que des
          fichiers, l'envoi est le seul geste irréversible de cet écran. */}
      {window.BarreSpoof ? <window.BarreSpoof concept={c} /> : null}
      {window.BarreEnvoi ? <window.BarreEnvoi concept={c} /> : null}

      {c.connectes > 0 && <VuePlanning c={c} surAvis={surAvis} />}

      {/* Pied de page : des gestes de VÉRIFICATION, pas de travail. Ils avaient le même poids
          visuel que « Lancer la publication », ce qui n'a aucun sens. */}
      {sante && window.DialogueSante && (
        <window.DialogueSante concept={c} surAvis={surAvis || (() => {})}
          surFermer={(fait) => { setSante(false); if (fait && onRecharger) onRecharger(); }} />
      )}

      <div style={{ display: "flex", gap: 14, alignItems: "center", flexWrap: "wrap",
        fontSize: 11.5, paddingTop: 2 }}>
        {/* CE QUI MANQUAIT : rien ne savait qu'un compte etait mort. Ni notre parc, ni
            bundle.social -- qui ne connait que le branchement, pas la sante. On a donc
            programme des publications sur des comptes bannis sans le moindre avertissement.
            Le verdict est enregistre, et « lancer la publication » refuse desormais un compte
            connu mort. */}
        <button onClick={() => setSante(true)} style={FF_LIEN}
          title="Interroge bundle.social et Instagram pour savoir quels comptes sont morts, détachés ou masqués">
          🩺 Vérifier les comptes
        </button>
        <button onClick={() => onDossiers && onDossiers(c, true)} style={FF_LIEN}
          title="Range les dossiers des comptes qui ne sont plus branchés dans « _anciens », crée ceux qui manquent, puis ouvre">
          📁 Ouvrir le dossier
        </button>
        {c.banque > 0 && (
          <button onClick={() => onVoir && onVoir(c)} style={FF_LIEN}>
            ▸ Voir les {c.banque} contenus
          </button>
        )}
        <span style={{ flex: 1 }} />
        <button onClick={() => onSupprimer && onSupprimer(c)}
          style={{ ...FF_LIEN, color: "var(--danger, #ff5c6c)" }}>🗑 Supprimer</button>
      </div>
    </div>
  );
}

// Le stock en une phrase courte, plutôt qu'en quatre pastilles dont trois à zéro.
function FF_RESUME_STOCK(stock) {
  const pleins = (window.FF_FORMATS || []).filter((f) => (stock[f.id] || 0) > 0);
  if (!pleins.length) return "banque vide — aucun contenu déposé";
  return pleins.map((f) => f.emoji + " " + (stock[f.id] || 0) + " " + f.label.toLowerCase()).join("  ");
}

const FF_LIEN = {
  background: "none", border: "none", padding: 0, font: "inherit", fontSize: 11.5,
  color: "var(--muted)", cursor: "pointer",
};

const btn = () => ({
  display: "inline-flex", alignItems: "center", gap: 6, padding: "7px 13px", borderRadius: 8,
  fontSize: 12.5, fontWeight: 600, cursor: "pointer", border: "1px solid var(--border-2)",
  background: "var(--surface-2)", color: "var(--text)",
});

// ─── La vue d'ensemble ────────────────────────────────────────────────────────────────
//
// Sans prop `concepts`, elle lit le backend. Avec, elle affiche ce qu'on lui donne — c'est
// ainsi que l'aperçu hors PhoneLabs fonctionne, sur le VRAI composant.
// ---------------------------------------------------------------------------
// Création d'un concept.
//
// Un vrai dialogue, et non window.prompt() : Electron n'implemente PAS prompt().
// Le bouton appelait donc une fonction qui ne rendait rien -- pas de fenetre, pas
// de message, pas d'erreur. Juste un clic sans effet.
//
// Un concept ne CONTIENT pas des comptes : il porte un FILTRE sur les categories
// du parc. Rien a synchroniser -- un compte qui change de categorie change de
// concept tout seul. D'ou l'apercu sous les cases : il montre le parc reel
// derriere le filtre AVANT de valider, pour qu'on ne cree pas un concept vide
// sans s'en apercevoir.
// ---------------------------------------------------------------------------
function DialogueConcept({ cats, concept, surAnnuler, surCreer }) {
  const { useState, useEffect, useRef } = React;
  const [nom, setNom] = useState((concept && concept.nom) || "");
  // La liste des categories est RELUE ici, et non plus seulement heritee : c'est la seule
  // facon de voir un compte ajoute a une categorie depuis l'ouverture du panneau.
  const [liste, setListe] = useState(cats || []);
  const [occupe, setOccupe] = useState("");
  const [sante, setSante] = useState(false);
  const [choisies, setChoisies] = useState((concept && concept.categories) || []);
  const [rythme, setRythme] = useState((concept && concept.rythme) || RYTHME_DEFAUT);
  const [envoi, setEnvoi] = useState(false);
  const [err, setErr] = useState("");
  const champ = useRef(null);

  const rafraichir = async () => {
    setOccupe("cats");
    try {
      const base = (window.Backend && window.Backend.base) || "";
      const r = await fetch(base + "/api/floodsflow/concepts", { cache: "no-store" });
      const d = await r.json();
      setListe(await catalogueCategories(d.categories || []));
    } catch (e) { setErr("actualisation impossible — " + e.message); }
    setOccupe("");
  };

  useEffect(() => { if (champ.current) champ.current.focus(); }, []);
  useEffect(() => { rafraichir(); }, []);
  useEffect(() => {
    const k = (e) => { if (e.key === "Escape") surAnnuler(); };
    window.addEventListener("keydown", k);
    return () => window.removeEventListener("keydown", k);
  }, [surAnnuler]);

  const bascule = (n) =>
    setChoisies((l) => (l.indexOf(n) >= 0 ? l.filter((x) => x !== n) : l.concat(n)));

  // Aucune case cochee = tout le parc (c'est ce que fait le backend). On l'affiche,
  // pour que ce ne soit pas une surprise apres coup.
  // Les comptes ARCHIVES ne comptent pas : ils sont sortis du travail, leur iPhone est
  // libere. Les inclure faisait annoncer 59 comptes la ou il n'y en avait aucun de vivant.
  const vises = (choisies.length ? liste.filter((c) => choisies.indexOf(c.nom) >= 0) : liste)
    .reduce((a, c) => a + (c.comptes || 0), 0);
  const parJour = vises * Math.max(1, Number(rythme) || RYTHME_DEFAUT);
  const requis = parJour * COUVERTURE_MIN;

  const valider = async () => {
    if (!nom.trim() || envoi) return;
    setEnvoi(true); setErr("");
    try {
      await surCreer({ nom: nom.trim(), categories: choisies,
        rythme: Math.max(1, Number(rythme) || RYTHME_DEFAUT) });
    } catch (e) { setErr(String((e && e.message) || e)); setEnvoi(false); }
  };

  const etiquette = { fontSize: 11, fontWeight: 700, letterSpacing: ".06em",
    textTransform: "uppercase", color: "var(--muted)", marginBottom: 6, display: "block" };
  const saisie = { width: "100%", padding: "9px 11px", borderRadius: 9, fontSize: 13.5,
    background: "var(--bg, #0b1220)", border: "1px solid var(--border)", color: "var(--text)",
    outline: "none", boxSizing: "border-box", font: "inherit" };

  return (
    <div onMouseDown={(e) => { if (e.target === e.currentTarget) surAnnuler(); }}
      style={{ position: "fixed", inset: 0, zIndex: 9000, background: "rgba(3,7,15,.72)",
        display: "flex", alignItems: "center", justifyContent: "center", padding: 20 }}>
      <div style={{ width: "min(520px, 100%)", maxHeight: "88vh", overflowY: "auto",
        background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 15,
        padding: "20px 22px", boxShadow: "0 24px 60px rgba(0,0,0,.55)" }}>

        <div style={{ fontSize: 16, fontWeight: 800, marginBottom: 3 }}>{concept ? "Modifier « " + concept.nom + " »" : "Nouveau concept"}</div>
        <div style={{ fontSize: 12, color: "var(--faint)", marginBottom: 18 }}>
          Un concept regroupe des comptes du parc et leur banque de contenu.
        </div>

        <label style={etiquette}>Nom</label>
        <input ref={champ} value={nom} onChange={(e) => setNom(e.target.value)}
          onKeyDown={(e) => { if (e.key === "Enter") valider(); }}
          placeholder="ex. Voitures de luxe" style={{ ...saisie, marginBottom: 18 }} />

        {/* LA LISTE VIEILLIT. Elle arrive en propriété, chargée une fois à l'ouverture du
            panneau : un compte ajouté à une catégorie depuis, ou déplacé, n'apparaît pas —
            on cochait alors une catégorie en croyant y trouver des comptes qui n'y sont plus.
            Elle se recharge à l'ouverture de ce dialogue, et à la demande. */}
        <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
          <label style={{ ...etiquette, marginBottom: 0, flex: 1 }}>Catégories du parc</label>
          <button onClick={rafraichir} disabled={!!occupe} style={FF_LIEN_MINI}
            title="Relit le parc : un compte ajouté ou déplacé depuis l'ouverture apparaîtra">
            {occupe === "cats" ? "…" : "↻ Actualiser"}
          </button>
          <button onClick={() => setSante(true)} disabled={!!occupe} style={FF_LIEN_MINI}
            title="Interroge bundle.social et Instagram sur les comptes de ces catégories, puis propose de ranger les morts dans « Compte Ban »">
            🩺 Vérifier
          </button>
        </div>
        {sante && window.DialogueSante && (
          <window.DialogueSante concept={concept || { id: "_", nom: nom, branches: [] }}
            categories={choisies}
            surAvis={(a) => setErr(a.ton === "ko" ? a.texte : "")}
            surFermer={(fait) => { setSante(false); if (fait) rafraichir(); }} />
        )}
        <div style={{ border: "1px solid var(--border)", borderRadius: 10, overflow: "hidden",
          marginBottom: 8 }}>
          {liste.length ? liste.map((c, i) => {
            const coche = choisies.indexOf(c.nom) >= 0;
            return (
              <div key={c.nom} onClick={() => bascule(c.nom)}
                style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 11px",
                  cursor: "pointer", fontSize: 13,
                  borderTop: i ? "1px solid var(--border)" : "none",
                  background: coche ? "rgba(34,224,122,.09)" : "transparent" }}>
                <span style={{ width: 15, height: 15, borderRadius: 4, flexShrink: 0,
                  border: "1px solid " + (coche ? "var(--accent)" : "var(--border)"),
                  background: coche ? "var(--accent)" : "transparent", color: "#06281c",
                  fontSize: 11, fontWeight: 900, lineHeight: "14px", textAlign: "center" }}>
                  {coche ? "✓" : ""}
                </span>
                <span style={{ flex: 1 }}>{c.nom}</span>
                {/* Le nombre d'ARCHIVES a cote du nombre d'actifs. « 0 » tout seul laisse
                    croire a une categorie neuve ; « 0 · 59 archivés » dit qu'elle a servi
                    et qu'elle a ete vidangee. */}
                {c.archives > 0 && (
                  <span style={{ color: "var(--faint)", fontSize: 11 }}
                    title={c.archives + " compte(s) archivé(s) : sortis du travail, iPhone libéré"}>
                    {c.archives} arch.
                  </span>
                )}
                {(c.voisines || []).length > 0 && (
                  <span title={"Ressemble à : " + c.voisines.join(", ")
                    + " — deux catégories proches sont presque toujours un doublon, et les"
                    + " comptes finissent répartis entre les deux sans qu'on s'en aperçoive."}
                    style={{ color: "var(--busy, #f0a020)", fontSize: 12 }}>⚠</span>
                )}
                <span style={{ color: c.comptes ? "var(--faint)" : "var(--busy, #f0a020)",
                  fontSize: 12, minWidth: 22, textAlign: "right" }}>{c.comptes}</span>
              </div>
            );
          }) : (
            <div style={{ padding: "12px 11px", fontSize: 12.5, color: "var(--faint)" }}>
              Aucune catégorie dans le parc.
            </div>
          )}
        </div>
        <div style={{ fontSize: 11.5, color: "var(--faint)", marginBottom: 18 }}>
          {choisies.length
            ? "Le concept suit ces catégories : un compte qui en change le quitte ou le rejoint tout seul."
            : "Aucune cochée = tout le parc."}
        </div>

        <label style={etiquette}>Rythme</label>
        <div style={{ display: "flex", alignItems: "center", gap: 9, marginBottom: 16 }}>
          <input type="number" min="1" max="24" value={rythme}
            onChange={(e) => setRythme(e.target.value)}
            style={{ ...saisie, width: 78, flex: "0 0 auto" }} />
          <span style={{ fontSize: 12.5, color: "var(--faint)" }}>publications par jour et par compte</span>
        </div>

        {/* L'apercu : ce que ce concept exigera reellement une fois cree. */}
        <div style={{ padding: "11px 13px", borderRadius: 10, marginBottom: 16,
          background: "var(--bg, #0b1220)", border: "1px solid var(--border)", fontSize: 12.5 }}>
          <b style={{ color: "var(--text)" }}>{vises}</b>
          <span style={{ color: "var(--faint)" }}> compte{vises > 1 ? "s" : ""} visé{vises > 1 ? "s" : ""} · </span>
          <b style={{ color: "var(--text)" }}>{parJour}</b>
          <span style={{ color: "var(--faint)" }}> contenus/jour une fois tous connectés · il en faudra </span>
          <b style={{ color: parJour ? "var(--accent)" : "var(--faint)" }}>{requis}</b>
          <span style={{ color: "var(--faint)" }}> en banque pour tenir {COUVERTURE_MIN} jours.</span>
        </div>

        {err ? (
          <div style={{ padding: "9px 12px", borderRadius: 9, marginBottom: 14, fontSize: 12.5,
            background: "rgba(255,92,108,.08)", border: "1px solid rgba(255,92,108,.25)",
            color: "var(--danger, #ff5c6c)" }}>⚠ {err}</div>
        ) : null}

        <div style={{ display: "flex", gap: 9, justifyContent: "flex-end" }}>
          <button onClick={surAnnuler} style={btn()}>Annuler</button>
          <button onClick={valider} disabled={!nom.trim() || envoi}
            style={{ ...btn(), background: nom.trim() ? "var(--accent)" : "var(--border)",
              color: nom.trim() ? "#06281c" : "var(--faint)",
              borderColor: nom.trim() ? "var(--accent)" : "var(--border)",
              cursor: nom.trim() && !envoi ? "pointer" : "default" }}>
            {envoi ? "…" : concept ? "Enregistrer" : "Créer le concept"}
          </button>
        </div>
      </div>
    </div>
  );
}

function FloodsFlowOverview({ concepts: fournis, onCreer: creerExterne, ...actions }) {
  const { useState, useEffect } = React;
  const [charges, setCharges] = useState(null);
  const [cats, setCats] = useState([]);
  const [erreur, setErreur] = useState("");

  const recharger = async () => {
    try {
      const base = (window.Backend && window.Backend.base) || "";
      // LE PARC D'ABORD. Sans cela, on affiche des concepts calculés sur un miroir périmé :
      // un compte créé ailleurs est visible dans le Stockage et absent d'ici, ce qui donne
      // « ça ne se met pas à jour » — et c'est vrai.
      const al = await ffAlignerLeParc();
      if (al.aligne) {
        setAvis({ ton: "info", texte: "Parc réaligné sur le cloud : " + al.comptes
          + " comptes" + (al.ecart ? " (" + (al.ecart > 0 ? "+" : "") + al.ecart + ")" : "") });
      }
      const r = await fetch(base + "/api/floodsflow/concepts", { cache: "no-store" });
      const d = await r.json();
      if (d.error) throw new Error(d.error);
      setCharges(d.concepts || []); setErreur("");
      // On fusionne avec les categories DECLAREES (cloud) : une categorie vide
      // n'apparait pas dans le parc, et c'est justement celle qu'on cherche a tort.
      setCats(await catalogueCategories(d.categories || []));
    } catch (e) {
      // Un backend pas encore relancé ne connaît pas la route : on le dit, plutôt que
      // d'afficher un écran vide qui ressemble à « aucun concept ».
      setCharges([]); setErreur("Backend injoignable ou pas encore relancé — " + e.message);
    }
  };
  useEffect(() => { if (!fournis) recharger(); }, []);

  // false = ferme, true = creation, un concept = modification. Sans la modification,
  // se tromper de categorie a la creation obligeait a supprimer et tout refaire --
  // en perdant la banque et le planning du concept au passage.
  const [dialogue, setDialogue] = useState(false);
  const [contenu, setContenu] = useState(null);        // concept dont on remplit la banque
  const [distrib, setDistrib] = useState(null);        // concept dont on repartit le stock

  // On remonte l'echec au dialogue au lieu de le taire : un concept qui n'est pas
  // cree doit le dire, sinon la liste reste vide sans explication.
  const envoyer = async (donnees) => {
    const base = (window.Backend && window.Backend.base) || "";
    const r = await fetch(base + "/api/floodsflow/concepts", {
      method: "POST", headers: { "content-type": "application/json" },
      body: JSON.stringify(donnees),
    });
    const d = await r.json().catch(() => ({}));
    if (!r.ok || d.error || d.ok === false) throw new Error(d.error || ("le backend a repondu " + r.status));
    setDialogue(false);
    await recharger();
  };

  // Modifier passe par PATCH sur le concept existant : sa banque, ses files et son planning
  // restent en place. Recreer aurait tout perdu.
  const modifier = async (donnees) => {
    const base = (window.Backend && window.Backend.base) || "";
    const r = await fetch(base + "/api/floodsflow/concepts/" + dialogue.id, {
      method: "PATCH", headers: { "content-type": "application/json" },
      body: JSON.stringify(donnees),
    });
    const d = await r.json().catch(() => ({}));
    if (!r.ok || d.error || d.ok === false) throw new Error(d.error || ("le backend a repondu " + r.status));
    setDialogue(false);
    await recharger();
  };
  const creer = creerExterne || (() => setDialogue(true));

  // Ces deux actions parlent au fournisseur : elles peuvent echouer pour des raisons
  // parfaitement normales (pas de cle, pas de contenu, aucun compte branche). Le refus est
  // affiche tel quel -- un bouton qui ne fait rien sans expliquer est la pire des reponses.
  const [avis, setAvis] = useState(null);      // {ton, texte}

  const appeler = async (chemin, corps) => {
    const base = (window.Backend && window.Backend.base) || "";
    const r = await fetch(base + chemin, {
      method: "POST", headers: { "content-type": "application/json" },
      body: JSON.stringify(corps || {}),
    });
    return r.json().catch(() => ({ error: "reponse illisible du backend" }));
  };

  const genererLiens = actions.onLiens || (async (c) => {
    setAvis({ ton: "info", texte: "Génération des liens de « " + c.nom + " »…" });
    const d = await appeler("/api/floodsflow/concepts/" + c.id + "/liens", { methode: "INSTAGRAM" });
    setAvis(d.ok
      ? { ton: "ok", texte: d.generes + " lien(s) généré(s)" +
          (d.deja_valides ? ", " + d.deja_valides + " encore valide(s) et laissé(s) intact(s)" : "") }
      : { ton: "ko", texte: d.error || "la génération a échoué" });
    await recharger();
  });

  const synchroniser = actions.onSync || (async (c) => {
    setAvis({ ton: "info", texte: "Interrogation de bundle.social…" });
    const d = await appeler("/api/floodsflow/concepts/" + c.id + "/sync");
    if (!d.ok) { setAvis({ ton: "ko", texte: d.error || "la synchronisation a echoue" }); return; }
    const bouts = [];
    if (d.confirmes && d.confirmes.length) bouts.push(d.confirmes.length + " confirme(s) branche(s)");
    // Le cas qui compte : declare branche, mais le fournisseur ne voit rien. Sans cette
    // ligne le compte serait sorti de la liste a faire sans jamais publier.
    if (d.infirmes && d.infirmes.length) {
      bouts.push("REMIS EN ATTENTE (non branches cote API) : " + d.infirmes.join(", "));
    }
    if (d.sans_lien) bouts.push(d.sans_lien + " sans lien genere");
    setAvis({ ton: (d.infirmes && d.infirmes.length) ? "ko" : "ok",
      texte: bouts.join(" · ") || "rien a corriger : l'etat local etait juste" });
    await recharger();
  });

  // ── Les dossiers sur le disque ──────────────────────────────────────────────────────
  // Un dossier « @pseudo » par compte, avec reels / story / photo dedans. On peut deposer a
  // la main et verifier a l'oeil, sans ouvrir l'application.
  // UN SEUL GESTE, ET IL SYNCHRONISE. On ouvrait un concept de 3 comptes branches et on y
  // trouvait 7 dossiers @pseudo : la fonction n'ajoutait jamais que des dossiers et n'en
  // retirait aucun. Ce qui n'a plus de compte branche en face descend dans « _anciens » --
  // deplace, jamais efface : ces dossiers peuvent contenir du contenu depose a la main.
  const creerDossiers = actions.onDossiers || (async (c, ouvrir) => {
    setAvis({ ton: "info", texte: "Synchronisation des dossiers…" });
    const d = await appeler("/api/floodsflow/concepts/" + c.id + "/dossiers", { ouvrir: !!ouvrir });
    if (!d.ok) { setAvis({ ton: "ko", texte: d.error || "synchronisation impossible" }); return d; }
    // UN RANGEMENT QUI N'A PAS EU LIEU DOIT SE DIRE. Sans ces trois cas, l'écran annonçait
    // « 0 dossier » du même ton bleu tranquille, qu'il ait tout rangé, rien rangé, ou refusé
    // de toucher au dossier d'un autre concept — et on croyait les dossiers à jour.
    const BLOQUE = {
      "aucun-compte-attendu": "AUCUN rangement : pas un seul compte branché ne correspond aux "
        + "catégories de ce concept. Vérifie qu'une catégorie n'a pas été renommée dans le "
        + "Stockage — les dossiers existants ont été laissés en place.",
      "racine-d-un-autre-concept": "AUCUN rangement : ce dossier appartient à un autre concept "
        + "(" + (d.autre_concept || "inconnu") + "). Deux concepts qui portent le même nom se "
        + "partagent une racine — renomme l'un des deux.",
      "spoof-en-cours": "AUCUN rangement : un spoof écrit en ce moment dans ces dossiers. "
        + "Relance quand il aura fini.",
    };
    const notes = [];
    if ((d.ranges || []).length) {
      notes.push(d.ranges.length + " rangé(s) dans _anciens : " + d.ranges.join(", "));
    }
    if ((d.remontes || []).length) notes.push(d.remontes.length + " remonté(s) de _anciens");
    if (d.ignores) notes.push(d.ignores + " compte(s) non branché(s), donc sans dossier");
    if ((d.collisions || []).length) notes.push("⚠ " + d.collisions.join(" · "));
    const bloque = BLOQUE[d.balayage];
    setAvis({ ton: bloque ? "ko" : d.comptes ? "ok" : "info",
      texte: (bloque ? bloque + " · " : "")
        + d.comptes + " dossier(s) pour les comptes branchés · " + d.chemin
        + (notes.length ? " · " + notes.join(" · ") : "") });
    await recharger();
    return d;
  });

  // Le televersement autonome a ete retire avec les deux boutons qu'il servait : il ne
  // connaissait que les Reels et dupliquait la logique du dialogue « Contenu source »,
  // qui couvre les quatre formats. Un seul chemin, donc un seul endroit ou se tromper.

  // L'ancien gestionnaire « publier » a ete retire avec le bouton qu'il servait : il appelait
  // /publier sans rien choisir et affichait la reponse. C'est le panneau de publication qui
  // fait ce travail maintenant, avec les formats, les comptes et le quota par jour.

  const supprimer = actions.onSupprimer || (async (c) => {
    if (!confirm("Supprimer le concept « " + c.nom + " » ?\n\nLes comptes et leur contenu ne bougent pas : un concept n'est qu'une vue sur le parc.")) return;
    const base = (window.Backend && window.Backend.base) || "";
    await fetch(base + "/api/floodsflow/concepts/" + c.id, { method: "DELETE" });
    recharger();
  });

  const concepts = fournis || charges || [];
  if (!fournis && charges === null) {
    return <div style={{ padding: 46, textAlign: "center", color: "var(--faint)" }}>chargement…</div>;
  }
  const onCreer = creer;
  actions = { ...actions, onSupprimer: supprimer, onLiens: genererLiens,
    onModifier: (c) => setDialogue(c),
    onSync: synchroniser, onDossiers: creerDossiers, onRecharger: recharger,
    onContenu: setContenu, onDistribuer: setDistrib, surAvis: setAvis };

  const TONS = {
    ok:   ["rgba(34,224,122,.08)",  "rgba(34,224,122,.28)",  "var(--accent)"],
    ko:   ["rgba(255,92,108,.08)",  "rgba(255,92,108,.25)",  "var(--danger, #ff5c6c)"],
    info: ["rgba(255,255,255,.04)", "var(--border)",         "var(--muted)"],
  };
  const bandeauAvis = avis ? (() => {
    const [fond, bord, texte] = TONS[avis.ton] || TONS.info;
    return (
      <div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 14px",
        marginBottom: 13, borderRadius: 10, fontSize: 12.5,
        background: fond, border: "1px solid " + bord, color: texte }}>
        <span style={{ flex: 1 }}>{avis.texte}</span>
        <button onClick={() => setAvis(null)} style={{ background: "none", border: "none",
          color: "inherit", cursor: "pointer", fontSize: 15, lineHeight: 1, padding: 0 }}>×</button>
      </div>
    );
  })() : null;

  const bandeauErreur = erreur ? (
    <div style={{ padding: "10px 14px", marginBottom: 13, borderRadius: 10, fontSize: 12.5,
      background: "rgba(255,92,108,.08)", border: "1px solid rgba(255,92,108,.25)",
      color: "var(--danger, #ff5c6c)" }}>⚠ {erreur}</div>
  ) : null;

  const total = concepts.reduce((a, c) => ({
    comptes: a.comptes + c.comptes, connectes: a.connectes + c.connectes, banque: a.banque + c.banque,
  }), { comptes: 0, connectes: 0, banque: 0 });
  // LES ARGUMENTS ETAIENT DANS LE DESORDRE : provision(jours, banque, connectes) recevait
  // (connectes, banque) -- donc `connectes` valait undefined, la couverture tombait dans la
  // branche « aucun compte » et le bandeau affichait « ∞ » en vert EN PERMANENCE, y compris
  // sur un parc a sec. C'est precisement le chiffre qu'on regarde le matin.
  //
  // On prend le concept le plus demuni, pas la somme : c'est lui qui tombe en panne d'abord,
  // et une moyenne le cacherait derriere les autres.
  const servis = concepts.filter((c) => c.connectes > 0);
  const pire = servis.length
    ? Math.min.apply(null, servis.map((c) => c.jours_couverture || 0)) : 0;
  const rythmeMax = servis.length
    ? Math.max.apply(null, servis.map((c) => c.rythme || RYTHME_DEFAUT)) : RYTHME_DEFAUT;
  const p = provision(pire, total.banque, total.connectes, rythmeMax);
  const enAttente = Math.max(0, total.comptes - total.connectes);

  return (
    <div>
      {bandeauErreur}
      {bandeauAvis}
      <CleAPI surAvis={setAvis} />
      {/* Le bandeau du haut répond à la seule question qui compte le matin :
          « est-ce que mes comptes ont de quoi publier aujourd'hui ? » */}
      <div style={{
        display: "flex", gap: 26, flexWrap: "wrap", alignItems: "center", padding: "15px 18px",
        background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 13, marginBottom: 16,
      }}>
        {[
          ["comptes attribués", total.comptes, "var(--text)"],
          ["connectés à l'API", total.connectes, "var(--accent)"],
          ["en attente d'authentification", enAttente, enAttente ? "var(--busy, #f0a020)" : "var(--faint)"],
          ["contenus en banque", total.banque, "var(--text)"],
        ].map(([l, v, c]) => (
          <div key={l}>
            <div style={{ fontSize: 22, fontWeight: 800, color: c, lineHeight: 1.15 }}>{v}</div>
            <div style={{ fontSize: 11, color: "var(--faint)" }}>{l}</div>
          </div>
        ))}
        <div style={{ marginLeft: "auto", textAlign: "right", minWidth: 190 }}>
          <div style={{ fontSize: 22, fontWeight: 800, color: COULEUR[p.ton], lineHeight: 1.15 }}>
            {p.parJour === 0 ? "—" : p.jours === Infinity ? "∞" : p.jours.toFixed(1) + " j"}
          </div>
          <div style={{ fontSize: 11, color: "var(--faint)" }}>
            de couverture · {p.parJour} contenus/jour
          </div>
        </div>
      </div>

      <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 12 }}>
        <span style={{ fontSize: 11.5, fontWeight: 700, letterSpacing: ".07em",
          textTransform: "uppercase", color: "var(--muted)", flex: 1 }}>Concepts</span>
        <button onClick={onCreer} style={{ ...btn(), background: "var(--accent)", color: "#06281c",
          borderColor: "var(--accent)" }}>+ Nouveau concept</button>
      </div>

      {concepts.length ? (
        <div style={{ display: "grid", gap: 15,
          gridTemplateColumns: "repeat(auto-fill, minmax(330px, 1fr))" }}>
          {concepts.map((c) => <CarteConcept key={c.id} c={c} {...actions} />)}
        </div>
      ) : (
        <div style={{ textAlign: "center", padding: "56px 0", color: "var(--faint)",
          background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 13 }}>
          Aucun concept. Crée le premier : il regroupera des comptes et leur banque de contenu.
        </div>
      )}

      {contenu && window.DialogueContenuSource ? (
        <window.DialogueContenuSource concept={contenu} surAvis={setAvis}
          surFermer={(fait) => { setContenu(null); if (fait) recharger(); }} />
      ) : null}

      {distrib && window.DialogueDistribution ? (
        <window.DialogueDistribution concept={distrib} surAvis={setAvis}
          surFermer={(fait) => { setDistrib(null); if (fait) recharger(); }} />
      ) : null}

      {dialogue ? (
        <DialogueConcept cats={cats} concept={dialogue === true ? null : dialogue}
          surAnnuler={() => setDialogue(false)}
          surCreer={dialogue === true ? envoyer : modifier} />
      ) : null}
    </div>
  );
}

Object.assign(window, { FloodsFlowOverview, DialogueConcept, CarteConcept, JaugeCouverture,
  provision, EtapeLigne });

// ═══════════════════════════════════════════════════════════════════════════════════════
// VUE D'ENSEMBLE — l'état du parc branché, d'un coup d'œil.
//
// Ce qu'on affiche est CE QU'ON SAIT. Les vues, la portée et les impressions viendraient des
// statistiques du fournisseur, dont l'endpoint n'est pas confirmé — l'implémentation de
// référence le laisse en « best effort » avec un TODO. Afficher un zéro à leur place ferait
// croire à zéro vue ; on dit plutôt que la source manque.
//
// Le reste vient de trois sources sûres : le parc (comptes branchés), la banque (contenu en
// stock) et le planificateur (publications programmées, jour par jour).
// ═══════════════════════════════════════════════════════════════════════════════════════
function FloodsFlowVue() {
  const { useState, useEffect } = React;
  const [concepts, setConcepts] = useState(null);
  const [planning, setPlanning] = useState([]);

  useEffect(() => {
    let vivant = true;
    (async () => {
      const base = (window.Backend && window.Backend.base) || "";
      try {
        const d = await (await fetch(base + "/api/floodsflow/concepts", { cache: "no-store" })).json();
        if (!vivant) return;
        const cs = d.concepts || [];
        setConcepts(cs);
        // Le planning est par concept : on agrège, sinon un parc réparti sur trois concepts
        // afficherait trois courbes qui ne veulent rien dire séparément.
        const tout = [];
        for (const c of cs) {
          try {
            const p = await (await fetch(base + "/api/floodsflow/concepts/" + c.id + "/planning",
              { cache: "no-store" })).json();
            (p.planning || []).forEach((l) => tout.push(l));
          } catch (e) {}
        }
        if (vivant) setPlanning(tout);
      } catch (e) { if (vivant) setConcepts([]); }
    })();
    return () => { vivant = false; };
  }, []);

  if (concepts === null) {
    return <div style={{ padding: 46, textAlign: "center", color: "var(--faint)" }}>chargement…</div>;
  }

  const total = concepts.reduce((a, c) => ({
    comptes: a.comptes + c.comptes,
    connectes: a.connectes + c.connectes,
    liens: a.liens + (c.liens_valides || 0),
  }), { comptes: 0, connectes: 0, liens: 0 });
  const enAttente = Math.max(0, total.comptes - total.connectes);

  const stock = {};
  (window.FF_FORMATS || []).forEach((f) => {
    stock[f.id] = concepts.reduce((a, c) => a + ((c.stock || {})[f.id] || 0), 0);
  });

  // PROGRAMMÉ ICI N'EST PAS PARTI. Le compteur additionnait toutes les lignes du planning et
  // annonçait « 3 publications aujourd'hui » alors que bundle.social n'avait jamais rien
  // reçu — le tableau de bord affirmait que la machine tournait pendant qu'elle ne faisait
  // rien. Une ligne ne publiera que si elle a été REMISE au fournisseur (statut PROGRAMME).
  const partie = (l) => l.statut === "PROGRAMME" || l.statut === "BROUILLON";
  const parJour = {};
  planning.forEach((l) => {
    const j = l.quand.slice(0, 10);
    const d = parJour[j] || (parJour[j] = { total: 0, partis: 0 });
    d.total += 1;
    d.partis += partie(l) ? 1 : 0;
  });
  const jours = Object.keys(parJour).sort();
  const maxJour = Math.max(1, ...jours.map((j) => parJour[j].total));
  const aujourdhui = new Date().toISOString().slice(0, 10);
  const dujour = parJour[aujourdhui] || { total: 0, partis: 0 };
  // ON NE RECLAME QUE CE QUI PEUT ENCORE PARTIR. Le bandeau comptait aussi les lignes dont
  // l'heure est passée — que « lancer la publication » refuse par construction, pour ne pas
  // faire publier tout le retard d'un coup. Il envoyait donc l'opérateur cliquer sur un
  // bouton qui ne pouvait rien pour elles, et le nombre ne baissait jamais.
  const nowISO = new Date().toISOString();
  const enAttenteDEnvoi = planning.filter((l) => !partie(l) && l.quand > nowISO).length;
  const perimees = planning.filter((l) => l.statut === "PREVU" && l.quand <= nowISO).length;

  const chiffre = (v, l, couleur) => (
    <div style={{ minWidth: 118 }}>
      <div style={{ fontSize: 24, fontWeight: 800, lineHeight: 1.1, color: couleur || "var(--text)" }}>{v}</div>
      <div style={{ fontSize: 11, color: "var(--faint)", marginTop: 2 }}>{l}</div>
    </div>
  );

  return (
    <div>
      <div style={{ display: "flex", gap: 26, flexWrap: "wrap", padding: "16px 18px",
        background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 13,
        marginBottom: 16 }}>
        {chiffre(total.connectes, "comptes branchés à l'API", "var(--accent)")}
        {chiffre(enAttente, "en attente d'authentification",
          enAttente ? "var(--busy, #f0a020)" : "var(--faint)")}
        {chiffre(dujour.partis, "publications parties aujourd'hui",
          dujour.partis ? "var(--accent)" : "var(--faint)")}
        {chiffre(concepts.length, "concepts")}
      </div>

      {/* Le seul avertissement qui compte quand on ouvre l'écran le matin : du travail est
          programmé et n'ira nulle part tant qu'on ne l'a pas remis au fournisseur. */}
      {(enAttenteDEnvoi > 0 || perimees > 0) && (
        <div style={{ display: "flex", alignItems: "center", gap: 10, padding: "10px 16px",
          marginBottom: 16, borderRadius: 11, fontSize: 12.5, lineHeight: 1.5,
          background: "rgba(240,160,32,.07)", border: "1px solid rgba(240,160,32,.26)",
          color: "var(--busy, #f0a020)" }}>
          <span style={{ flex: 1 }}>
            {enAttenteDEnvoi > 0 && (
              <><b>{enAttenteDEnvoi}</b> publication(s) programmée(s) ici mais <b>jamais remises
              à bundle.social</b> — elles ne partiront pas. Ouvre un concept et lance la
              publication.</>
            )}
            {enAttenteDEnvoi > 0 && perimees > 0 && <br />}
            {perimees > 0 && (
              <><b>{perimees}</b> dont l'heure est déjà passée : celles-là ne partiront plus du
              tout — reprogramme-les sur des jours à venir.</>
            )}
          </span>
        </div>
      )}

      {/* Le stock, par format. C'est ce qui décide si le parc peut publier demain. */}
      <div style={{ padding: "14px 18px", background: "var(--surface)",
        border: "1px solid var(--border)", borderRadius: 13, marginBottom: 16 }}>
        <div style={{ fontSize: 11, fontWeight: 700, letterSpacing: ".06em",
          textTransform: "uppercase", color: "var(--muted)", marginBottom: 10 }}>
          Contenu en banque
        </div>
        <div style={{ display: "flex", gap: 22, flexWrap: "wrap" }}>
          {(window.FF_FORMATS || []).map((f) => (
            <div key={f.id} style={{ minWidth: 100 }}>
              <div style={{ fontSize: 19, fontWeight: 800,
                color: stock[f.id] ? "var(--text)" : "var(--faint)" }}>{stock[f.id]}</div>
              <div style={{ fontSize: 11, color: "var(--faint)" }}>{f.emoji} {f.label}</div>
            </div>
          ))}
        </div>
      </div>

      {/* Les publications programmées, jour par jour. */}
      <div style={{ padding: "14px 18px", background: "var(--surface)",
        border: "1px solid var(--border)", borderRadius: 13, marginBottom: 16 }}>
        <div style={{ fontSize: 11, fontWeight: 700, letterSpacing: ".06em",
          textTransform: "uppercase", color: "var(--muted)", marginBottom: 12 }}>
          Publications programmées
        </div>
        {!jours.length ? (
          <div style={{ fontSize: 12.5, color: "var(--faint)" }}>
            Rien de programmé. Le planificateur remplit les jours à venir depuis la carte
            d'un concept, dans Onboarding.
          </div>
        ) : (
          /* La barre est en DEUX parts : le plein est ce qui est réellement remis au
             fournisseur, le creux ce qui n'attend encore que chez nous. Une barre pleine
             uniforme laissait croire que toute la colonne allait partir. */
          <React.Fragment>
            <div style={{ display: "flex", alignItems: "flex-end", gap: 8, height: 96 }}>
              {jours.map((j) => {
                const d = parJour[j];
                const h = Math.max(4, (d.total / maxJour) * 62);
                const hp = d.total ? h * (d.partis / d.total) : 0;
                return (
                  /* maxWidth : à trois jours, `flex: 1` étirait chaque barre sur un tiers de
                     l'écran — on lisait des rectangles, plus un graphique. */
                  <div key={j} style={{ flex: 1, minWidth: 26, maxWidth: 54, textAlign: "center" }}>
                    <div style={{ fontSize: 11, color: "var(--faint)", marginBottom: 3 }}>{d.total}</div>
                    <div title={j + " · " + d.partis + " remise(s) au fournisseur sur " + d.total}
                      style={{ height: h, borderRadius: 4, overflow: "hidden",
                        display: "flex", flexDirection: "column", justifyContent: "flex-end",
                        background: "var(--surface-2, rgba(255,255,255,.05))",
                        border: "1px solid " + (j === aujourdhui ? "var(--accent)" : "var(--border)") }}>
                      <div style={{ height: hp, background: j === aujourdhui
                        ? "var(--accent)" : "var(--border-2, var(--border))" }} />
                    </div>
                    <div style={{ fontSize: 10, color: "var(--faint)", marginTop: 4 }}>{j.slice(8)}/{j.slice(5, 7)}</div>
                  </div>
                );
              })}
            </div>
            <div style={{ fontSize: 11, color: "var(--faint)", marginTop: 9 }}>
              Le plein = remis à bundle.social, il partira tout seul. Le creux = programmé ici
              seulement, il ne partira pas.
            </div>
          </React.Fragment>
        )}
      </div>

      {/* Ce qu'on ne sait PAS. Le dire vaut mieux qu'un zéro qui ment. */}
      <div style={{ padding: "12px 16px", borderRadius: 11, fontSize: 12.5,
        background: "var(--surface-2, rgba(255,255,255,.03))", border: "1px dashed var(--border)",
        color: "var(--faint)" }}>
        <b style={{ color: "var(--muted)" }}>Vues, portée et impressions : pas encore disponibles.</b>
        {" "}Elles viendraient des statistiques du fournisseur, dont l'endpoint n'est pas
        confirmé — afficher 0 laisserait croire à zéro vue. À brancher une fois la source
        validée sur un compte témoin.
      </div>
    </div>
  );
}

Object.assign(window, { FloodsFlowVue });
