/* global React */
// ─── FloodsFlow — la banque de contenu ────────────────────────────────────────────────
//
// Deux gestes, volontairement separes :
//
//   1. APPROVISIONNER  on depose des fichiers dans les quatre dossiers de la banque.
//                      Rien n'est encore attribue a personne.
//   2. DISTRIBUER      on choisit QUELS comptes recoivent ce stock.
//
// Les confondre serait le defaut du modele precedent : deposer 200 videos les envoyait
// aussitot a tous les comptes branches. On veut pouvoir alimenter la banque le lundi et
// decider le mardi que seuls trois comptes sur dix en profitent.
//
// « Reels d'essai » a son dossier alors qu'aucun compte ne peut encore s'en servir :
// Instagram exige 1 000 abonnes et un compte public professionnel. Le contenu se range des
// maintenant, il attend.

const FF_FORMATS = [
  { id: "reels", label: "Reels", emoji: "🎬", accept: "video/*" },
  { id: "reels_essai", label: "Reels d'essai", emoji: "🧪", accept: "video/*",
    note: "Instagram exige 1 000 abonnés et un compte professionnel public" },
  { id: "story", label: "Story", emoji: "⏱", accept: "video/*,image/*" },
  { id: "photo", label: "Photo (publication)", emoji: "🖼", accept: "image/*" },
];

const fcBase = () => (window.Backend && window.Backend.base) || "";

async function fcAppel(chemin, corps) {
  const r = await fetch(fcBase() + chemin, {
    method: "POST", headers: { "content-type": "application/json" },
    body: JSON.stringify(corps || {}),
  });
  return r.json().catch(() => ({ error: "réponse illisible du backend" }));
}

const fcBouton = (accent, plein) => ({
  display: "inline-flex", alignItems: "center", justifyContent: plein ? "center" : "flex-start",
  gap: 6, padding: "8px 13px", borderRadius: 9, fontSize: 12.5, fontWeight: 600,
  cursor: "pointer", font: "inherit", width: plein ? "100%" : "auto",
  background: accent ? "var(--accent)" : "var(--surface-2, rgba(255,255,255,.05))",
  color: accent ? "#06281c" : "var(--text)",
  border: "1px solid " + (accent ? "var(--accent)" : "var(--border)"),
});

const fcVoile = {
  position: "fixed", inset: 0, zIndex: 9000, background: "rgba(3,7,15,.72)",
  display: "flex", alignItems: "center", justifyContent: "center", padding: 20,
};
const fcBoite = {
  width: "min(560px, 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)",
};

// Poids lisible. On l'affiche AVANT de demander confirmation : « vider » ne veut pas dire
// grand-chose, « 411 Mo » si.
const fcPoids = (o) => {
  const n = Number(o) || 0;
  if (n >= 1024 * 1024 * 1024) return (n / 1073741824).toFixed(1) + " Go";
  if (n >= 1024 * 1024) return Math.round(n / 1048576) + " Mo";
  if (n >= 1024) return Math.round(n / 1024) + " Ko";
  return n + " o";
};

// ══ 1. APPROVISIONNER ET VIDANGER — le dialogue « Contenu source » ════════════════════
//
// Une seule fenetre pour les deux sens : on remplit un format, on le vide. Les avoir separes
// obligeait a chercher ailleurs le geste inverse -- et le geste inverse n'existait meme pas :
// la route de vidage existait cote backend mais AUCUN bouton n'y menait.
//
// CE QUE LA FENETRE MONTRE AVANT DE VIDER, et pourquoi. « Vider les Reels » ne dit pas ce
// qu'on perd. On affiche donc, par format : ce qu'il y a en banque, ce qui est deja distribue
// aux comptes, combien de fichiers sur le disque et leur poids. Et ce qui SURVIT -- les
// publications deja remises a bundle.social ne bougent pas, le fournisseur en a sa copie.
function DialogueContenuSource({ concept, surFermer, surAvis }) {
  const [choix, setChoix] = React.useState({});      // format -> [File]
  const [envoi, setEnvoi] = React.useState("");
  const [survol, setSurvol] = React.useState(null);  // format survole par un glisser-deposer
  const [inv, setInv] = React.useState(null);        // inventaire a blanc de la vidange
  const [definitif, setDefinitif] = React.useState(false);
  const [vidange, setVidange] = React.useState("");  // format en cours de vidage
  const stock = concept.stock || {};
  // Une vidange change l'ecran d'accueil meme si on ferme par « Annuler » : sans ce drapeau,
  // on revenait sur une carte qui annonce encore 28 contenus en banque.
  const modifie = React.useRef(false);

  const chargerInventaire = React.useCallback(async () => {
    try {
      const r = await fetch(fcBase() + "/api/floodsflow/concepts/" + concept.id + "/vider",
        { cache: "no-store" });
      const d = await r.json();
      if (d && d.ok) { setInv(d); return d; }
    } catch (e) { /* backend injoignable : la fenetre reste utilisable pour l'envoi */ }
    return null;
  }, [concept.id]);

  React.useEffect(() => { chargerInventaire(); }, [chargerInventaire]);

  const viderCes = async (formats) => {
    if (vidange || envoi) return;
    // ON RELIT AVANT DE DEMANDER. L'inventaire était chargé une fois à l'ouverture : sur une
    // fenêtre restée ouverte pendant qu'une autre approvisionne, on confirmait les chiffres
    // d'un lot pour en détruire un autre.
    const frais = await chargerInventaire();
    const d = (frais && frais.formats) || {};
    const vises = formats.filter((f) => d[f]
      && (d[f].banque || d[f].distribues || d[f].fichiers || d[f].prevus));
    if (!vises.length) { surAvis({ ton: "info", texte: "rien à vider ici" }); return; }
    if (frais && frais.autre_concept) {
      surAvis({ ton: "ko", texte: "ce dossier appartient au concept " + frais.autre_concept
        + " — renomme l'un des deux avant de vider" });
      return;
    }
    const somme = (cle) => vises.reduce((a, f) => a + (d[f][cle] || 0), 0);
    const lignes = vises.map((f) => "  · " + d[f].libelle + " : " + d[f].banque
      + " en banque, " + d[f].fichiers + " fichier(s) (" + fcPoids(d[f].octets) + ")");
    const partis = somme("partis") + somme("brouillons");
    const manuels = somme("manuels");
    const anciens = somme("dorment_dans_anciens");
    const ok = confirm(
      (vises.length > 1 ? "Vider " + vises.length + " formats" : "Vider « " + d[vises[0]].libelle + " »")
      + " de « " + concept.nom + " » ?\n\n" + lignes.join("\n")
      + "\n\nCe qui part : la banque, les files des comptes, les publications encore prévues, "
      + "et le contenu des dossiers de ce format."
      + (partis ? "\n\nCe qui RESTE : " + partis + " publication(s) déjà remise(s) à "
        + "bundle.social. Le fournisseur en a sa propre copie, elles partiront quand même. "
        + "Annule-les depuis le planning si tu veux vraiment repartir de zéro." : "")
      + (anciens ? "\n\n" + anciens + " fichier(s) dorment dans _anciens (comptes débranchés) "
        + "et reviendront si ces comptes se rebranchent — la vidange n'y touche pas." : "")
      + "\n\n" + (definitif
        ? "SUPPRESSION DÉFINITIVE : " + fcPoids(somme("octets")) + " effacés pour de bon."
          + (manuels ? " Sauf " + manuels + " fichier(s) déposés à la main, qui seront rangés "
            + "dans _anciens : eux n'ont aucune autre copie." : "")
        : "Les fichiers descendent dans _anciens/_vidange-… — conservés pour inspection, mais "
          + "les y remonter ne les remettra pas en service."));
    if (!ok) return;
    setVidange(vises.join(","));
    try {
      const r = await fcAppel("/api/floodsflow/concepts/" + concept.id + "/vider",
        { formats: vises, disque: true, definitif: definitif });
      if (!r.ok) { surAvis({ ton: "ko", texte: r.error || "vidange refusée" }); return; }
      modifie.current = true;
      // La barre de travail garde son bilan (« ✓ 84 fichiers déposés ») jusqu'à ce qu'on la
      // réveille : elle écoute cet événement, et sans lui elle annonce des fichiers effacés.
      try {
        window.dispatchEvent(new CustomEvent("floodsflow:spoof", { detail: { id: concept.id } }));
      } catch (e) { /* vieux navigateur */ }
      const ou = r.range_dans ? " · rangés dans _anciens/" + r.range_dans
        : (definitif ? " · supprimés" : "");
      surAvis({ ton: (r.erreurs && r.erreurs.length) ? "ko" : "ok",
        texte: (r.retires.banque || 0) + " en banque, " + (r.retires.prevus || 0)
          + " publication(s) prévue(s) et " + r.fichiers + " fichier(s) ("
          + fcPoids(r.octets) + ")" + ou
          + (r.preserves ? " · dont " + r.preserves + " déposé(s) à la main, rangés et non supprimés" : "")
          + (r.partis_intacts ? " · " + r.partis_intacts + " déjà chez bundle.social, intacte(s)" : "")
          + (r.partis_hors_concept ? " · " + r.partis_hors_concept
            + " sur des comptes sortis du concept, invisibles au planning" : "")
          + (r.brouillons_gardes ? " · " + r.brouillons_gardes + " brouillon(s) conservé(s)" : "")
          + ((r.non_vides || []).length
            ? " · NON VIDÉ : " + r.non_vides.join(", ") + " (" + r.erreurs.length
              + " fichier(s) ouverts ailleurs — ferme-les et relance)"
            : "") });
      await chargerInventaire();
    } catch (e) {
      surAvis({ ton: "ko", texte: "vidange interrompue — " + e.message });
    } finally { setVidange(""); }
  };

  const choisir = (fmt) => {
    const inp = document.createElement("input");
    inp.type = "file"; inp.multiple = true; inp.accept = fmt.accept;
    inp.onchange = () => {
      if (inp.files && inp.files.length) {
        setChoix((c) => Object.assign({}, c, { [fmt.id]: [].slice.call(inp.files) }));
      }
    };
    inp.click();
  };

  const total = FF_FORMATS.reduce((a, f) => a + ((choix[f.id] || []).length), 0);
  const tousFormats = (inv && inv.formats) || {};
  const aVider = FF_FORMATS.some((f) => {
    const d = tousFormats[f.id] || {};
    return !!(d.banque || d.distribues || d.fichiers || d.prevus);
  });
  const poidsTotal = FF_FORMATS.reduce((a, f) => a + ((tousFormats[f.id] || {}).octets || 0), 0);

  // Un verrou SYNCHRONE. `envoi` ne devient vrai qu'après l'appel réseau aux dossiers : deux
  // clics rapides passaient tous deux le test et téléversaient les fichiers en double.
  const verrou = React.useRef(false);

  const enregistrer = async () => {
    if (!total || envoi || verrou.current) return;
    verrou.current = true;
    try {
    // Les dossiers d'abord : c'est eux qui donnent le chemin relatif exact. Le recalculer
    // ici obligerait a dupliquer l'assainissement des noms cote navigateur.
    //
    // `avec_comptes: false` : on ne veut QUE le pot commun `_a-repartir`. Sans ce drapeau,
    // deposer du contenu creait un dossier @pseudo pour chaque compte de la categorie,
    // branche ou non -- c'est ainsi qu'un concept de 3 comptes branches se retrouvait avec
    // 7 dossiers, dont 4 appartenant a des comptes qui ne publieront jamais.
    const d = await fcAppel("/api/floodsflow/concepts/" + concept.id + "/dossiers",
      { avec_comptes: false });
    if (!d.ok) { surAvis({ ton: "ko", texte: d.error || "dossiers indisponibles" }); return; }

    let places = 0;
    const rates = [];
    for (const fmt of FF_FORMATS) {
      const fichiers = choix[fmt.id] || [];
      if (!fichiers.length) continue;
      const chemin = d.rel_a_repartir + "/" + fmt.id;
      const envoyes = [];
      for (let i = 0; i < fichiers.length; i++) {
        const f = fichiers[i];
        setEnvoi(fmt.label + " — " + (i + 1) + "/" + fichiers.length);
        try {
          const r = await fetch(fcBase() + "/api/media/upload?path=" + encodeURIComponent(chemin)
            + "&name=" + encodeURIComponent(f.name), { method: "POST", body: f });
          if (!r.ok) throw new Error("HTTP " + r.status);
          envoyes.push({ nom: f.name, chemin: chemin + "/" + f.name });
        } catch (e) { rates.push(fmt.label + " · " + f.name); }
      }
      if (envoyes.length) {
        // repartir: false — on remplit la banque, on ne donne encore rien a personne.
        const imp = await fcAppel("/api/floodsflow/concepts/" + concept.id + "/importer",
          { dossier: fmt.id, elements: envoyes, repartir: false });
        if (imp.ok) places += imp.importes;
        else rates.push(fmt.label + " · " + (imp.error || "refus du backend"));
      }
    }
    setEnvoi("");
    surAvis({ ton: rates.length ? "ko" : "ok",
      texte: places + " contenu(s) en banque"
        + (rates.length ? " · " + rates.length + " échec(s) : " + rates.slice(0, 3).join(", ") : "")
        + " · rien n'est encore distribué" });
    surFermer(true);
    } catch (e) {
      setEnvoi("");
      surAvis({ ton: "ko", texte: "envoi interrompu — " + e.message });
    } finally {
      verrou.current = false;
    }
  };

  return (
    <div style={fcVoile} onMouseDown={(e) => { if (e.target === e.currentTarget) surFermer(modifie.current); }}>
      <div style={fcBoite}>
        <div style={{ fontSize: 16, fontWeight: 800, marginBottom: 3 }}>Contenu source</div>
        <div style={{ fontSize: 12, color: "var(--faint)", marginBottom: 16 }}>
          Approvisionne la banque de « {concept.nom} ». La distribution aux comptes est
          un geste séparé, juste après.
        </div>

        {/* Le glisser-deposer vit ICI depuis qu'on a retire la zone de depot de la carte :
            c'etait le troisieme chemin vers le meme depot, et le seul a ne connaitre que les
            Reels. Depose sur la ligne du format, le fichier va dans le bon dossier. */}
        {FF_FORMATS.map((f) => {
          const pris = (choix[f.id] || []).length;
          const dessus = survol === f.id;
          const d = ((inv && inv.formats) || {})[f.id] || {};
          const aQuoi = !!(d.banque || d.distribues || d.fichiers || d.prevus);
          return (
            <div key={f.id}
              onDragOver={(e) => { e.preventDefault(); setSurvol(f.id); }}
              onDragLeave={() => setSurvol(null)}
              onDrop={(e) => {
                e.preventDefault(); setSurvol(null);
                const fs = [].slice.call(e.dataTransfer.files || []);
                if (fs.length) setChoix((c) => Object.assign({}, c, { [f.id]: fs }));
              }}
              style={{ display: "flex", alignItems: "center", gap: 10,
                padding: "10px 8px", borderRadius: 9, transition: ".15s",
                borderTop: "1px solid var(--border)",
                background: dessus ? "var(--accent-soft, rgba(34,224,122,.10))" : "transparent",
                outline: dessus ? "1.5px dashed var(--accent)" : "none" }}>
              <span style={{ fontSize: 16 }}>{f.emoji}</span>
              <div style={{ flex: 1, minWidth: 0 }}>
                <div style={{ fontSize: 13 }}>{f.label}</div>
                <div style={{ fontSize: 11, color: "var(--faint)" }}>
                  {(d.banque !== undefined ? d.banque : (stock[f.id] || 0)) + " en banque"}
                  {d.distribues ? " · " + d.distribues + " distribué(s)" : ""}
                  {d.fichiers ? " · " + d.fichiers + " fichier(s) " + fcPoids(d.octets) : ""}
                  {d.partis ? " · " + d.partis + " déjà chez bundle.social" : ""}
                  {f.note ? " · " + f.note : ""}
                </div>
              </div>
              {pris > 0 && (
                <span style={{ fontSize: 11.5, color: "var(--accent)" }}>+{pris}</span>
              )}
              <button onClick={() => choisir(f)} style={fcBouton()}>Choisir…</button>
              <button onClick={() => viderCes([f.id])} disabled={!aQuoi || !!vidange || !!envoi}
                title={aQuoi ? "Vider ce format : banque, files, publications prévues et fichiers"
                             : "rien à vider"}
                style={{ ...fcBouton(), opacity: aQuoi ? 1 : .35, padding: "8px 11px",
                  color: aQuoi ? "var(--danger, #ff6b6b)" : "var(--faint)",
                  border: "1px solid " + (aQuoi ? "rgba(255,107,107,.4)" : "var(--border)") }}>
                {vidange.split(",").indexOf(f.id) >= 0 ? "…" : "Vider"}
              </button>
            </div>
          );
        })}
        <div style={{ fontSize: 11, color: "var(--faint)", marginTop: 9 }}>
          Glisser-déposer accepté directement sur la ligne du format.
        </div>

        {/* LA VIDANGE. Volontairement en bas, discrete, derriere une confirmation qui chiffre
            ce qui part — mais presente : c'est le geste qui permet de repartir sur du contenu
            neuf sans aller effacer des dossiers a la main. */}
        <div style={{ marginTop: 16, paddingTop: 13, borderTop: "1px solid var(--border)" }}>
          <div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
            <label style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 11.5,
              color: "var(--faint)", cursor: "pointer", flex: 1, minWidth: 200 }}>
              <input type="checkbox" checked={definitif}
                onChange={(e) => setDefinitif(e.target.checked)} />
              Supprimer définitivement{definitif ? "" : " (sinon rangé dans _anciens)"}
            </label>
            <button onClick={() => viderCes(FF_FORMATS.map((f) => f.id))}
              disabled={!aVider || !!vidange || !!envoi}
              style={{ ...fcBouton(), opacity: aVider ? 1 : .35,
                color: aVider ? "var(--danger, #ff6b6b)" : "var(--faint)",
                border: "1px solid " + (aVider ? "rgba(255,107,107,.4)" : "var(--border)") }}>
              {vidange ? "Vidange…" : "Tout vider" + (poidsTotal ? " · " + fcPoids(poidsTotal) : "")}
            </button>
          </div>
          {inv && inv.autre_concept && (
            <div style={{ fontSize: 11, color: "var(--danger, #ff6b6b)", marginTop: 7 }}>
              Ce dossier appartient au concept {inv.autre_concept} — la vidange est refusée
              tant que l'un des deux n'est pas renommé.
            </div>
          )}
          {inv && (inv.spoof_en_cours || inv.envoi_en_cours) && (
            <div style={{ fontSize: 11, color: "var(--faint)", marginTop: 7 }}>
              Un {inv.spoof_en_cours ? "spoof" : "envoi"} est en cours : la vidange sera refusée
              tant qu'il tourne.
            </div>
          )}
        </div>

        {envoi && (
          <div style={{ marginTop: 14, fontSize: 12.5, color: "var(--muted)" }}>envoi : {envoi}</div>
        )}

        <div style={{ display: "flex", gap: 9, justifyContent: "flex-end", marginTop: 18 }}>
          <button onClick={() => surFermer(modifie.current)} style={fcBouton()}>
            {modifie.current ? "Fermer" : "Annuler"}
          </button>
          <button onClick={enregistrer} disabled={!total || !!envoi}
            style={{ ...fcBouton(!!total && !envoi) }}>
            {envoi ? "…" : total ? "Enregistrer " + total + " fichier(s)" : "Enregistrer"}
          </button>
        </div>
      </div>
    </div>
  );
}

// ══ 2. DISTRIBUER — choisir qui reçoit ════════════════════════════════════════════════
function DialogueDistribution({ concept, surFermer, surAvis }) {
  const branches = concept.branches || [];
  const [choisis, setChoisis] = React.useState(branches);
  // PLUSIEURS formats a la fois : on distribue Reels + Story + Photo en un geste. Un seul
  // format par passage obligeait a rouvrir la fenetre pour chacun.
  const [formats, setFormats] = React.useState(["reels"]);
  const [envoi, setEnvoi] = React.useState(null);   // {etape, fait, total, quoi, produits, erreurs}
  const stock = concept.stock || {};
  const basculeFormat = (id) =>
    setFormats((l) => (l.indexOf(id) >= 0 ? l.filter((x) => x !== id) : l.concat(id)));
  const aDistribuer = formats.filter((f) => (stock[f] || 0) > 0);

  const bascule = (u) =>
    setChoisis((l) => (l.indexOf(u) >= 0 ? l.filter((x) => x !== u) : l.concat(u)));

  // DEUX TEMPS, ET LE SECOND MANQUAIT COMPLETEMENT.
  //
  //   1. DISTRIBUER  attribuer les contenus aux files des comptes. Instantané, en mémoire.
  //   2. SPOOFER     produire une copie UNIQUE par compte et la déposer dans son dossier.
  //
  // Le bouton s'appelait « Lancer le spoof » et ne faisait que le premier temps. Aucun
  // fichier n'était produit : les dossiers @pseudo restaient vides, et la barre comptait des
  // formats — deux, trois graduations — pendant qu'aucun encodage n'avait lieu.
  //
  // LA FENÊTRE SE FERME DÈS QUE LE TRAVAIL EST LANCÉ. 28 vidéos sur 3 comptes font 84
  // encodages ffmpeg, soit un quart d'heure : rester bloqué devant une popup pendant ce
  // temps-là n'a aucun intérêt. Le travail vit côté backend, et c'est la CARTE DU CONCEPT qui
  // affiche sa progression (BarreSpoof) — on peut donc continuer à travailler ailleurs.
  const lancer = async () => {
    if (!choisis.length || !aDistribuer.length || envoi) return;
    const bilan = [];
    const avertissements = [];
    try {
      for (let i = 0; i < aDistribuer.length; i++) {
        const fmt = aDistribuer[i];
        const lib = (FF_FORMATS.find((f) => f.id === fmt) || {}).label || fmt;
        setEnvoi({ etape: "distribution", fait: i, total: aDistribuer.length, quoi: lib });
        const d = await fcAppel("/api/floodsflow/concepts/" + concept.id + "/distribuer",
          { dossier: fmt, comptes: choisis });
        if (!d.ok) { bilan.push(lib + " : " + (d.error || "refus")); continue; }
        const ajoutes = Object.keys(d.ajoutes || {}).reduce((a, u) => a + d.ajoutes[u], 0);
        bilan.push(lib + " : " + ajoutes + " sur " + d.comptes + " compte(s)");
        if (d.avertissement) avertissements.push(d.avertissement);
      }

      setEnvoi({ etape: "spoof", fait: 0, total: 0, quoi: "démarrage…", produits: 0, erreurs: [] });
      const s = await fcAppel("/api/floodsflow/concepts/" + concept.id + "/spoof",
        { comptes: choisis, formats: aDistribuer });
      if (!s.ok) {
        // Un refus ici n'annule pas la distribution, qui a bien eu lieu : on le dit tel quel
        // plutôt que de laisser croire que rien ne s'est passé.
        setEnvoi(null);
        surAvis({ ton: "ko", texte: bilan.join(" · ") + " — mais AUCUN fichier produit : "
          + (s.error || "spoof refusé") });
        return;
      }

      // La carte prend le relais. L'événement la réveille tout de suite, sans quoi elle
      // n'apprendrait l'existence du travail qu'au prochain rechargement du panneau.
      try {
        window.dispatchEvent(new CustomEvent("floodsflow:spoof", { detail: { id: concept.id } }));
      } catch (e) { /* navigateur trop ancien : la carte interrogera d'elle-même */ }

      const notes = [];
      if (avertissements.length) notes.push(avertissements[0]);
      if (s.deja) notes.push("un spoof était déjà en cours, celui-ci n'a pas été relancé");
      else if (s.absents && s.absents.length) {
        notes.push(s.absents.length + " source(s) introuvable(s) sur le disque");
      }
      surAvis({ ton: "ok",
        texte: bilan.join(" · ") + " · spoof lancé : " + (s.total || 0) + " fichier(s) à produire"
          + (s.ignores ? ", " + s.ignores + " déjà en place" : "")
          + " — la progression s'affiche sur la carte, tu peux continuer"
          + (notes.length ? " · " + notes.join(" · ") : "") });
      surFermer(true);
    } catch (e) {
      // Une coupure réseau laissait « envoi » figé : la barre restait à l'écran et le bouton
      // était mort jusqu'à la fermeture de la fenêtre.
      setEnvoi(null);
      surAvis({ ton: "ko", texte: (bilan.length ? bilan.join(" · ") + " · " : "")
        + "interrompu — " + e.message });
    }
  };

  return (
    <div style={fcVoile} onMouseDown={(e) => { if (e.target === e.currentTarget) surFermer(false); }}>
      <div style={fcBoite}>
        <div style={{ fontSize: 16, fontWeight: 800, marginBottom: 3 }}>Distribuer le contenu</div>
        <div style={{ fontSize: 12, color: "var(--faint)", marginBottom: 16 }}>
          Chaque compte reçoit sa propre file, décalée d'une journée entière : deux comptes
          ne publient jamais la même vidéo le même jour.
        </div>

        <div style={{ display: "flex", gap: 7, flexWrap: "wrap", marginBottom: 16 }}>
          {FF_FORMATS.map((f) => {
            const on = formats.indexOf(f.id) >= 0;
            const vide = !(stock[f.id] || 0);
            return (
              <button key={f.id} onClick={() => basculeFormat(f.id)}
                title={vide ? "Banque vide pour ce format" : f.label}
                style={{ ...fcBouton(on && !vide), padding: "6px 11px", fontSize: 12,
                  opacity: vide ? .5 : 1 }}>
                {on ? "✓ " : ""}{f.emoji} {f.label} · {stock[f.id] || 0}
              </button>
            );
          })}
        </div>

        {!branches.length ? (
          <div style={{ padding: "12px 0", fontSize: 12.5, color: "var(--busy, #f0a020)" }}>
            Aucun compte branché à l'API dans ce concept — rien à distribuer.
          </div>
        ) : (
          <React.Fragment>
            <div style={{ display: "flex", alignItems: "center", gap: 9, marginBottom: 6 }}>
              <span style={{ flex: 1, fontSize: 11, fontWeight: 700, letterSpacing: ".06em",
                textTransform: "uppercase", color: "var(--muted)" }}>
                Comptes ({choisis.length}/{branches.length})
              </span>
              <button onClick={() => setChoisis(choisis.length === branches.length ? [] : branches)}
                style={{ ...fcBouton(), padding: "3px 9px", fontSize: 11 }}>
                {choisis.length === branches.length ? "Aucun" : "Tous"}
              </button>
            </div>
            <div style={{ border: "1px solid var(--border)", borderRadius: 10,
              overflow: "hidden", marginBottom: 16 }}>
              {branches.map((u, i) => {
                const on = choisis.indexOf(u) >= 0;
                return (
                  <div key={u} onClick={() => bascule(u)}
                    style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 11px",
                      cursor: "pointer", fontSize: 13,
                      borderTop: i ? "1px solid var(--border)" : "none",
                      background: on ? "rgba(34,224,122,.09)" : "transparent" }}>
                    <span style={{ width: 15, height: 15, borderRadius: 4, flexShrink: 0,
                      fontSize: 11, fontWeight: 900, lineHeight: "14px", textAlign: "center",
                      color: "#06281c",
                      border: "1px solid " + (on ? "var(--accent)" : "var(--border)"),
                      background: on ? "var(--accent)" : "transparent" }}>{on ? "✓" : ""}</span>
                    <span style={{ flex: 1 }}>{u}</span>
                  </div>
                );
              })}
            </div>
          </React.Fragment>
        )}

        {/* Le temps de la DISTRIBUTION seulement — quelques dixièmes de seconde. Le spoof,
            lui, dure un quart d'heure : sa barre est sur la carte, pas ici, et la fenêtre se
            ferme dès qu'il démarre. */}
        {envoi && (
          <div style={{ marginBottom: 14 }}>
            <div style={{ display: "flex", justifyContent: "space-between", fontSize: 12,
              color: "var(--muted)", marginBottom: 5, gap: 10 }}>
              <span style={{ flex: 1, minWidth: 0, overflow: "hidden",
                textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
                {envoi.etape === "distribution"
                  ? "Distribution — " + envoi.quoi
                  : "Démarrage du spoof…"}
              </span>
              <span className="mono">{envoi.fait}/{envoi.total}</span>
            </div>
            <div style={{ height: 6, borderRadius: 99, background: "var(--surface-2)", overflow: "hidden" }}>
              <div style={{ width: (100 * envoi.fait / Math.max(1, envoi.total)) + "%",
                height: "100%", background: "var(--accent)", transition: "width .2s" }} />
            </div>
          </div>
        )}

        <div style={{ display: "flex", gap: 9, justifyContent: "flex-end" }}>
          <button onClick={() => surFermer(false)} style={fcBouton()}>Annuler</button>
          <button onClick={lancer} disabled={!choisis.length || !!envoi || !aDistribuer.length}
            style={{ ...fcBouton(!!choisis.length && !!aDistribuer.length) }}>
            {envoi ? "…" : aDistribuer.length
              ? "Distribuer + spoofer " + aDistribuer.length + " format(s) × " + choisis.length + " compte(s)"
              : "Aucun format approvisionné"}
          </button>
        </div>
      </div>
    </div>
  );
}

// ══ La barre du spoof, posée sur la carte ═════════════════════════════════════════════
//
// Elle vivait DANS la fenêtre de distribution, ce qui obligeait à rester devant pendant un
// quart d'heure — 84 encodages ffmpeg — sans pouvoir rien faire d'autre. Le travail se passe
// côté backend : la fenêtre se ferme, et c'est la carte qui rend compte.
//
// Elle se réveille de deux façons : au montage (on retrouve donc un travail lancé avant de
// rouvrir le panneau, ou depuis une autre fenêtre) et sur l'événement émis au lancement.
function BarreTravail({ concept, route, evenement, titre, resume }) {
  const { useState, useEffect, useRef } = React;
  const [etat, setEtat] = useState(null);
  const vivant = useRef(true);
  const boucle = useRef(false);

  useEffect(() => {
    vivant.current = true;

    const suivre = async () => {
      if (boucle.current) return;          // une seule boucle, même si l'événement double
      boucle.current = true;
      let hoquets = 0;
      try {
        for (;;) {
          let e;
          try {
            const r = await fetch(fcBase() + "/api/floodsflow/concepts/" + concept.id
              + "/" + route, { cache: "no-store" });
            e = await r.json();
            hoquets = 0;
          } catch (err) {
            // UN HOQUET RÉSEAU N'EST PAS UNE FIN DE TRAVAIL. La barre disparaissait au
            // premier échec de requête — en plein envoi irréversible, l'écran redevenait
            // muet alors que les publications continuaient de partir. On réessaie.
            if (++hoquets > 10) { setEtat(null); return; }
            await new Promise((r) => setTimeout(r, 2000));
            continue;
          }
          if (!vivant.current) return;
          // Rien à montrer : jamais lancé, ou fini depuis un moment et déjà lu.
          if (e.inconnu || (e.termine && !e.total)) {
            setEtat(null);
            // ON CONTINUE DE REGARDER, PLUS LENTEMENT. Sans cela, une carte montée avant le
            // lancement ne voyait jamais le travail : elle avait constaté « rien » une fois
            // et s'arrêtait là. C'est le cas dès qu'un spoof part d'une autre fenêtre — ou
            // simplement si la carte s'est affichée un instant trop tôt.
            await new Promise((r) => setTimeout(r, 8000));
            continue;
          }
          setEtat(e);
          if (e.termine) return;           // on laisse le bilan affiché
          await new Promise((r) => setTimeout(r, 1200));
        }
      } finally { boucle.current = false; }
    };

    const surEvenement = (ev) => {
      if (!ev || !ev.detail || ev.detail.id === concept.id) suivre();
    };
    window.addEventListener(evenement, surEvenement);
    suivre();
    return () => { vivant.current = false; window.removeEventListener(evenement, surEvenement); };
  }, [concept.id]);

  if (!etat) return null;
  const part = 100 * (etat.fait || 0) / Math.max(1, etat.total || 0);
  const rates = (etat.erreurs || []).length + (etat.erreurs_en_plus || 0);
  const fini = !!etat.termine;
  const couleur = etat.annule ? "var(--busy, #f0a020)"
    : rates ? "var(--danger, #ff5c6c)" : "var(--accent)";

  const arreter = () => {
    fetch(fcBase() + "/api/floodsflow/concepts/" + concept.id + "/" + route,
      { method: "DELETE" });
  };

  return (
    <div style={{ border: "1px solid var(--border)", borderRadius: 11, padding: "10px 12px",
      background: "var(--surface-2, rgba(255,255,255,.03))" }}>
      <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 6 }}>
        <span style={{ flex: 1, minWidth: 0, fontSize: 12, color: "var(--muted)",
          overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
          {etat.annule ? "⏹ " + titre + " arrêté"
            : fini ? (rates ? "⚠ " + titre + " terminé" : "✓ " + titre + " terminé")
            : titre + " — " + (etat.en_cours || "…")}
        </span>
        <span className="mono" style={{ fontSize: 12, fontWeight: 700, color: couleur }}>
          {etat.fait || 0}/{etat.total || 0}
        </span>
        {!fini && (
          <button onClick={arreter} title="Arrête après le fichier en cours"
            style={{ background: "none", border: "1px solid rgba(255,92,108,.3)",
              color: "var(--danger, #ff5c6c)", borderRadius: 7, fontSize: 11, padding: "2px 8px",
              cursor: "pointer", font: "inherit", fontWeight: 600 }}>Arrêter</button>
        )}
      </div>
      <div style={{ height: 6, borderRadius: 99, background: "var(--surface)", overflow: "hidden" }}>
        <div style={{ width: part + "%", height: "100%", background: couleur,
          transition: "width .3s" }} />
      </div>
      <div style={{ fontSize: 11, color: "var(--faint)", marginTop: 6, lineHeight: 1.45 }}>
        {resume(etat)}
        {rates ? " · " + rates + " échec(s) : " + (etat.erreurs || [])[0] : ""}
        {etat.annule && !fini ? " · arrêt demandé, le fichier en cours se termine" : ""}
        {etat.annule && fini ? " — relance pour reprendre là où il s'est arrêté" : ""}
      </div>
    </div>
  );
}

// Les deux travaux de fond de la carte. Même barre, deux routes : le spoof produit des
// fichiers sur le disque, l'envoi les remet à bundle.social. Ils s'enchaînent dans cet ordre
// et ne se confondent pas — c'est important, parce que seul le second est irréversible.
function BarreSpoof({ concept }) {
  return <BarreTravail concept={concept} route="spoof" evenement="floodsflow:spoof"
    titre="✨ Spoof" resume={(e) => (e.produits || 0) + " fichier(s) déposé(s)"
      + (e.ignores ? " · " + e.ignores + " déjà en place" : "")
      + ((e.absents || []).length ? " · " + e.absents.length + " source(s) introuvable(s)" : "")} />;
}

function BarreEnvoi({ concept }) {
  return <BarreTravail concept={concept} route="publier" evenement="floodsflow:envoi"
    titre="📤 Envoi" resume={(e) => (e.envoyes || 0) + " publication(s) remises à bundle.social"
      + (e.termine && e.envoyes ? " — elles partiront aux heures prévues, même ordinateur éteint" : "")} />;
}

// ══ L'état de santé des comptes ═══════════════════════════════════════════════════════
//
// L'opérateur : « sur les 3 comptes que j'ai mis, il y en a 2 qui ont sauté, et ça m'a pas
// averti avant de programmer ». Rien ne le savait, en effet : ni notre parc, ni bundle.social
// — qui ne connaît que le BRANCHEMENT. Un compte banni garde son rattachement OAuth.
//
// Trois sources sont interrogées (voir backend/floodsflow_sante.py), et le verdict le pire
// l'emporte. « Indéterminé » n'est PAS un feu vert : c'est précisément l'erreur qu'on répare.
const FS_VERDICTS = {
  vivant:            ["✓", "var(--accent)"],
  faible:            ["◍", "var(--busy, #f0a020)"],
  indetermine:       ["?", "var(--muted)"],
  checkpoint:        ["🔒", "var(--busy, #f0a020)"],
  invisible:         ["⚠", "var(--danger, #ff5c6c)"],
  echec_publication: ["⚠", "var(--danger, #ff5c6c)"],
  detache:           ["✖", "var(--danger, #ff5c6c)"],
  banni:             ["✖", "var(--danger, #ff5c6c)"],
  mauvais_compte:    ["✖", "var(--danger, #ff5c6c)"],
};

function DialogueSante({ concept, surFermer, surAvis, categories }) {
  const { useState, useEffect, useRef } = React;
  const [etat, setEtat] = useState(null);
  const [demarre, setDemarre] = useState(false);
  const vivant = useRef(true);

  const suivre = async () => {
    for (;;) {
      let e;
      try {
        const r = await fetch(fcBase() + "/api/floodsflow/concepts/" + concept.id + "/sante",
          { cache: "no-store" });
        e = await r.json();
      } catch (err) { return; }
      if (!vivant.current) return;
      setEtat(e);
      if (e.termine) return;
      await new Promise((r) => setTimeout(r, 1500));
    }
  };

  useEffect(() => {
    vivant.current = true;
    suivre();                       // on retrouve une vérification déjà en cours
    return () => { vivant.current = false; };
  }, [concept.id]);

  const lancer = async () => {
    setDemarre(true);
    const d = await fcAppel("/api/floodsflow/concepts/" + concept.id + "/sante",
      categories ? { categories: categories } : {});
    if (!d.ok) {
      setDemarre(false);
      surAvis({ ton: "ko", texte: d.error || "vérification impossible" });
      return;
    }
    suivre();
  };

  // CE QU'ON S'AUTORISE A CLASSER TOUT SEUL. Trois sources parlent, et une seule s'est
  // montrée fiable : les publications refusées par le fournisseur, plus le rattachement.
  // « invisible » et « indéterminé » n'en font PAS partie — mesuré sur ce parc, un compte
  // opérationnel rend le même profil vide qu'un banni. Classer là-dessus mettrait au rebut
  // un compte qui marche, ce qui est pire que le problème qu'on répare.
  const CLASSABLES = ["banni", "detache", "mauvais_compte", "checkpoint", "echec_publication"];

  // LE CLASSEMENT PASSE PAR LE MAGASIN DE L'APPLICATION, PAS PAR LE BACKEND.
  //
  // J'avais d'abord écrit `igaccounts` côté serveur. Ça marchait — et c'était dangereux : le
  // Stockage de comptes garde sa propre copie dans localStorage et REPOUSSE LE TABLEAU ENTIER
  // à chaque modification. Résultat constaté : l'écran ne voyait rien changer (il lit sa
  // copie), et sa prochaine sauvegarde aurait remis les comptes classés à leur ancienne
  // catégorie, en repoussant un instantané pris avant.
  //
  // `flagProblem` est le geste que le Stockage utilise lui-même : il écrit localStorage,
  // pousse au backend, et prévient les écrans ouverts — qui se rafraîchissent aussitôt.
  const classer = async (aClasser) => {
    const magasin = window.AccountCreationStore;
    if (!magasin || !magasin.flagProblem) {
      surAvis({ ton: "ko", texte: "le Stockage de comptes n'est pas chargé — recharge la page" });
      return;
    }
    if (!aClasser.length) return;
    const noms = aClasser.map((x) => "@" + x.compte).join("\n");
    if (!confirm("Ranger " + aClasser.length + " compte(s) dans « Compte Ban » ?\n\n" + noms
      + "\n\nLeur catégorie change et ig_plug passe à faux, avec le motif de la vérification."
      + "\nLeur iPhone reste attribué : si le compte est bien perdu, libère-le depuis le Stockage."
      + "\n\nC'est réversible : la catégorie se recorrige d'un clic.")) return;

    const parNom = {};
    (magasin.list() || []).forEach((a) => {
      if (a && a.username) parNom[String(a.username).toLowerCase()] = a;
    });
    const faits = [], rates = [], ids = [];
    for (const x of aClasser) {
      const a = parNom[String(x.compte).toLowerCase()];
      if (!a) { rates.push(x.compte + " (introuvable dans le Stockage)"); continue; }
      const motif = ((x.raisons || [])[0] || "vérification FloodsFlow").slice(0, 160);
      const r = magasin.flagProblem(a.id, "ban", motif);
      if (r && r.ok) { faits.push(x.compte); ids.push(a.id); } else rates.push(x.compte);
    }
    // ET ON L'ARCHIVE. Changer la catégorie ne suffisait pas : le compte restait dans la liste
    // de travail, à la vue de tous, simplement rangé ailleurs. « Compte Ban » compte 57
    // archivés pour 1 actif — l'archive EST la destination normale d'un compte mort. Archiver
    // libère aussi son iPhone, ce qu'un compte banni n'a plus de raison d'occuper.
    if (ids.length) { try { magasin.archive(ids); } catch (e) { /* pas fatal */ } }

    // ON VÉRIFIE QUE C'EST VRAIMENT PARTI, au lieu de l'affirmer.
    //
    // Le magasin écrit localStorage puis pousse le tableau ENTIER vers le cloud, sans regarder
    // la réponse : une session expirée rendait un 401 muet, la synchro d'après (10 s) ramenait
    // la version du cloud, et le compte banni réapparaissait dans sa catégorie. C'est
    // exactement ce qui a été observé. On refait donc l'écriture nous-mêmes, en attendant son
    // verdict, et on le dit.
    let souci = "";
    if (ids.length && window.Backend && window.Backend.saveIgAccounts) {
      try {
        const ok = await window.Backend.saveIgAccounts(magasin.list());
        if (!ok) souci = window.Backend.dernierEchecIg || "le cloud n'a pas accepté";
      } catch (e) { souci = e.message; }
    }

    surAvis(faits.length
      ? { ton: (rates.length || souci) ? "ko" : "ok",
          texte: faits.length + " compte(s) archivé(s) dans « Compte Ban »"
            + (rates.length ? " · échecs : " + rates.join(", ") : "")
            + (souci
              ? " — MAIS LE CLOUD A REFUSÉ (" + souci + ") : le changement est local et sera "
                + "annulé à la prochaine synchro. Reconnecte-toi puis recommence."
              : " — le Stockage est à jour") }
      : { ton: "ko", texte: "aucun compte classé : " + rates.join(", ") });
    if (faits.length) surFermer(true);
  };

  const res = (etat && etat.resultats) || [];
  const parVerdict = {};
  res.forEach((x) => { parVerdict[x.verdict] = (parVerdict[x.verdict] || 0) + 1; });
  const encours = etat && !etat.termine && etat.total;

  return (
    <div style={fcVoile} onMouseDown={(e) => { if (e.target === e.currentTarget) surFermer(false); }}>
      <div style={fcBoite}>
        <div style={{ fontSize: 16, fontWeight: 800, marginBottom: 3 }}>Vérifier les comptes</div>
        <div style={{ fontSize: 12, color: "var(--faint)", marginBottom: 14, lineHeight: 1.5 }}>
          Trois sources, parce qu'aucune ne sait tout : le <b>rattachement</b> chez
          bundle.social (détecte un compte détaché ou un <b>autre</b> compte branché sur la
          team), les <b>publications ratées</b> chez le fournisseur, et le <b>profil public
          Instagram</b> — la seule source qui sache pour un bannissement, et la plus fragile :
          elle bride après quelques requêtes, et on préfère alors dire « on ne sait pas ».
        </div>

        {!etat || etat.inconnu ? (
          <button onClick={lancer} disabled={demarre} style={fcBouton(!demarre, true)}>
            {demarre ? "…" : "🩺 Lancer la vérification"}
          </button>
        ) : (
          <React.Fragment>
            {encours ? (
              <div style={{ marginBottom: 12 }}>
                <div style={{ display: "flex", justifyContent: "space-between", fontSize: 12,
                  color: "var(--muted)", marginBottom: 5 }}>
                  <span>{etat.en_cours || "…"}</span>
                  <span className="mono">{etat.fait}/{etat.total}</span>
                </div>
                <div style={{ height: 6, borderRadius: 99, background: "var(--surface-2)",
                  overflow: "hidden" }}>
                  <div style={{ width: (100 * etat.fait / Math.max(1, etat.total)) + "%",
                    height: "100%", background: "var(--accent)", transition: "width .3s" }} />
                </div>
                <div style={{ fontSize: 11, color: "var(--faint)", marginTop: 6 }}>
                  Environ 4 s par compte : Instagram bride si on va plus vite.
                </div>
              </div>
            ) : (
              <div style={{ display: "flex", gap: 7, flexWrap: "wrap", marginBottom: 12 }}>
                {Object.keys(parVerdict).map((v) => {
                  const [ic, co] = FS_VERDICTS[v] || ["·", "var(--muted)"];
                  return (
                    <span key={v} style={{ padding: "3px 9px", borderRadius: 99, fontSize: 11.5,
                      fontWeight: 700, color: co,
                      background: "color-mix(in srgb, " + co + " 12%, transparent)",
                      border: "1px solid color-mix(in srgb, " + co + " 30%, transparent)" }}>
                      {ic} {parVerdict[v]} {v.replace(/_/g, " ")}
                    </span>
                  );
                })}
              </div>
            )}

            <div style={{ border: "1px solid var(--border)", borderRadius: 10,
              overflow: "hidden", maxHeight: "44vh", overflowY: "auto" }}>
              {res.length === 0 ? (
                <div style={{ padding: 11, fontSize: 12, color: "var(--faint)" }}>…</div>
              ) : res.map((x, i) => {
                const [ic, co] = FS_VERDICTS[x.verdict] || ["·", "var(--muted)"];
                const d = x.detail || {};
                return (
                  <div key={i} style={{ padding: "8px 11px",
                    borderTop: i ? "1px solid var(--border)" : "none" }}>
                    <div style={{ display: "flex", alignItems: "baseline", gap: 9, fontSize: 13 }}>
                      <span style={{ color: co, fontWeight: 800, width: 12 }}>{ic}</span>
                      <span style={{ flex: 1, minWidth: 0, overflow: "hidden",
                        textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{x.compte}</span>
                      {d.abonnes != null && (
                        <span style={{ fontSize: 11, color: "var(--faint)" }}>
                          {d.abonnes} abonnés · {d.publications} pub.
                          {d.vues_moyennes ? " · " + d.vues_moyennes + " vues moy." : ""}
                        </span>
                      )}
                      <span style={{ fontSize: 11, fontWeight: 700, color: co }}>
                        {x.verdict.replace(/_/g, " ")}
                      </span>
                    </div>
                    {(x.raisons || []).length > 0 && (
                      <div style={{ fontSize: 11, color: "var(--faint)", marginLeft: 21,
                        marginTop: 2, lineHeight: 1.45 }}>{x.raisons.join(" · ")}</div>
                    )}
                  </div>
                );
              })}
            </div>
          </React.Fragment>
        )}

        {!encours && res.length > 0 && (() => {
          const morts = res.filter((x) => CLASSABLES.indexOf(x.verdict) >= 0);
          const doutes = res.filter((x) => ["invisible", "indetermine"].indexOf(x.verdict) >= 0);
          return (
            <div style={{ marginTop: 12, fontSize: 11.5, lineHeight: 1.5 }}>
              {morts.length > 0 ? (
                <button onClick={() => classer(morts)} style={fcBouton(true, true)}>
                  🚫 Ranger {morts.length} compte(s) dans « Compte Ban »
                </button>
              ) : (
                <div style={{ color: "var(--faint)" }}>
                  Aucun compte à classer : rien n'est établi avec certitude.
                </div>
              )}
              {doutes.length > 0 && (
                <div style={{ color: "var(--faint)", marginTop: 7 }}>
                  {doutes.length} compte(s) ne sont <b>pas</b> proposés au classement — Instagram
                  n'a rien dit de concluant à leur sujet. Un compte opérationnel de ce parc rend
                  exactement le même profil vide qu'un compte banni : on ne met personne au
                  rebut là-dessus.
                </div>
              )}
            </div>
          );
        })()}

        <div style={{ display: "flex", gap: 9, justifyContent: "flex-end", marginTop: 14 }}>
          {encours ? (
            <button onClick={() => fetch(fcBase() + "/api/floodsflow/concepts/" + concept.id
              + "/sante", { method: "DELETE" })}
              style={{ ...fcBouton(), borderColor: "rgba(255,92,108,.3)",
                color: "var(--danger, #ff5c6c)" }}>Arrêter</button>
          ) : etat && !etat.inconnu ? (
            <button onClick={lancer} style={fcBouton()}>Relancer</button>
          ) : null}
          <button onClick={() => surFermer(true)} style={fcBouton()}>Fermer</button>
        </div>
      </div>
    </div>
  );
}

// ══ La ligne de stock, posée sur la carte du concept ══════════════════════════════════
function LigneStock({ concept }) {
  const s = concept.stock || {};
  const rien = FF_FORMATS.every((f) => !(s[f.id] || 0));
  return (
    <div style={{ display: "flex", gap: 7, flexWrap: "wrap", fontSize: 11 }}>
      {FF_FORMATS.map((f) => (
        <span key={f.id} title={f.label + " en banque"}
          style={{ padding: "3px 9px", borderRadius: 99, fontWeight: 700,
            background: (s[f.id] || 0) ? "rgba(34,224,122,.10)" : "var(--surface-2, rgba(255,255,255,.04))",
            border: "1px solid " + ((s[f.id] || 0) ? "rgba(34,224,122,.28)" : "var(--border)"),
            color: (s[f.id] || 0) ? "var(--accent)" : "var(--faint)" }}>
          {f.emoji} {s[f.id] || 0}
        </span>
      ))}
      {rien && (
        <span style={{ color: "var(--faint)", alignSelf: "center" }}>banque vide</span>
      )}
    </div>
  );
}

// ====================================================================================
// ─── FloodsFlow — programmer les publications ─────────────────────────────────────────
//
// Le bouton disait « Programmer 3 jours » et faisait exactement cela : trois jours, tous les
// comptes, la montée en charge et rien d'autre. Aucune de ces trois décisions n'était offerte
// à l'opérateur, alors que les trois lui appartiennent.
//
// Ici on choisit : QUI publie, PENDANT COMBIEN DE JOURS, et COMBIEN DE FOIS CHAQUE JOUR —
// jour par jour, parce qu'un programme réel n'est pas plat : trois par jour la première
// semaine, puis cinq quand le compte a pris de l'âge.
//
// La montée en charge reste le PRÉFIXE PROPOSÉ, jamais imposée : un compte neuf qui publie
// comme un compte établi se fait repérer, donc le chemin par défaut reste le prudent. Mais
// c'est un défaut, pas un mur — l'opérateur peut le remplacer et voit ce qu'il risque.

// La fenêtre de publication (13h–23h UTC) et l'écart minimal de 90 min entre deux
// publications d'un même compte plafonnent mécaniquement le nombre de créneaux par jour.
// L'afficher évite de saisir 10 et de recevoir 6 sans explication.
const FP_MAX_JOUR = Math.floor((23 * 60 - 13 * 60) / 90);   // = 6

function DialoguePlanning({ concept, surFermer, surAvis, mode }) {
  const { useState } = React;
  const publier = mode !== "programmer";       // ouvert depuis « Lancer la publication »
  const branches = concept.branches || [];
  const rythme = Math.max(1, concept.rythme || 3);
  const stock = concept.stock || {};
  const [choisis, setChoisis] = useState(branches);
  // QUOI publier. Un format sans contenu en banque ne peut pas être coché : le programme
  // l'aurait sauté en silence et l'opérateur aurait cru avoir demandé des Story.
  const [formats, setFormats] = useState(
    (stock.reels || 0) > 0 ? ["reels"]
      : (FF_FORMATS.find((f) => (stock[f.id] || 0) > 0) || {}).id
        ? [(FF_FORMATS.find((f) => (stock[f.id] || 0) > 0) || {}).id] : []);
  const [envoyer, setEnvoyer] = useState(publier);
  const [brouillon, setBrouillon] = useState(false);
  const [jours, setJours] = useState(7);
  // La rampe par défaut : 1/jour la première semaine, 2 la deuxième… jusqu'au rythme visé.
  // On la déroule sur les jours demandés pour que l'opérateur VOIE ce qu'il accepte.
  const rampe = (n) => {
    const out = [];
    for (let d = 0; d < n; d++) out.push(Math.min(rythme, 1 + Math.floor(d / 7)));
    return out;
  };
  const [quotas, setQuotas] = useState(rampe(7));
  const [occupe, setOccupe] = useState(false);
  const [remplacer, setRemplacer] = useState(false);
  // LE RYTHME EN UN CHOIX, PAS EN SEPT CASES. La grille de sept nombres était le morceau
  // incompréhensible du panneau : sept champs sans légende, dont on ne devinait ni l'unité
  // ni ce qu'ils changeaient. Elle existe toujours — c'est elle qui permet « 3 par jour puis
  // 5 » — mais derrière un dépli, pour ceux qui la cherchent.
  const [reglage, setReglage] = useState("rampe");   // rampe | fixe | detail
  const [fixe, setFixe] = useState(rythme);
  // LES HEURES. « Automatique » tire des heures différentes pour chaque compte : c'est ce
  // qui empêche dix comptes de publier à la même minute. « Choisies » rend la main à
  // l'opérateur — il connaît son audience — mais garde une dispersion de quelques minutes,
  // sinon on retombe exactement sur le signal qu'on évitait.
  const [horaires, setHoraires] = useState("auto");  // auto | choisies
  const [fuseau, setFuseau] = useState("Europe/Paris");
  const [heures, setHeures] = useState(["09:00", "14:00", "19:00"]);
  const [dispersion, setDispersion] = useState(12);
  const [essaiCompte, setEssaiCompte] = useState((concept.branches || [])[0] || "");
  const [essaiMinutes, setEssaiMinutes] = useState(10);

  const poser = (mode, valeur, n) => {
    const d = n || jours;
    if (mode === "rampe") setQuotas(rampe(d));
    else if (mode === "fixe") setQuotas(Array.from({ length: d }, () => valeur));
  };
  const choisirReglage = (mode) => { setReglage(mode); poser(mode, fixe); };
  const choisirFixe = (v) => {
    const n = Math.max(0, Math.min(FP_MAX_JOUR, v || 0));
    setFixe(n); setReglage("fixe"); poser("fixe", n);
  };

  const changerJours = (n) => {
    // Un champ vidé rend NaN. Sans ce garde-fou, effacer le nombre pour le retaper ramenait
    // la durée à 1 et détruisait au passage tous les quotas saisis à la main.
    if (!n || n < 1) return;
    const v = Math.max(1, Math.min(30, n));
    setJours(v);
    if (reglage !== "detail") { poser(reglage, fixe, v); return; }
    // En mode détaillé on garde ce que l'opérateur a saisi et on prolonge avec la dernière
    // valeur : remettre la rampe effacerait son travail à chaque coup de flèche.
    setQuotas((q) => Array.from({ length: v }, (_, i) => (i < q.length ? q[i] : q[q.length - 1] || 1)));
  };
  const changerQuota = (i, n) =>
    setQuotas((q) => q.map((x, j) => (j === i ? Math.max(0, Math.min(FP_MAX_JOUR, n || 0)) : x)));

  const bascule = (u) =>
    setChoisis((l) => (l.indexOf(u) >= 0 ? l.filter((x) => x !== u) : l.concat(u)));
  const basculeFormat = (id) =>
    setFormats((l) => (l.indexOf(id) >= 0 ? l.filter((x) => x !== id) : l.concat(id)));

  const maxQuota = Math.max(1, ...quotas);
  const total = quotas.reduce((a, b) => a + b, 0) * choisis.length;
  const attendu = rampe(jours);
  const auDessus = quotas.some((q, i) => q > attendu[i]);
  // Ce que ça coûte en contenu : c'est le chiffre qui manque quand on programme 30 jours et
  // qu'on découvre le troisième jour qu'il n'y avait de quoi en tenir neuf.
  const enFile = Math.round((concept.jours_couverture || 0) * rythme);
  const parCompte = quotas.reduce((a, b) => a + b, 0);
  const manque = Math.max(0, parCompte - enFile);

  // UNE SEULE PORTE POUR LES DEUX MOITIÉS DU MÊME GESTE.
  //
  // Programmer et envoyer étaient deux boutons distincts, dans deux endroits différents, et
  // il fallait se souvenir de l'ordre. Ici on décrit ce qu'on veut — quoi, pour qui, combien
  // par jour, pendant combien de jours — et le backend programme puis remet le tout à
  // bundle.social, qui publiera à l'heure dite. La machine peut être éteinte ensuite.
  //
  // « REMPLACER » PASSE PAR LE POST, PAS PAR UN DELETE : le backend ne lit le corps que sur
  // POST/PATCH, donc le filtre par compte était structurellement hors d'atteinte — décocher
  // sept comptes sur dix effaçait quand même le planning des dix.
  const lancer = async () => {
    if (!choisis.length || !formats.length || occupe) return;
    setOccupe(true);
    try {
      // LES HEURES PARTENT DÉJÀ EN UTC, ET UNE LISTE PAR JOUR. Le décalage d'un fuseau
      // change au passage à l'heure d'hiver : une liste unique se tromperait d'une heure à
      // partir de ce jour-là, sans que rien ne le signale.
      let heuresUTC = null;
      if (horaires === "choisies") {
        const t0 = new Date();
        heuresUTC = Array.from({ length: jours }, (_, j) => {
          const d = new Date(t0.getFullYear(), t0.getMonth(), t0.getDate() + j);
          return heures.slice(0, maxQuota)
            .map((h) => FP_VERS_UTC(d.getFullYear(), d.getMonth(), d.getDate(), h, fuseau));
        });
      }
      const d = await fcAppel("/api/floodsflow/concepts/" + concept.id + "/publier", {
        jours: jours, comptes: choisis, quotas: quotas, formats: formats,
        remplacer: !!remplacer, brouillon: !!brouillon,
        programmer_seulement: !envoyer,
        heures: heuresUTC, dispersion: dispersion,
      });
      const p = d.planification || d;
      const notes = [];
      if (p.a_sec && p.a_sec.length) notes.push("à court de contenu : " + p.a_sec.join(", "));
      if (p.satures && p.satures.length) {
        notes.push("journée déjà prise par un autre concept : " + p.satures.join(", "));
      }
      // Les jours déjà programmés sont SAUTÉS, quota imposé ou non. Sans cette ligne on
      // demandait 35 publications, on en recevait 12, et on croyait manquer de contenu.
      if (p.intacts && p.intacts.length) {
        notes.push(p.intacts.length + " jour(s) déjà programmé(s), laissé(s) intacts ("
          + p.intacts.slice(0, 3).join(", ") + ")");
      }
      const programmees = p.ajoutes || 0;

      if (!envoyer) {
        // Un refus du backend ici (envoi en cours, concept introuvable) s'affichait comme un
        // succès bénin « rien à ajouter » : on ne regardait jamais d.ok.
        if (d.ok === false) {
          setOccupe(false);
          surAvis({ ton: "ko", texte: d.error || "la planification a été refusée" });
          return;
        }
        surAvis(programmees
          ? { ton: "ok", texte: programmees + " publication(s) programmée(s) sur "
              + choisis.length + " compte(s)" + (notes.length ? " · " + notes.join(" · ") : "") }
          : { ton: notes.length ? "ko" : "info",
              texte: notes.length ? "Rien programmé — " + notes.join(" · ")
                : "Rien à ajouter : ces jours sont déjà programmés (coche « remplacer »)." });
        surFermer(true);
        return;
      }

      if (!d.ok) {
        // La planification a pu réussir alors que l'envoi refuse : on dit les deux, sinon on
        // croit que rien n'a eu lieu et on reprogramme par-dessus.
        setOccupe(false);
        surAvis({ ton: "ko", texte: (programmees ? programmees + " programmée(s), mais " : "")
          + "AUCUN envoi : " + (d.error || "refus du backend")
          + (notes.length ? " · " + notes.join(" · ") : "") });
        return;
      }
      try {
        window.dispatchEvent(new CustomEvent("floodsflow:envoi", { detail: { id: concept.id } }));
      } catch (e) { /* la carte interrogera d'elle-même */ }
      if (d.sans_fichier && d.sans_fichier.length) {
        notes.push(d.sans_fichier.length + " sans fichier spoofé, ignorée(s)");
      }
      if (d.passees) notes.push(d.passees + " dont l'heure est déjà passée, ignorée(s)");
      // Le pire des cas silencieux : la team porte un AUTRE compte Instagram que celui visé.
      if (d.mauvais_compte && d.mauvais_compte.length) {
        notes.push("⚠ MAUVAIS COMPTE : " + d.mauvais_compte.join(" · "));
      }
      surAvis({ ton: (d.mauvais_compte || []).length ? "ko" : "ok",
        texte: (programmees ? programmees + " programmée(s) · " : "")
          + (d.demandees || 0) + " publication(s) en cours d'envoi vers bundle.social"
          + (brouillon ? " EN ESSAI À BLANC (brouillons, rien ne partira ; le planning reste intact)" : "")
          + " — la progression s'affiche sur la carte"
          + (notes.length ? " · " + notes.join(" · ") : "") });
      surFermer(true);
    } catch (e) {
      surAvis({ ton: "ko", texte: "impossible — " + e.message });
      setOccupe(false);
    }
  };

  // Le test passe par le CHEMIN NORMAL : même réservation, même marquage, même protection
  // contre le double envoi. Ce n'est pas une voie parallèle, c'est la voie habituelle avec un
  // horaire proche et un seul compte — sinon le test ne prouverait rien sur le vrai chemin.
  const lancerEssai = async () => {
    if (occupe || !essaiCompte) return;
    setOccupe(true);
    const d = await fcAppel("/api/floodsflow/concepts/" + concept.id + "/essai",
      { compte: essaiCompte, dans_minutes: essaiMinutes });
    setOccupe(false);
    if (!d.ok) { surAvis({ ton: "ko", texte: d.error || "test impossible" }); return; }
    const h = new Date(d.quand).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
    try {
      window.dispatchEvent(new CustomEvent("floodsflow:envoi", { detail: { id: concept.id } }));
    } catch (e) { /* la carte interrogera d'elle-même */ }
    surAvis({ ton: "ok", texte: "Test programmé sur @" + d.compte + " à " + h + " — "
      + d.contenu + ". Regarde le compte à cette heure-là ; la carte affichera l'envoi." });
    surFermer(true);
  };

  const jourNom = (d) => {
    const t = new Date();
    t.setDate(t.getDate() + d);
    return t.toLocaleDateString([], { weekday: "short", day: "numeric", month: "short" });
  };

  return (
    <div style={fcVoile} onMouseDown={(e) => { if (e.target === e.currentTarget) surFermer(false); }}>
      <div style={fcBoite}>
        <div style={{ fontSize: 16, fontWeight: 800, marginBottom: 3 }}>
          {publier ? "Lancer la publication" : "Programmer les publications"}
        </div>
        {/* CE QUI VA SE PASSER, EN UNE PHRASE, AVANT TOUT LE RESTE.
            L'opérateur : « quand je mets la pop-up, je comprends un peu rien ». Il avait
            raison : on lui montrait sept champs de saisie et aucune conclusion. Cette phrase
            se recalcule à chaque réglage — c'est elle qu'on lit, le reste sert à la changer. */}
        <div style={{ padding: "11px 13px", borderRadius: 10, marginBottom: 14,
          background: "var(--surface-2, rgba(255,255,255,.04))",
          border: "1px solid var(--border)", fontSize: 12.5, lineHeight: 1.55 }}>
          {!choisis.length || !formats.length ? (
            <span style={{ color: "var(--busy, #f0a020)" }}>
              Choisis au moins un contenu et un compte.
            </span>
          ) : (
            <React.Fragment>
              {choisis.length > 1
                ? <>Chacun des <b>{choisis.length}</b> comptes publiera </>
                : <>Le compte <b>@{choisis[0]}</b> publiera </>}
              <b>{FP_PHRASE_RYTHME(quotas, formats)}</b> pendant <b>{jours}</b> jour
              {jours > 1 ? "s" : ""} — soit <b>{total}</b> publication{total > 1 ? "s" : ""} au total.
              <div style={{ marginTop: 5, color: "var(--faint)", fontSize: 11.5 }}>
                Les heures sont tirées au hasard entre {FP_FENETRE()}, différentes pour chaque
                compte et stables d'un jour à l'autre, avec 90 min minimum entre deux.
                {envoyer
                  ? " C'est bundle.social qui publiera à l'heure dite : ton ordinateur peut être éteint."
                  : " Rien ne sera envoyé : les publications resteront ici."}
              </div>
            </React.Fragment>
          )}
        </div>

        {/* QUOI. Un format vide n'est pas cochable : le programme l'aurait sauté en silence. */}
        <span style={fpTitre}>Contenu</span>
        <div style={{ display: "flex", gap: 7, flexWrap: "wrap", margin: "6px 0 16px" }}>
          {FF_FORMATS.map((f) => {
            const vide = !(stock[f.id] || 0);
            const on = formats.indexOf(f.id) >= 0;
            return (
              <button key={f.id} onClick={() => !vide && basculeFormat(f.id)}
                title={vide ? "Banque vide pour ce format — approvisionne-la d'abord"
                  : f.note || f.label}
                style={{ ...fcBouton(on && !vide), padding: "6px 11px", fontSize: 12,
                  opacity: vide ? .45 : 1, cursor: vide ? "default" : "pointer" }}>
                {on ? "✓ " : ""}{f.emoji} {f.label} · {stock[f.id] || 0}
              </button>
            );
          })}
        </div>

        {/* ── Qui publie ── */}
        <div style={{ display: "flex", alignItems: "center", gap: 9, marginBottom: 6 }}>
          <span style={fpTitre}>Comptes ({choisis.length}/{branches.length})</span>
          <button onClick={() => setChoisis(choisis.length === branches.length ? [] : branches)}
            style={{ ...fcBouton(), padding: "3px 9px", fontSize: 11 }}>
            {choisis.length === branches.length ? "Aucun" : "Tous"}
          </button>
        </div>
        {!branches.length ? (
          <div style={{ padding: "10px 0", fontSize: 12.5, color: "var(--busy, #f0a020)" }}>
            Aucun compte branché à l'API dans ce concept.
          </div>
        ) : (
          <div style={{ border: "1px solid var(--border)", borderRadius: 10, overflow: "hidden",
            marginBottom: 16, maxHeight: 168, overflowY: "auto" }}>
            {branches.map((u, i) => {
              const on = choisis.indexOf(u) >= 0;
              return (
                <div key={u} onClick={() => bascule(u)}
                  style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 11px",
                    cursor: "pointer", fontSize: 13,
                    borderTop: i ? "1px solid var(--border)" : "none",
                    background: on ? "rgba(34,224,122,.09)" : "transparent" }}>
                  <span style={{ width: 15, height: 15, borderRadius: 4, flexShrink: 0,
                    fontSize: 11, fontWeight: 900, lineHeight: "14px", textAlign: "center",
                    color: "#06281c",
                    border: "1px solid " + (on ? "var(--accent)" : "var(--border)"),
                    background: on ? "var(--accent)" : "transparent" }}>{on ? "✓" : ""}</span>
                  <span style={{ flex: 1 }}>{u}</span>
                </div>
              );
            })}
          </div>
        )}

        {/* ── Combien de jours, et à quel rythme ── */}
        <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 12 }}>
          <span style={{ ...fpTitre, flex: "none" }}>Durée</span>
          <input type="number" min="1" max="30" value={jours}
            onChange={(e) => changerJours(+e.target.value)} style={fpChamp} />
          <span style={{ fontSize: 12, color: "var(--muted)" }}>jours</span>
        </div>

        <span style={fpTitre}>Rythme</span>
        <div style={{ marginTop: 6, marginBottom: 12, border: "1px solid var(--border)",
          borderRadius: 10, overflow: "hidden" }}>
          <div onClick={() => choisirReglage("rampe")}
            style={FP_OPTION(reglage === "rampe")}>
            <span style={FP_PUCE(reglage === "rampe")} />
            <div style={{ flex: 1 }}>
              <div style={{ fontSize: 12.5, fontWeight: 600 }}>Montée en charge — recommandé</div>
              <div style={{ fontSize: 11, color: "var(--faint)" }}>
                1 par jour la première semaine, 2 la deuxième, jusqu'à {rythme}. Un compte
                neuf qui publie comme un compte établi se fait repérer.
              </div>
            </div>
          </div>
          <div onClick={() => choisirReglage("fixe")}
            style={{ ...FP_OPTION(reglage === "fixe"), borderTop: "1px solid var(--border)" }}>
            <span style={FP_PUCE(reglage === "fixe")} />
            <div style={{ flex: 1, display: "flex", alignItems: "center", gap: 8 }}>
              <span style={{ fontSize: 12.5, fontWeight: 600 }}>Rythme fixe</span>
              <input type="number" min="0" max={FP_MAX_JOUR} value={fixe}
                onClick={(e) => e.stopPropagation()}
                onChange={(e) => choisirFixe(+e.target.value)}
                style={{ ...fpChamp, width: 52, padding: "4px 7px" }} />
              <span style={{ fontSize: 12, color: "var(--muted)" }}>par jour et par compte</span>
            </div>
          </div>
          <div onClick={() => setReglage(reglage === "detail" ? "rampe" : "detail")}
            style={{ ...FP_OPTION(reglage === "detail"), borderTop: "1px solid var(--border)" }}>
            <span style={FP_PUCE(reglage === "detail")} />
            <div style={{ flex: 1 }}>
              <div style={{ fontSize: 12.5, fontWeight: 600 }}>
                Jour par jour {reglage === "detail" ? "▾" : "▸"}
              </div>
              <div style={{ fontSize: 11, color: "var(--faint)" }}>
                Pour un programme irrégulier : 3, 3, 5, 1…
              </div>
            </div>
          </div>
        </div>

        {/* ── À QUELLE HEURE ── */}
        <div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 6 }}>
          <span style={{ ...fpTitre, flex: "none" }}>Horaires</span>
          <span style={{ flex: 1 }} />
          <select value={fuseau} onChange={(e) => setFuseau(e.target.value)}
            style={{ ...fpChamp, width: "auto", padding: "5px 8px" }}>
            {FP_FUSEAUX.map((f) => <option key={f.id} value={f.id}>{f.label}</option>)}
          </select>
        </div>
        <div style={{ marginBottom: 12, border: "1px solid var(--border)",
          borderRadius: 10, overflow: "hidden" }}>
          <div onClick={() => setHoraires("auto")} style={FP_OPTION(horaires === "auto")}>
            <span style={FP_PUCE(horaires === "auto")} />
            <div style={{ flex: 1 }}>
              <div style={{ fontSize: 12.5, fontWeight: 600 }}>Automatique — recommandé</div>
              <div style={{ fontSize: 11, color: "var(--faint)" }}>
                Des heures différentes pour chaque compte, entre {FP_FENETRE()}, stables d'un
                jour à l'autre. C'est ce qui empêche tes comptes de publier tous en même temps.
              </div>
            </div>
          </div>
          <div onClick={() => setHoraires("choisies")}
            style={{ ...FP_OPTION(horaires === "choisies"), borderTop: "1px solid var(--border)",
              flexDirection: "column", alignItems: "stretch" }}>
            <div style={{ display: "flex", alignItems: "flex-start", gap: 10 }}>
              <span style={FP_PUCE(horaires === "choisies")} />
              <div style={{ flex: 1 }}>
                <div style={{ fontSize: 12.5, fontWeight: 600 }}>
                  Je choisis les heures ({FP_FUSEAUX.find((f) => f.id === fuseau).label})
                </div>
                <div style={{ fontSize: 11, color: "var(--faint)" }}>
                  Une heure par publication de la journée — {maxQuota} demandée
                  {maxQuota > 1 ? "s" : ""} au plus fort.
                </div>
              </div>
            </div>
            {horaires === "choisies" && (
              <div style={{ marginTop: 9, marginLeft: 23 }}
                onClick={(e) => e.stopPropagation()}>
                <div style={{ display: "flex", gap: 7, flexWrap: "wrap", alignItems: "center" }}>
                  {Array.from({ length: maxQuota }, (_, i) => (
                    <input key={i} type="time" value={heures[i] || "12:00"}
                      onChange={(e) => setHeures((h) => {
                        const c = Array.from({ length: maxQuota }, (_, j) => h[j] || "12:00");
                        c[i] = e.target.value; return c;
                      })}
                      style={{ ...fpChamp, width: 96, padding: "5px 8px" }} />
                  ))}
                </div>
                <div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 8,
                  fontSize: 11.5, color: "var(--muted)" }}>
                  <span>dispersion ±</span>
                  <input type="number" min="0" max="45" value={dispersion}
                    onChange={(e) => setDispersion(Math.max(0, Math.min(45, +e.target.value || 0)))}
                    style={{ ...fpChamp, width: 52, padding: "4px 6px" }} />
                  <span>min</span>
                </div>
                {/* SANS DISPERSION, TOUS LES COMPTES PUBLIENT À LA MÊME MINUTE. C'est le signal
                    d'automatisation le plus lisible qui soit — et la raison d'être du placement
                    automatique qu'on vient de désactiver. */}
                <div style={{ fontSize: 11, marginTop: 6, lineHeight: 1.45,
                  color: dispersion ? "var(--faint)" : "var(--danger, #ff5c6c)" }}>
                  {dispersion
                    ? "Chaque compte publiera à quelques minutes près de ton heure — jamais tous à la même seconde."
                    : "⚠ Sans dispersion, tes " + choisis.length + " comptes publieront à la MÊME minute. C'est ce qui se repère le plus vite."}
                </div>
                <div style={{ fontSize: 11, color: "var(--faint)", marginTop: 4 }}>
                  soit {heures.slice(0, maxQuota).map((h) => FP_APERCU_UTC(h, fuseau)).join(", ")} UTC
                </div>
              </div>
            )}
          </div>
        </div>

        <div style={{ display: reglage === "detail" ? "grid" : "none",
          gridTemplateColumns: "repeat(auto-fill, minmax(84px, 1fr))",
          gap: 7, marginBottom: 12 }}>
          {quotas.map((q, i) => (
            <div key={i} style={{ border: "1px solid " + (q > attendu[i] ? "rgba(240,160,32,.4)" : "var(--border)"),
              borderRadius: 9, padding: "6px 7px", background: "var(--surface-2, rgba(255,255,255,.03))" }}>
              <div style={{ fontSize: 10, color: "var(--faint)", marginBottom: 3,
                whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{jourNom(i)}</div>
              <input type="number" min="0" max={FP_MAX_JOUR} value={q}
                onChange={(e) => changerQuota(i, +e.target.value)}
                style={{ ...fpChamp, width: "100%", padding: "4px 6px" }} />
            </div>
          ))}
        </div>

        {auDessus && (
          <div style={{ fontSize: 11.5, color: "var(--busy, #f0a020)", marginBottom: 10,
            lineHeight: 1.45 }}>
            ⚠ Au-dessus de la montée en charge ({attendu.join("/")}). Un compte neuf qui publie
            comme un compte établi se fait repérer — c'est ton choix, il est juste affiché.
          </div>
        )}
        {manque > 0 && (
          <div style={{ fontSize: 11.5, color: "var(--danger, #ff5c6c)", marginBottom: 10,
            lineHeight: 1.45 }}>
            ⚠ {parCompte} publications demandées par compte, {enFile} en file chez le plus
            démuni : il en manque {manque}. Les jours au-delà resteront vides.
          </div>
        )}

        {/* ── TESTER D'ABORD ──────────────────────────────────────────────────────────
            Tout le reste se vérifie sur pièces : des fichiers sur le disque, des lignes dans
            un planning, une réponse d'API. Mais « est-ce que ça poste vraiment sur Instagram »
            ne se vérifie qu'en postant. Sans ce bouton, on programme une semaine et on
            découvre le résultat le lendemain. */}
        {publier && branches.length > 0 && (
          <div style={{ border: "1px dashed var(--border-2)", borderRadius: 10,
            padding: "10px 12px", marginBottom: 12,
            background: "var(--surface-2, rgba(255,255,255,.02))" }}>
            <div style={{ fontSize: 12.5, fontWeight: 600, marginBottom: 3 }}>
              Tester d'abord sur un seul compte
            </div>
            <div style={{ fontSize: 11, color: "var(--faint)", marginBottom: 8, lineHeight: 1.45 }}>
              Une vraie publication, dans quelques minutes, pour voir de tes yeux qu'elle
              arrive. Elle consomme un contenu de la file du compte, comme n'importe quelle
              autre.
            </div>
            <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
              <select value={essaiCompte} onChange={(e) => setEssaiCompte(e.target.value)}
                style={{ ...fpChamp, width: "auto", padding: "5px 8px", maxWidth: 190 }}>
                {branches.map((u) => <option key={u} value={u}>{u}</option>)}
              </select>
              <span style={{ fontSize: 12, color: "var(--muted)" }}>dans</span>
              <input type="number" min="3" max="180" value={essaiMinutes}
                onChange={(e) => setEssaiMinutes(Math.max(3, Math.min(180, +e.target.value || 10)))}
                style={{ ...fpChamp, width: 58, padding: "5px 8px" }} />
              <span style={{ fontSize: 12, color: "var(--muted)" }}>min</span>
              <span style={{ flex: 1 }} />
              <button onClick={lancerEssai} disabled={occupe || !essaiCompte}
                style={{ ...fcBouton(), padding: "6px 12px", fontSize: 12 }}>
                🧪 Publier un test
              </button>
            </div>
          </div>
        )}

        <label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12,
          color: "var(--muted)", marginBottom: 8, cursor: "pointer" }}>
          <input type="checkbox" checked={remplacer} onChange={(e) => setRemplacer(e.target.checked)} />
          Remplacer le planning existant (n'efface que ce qui est encore prévu)
        </label>

        <label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12,
          color: "var(--muted)", marginBottom: 8, cursor: "pointer" }}>
          <input type="checkbox" checked={envoyer} onChange={(e) => setEnvoyer(e.target.checked)} />
          Envoyer à bundle.social maintenant (sinon on ne fait que programmer ici)
        </label>

        {/* L'ESSAI À BLANC. C'est le seul moyen d'éprouver toute la chaîne — téléversement,
            format, horaire — sans que rien ne paraisse sur un vrai compte. À faire une fois
            avant le premier lancement réel. */}
        {envoyer && (
          <label style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12,
            color: brouillon ? "var(--busy, #f0a020)" : "var(--muted)", marginBottom: 14,
            cursor: "pointer" }}>
            <input type="checkbox" checked={brouillon} onChange={(e) => setBrouillon(e.target.checked)} />
            Essai à blanc — crée des BROUILLONS chez bundle.social, rien ne sera publié
          </label>
        )}

        <div style={{ display: "flex", gap: 9, justifyContent: "flex-end", alignItems: "center" }}>
          {/* « AU PLUS », et c'est important. Le nombre réellement envoyé est plus petit dès
              qu'un jour est déjà programmé, qu'une heure est passée, ou qu'un fichier spoofé
              manque. Annoncer un chiffre rond que le résultat contredit ensuite use la
              confiance dans tous les autres chiffres de l'écran. */}
          <span style={{ flex: 1, fontSize: 11.5, color: "var(--faint)" }}
            title="Les jours déjà programmés, les heures passées et les contenus sans fichier spoofé sont écartés — le bilan après coup donne le chiffre exact.">
            au plus <b style={{ color: "var(--muted)" }}>{total}</b> publication(s)
            {formats.length ? " · " + formats.length + " format(s)" : " · aucun format choisi"}
          </span>
          <button onClick={() => surFermer(false)} style={fcBouton()}>Annuler</button>
          <button onClick={lancer} disabled={!choisis.length || !formats.length || occupe}
            style={fcBouton(!!choisis.length && !!formats.length && !occupe)}>
            {occupe ? "…" : !formats.length ? "Choisis un contenu"
              : envoyer ? (brouillon ? "🧪 Essai à blanc" : "▶ Programmer et publier")
              : "🗓 Programmer"}
          </button>
        </div>
      </div>
    </div>
  );
}

// « 1 par jour cette semaine, puis 2 », « 3 Reels par jour », « 3, 3, 5, 1 par jour » — la
// phrase qui remplace la lecture de sept cases. C'est ce qu'on veut savoir avant de cliquer.
function FP_PHRASE_RYTHME(quotas, formats) {
  const q = quotas || [];
  if (!q.length) return "rien";
  // Le NOM du contenu, pas seulement le nombre : « 1 par jour » ne disait pas 1 quoi.
  const f = (formats || []).map((id) =>
    (FF_FORMATS.find((x) => x.id === id) || {}).label || id);
  const quoi = !f.length ? "publication" : f.length === 1 ? f[0]
    : f.slice(0, -1).join(", ") + " et " + f[f.length - 1];
  const suffixe = " " + quoi + " par jour";

  const distincts = [...new Set(q)];
  if (distincts.length === 1) return distincts[0] + suffixe;
  // Une montée : les valeurs ne font que croître.
  const croissant = q.every((x, i) => i === 0 || x >= q[i - 1]);
  if (croissant) {
    const paliers = [];
    q.forEach((x) => { if (paliers[paliers.length - 1] !== x) paliers.push(x); });
    return paliers.join(" puis ") + suffixe;
  }
  return q.slice(0, 8).join(", ") + (q.length > 8 ? "…" : "") + suffixe;
}

// ── Les fuseaux ───────────────────────────────────────────────────────────────────────
//
// C'EST LE NAVIGATEUR QUI CONVERTIT, et c'est délibéré : il connaît la base des fuseaux et
// leurs changements d'heure, ce que le Python de cette machine ne sait pas (pas de `tzdata`).
// Le backend ne reçoit donc que des heures UTC, déjà justes — y compris de part et d'autre
// d'un passage à l'heure d'hiver, où « 9 h à Paris » ne vaut pas le même UTC selon le jour.
const FP_FUSEAUX = [
  { id: "Europe/Paris", label: "Paris" },
  { id: "America/Los_Angeles", label: "Los Angeles" },
  { id: "America/New_York", label: "New York" },
];

// Le décalage d'un fuseau, en minutes, à un instant donné.
function FP_DECALAGE(zone, quand) {
  try {
    const f = new Intl.DateTimeFormat("en-US", {
      timeZone: zone, hour12: false, year: "numeric", month: "2-digit", day: "2-digit",
      hour: "2-digit", minute: "2-digit", second: "2-digit",
    });
    const p = {};
    f.formatToParts(quand).forEach((x) => { p[x.type] = x.value; });
    const h = p.hour === "24" ? 0 : +p.hour;
    const local = Date.UTC(+p.year, +p.month - 1, +p.day, h, +p.minute, +p.second);
    return Math.round((local - quand.getTime()) / 60000);
  } catch (e) { return 0; }
}

// « 09:00 le 3 novembre à Paris » -> l'heure UTC correspondante, « HH:MM ».
// DEUX PASSES : le décalage dépend de l'instant, et l'instant dépend du décalage. Une seule
// passe se trompe d'une heure les jours de changement d'heure — précisément les jours où
// personne ne pense à vérifier.
function FP_VERS_UTC(annee, mois, jour, hhmm, zone) {
  const [h, m] = String(hhmm || "0:0").split(":").map((x) => parseInt(x, 10) || 0);
  let t = Date.UTC(annee, mois, jour, h, m);
  for (let i = 0; i < 2; i++) t = Date.UTC(annee, mois, jour, h, m) - FP_DECALAGE(zone, new Date(t)) * 60000;
  const d = new Date(t);
  return String(d.getUTCHours()).padStart(2, "0") + ":" + String(d.getUTCMinutes()).padStart(2, "0");
}

function FP_APERCU_UTC(hhmm, zone) {
  const d = new Date();
  return FP_VERS_UTC(d.getFullYear(), d.getMonth(), d.getDate(), hhmm, zone);
}

// La fenêtre du planificateur (13h–23h UTC) dite dans le fuseau de la machine : personne ne
// doit convertir de tête, et « 13 h » lu à Paris ferait croire à des publications le matin.
function FP_FENETRE() {
  const h = (utc) => {
    const d = new Date();
    d.setUTCHours(utc, 0, 0, 0);
    return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
  };
  return h(13) + " et " + h(23);
}

const FP_OPTION = (actif) => ({
  display: "flex", alignItems: "flex-start", gap: 10, padding: "10px 12px", cursor: "pointer",
  background: actif ? "color-mix(in srgb, var(--accent) 7%, transparent)" : "transparent",
});
const FP_PUCE = (actif) => ({
  width: 13, height: 13, borderRadius: 99, marginTop: 2, flexShrink: 0,
  border: "2px solid " + (actif ? "var(--accent)" : "var(--border-2)"),
  background: actif ? "var(--accent)" : "transparent",
  boxShadow: actif ? "inset 0 0 0 2px var(--surface)" : "none",
});

const fpTitre = {
  flex: 1, fontSize: 11, fontWeight: 700, letterSpacing: ".06em",
  textTransform: "uppercase", color: "var(--muted)",
};
const fpChamp = {
  width: 62, padding: "6px 8px", borderRadius: 8, border: "1px solid var(--border)",
  background: "var(--surface-2)", color: "var(--text)", fontSize: 13, outline: "none",
  font: "inherit",
};

Object.assign(window, {
  DialogueContenuSource, DialogueDistribution, DialoguePlanning, LigneStock,
  BarreSpoof, BarreEnvoi, BarreTravail, DialogueSante,
  FF_FORMATS, FP_MAX_JOUR,
});
