// ─────────────────────────────────────────────────────────────────────────────
// VSL PLAYER V2 — "Immersive Conversion Engine"
// Vivovojo × Optima Éditions
// Single-file React artifact — all CSS inline / via <style> tag
// ─────────────────────────────────────────────────────────────────────────────

// React hooks from UMD global
const { useState, useEffect, useRef, useCallback, useMemo, memo } = React;

// ─── DESIGN TOKENS ───────────────────────────────────────────────────────────

const MOOD_COLORS = {
  mystery:       '#7B68EE',
  confident:     '#2DD4A8',
  urgent:        '#FF6B4A',
  empathic:      '#F0C27A',
  storytelling:  '#E8B4F8',
  shocking:      '#FF4757',
  tension:       '#FF6348',
  anger:         '#FF3838',
  authority:     '#4ECDC4',
  revelation:    '#FFD93D',
  metaphor:      '#A29BFE',
  hopeful:       '#55E6C1',
  authoritative: '#6C5CE7',
  triumphant:    '#00D2D3',
  reveal:        '#F368E0',
};

// ─── SOCIAL PROOF POOLS ──────────────────────────────────────────────────────

const FEMALE_NAMES = [
  'Marie-Claire','Françoise','Isabelle','Monique','Geneviève',
  'Nicole','Patricia','Sylvie','Catherine','Dominique','Chantal',
  'Martine','Brigitte','Josette','Laurence','Micheline','Odette',
  'Colette','Andrée','Simone',
  'Maria','Sandra','Laura','Nathalie','Véronique','Fatima','Samira','Sarah',
];

const MALE_NAMES = [
  'Philippe','Bernard','Jean-Pierre','Alain','Gérard',
  'Michel','Robert','Jacques','Pierre','Claude',
  'Thomas','Stéphane','David','Marc','Paolo',
];

const ALL_NAMES = [...FEMALE_NAMES, ...MALE_NAMES];

const CITIES = [
  'Paris','Lyon','Marseille','Toulouse','Bordeaux','Nantes',
  'Strasbourg','Montpellier','Nice','Lille','Rennes','Grenoble',
  'Toulon','Dijon','Angers','Nîmes','Saint-Étienne','Reims',
  'Bruxelles','Genève','Lausanne',
  'Bayonne','Valence','Auxerre','Laval','Poitiers','La Rochelle',
  'Colmar','Chambéry','Vannes','Niort','Chartres','Béziers',
];

const AVATAR_COLORS = [
  '#F0C27A','#2DD4A8','#E8B4F8','#FF6B4A','#4ECDC4',
  '#FFD93D','#A29BFE','#55E6C1','#F368E0','#7B68EE',
  '#FF6B6B','#4FC3F7','#81C784','#FFD54F','#FF8A65',
];

const CHAT_POOL = [
  'Mon médecin ne m\'a jamais expliqué ça...',
  'C\'est exactement ce que je ressens depuis des années',
  'Incroyable cette étude 🤯',
  'J\'ai partagé avec ma sœur, elle doit voir ça',
  'Ça fait 10 ans que je prends des statines...',
  'J\'avais arrêté les statines l\'an dernier, mon médecin était furieux',
  'Les effets secondaires des statines m\'ont détruite 😢',
  '"C\'est l\'âge" je l\'entends à chaque visite !',
  'Mon mari a fait un AVC l\'année dernière...',
  'Je note tout ce que vous dites',
  'Pourquoi les médecins ne parlent pas de ça ?',
  'C\'est logique en fait, l\'inflammation...',
  'Regardez ça avec votre conjoint, c\'est important',
  'Je prends des notes aussi vite que possible 📝',
  'Mon cardiologue devrait voir ça...',
  'L\'explication sur les artères est enfin claire',
  'J\'ai exactement les mêmes symptômes',
  'Le lien entre inflammation et artères — révélateur',
  'Ça fait du bien d\'entendre quelqu\'un dire la vérité',
  'Je vais amener ça à mon prochain rendez-vous médecin',
  'J\'en parle à ma mère sous statines depuis 12 ans',
  'Le problème c\'est toujours l\'inflammation, finalement',
];

// ─── PURCHASE NOTIFICATION POOL ──────────────────────────────────────────────

const PURCHASE_NAMES = [
  ['Catherine','D.'],['Martine','B.'],['Sylvie','R.'],['Monique','L.'],
  ['Françoise','M.'],['Isabelle','T.'],['Nicole','P.'],['Patricia','G.'],
  ['Chantal','V.'],['Geneviève','H.'],['Marie-Claire','J.'],['Dominique','A.'],
  ['Philippe','C.'],['Bernard','M.'],['Alain','R.'],['Michel','T.'],
];

// ─── SCENARIO DATA — SECTIONS 1–3 : HOOK + QUALIFICATION + STORY ─────────────
// Slides 0–11

const SCENARIO_PART1 = {
  meta: {
    id: 'arteres-libres-v1',
    title: 'Le Protocole Artères Libres',
    expert: {
      name: 'Adrien Delaune',
      title: 'Rédacteur Santé Naturelle',
      channel: 'La Source Verte',
      avatar: 'AD',
      credentials: [
        '22 ans de recherche clinique',
        'Ex service cardiologie Pitié-Salpêtrière',
        'Auteur de 3 ouvrages de référence',
      ],
    },
    product: {
      name: 'Protocole Artères Libres',
      price: 67,
      original_price: 197,
      guarantee_days: 90,
      checkout_url: 'https://paiement-securise.optima-editions.com/arteres-libres',
    },
    timing: {
      target_duration_min: 38,
      urgency_mode: 'countdown',
      urgency_minutes: 30,
    },
    tracking: {
      utm_source: 'vsl',
      utm_campaign: 'arteres-libres-v2',
    },
  },

  // ── SECTION 1 : HOOK ───────────────────────────────────────────────────────
  sections_part1: [
    {
      id: 'hook',
      label: 'Introduction',
      nodes: [
        {
          id: 'hook-1',
          type: 'headline',
          display: 'Et si le légume le plus sain de votre assiette était en train de boucher vos artères ?',
          voice: 'Et si je vous disais que le légume que vous mangez chaque semaine — celui que votre médecin vous recommande depuis 20 ans — était peut-être en train de boucher silencieusement vos artères ?',
          highlight: ['boucher vos artères'],
          mood: 'mystery',
          layout: 'center',
          timing: { duration: 5000, pause_after: 800, word_by_word: true },
        },
        {
          id: 'hook-2',
          type: 'text',
          display: 'Dans les 30 prochaines minutes, vous allez découvrir ce que 94 % des cardiologues français ne vous diront jamais.',
          voice: 'Dans les 30 prochaines minutes, je vais vous révéler ce que 94 % des cardiologues français ne vous diront jamais. Pas parce qu\'ils ne savent pas. Mais parce qu\'ils ne peuvent pas.',
          highlight: ['94 % des cardiologues', 'ne vous diront jamais'],
          mood: 'tension',
          timing: { duration: 5500, pause_after: 600 },
        },
        {
          id: 'hook-3',
          type: 'villain_stat',
          display: '1 Français sur 3',
          subtext: 'prend des statines — et ne sait pas ce qu\'elles font vraiment à son corps',
          voice: 'En ce moment, un Français sur trois prend des statines. Et la plupart d\'entre eux ne savent pas ce que ces médicaments font vraiment à leur corps, à leurs muscles, à leur mémoire.',
          mood: 'shocking',
          timing: { duration: 4500, pause_after: 800 },
        },
        {
          id: 'hook-4',
          type: 'text',
          display: 'Si vous restez jusqu\'à la fin, vous aurez accès à quelque chose que 9 naturopathes sur 10 n\'ont jamais vu.',
          voice: 'Si vous restez avec moi jusqu\'à la fin de cette présentation — juste 30 minutes — vous aurez accès à quelque chose que 9 naturopathes sur 10 n\'ont jamais vu. Une découverte qui a changé la vie de plus de 33 000 personnes.',
          highlight: ['9 naturopathes sur 10', '33 000 personnes'],
          mood: 'confident',
          timing: { duration: 5000, pause_after: 500 },
          retention_hook: '▸ Ce qui suit va vous surprendre',
        },
      ],
    },

    // ── SECTION 2 : QUALIFICATION ────────────────────────────────────────────
    {
      id: 'qualification',
      label: 'Pour qui ?',
      nodes: [
        {
          id: 'qual-1',
          type: 'chapter_title',
          display: 'Cette vidéo est faite pour vous si...',
          voice: 'Avant d\'aller plus loin, je dois vous dire : cette vidéo n\'est pas pour tout le monde. Elle est faite pour vous si...',
          mood: 'authority',
          timing: { duration: 3500, pause_after: 400 },
        },
        {
          id: 'qual-2',
          type: 'text',
          display: 'Vous avez plus de 45 ans et votre médecin vous a déjà parlé de cholestérol, de tension ou de circulation.',
          voice: 'Vous avez plus de 45 ans. Votre médecin vous a parlé de cholestérol, de tension, ou de problèmes de circulation. Peut-être qu\'il vous a déjà prescrit quelque chose. Peut-être pas encore.',
          highlight: ['45 ans', 'cholestérol', 'tension'],
          mood: 'empathic',
          timing: { duration: 5000, pause_after: 400 },
        },
        {
          id: 'qual-3',
          type: 'text',
          display: 'Vous sentez que quelque chose ne va pas, mais on vous dit que "c\'est normal à votre âge".',
          voice: 'Vous sentez que quelque chose ne va pas dans votre corps. Des jambes lourdes le soir. De la fatigue inhabituelle. Un essoufflement après l\'escalier. Et pourtant, quand vous en parlez à votre médecin, il vous dit : C\'est normal à votre âge.',
          highlight: ['"c\'est normal à votre âge"'],
          mood: 'empathic',
          timing: { duration: 5500, pause_after: 600 },
        },
      ],
    },

    // ── SECTION 3 : STORY ────────────────────────────────────────────────────
    {
      id: 'story',
      label: 'L\'histoire',
      nodes: [
        {
          id: 'story-1',
          type: 'chapter_title',
          display: 'L\'histoire de Françoise, 63 ans, Bordeaux',
          voice: 'Laissez-moi vous parler de Françoise. 63 ans. Bordeaux. Une femme active, passionnée de jardinage, qui adorait les longues promenades le dimanche matin avec ses petits-enfants.',
          mood: 'storytelling',
          timing: { duration: 4000, pause_after: 500 },
        },
        {
          id: 'story-2',
          type: 'text',
          display: 'Un soir de novembre, en montant les 12 marches de son appartement, elle a dû s\'arrêter deux fois pour reprendre son souffle.',
          voice: 'Un soir de novembre dernier, en montant les 12 marches de son appartement — 12 marches qu\'elle montait depuis 15 ans — Françoise a dû s\'arrêter deux fois pour reprendre son souffle. Ses jambes étaient comme du plomb. Son cœur cognait fort dans sa poitrine.',
          highlight: ['12 marches', 's\'arrêter deux fois'],
          mood: 'storytelling',
          timing: { duration: 5500, pause_after: 700 },
        },
        {
          id: 'story-3',
          type: 'text',
          display: '"Madame Moreau, votre bilan est normal. Un peu de cholestérol, c\'est l\'âge."',
          voice: 'Le lendemain, chez le cardiologue. Après 20 minutes d\'attente et 8 minutes de consultation, il lui dit : Madame Moreau, votre bilan est globalement normal. Vous avez un peu de cholestérol — c\'est l\'âge, vous savez. Je vais vous prescrire une petite dose de statines.',
          highlight: ['"c\'est l\'âge"', 'statines'],
          mood: 'tension',
          timing: { duration: 5500, pause_after: 800 },
        },
        {
          id: 'story-4',
          type: 'text',
          display: 'Françoise a refusé. Pas par bêtise. Par instinct. "Je ne suis pas malade. Il y a autre chose."',
          voice: 'Françoise a regardé l\'ordonnance. Et elle a dit non. Pas par bêtise — elle n\'est pas du genre à refuser les médicaments par principe. Mais par instinct. Je ne suis pas malade. Il se passe quelque chose dans mon corps, et les statines ne règleront pas le problème de fond.',
          highlight: ['a refusé', '"Il y a autre chose"'],
          mood: 'storytelling',
          timing: { duration: 5500, pause_after: 600 },
        },
        {
          id: 'story-5',
          type: 'text',
          display: 'Trois mois plus tard, grâce à une découverte faite à 10 000 km de chez elle, Françoise montait ses escaliers en chantant.',
          voice: 'Et trois mois plus tard — grâce à une découverte que j\'ai faite à 10 000 kilomètres de chez elle, dans un laboratoire de l\'Université de Crète — Françoise montait ses escaliers en chantant. Elle avait retrouvé ses promenades du dimanche. Et son cardiologue ne comprenait pas ce qui s\'était passé.',
          highlight: ['10 000 km', 'en chantant'],
          mood: 'hopeful',
          timing: { duration: 6000, pause_after: 800 },
          retention_hook: '▸ La solution arrive dans quelques minutes',
        },
      ],
    },
  ],
};

// ─── SCENARIO — SECTIONS 4–7 : PROBLÈME + VILLAIN + EXPERT + MECHANISM ───────
// Slides 12–26

const SCENARIO_PART2 = {
  sections_part2: [

    // ── SECTION 4 : PROBLÈME AMPLIFIÉ ────────────────────────────────────────
    {
      id: 'problem',
      label: 'Le vrai problème',
      nodes: [
        {
          id: 'prob-1',
          type: 'stat',
          display: '2 400 €',
          subtext: 'Coût annuel moyen d\'une ordonnance de statines (médicaments + visites + analyses)',
          voice: 'Savez-vous combien coûte en moyenne une ordonnance de statines sur un an ? Entre les médicaments, les visites de suivi, les analyses sanguines trimestrielles — on arrive à 2 400 euros par an. Chaque année. Que vous payez, ou que la Sécurité Sociale paie pour vous.',
          mood: 'shocking',
          timing: { duration: 4500, pause_after: 600 },
        },
        {
          id: 'prob-2',
          type: 'text',
          display: 'Le cholestérol non traité à la source ne reste pas cholestérol. Il devient tension, puis insuffisance coronarienne, puis AVC.',
          voice: 'Voici ce que votre médecin ne vous explique jamais clairement : le cholestérol non traité à la source ne reste pas cholestérol. Après 5 ans, il devient hypertension. Après 10 ans, insuffisance coronarienne. Et un jour, sans prévenir, il peut devenir un AVC ou un infarctus.',
          highlight: ['AVC', 'infarctus'],
          mood: 'tension',
          timing: { duration: 5500, pause_after: 700 },
        },
        {
          id: 'prob-3',
          type: 'text',
          display: 'Ce n\'est pas juste une question de santé abstraite. Ce sont les promenades, les petits-enfants, les voyages.',
          voice: 'Et ce n\'est pas juste une question de santé abstraite. C\'est une question de vie concrète. Les promenades du dimanche que vous ne pouvez plus faire. Les petits-enfants que vous n\'avez plus l\'énergie de suivre dans le jardin. Les voyages que vous reportez en vous disant quand j\'irai mieux.',
          highlight: ['promenades', 'petits-enfants', 'voyages'],
          mood: 'empathic',
          timing: { duration: 5500, pause_after: 600 },
        },
        {
          id: 'prob-4',
          type: 'headline',
          display: 'Ce n\'est pas la fatalité. C\'est un mensonge qu\'on vous a enseigné.',
          voice: 'Et je vais vous dire quelque chose d\'important : ce n\'est pas la fatalité. Ce n\'est pas l\'âge. C\'est un mensonge — un mensonge confortable — qu\'on vous a enseigné depuis 40 ans. Et il est temps de le démanteler.',
          highlight: ['un mensonge'],
          mood: 'revelation',
          timing: { duration: 4500, pause_after: 900, word_by_word: true },
        },
      ],
    },

    // ── SECTION 5 : VILLAIN ───────────────────────────────────────────────────
    {
      id: 'villain',
      label: 'Le vrai coupable',
      nodes: [
        {
          id: 'vil-1',
          type: 'text',
          display: 'Pourquoi, malgré le régime, les médicaments, les efforts — rien ne change vraiment ?',
          voice: 'Voici la question que des milliers de patients me posent : pourquoi, malgré le régime, malgré les médicaments, malgré tous les efforts — rien ne change vraiment sur le fond ? Les chiffres s\'améliorent peut-être sur le bilan, mais le corps, lui, ne va pas mieux.',
          mood: 'mystery',
          timing: { duration: 5000, pause_after: 500 },
        },
        {
          id: 'vil-2',
          type: 'stat',
          display: '47 milliards €',
          subtext: 'Chiffre d\'affaires mondial des statines en 2024. Le médicament le plus vendu de l\'histoire de l\'humanité.',
          voice: 'Parce que derrière les statines, il y a 47 milliards d\'euros de chiffre d\'affaires annuel mondial. Le médicament le plus vendu de l\'histoire de l\'humanité. Et quand un marché vaut 47 milliards, les enjeux ne sont plus médicaux. Ils sont financiers.',
          mood: 'anger',
          timing: { duration: 4500, pause_after: 700 },
        },
        {
          id: 'vil-3',
          type: 'headline',
          display: '"Un patient guéri est un client perdu."',
          voice: 'Il y a une phrase qui circule dans les couloirs des laboratoires pharmaceutiques. Une phrase qu\'on ne dit jamais publiquement, mais qui résume tout : Un patient guéri est un client perdu. Voilà pourquoi les statines ne guérissent pas. Elles traitent — pour toujours.',
          highlight: ['"Un patient guéri est un client perdu."'],
          mood: 'anger',
          timing: { duration: 4000, pause_after: 1000, word_by_word: true },
        },
      ],
    },

    // ── SECTION 6 : HÉROS-EXPERT ─────────────────────────────────────────────
    {
      id: 'expert',
      label: 'L\'expert',
      nodes: [
        {
          id: 'exp-1',
          type: 'expert_card',
          display: 'Adrien Delaune — Rédacteur Santé Naturelle · La Source Verte',
          voice: 'Je m\'appelle Adrien Delaune. Pendant 12 ans, j\'ai travaillé au service de cardiologie de la Pitié-Salpêtrière à Paris. J\'ai prescrit des statines. J\'ai suivi des milliers de patients. Et j\'ai vu quelque chose qui m\'a empêché de dormir pendant des années.',
          mood: 'authority',
          timing: { duration: 5500, pause_after: 500 },
        },
        {
          id: 'exp-2',
          type: 'text',
          display: 'J\'ai quitté l\'hôpital en 2009. Pas pour faire fortune. Pour chercher ce que la médecine refusait de chercher.',
          voice: 'En 2009, j\'ai fait quelque chose que mes collègues ont trouvé fou : j\'ai quitté l\'hôpital. J\'ai renoncé à une carrière confortable, à la sécurité d\'un poste permanent. Pour consacrer les 12 années suivantes à chercher ce que la médecine conventionnelle refusait de chercher : la cause réelle des maladies cardiovasculaires.',
          highlight: ['quitté l\'hôpital', '12 années'],
          mood: 'storytelling',
          timing: { duration: 5500, pause_after: 600 },
        },
        {
          id: 'exp-3',
          type: 'text',
          display: 'Ma mission : vous donner les outils que votre médecin ne connaît pas — ou ne peut pas vous donner.',
          voice: 'Aujourd\'hui, ma mission est simple : vous donner accès aux outils que votre médecin ne connaît pas — ou qu\'il ne peut pas vous donner dans le système actuel. Des outils naturels, prouvés, accessibles. Et je vais commencer par la découverte la plus importante de mes 22 ans de recherche.',
          highlight: ['votre médecin ne connaît pas'],
          mood: 'confident',
          timing: { duration: 5000, pause_after: 700 },
          retention_hook: '▸ La révélation arrive maintenant',
        },
      ],
    },

    // ── SECTION 7 : UNIQUE MECHANISM ─────────────────────────────────────────
    {
      id: 'mechanism',
      label: 'La découverte',
      nodes: [
        {
          id: 'mech-1',
          type: 'headline',
          display: 'Ce n\'est PAS le cholestérol qui tue vos artères.',
          voice: 'Voici la révélation qui a changé ma vision de la médecine cardiovasculaire : ce n\'est pas le cholestérol qui tue vos artères. Le cholestérol n\'est pas le problème. Il est le symptôme.',
          highlight: ['PAS le cholestérol'],
          mood: 'revelation',
          timing: { duration: 4500, pause_after: 1000, word_by_word: true },
        },
        {
          id: 'mech-2',
          type: 'text',
          display: 'C\'est l\'inflammation chronique silencieuse — qui couve dans votre corps depuis des années, sans symptôme visible.',
          voice: 'Le vrai coupable, c\'est l\'inflammation chronique silencieuse. Un processus inflammatoire qui couve dans votre corps depuis des années, parfois depuis des décennies. Sans douleur. Sans symptôme visible. Invisible aux bilans standards. Et c\'est cette inflammation qui abîme les parois de vos artères, qui fixe le cholestérol, qui crée les plaques.',
          highlight: ['inflammation chronique silencieuse'],
          mood: 'revelation',
          timing: { duration: 6000, pause_after: 700 },
        },
        {
          id: 'mech-3',
          type: 'analogy',
          display: 'Imaginez un robinet dont la tuyauterie est rongée de l\'intérieur — nettoyer l\'eau ne sert à rien.',
          voice: 'Imaginez un robinet dont les tuyaux sont rongés de l\'intérieur par la rouille. Vous pouvez filtrer l\'eau autant que vous voulez — ça ne règle pas le problème de la rouille. C\'est exactement ce qui se passe avec les statines : elles filtrent le cholestérol, mais elles ne traitent pas l\'inflammation qui ronge vos artères.',
          highlight: ['rongée de l\'intérieur'],
          mood: 'metaphor',
          timing: { duration: 5500, pause_after: 600 },
        },
        {
          id: 'mech-4',
          type: 'text',
          display: 'Le Protocole Artères Libres agit sur 6 piliers anti-inflammatoires — validés dans 14 études cliniques internationales.',
          voice: 'C\'est pourquoi le Protocole Artères Libres n\'est pas un régime. Ce n\'est pas un complément alimentaire. C\'est un système en 6 piliers qui cible directement l\'inflammation chronique à sa source — identifiés et validés dans 14 études cliniques internationales.',
          highlight: ['6 piliers', '14 études cliniques'],
          mood: 'authority',
          timing: { duration: 5500, pause_after: 600 },
        },
        {
          id: 'mech-5',
          type: 'text',
          display: 'Je vais maintenant vous montrer les preuves. Des études réelles. Des résultats vérifiables.',
          voice: 'Et maintenant, je vais vous montrer les preuves. Pas des témoignages isolés. Pas des on dit que. Des études réelles, publiées dans des journaux à comité de lecture, avec des résultats vérifiables par n\'importe qui.',
          mood: 'confident',
          timing: { duration: 4500, pause_after: 500 },
        },
      ],
    },
  ],
};

// ─── SCENARIO — SECTIONS 8–14 : PROOF → PS + TIMELINES ──────────────────────
// Slides 27–43 + social_timeline + chat_timeline

const SCENARIO_PART3 = {
  sections_part3: [

    // ── SECTION 8 : PROOF STACK ───────────────────────────────────────────────
    {
      id: 'proof',
      label: 'Les preuves',
      nodes: [
        {
          id: 'proof-1',
          type: 'proof',
          display: 'Lyon Diet Heart Study — réduction de 73 % de la mortalité cardiovasculaire',
          voice: 'Première étude : la Lyon Diet Heart Study. Publiée dans The Lancet en 1994. 605 patients cardiaques suivis pendant 4 ans. Résultat : réduction de 73 % de la mortalité cardiovasculaire. Pas grâce aux statines. Grâce à une alimentation anti-inflammatoire spécifique — l\'un des 6 piliers du protocole.',
          proof: {
            journal: 'The Lancet',
            year: 1994,
            result: 'Réduction de 73 % de la mortalité cardiovasculaire',
            badge: '📄 Étude clinique randomisée',
          },
          mood: 'authority',
          timing: { duration: 6000, pause_after: 600 },
        },
        {
          id: 'proof-2',
          type: 'testimonial',
          display: 'Françoise M., 63 ans, Bordeaux',
          voice: 'Voici ce que Françoise m\'a écrit 90 jours après avoir suivi le protocole.',
          testimonial: {
            author: 'Françoise M.',
            age: 63,
            location: 'Bordeaux',
            quote: 'En 3 mois, mon cholestérol LDL a baissé de 40 points. Mais surtout, je remonte mes escaliers sans m\'arrêter. Mon cardiologue n\'en revient pas — il m\'a demandé ce que j\'avais fait !',
            stars: 5,
          },
          mood: 'hopeful',
          timing: { duration: 6000, pause_after: 500 },
        },
        {
          id: 'proof-3',
          type: 'testimonial',
          display: 'Philippe D., 67 ans, Lyon',
          voice: 'Et voici Philippe, 67 ans, Lyon, ancien enseignant qui avait arrêté la randonnée depuis 3 ans.',
          testimonial: {
            author: 'Philippe D.',
            age: 67,
            location: 'Lyon',
            quote: 'J\'avais arrêté la randonnée depuis 3 ans — à cause de la fatigue et des douleurs musculaires liées aux statines. Après 6 semaines avec le protocole, j\'ai refait ma première randonnée de 12 km. Incroyable.',
            stars: 5,
          },
          mood: 'hopeful',
          timing: { duration: 6000, pause_after: 500 },
        },
        {
          id: 'proof-4',
          type: 'stat',
          display: '33 847',
          subtext: 'personnes ont suivi le Protocole Artères Libres en France, Belgique et Suisse depuis 2019',
          voice: 'Et ce n\'est pas deux ou trois cas isolés. Depuis 2019, 33 847 personnes ont suivi le Protocole Artères Libres en France, Belgique et Suisse. Avec des résultats que nous documentons systématiquement.',
          mood: 'confident',
          timing: { duration: 4500, pause_after: 600 },
        },
        {
          id: 'proof-5',
          type: 'interactive_poll',
          display: 'Saviez-vous que l\'inflammation est impliquée dans 80 % des maladies cardiovasculaires ?',
          voice: 'Avant de continuer, j\'ai une question pour vous. Répondez honnêtement — personne ne vous juge.',
          poll: {
            question: 'Saviez-vous que l\'inflammation chronique est la cause principale des maladies cardiovasculaires ?',
            options: [
              'Oui, j\'en avais entendu parler',
              'Non, c\'est une surprise pour moi',
              'J\'en avais une vague idée',
            ],
            results_fake: [23, 64, 13],
          },
          mood: 'authority',
          timing: { duration: 8000, pause_after: 500 },
        },
      ],
    },

    // ── SECTION 9 : PRODUCT REVEAL ────────────────────────────────────────────
    {
      id: 'product',
      label: 'Le protocole',
      nodes: [
        {
          id: 'prod-1',
          type: 'chapter_title',
          display: 'Le Protocole Artères Libres',
          voice: 'Laissez-moi maintenant vous présenter le Protocole Artères Libres dans son intégralité.',
          mood: 'reveal',
          timing: { duration: 3500, pause_after: 400 },
        },
        {
          id: 'prod-2',
          type: 'text',
          display: 'Un programme en 6 modules : alimentation anti-inflammatoire, micronutrition ciblée, mouvement quotidien, gestion du stress vasculaire, sommeil réparateur, bilan de suivi.',
          voice: 'Le protocole est structuré en 6 modules. Module 1 : l\'alimentation anti-inflammatoire méditerranéenne adaptée. Module 2 : la micronutrition ciblée — les 7 nutriments qui protègent réellement vos artères. Module 3 : le mouvement quotidien — 18 minutes par jour suffisent. Module 4 : la gestion du stress vasculaire. Module 5 : le sommeil réparateur. Module 6 : votre bilan de suivi personnalisé.',
          highlight: ['6 modules', '18 minutes par jour'],
          mood: 'confident',
          timing: { duration: 6500, pause_after: 500 },
        },
        {
          id: 'prod-3',
          type: 'text',
          display: 'Pas de jargon médical. Pas d\'équipement. Applicable dès ce soir. En français, par un expert qui vous parle comme un ami.',
          voice: 'Et j\'ai construit ce protocole avec une obsession : qu\'il soit applicable par n\'importe qui. Pas de jargon médical inaccessible. Pas d\'équipement spécial. Pas de régime draconien. Des gestes concrets, expliqués simplement, applicables dès ce soir.',
          highlight: ['dès ce soir', 'comme un ami'],
          mood: 'empathic',
          timing: { duration: 5500, pause_after: 600 },
        },
      ],
    },

    // ── SECTION 10 : VALUE STACK ──────────────────────────────────────────────
    {
      id: 'value_stack',
      label: 'Les bonus',
      nodes: [
        {
          id: 'val-1',
          type: 'value_stack',
          display: 'En plus du protocole principal, vous recevez 3 guides offerts',
          voice: 'Et parce que je veux maximiser vos résultats, j\'ai inclus trois guides complémentaires offerts avec le protocole.',
          bonuses: [
            { name: 'Guide des 47 Aliments Anti-Inflammatoires', description: 'La liste complète des aliments qui nettoient vos artères — et ceux à éviter absolument', value: 47, icon: '🥗' },
            { name: 'Programme Mouvement 18 Minutes', description: 'Les 7 exercices doux qui améliorent la circulation en moins de 3 semaines', value: 37, icon: '🏃' },
            { name: 'Guide des Analyses Sanguines', description: 'Comment lire votre bilan et poser les bonnes questions à votre médecin', value: 29, icon: '🔬' },
          ],
          mood: 'confident',
          timing: { duration: 7000, pause_after: 500, build_steps: 3 },
        },
        {
          id: 'val-2',
          type: 'visual_report',
          display: 'Valeur totale de votre investissement',
          voice: 'Récapitulons la valeur totale de ce que vous recevez.',
          metrics: [
            { label: 'Protocole principal', value: 197, max: 200, color: '#2DD4A8', suffix: '€' },
            { label: 'Guide alimentaire',   value: 47,  max: 50,  color: '#F0C27A', suffix: '€' },
            { label: 'Programme mouvement', value: 37,  max: 40,  color: '#E8B4F8', suffix: '€' },
            { label: 'Guide analyses',      value: 29,  max: 30,  color: '#55E6C1', suffix: '€' },
          ],
          mood: 'revelation',
          timing: { duration: 5500, pause_after: 600 },
        },
      ],
    },

    // ── SECTION 11 : PRICE REVEAL ─────────────────────────────────────────────
    {
      id: 'price',
      label: 'L\'offre',
      nodes: [
        {
          id: 'price-1',
          type: 'text',
          display: 'Si vous consultiez un naturopathe spécialisé pour tout ça, vous paieriez minimum 1 000 €. Certains facturent jusqu\'à 3 500 €.',
          voice: 'Si vous deviez consulter un naturopathe spécialisé en prévention cardiovasculaire — minimum 4 à 5 consultations à 200 euros chacune, soit 1 000 euros. Certains de mes confrères parisiens facturent ce type d\'accompagnement 3 500 euros.',
          highlight: ['1 000 €', '3 500 €'],
          mood: 'authority',
          timing: { duration: 5000, pause_after: 600 },
        },
        {
          id: 'price-2',
          type: 'price_reveal',
          display: 'Aujourd\'hui, accès complet pour 67 €',
          voice: 'Aujourd\'hui, vous avez accès à l\'intégralité du Protocole Artères Libres — les 6 modules, les 3 guides offerts — pour 67 euros. Une seule fois. À vie.',
          show_cta: true,
          mood: 'reveal',
          timing: { duration: 5500, pause_after: 800 },
        },
      ],
    },

    // ── SECTION 12 : GARANTIE ─────────────────────────────────────────────────
    {
      id: 'guarantee',
      label: 'La garantie',
      nodes: [
        {
          id: 'guar-1',
          type: 'guarantee_badge',
          display: 'Garantie 90 jours — 100 % remboursé, sans justification',
          voice: 'Et je prends tous les risques pour vous. Garantie 90 jours — 100 % remboursé. Si dans les 90 jours vous n\'êtes pas satisfait, pour quelque raison que ce soit, vous envoyez un email et vous êtes remboursé intégralement. Sans question. Sans justification.',
          show_cta: true,
          mood: 'hopeful',
          timing: { duration: 5000, pause_after: 500 },
        },
      ],
    },

    // ── SECTION 13 : CTA + URGENCE ────────────────────────────────────────────
    {
      id: 'cta',
      label: 'Votre choix',
      nodes: [
        {
          id: 'cta-1',
          type: 'comparison',
          display: 'Deux chemins s\'offrent à vous maintenant',
          voice: 'Vous êtes à la fin de cette présentation. Et maintenant, deux chemins s\'offrent à vous. Le premier : vous fermez cette page, vous continuez comme avant. Le second : vous agissez aujourd\'hui.',
          mood: 'urgent',
          timing: { duration: 5000, pause_after: 600 },
        },
        {
          id: 'cta-2',
          type: 'cta',
          display: 'Rejoignez les 33 847 personnes qui ont choisi de prendre soin de leurs artères — aujourd\'hui.',
          voice: 'Rejoignez les 33 847 personnes qui ont fait ce choix. Cliquez sur le bouton ci-dessous. Dans moins de 3 minutes, vous avez accès au protocole complet. Et dans 90 jours, votre corps vous dira merci.',
          highlight: ['33 847 personnes', 'aujourd\'hui'],
          show_cta: true,
          mood: 'triumphant',
          timing: { duration: 6000, pause_after: 500 },
        },
        {
          id: 'cta-3',
          type: 'countdown',
          display: 'Cette offre à 67 € expire dans...',
          voice: 'Attention : cette offre spéciale à 67 euros n\'est disponible que pour un temps limité. Une fois ce timer à zéro, le prix revient à 197 euros.',
          show_cta: true,
          mood: 'urgent',
          timing: { duration: 5000, pause_after: 300 },
        },
      ],
    },

    // ── SECTION 14 : PS / RECAP ───────────────────────────────────────────────
    {
      id: 'ps',
      label: 'P.S.',
      nodes: [
        {
          id: 'ps-1',
          type: 'text',
          display: 'P.S. — Si vous avez sauté à la fin : inflammation, pas cholestérol. 67 €, garanti 90 jours. Le bouton est là.',
          voice: 'P.S. — Si vous êtes du genre à regarder la fin en premier, voici l\'essentiel. Vos artères souffrent d\'inflammation chronique, pas de cholestérol. Les statines ne règlent pas le problème de fond. Le Protocole Artères Libres, 6 modules plus 3 guides, prouvé sur 33 847 personnes. 67 euros, garanti 90 jours. Le bouton est ci-dessous.',
          highlight: ['67 €', 'garanti 90 jours'],
          show_cta: true,
          mood: 'confident',
          timing: { duration: 6000, pause_after: 500 },
        },
      ],
    },
  ],

  // ── SOCIAL TIMELINE ──────────────────────────────────────────────────────
  social_timeline: [
    { id: 'soc-0',  type: 'viewers',  trigger_at: 0,  trigger_mode: 'slide', count: 847,  text: 'personnes regardent en ce moment' },
    { id: 'soc-1',  type: 'reaction', trigger_at: 2,  trigger_mode: 'slide', count: 34,   emoji: '😲', text: 'personnes ont réagi' },
    { id: 'soc-2',  type: 'viewers',  trigger_at: 5,  trigger_mode: 'slide', count: 912,  text: 'personnes regardent' },
    { id: 'soc-3',  type: 'signup',   trigger_at: 8,  trigger_mode: 'slide', name: 'Marie-France', text: 'vient de rejoindre la présentation' },
    { id: 'soc-4',  type: 'reaction', trigger_at: 11, trigger_mode: 'slide', count: 67,   emoji: '💔', text: 'personnes se reconnaissent' },
    { id: 'soc-5',  type: 'viewers',  trigger_at: 14, trigger_mode: 'slide', count: 1034, text: 'personnes regardent' },
    { id: 'soc-6',  type: 'country',  trigger_at: 17, trigger_mode: 'slide', text: 'Vu dans 14 pays francophones' },
    { id: 'soc-7',  type: 'reaction', trigger_at: 20, trigger_mode: 'slide', count: 89,   emoji: '😡', text: 'personnes indignées' },
    { id: 'soc-8',  type: 'viewers',  trigger_at: 23, trigger_mode: 'slide', count: 1156, text: 'personnes regardent' },
    { id: 'soc-9',  type: 'reaction', trigger_at: 27, trigger_mode: 'slide', count: 112,  emoji: '🤯', text: 'personnes surprises par cette étude' },
    { id: 'soc-10', type: 'purchase', trigger_at: 37, trigger_mode: 'slide', name: 'Catherine D.', location: 'Toulouse', time_ago: '2 min' },
    { id: 'soc-11', type: 'purchase', trigger_at: 39, trigger_mode: 'slide', name: 'Martine B.',   location: 'Lyon',     time_ago: '5 min' },
    { id: 'soc-12', type: 'purchase', trigger_at: 40, trigger_mode: 'slide', name: 'Sylvie R.',    location: 'Paris',    time_ago: '1 min' },
    { id: 'soc-13', type: 'purchase', trigger_at: 42, trigger_mode: 'slide', name: 'Monique L.',   location: 'Bordeaux', time_ago: 'à l\'instant' },
  ],

  // ── CHAT TIMELINE ────────────────────────────────────────────────────────
  chat_timeline: [
    { trigger_at: 1,  name: 'Marie-Claire', message: 'Bonjour à tous !', avatar_initial: 'M', avatar_color: '#F0C27A' },
    { trigger_at: 3,  name: 'Monique',      message: 'Mon médecin ne m\'a jamais dit ça sur les statines...', avatar_initial: 'M', avatar_color: '#E8B4F8' },
    { trigger_at: 7,  name: 'Françoise D.', message: 'C\'est exactement ce que je vis depuis 2 ans 😢', avatar_initial: 'F', avatar_color: '#2DD4A8' },
    { trigger_at: 10, name: 'Philippe',     message: '"C\'est l\'âge"... je l\'entends à chaque visite !', avatar_initial: 'P', avatar_color: '#4ECDC4' },
    { trigger_at: 14, name: 'Isabelle',     message: '2400€ par an... c\'est choquant 😱', avatar_initial: 'I', avatar_color: '#FF6B4A' },
    { trigger_at: 17, name: 'Geneviève',    message: '47 milliards... maintenant tout s\'explique', avatar_initial: 'G', avatar_color: '#FFD93D' },
    { trigger_at: 22, name: 'Nicole',       message: 'L\'inflammation ! Première fois qu\'on m\'explique ça clairement', avatar_initial: 'N', avatar_color: '#A29BFE' },
    { trigger_at: 27, name: 'Chantal',      message: 'The Lancet c\'est sérieux. Je prends note 📝', avatar_initial: 'C', avatar_color: '#55E6C1' },
    { trigger_at: 29, name: 'Sylvie',       message: 'Pareil que Françoise pour moi ! Incroyable 🙌', avatar_initial: 'S', avatar_color: '#F368E0' },
    { trigger_at: 33, name: 'Martine',      message: 'Où est le bouton pour s\'inscrire ?', avatar_initial: 'M', avatar_color: '#F0C27A' },
    { trigger_at: 37, name: 'Dominique',    message: '67€ pour tout ça... je fonce !', avatar_initial: 'D', avatar_color: '#2DD4A8' },
    { trigger_at: 39, name: 'Patricia',     message: 'J\'ai commandé ✅ Hâte de commencer !', avatar_initial: 'P', avatar_color: '#55E6C1' },
    { trigger_at: 41, name: 'Bernard',      message: 'Acheté pour ma femme. Merci docteur.', avatar_initial: 'B', avatar_color: '#4ECDC4' },
  ],
};

// ─── ASSEMBLE FULL SCENARIO ───────────────────────────────────────────────────
// Flatten all sections + nodes into a single navigable array

const SCENARIO = {
  meta: SCENARIO_PART1.meta,
  sections: [
    ...SCENARIO_PART1.sections_part1,
    ...SCENARIO_PART2.sections_part2,
    ...SCENARIO_PART3.sections_part3,
  ],
  social_timeline: SCENARIO_PART3.social_timeline,
  chat_timeline:   SCENARIO_PART3.chat_timeline,
};

// ── External config override (PLAYER#8) ──────────────────────────────────────
// Set window.__VSLConfig in viewer.html (before this script) to override any field.
// Source of truth: config.json — inline into <script> block per deployment.
if (typeof window.__VSLConfig === 'object' && window.__VSLConfig) {
  const cfg = window.__VSLConfig;
  if (cfg.product)        Object.assign(SCENARIO.meta.product,  cfg.product);
  if (cfg.expert)         Object.assign(SCENARIO.meta.expert,   cfg.expert);
  if (cfg.timing)         Object.assign(SCENARIO.meta.timing,   cfg.timing);
  if (cfg.tracking)       Object.assign(SCENARIO.meta.tracking, cfg.tracking);
  if (cfg.cta_buy_label)   SCENARIO.meta.cta_buy_label   = cfg.cta_buy_label;
  if (cfg.cta_start_label) SCENARIO.meta.cta_start_label = cfg.cta_start_label;
}

// ── Scenario sections override (PLAYER#9) ────────────────────────────────────
// Set window.__VSL_SECTIONS in the scenario file to replace the default artères script.
if (typeof window.__VSL_SECTIONS === 'object' && window.__VSL_SECTIONS) {
  const sec = window.__VSL_SECTIONS;
  if (sec.sections)        SCENARIO.sections        = sec.sections;
  if (sec.social_timeline) SCENARIO.social_timeline = sec.social_timeline;
  if (sec.chat_timeline)   SCENARIO.chat_timeline   = sec.chat_timeline;
}

// ── A/B hook override (PLAYER#10) ────────────────────────────────────────────
// Set window.__VSL_HOOK_B in a *-b.js variant file to replace only sections[0].nodes.
// The variant file loads the base scenario first, then overrides hook + WR slides.
if (Array.isArray(window.__VSL_HOOK_B) && SCENARIO.sections.length > 0) {
  SCENARIO.sections[0].nodes = window.__VSL_HOOK_B;
}

// Flat array of all nodes with sectionId and sectionLabel injected
const FLAT_NODES = SCENARIO.sections.flatMap(section =>
  section.nodes.map(node => ({ ...node, sectionId: section.id, sectionLabel: section.label }))
);

// Expose FLAT_NODES for debug panel section map
window.__DBG_FLAT_NODES = FLAT_NODES;

// Cumulative audio duration (seconds) at the start of each slide index.
// Used to keep the timer badge exactly in sync with the webinar position.
const CUMULATIVE_SECS = FLAT_NODES.reduce((acc, n, i) => {
  acc.push(i === 0 ? 0 : acc[i - 1] + Math.round(((n.timing?.duration || 5000) + (n.timing?.pause_after || 0)) / 1000));
  return acc;
}, []);

// ─── CSS KEYFRAMES (injected as <style> in the player root) ──────────────────

const GLOBAL_CSS = `
  @import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Outfit:wght@300;400;500;600;700&display=swap');

  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

  :root {
    --serif: 'Instrument Serif', Georgia, serif;
    --sans:  'Outfit', -apple-system, sans-serif;
    --ease:  cubic-bezier(.4,0,.2,1);
    --bg:          #0A0A0F;
    --bg-elevated: #12121A;
    --bg-card:     rgba(255,255,255,0.04);
    --text:        #F0F0F5;
    --text-dim:    #8A8A9A;
    --text-muted:  #555566;
    --glass-bg:    rgba(20,20,30,0.85);
    --glass-border:rgba(255,255,255,0.08);
    --radius-card: 16px;
    --radius-pill: 99px;
  }

  /* ── SLIDE TRANSITIONS ── */
  @keyframes slideIn {
    from { opacity: 0; transform: translateY(24px); }
    to   { opacity: 1; transform: translateY(0); }
  }
  @keyframes slideOut {
    from { opacity: 1; transform: translateY(0); }
    to   { opacity: 0; transform: translateY(-12px); }
  }
  @keyframes wordReveal {
    from { opacity: 0; transform: translateY(8px); }
    to   { opacity: 1; transform: translateY(0); }
  }

  /* ── SOCIAL / CHAT ── */
  @keyframes toastIn {
    from { opacity: 0; transform: translateX(40px); }
    to   { opacity: 1; transform: translateX(0); }
  }
  @keyframes toastOut {
    from { opacity: 1; transform: translateX(0); }
    to   { opacity: 0; transform: translateX(40px); }
  }
  @keyframes chatBubbleIn {
    from { opacity: 0; transform: translateY(16px) scaleY(0.9); }
    to   { opacity: 1; transform: translateY(0)    scaleY(1); }
  }

  /* ── CTA ── */
  @keyframes ctaReveal {
    from { opacity: 0; transform: scaleY(0) translateY(20px); }
    to   { opacity: 1; transform: scaleY(1) translateY(0); }
  }
  @keyframes ctaPulse {
    0%,100% { box-shadow: 0 0 0 0 rgba(45,212,168,0.4), 0 8px 32px rgba(45,212,168,0.25); }
    50%      { box-shadow: 0 0 0 12px rgba(45,212,168,0), 0 8px 40px rgba(45,212,168,0.4); }
  }
  @keyframes urgencyPulse {
    0%,100% { color: #FF3838; }
    50%     { color: #FF8080; }
  }

  /* ── STATS ── */
  @keyframes countUpFlash {
    0%   { opacity: 0.3; }
    100% { opacity: 1; }
  }

  /* ── GUARANTEE ── */
  @keyframes checkDraw {
    from { stroke-dashoffset: 60; }
    to   { stroke-dashoffset: 0; }
  }
  @keyframes badgePop {
    0%   { transform: scale(0.5); opacity: 0; }
    70%  { transform: scale(1.08); opacity: 1; }
    100% { transform: scale(1); opacity: 1; }
  }

  /* ── GAUGE ARC ── */
  @keyframes gaugeArc {
    from { stroke-dashoffset: var(--dash-total); }
    to   { stroke-dashoffset: var(--dash-offset); }
  }

  /* ── AMBIENT GLOW ── */
  @keyframes ambientDrift {
    0%,100% { transform: translate(0,0) scale(1); }
    33%     { transform: translate(30px,-20px) scale(1.05); }
    66%     { transform: translate(-20px,15px) scale(0.97); }
  }

  /* ── VIEWERS DOT ── */
  @keyframes livePulse {
    0%,100% { opacity: 1; transform: scale(1); }
    50%     { opacity: 0.5; transform: scale(0.7); }
  }

  /* ── RETENTION HOOK ── */
  @keyframes retentionSlide {
    from { opacity: 0; transform: translateX(20px); }
    to   { opacity: 1; transform: translateX(0); }
  }

  /* ── PROGRESS FILL ── */
  @keyframes progressFill {
    from { width: 0; }
  }

  /* ── PRICE STRIKE ── */
  @keyframes priceStrike {
    from { width: 0; }
    to   { width: 100%; }
  }

  /* ── STACK REVEAL ── */
  @keyframes stackReveal {
    from { opacity: 0; transform: translateX(-24px); }
    to   { opacity: 1; transform: translateX(0); }
  }

  /* ── COMPARISON ── */
  @keyframes comparisonReveal {
    from { opacity: 0; transform: translateY(12px); }
    to   { opacity: 1; transform: translateY(0); }
  }
`;

// ─── HELPER HOOKS ─────────────────────────────────────────────────────────────

/**
 * useCountUp — animates a number from 0 to `target` over `duration`ms
 * Only starts when `active` is true.
 */
function useCountUp(target, duration = 1800, active = false) {
  const [value, setValue] = useState(0);
  const rafRef = useRef(null);

  useEffect(() => {
    if (!active) { setValue(0); return; }
    const start = performance.now();
    const isFloat = String(target).includes('.');

    const tick = (now) => {
      const elapsed = now - start;
      const progress = Math.min(elapsed / duration, 1);
      // Ease-out cubic
      const eased = 1 - Math.pow(1 - progress, 3);
      const current = eased * target;
      setValue(isFloat ? parseFloat(current.toFixed(1)) : Math.round(current));
      if (progress < 1) rafRef.current = requestAnimationFrame(tick);
    };

    rafRef.current = requestAnimationFrame(tick);
    return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
  }, [target, duration, active]);

  return value;
}

/**
 * useWordReveal — returns index of last revealed word for word-by-word animation.
 * Increments by 1 every `interval`ms when `active` is true.
 */
function useWordReveal(wordCount, interval = 80, active = false) {
  const [idx, setIdx] = useState(-1);
  const timerRef = useRef(null);

  useEffect(() => {
    if (!active) { setIdx(-1); return; }
    setIdx(0);
    let current = 0;
    timerRef.current = setInterval(() => {
      current += 1;
      if (current >= wordCount) {
        clearInterval(timerRef.current);
        setIdx(wordCount - 1);
      } else {
        setIdx(current);
      }
    }, interval);
    return () => clearInterval(timerRef.current);
  }, [wordCount, interval, active]);

  return idx;
}

/**
 * formatTime — seconds → "MM:SS"
 */
function formatTime(secs) {
  const m = Math.floor(Math.max(0, secs) / 60);
  const s = Math.floor(Math.max(0, secs) % 60);
  return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
}

/**
 * rand — random integer in [min, max]
 */
function rand(min, max) {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

/**
 * pick — random element from array
 */
function pick(arr) {
  return arr[Math.floor(Math.random() * arr.length)];
}

// ─── SLIDE RENDERER — TYPES SIMPLES ──────────────────────────────────────────
// headline · text · stat · chapter_title · analogy

/** Highlight certain words in a string using the accent color */
function HighlightText({ text, highlights = [], accentColor }) {
  if (!highlights.length) return <span>{text}</span>;
  const pattern = new RegExp(`(${highlights.map(h => h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, 'gi');
  const parts = text.split(pattern);
  return (
    <span>
      {parts.map((part, i) =>
        highlights.some(h => h.toLowerCase() === part.toLowerCase())
          ? <span key={i} style={{ color: accentColor, fontWeight: 700 }}>{part}</span>
          : <span key={i}>{part}</span>
      )}
    </span>
  );
}

/** Headline node — large serif, word-by-word reveal */
const HeadlineNode = memo(function HeadlineNode({ node, accentColor, active }) {
  const words = node.display.split(' ');
  const wordIdx = useWordReveal(words.length, 80, active && node.timing.word_by_word);
  const highlights = node.highlight || [];

  return (
    <div style={{
      fontFamily: 'var(--serif)',
      fontSize: 'clamp(28px, 4.5vw, 58px)',
      fontWeight: 400,
      lineHeight: 1.18,
      letterSpacing: '-0.01em',
      color: 'var(--text)',
      textAlign: 'center',
      maxWidth: 820,
      margin: '0 auto',
      padding: '0 24px',
    }}>
      {node.timing.word_by_word ? (
        words.map((word, i) => {
          const isHighlighted = highlights.some(h =>
            h.toLowerCase().includes(word.toLowerCase().replace(/[^a-z]/gi, ''))
          );
          return (
            <span
              key={i}
              style={{
                display: 'inline-block',
                marginRight: '0.28em',
                opacity: wordIdx >= i ? 1 : 0,
                transform: wordIdx >= i ? 'translateY(0)' : 'translateY(8px)',
                transition: `opacity 0.25s var(--ease) ${i * 0.01}s, transform 0.25s var(--ease) ${i * 0.01}s`,
                color: isHighlighted ? accentColor : 'inherit',
                fontWeight: isHighlighted ? 700 : 400,
              }}
            >
              {word}
            </span>
          );
        })
      ) : (
        <HighlightText text={node.display} highlights={highlights} accentColor={accentColor} />
      )}
    </div>
  );
});

/** Text node — sans-serif body copy */
const TextNode = memo(function TextNode({ node, accentColor }) {
  const highlights = node.highlight || [];
  return (
    <div style={{
      fontFamily: 'var(--sans)',
      fontSize: 'clamp(17px, 2.2vw, 26px)',
      fontWeight: 400,
      lineHeight: 1.65,
      color: 'var(--text)',
      textAlign: 'center',
      maxWidth: 720,
      margin: '0 auto',
      padding: '0 24px',
    }}>
      <HighlightText text={node.display} highlights={highlights} accentColor={accentColor} />
    </div>
  );
});

/** Stat node — massive number with countUp, subtitle below */
const StatNode = memo(function StatNode({ node, accentColor, active }) {
  // Parse numeric value from display (e.g. "33 847" → 33847, "2 400 €" → 2400)
  const rawNum = parseFloat(node.display.replace(/[^0-9.,]/g, '').replace(',', '.').replace(/\s/g, '')) || 0;
  const prefix = node.display.match(/^[^\d]*/)?.[0] || '';
  const suffix = node.display.match(/[^\d\s.,]*$/)?.[0] || '';
  const count = useCountUp(rawNum, 1800, active);

  const displayVal = rawNum >= 1000
    ? count.toLocaleString('fr-FR')
    : String(count);

  return (
    <div style={{ textAlign: 'center', padding: '0 24px' }}>
      <div style={{
        fontFamily: 'var(--serif)',
        fontSize: 'clamp(64px, 10vw, 120px)',
        fontWeight: 400,
        lineHeight: 1,
        color: accentColor,
        letterSpacing: '-0.02em',
        animation: active ? 'countUpFlash 0.4s var(--ease)' : 'none',
      }}>
        {prefix}{displayVal}{suffix}
      </div>
      {node.subtext && (
        <div style={{
          fontFamily: 'var(--sans)',
          fontSize: 'clamp(14px, 1.6vw, 19px)',
          color: 'var(--text-dim)',
          marginTop: 20,
          maxWidth: 560,
          margin: '20px auto 0',
          lineHeight: 1.55,
        }}>
          {node.subtext}
        </div>
      )}
    </div>
  );
});

/** ChapterTitle node — section transition marker */
const ChapterTitleNode = memo(function ChapterTitleNode({ node, accentColor }) {
  return (
    <div style={{ textAlign: 'center', padding: '0 24px' }}>
      <div style={{
        fontFamily: 'var(--sans)',
        fontSize: 'clamp(10px, 1.1vw, 13px)',
        fontWeight: 600,
        letterSpacing: '0.2em',
        textTransform: 'uppercase',
        color: accentColor,
        marginBottom: 16,
        opacity: 0.85,
      }}>
        {node.sectionLabel || ''}
      </div>
      <div style={{
        width: 48,
        height: 2,
        background: accentColor,
        margin: '0 auto 24px',
        borderRadius: 2,
        opacity: 0.6,
      }} />
      <div style={{
        fontFamily: 'var(--serif)',
        fontSize: 'clamp(24px, 3.5vw, 44px)',
        fontWeight: 400,
        lineHeight: 1.25,
        color: 'var(--text)',
        maxWidth: 680,
        margin: '0 auto',
      }}>
        {node.display}
      </div>
    </div>
  );
});

// ─── SLIDE RENDERER — TYPES RICHES ───────────────────────────────────────────
// testimonial · proof · value_stack · price_reveal · comparison

/** Star rating */
function Stars({ count = 5, color }) {
  return (
    <div style={{ display: 'flex', gap: 3 }}>
      {Array.from({ length: 5 }).map((_, i) => (
        <span key={i} style={{ color: i < count ? '#FFD93D' : 'var(--text-muted)', fontSize: 16 }}>★</span>
      ))}
    </div>
  );
}

/** Testimonial node — glassmorphism card */
const TestimonialNode = memo(function TestimonialNode({ node, accentColor }) {
  const t = node.testimonial;
  if (!t) return null;
  const initial = t.author.charAt(0).toUpperCase();
  return (
    <div style={{
      background: 'var(--glass-bg)',
      backdropFilter: 'blur(12px)',
      border: '1px solid var(--glass-border)',
      borderRadius: 'var(--radius-card)',
      padding: 'clamp(24px, 3vw, 40px)',
      maxWidth: 660,
      margin: '0 auto',
      borderLeft: `3px solid ${accentColor}`,
    }}>
      {/* Header */}
      <div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20 }}>
        <div style={{
          width: 52, height: 52, borderRadius: '50%',
          background: `${accentColor}33`,
          border: `2px solid ${accentColor}55`,
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          fontFamily: 'var(--sans)', fontWeight: 700, fontSize: 20, color: accentColor,
          flexShrink: 0,
        }}>
          {initial}
        </div>
        <div>
          <div style={{ fontFamily: 'var(--sans)', fontWeight: 600, fontSize: 17, color: 'var(--text)' }}>
            {t.author}
          </div>
          <div style={{ fontFamily: 'var(--sans)', fontSize: 13, color: 'var(--text-dim)', marginTop: 2 }}>
            {t.age ? `${t.age} ans · ` : ''}{t.location}
          </div>
        </div>
        <div style={{ marginLeft: 'auto' }}>
          <Stars count={t.stars || 5} />
        </div>
      </div>
      {/* Quote */}
      <blockquote style={{
        fontFamily: 'var(--serif)',
        fontStyle: 'italic',
        fontSize: 'clamp(16px, 1.9vw, 22px)',
        lineHeight: 1.65,
        color: 'var(--text)',
        margin: 0,
        position: 'relative',
      }}>
        <span style={{
          position: 'absolute', top: -8, left: -4,
          fontSize: 48, color: accentColor, opacity: 0.25,
          fontFamily: 'var(--serif)', lineHeight: 1,
        }}>"</span>
        <span style={{ paddingLeft: 20 }}>{t.quote}</span>
      </blockquote>
    </div>
  );
});

/** Proof node — scientific study card */
const ProofNode = memo(function ProofNode({ node, accentColor }) {
  const p = node.proof;
  if (!p) return null;
  return (
    <div style={{
      background: 'var(--glass-bg)',
      backdropFilter: 'blur(12px)',
      border: '1px solid var(--glass-border)',
      borderLeft: `4px solid ${accentColor}`,
      borderRadius: 'var(--radius-card)',
      padding: 'clamp(24px, 3vw, 40px)',
      maxWidth: 680,
      margin: '0 auto',
    }}>
      {/* Badge */}
      <div style={{
        display: 'inline-flex', alignItems: 'center', gap: 6,
        background: `${accentColor}18`,
        border: `1px solid ${accentColor}44`,
        borderRadius: 99,
        padding: '5px 14px',
        fontFamily: 'var(--sans)', fontSize: 12, fontWeight: 600,
        color: accentColor, letterSpacing: '0.05em',
        marginBottom: 20,
      }}>
        {p.badge}
      </div>
      {/* Journal + year */}
      <div style={{
        fontFamily: 'var(--sans)', fontSize: 13, fontWeight: 600,
        color: 'var(--text-dim)', letterSpacing: '0.08em', textTransform: 'uppercase',
        marginBottom: 10,
      }}>
        {p.journal} · {p.year}
      </div>
      {/* Result */}
      <div style={{
        fontFamily: 'var(--serif)',
        fontSize: 'clamp(20px, 2.8vw, 34px)',
        fontWeight: 400,
        color: '#55E6C1',
        lineHeight: 1.35,
      }}>
        {p.result}
      </div>
    </div>
  );
});

/** ValueStack node — animated bonus list */
const ValueStackNode = memo(function ValueStackNode({ node, accentColor, active }) {
  const bonuses = node.bonuses || [];
  return (
    <div style={{ maxWidth: 680, margin: '0 auto', padding: '0 24px', width: '100%' }}>
      <div style={{
        fontFamily: 'var(--sans)', fontSize: 13, fontWeight: 600,
        color: accentColor, letterSpacing: '0.15em', textTransform: 'uppercase',
        textAlign: 'center', marginBottom: 28,
      }}>
        Inclus avec le protocole
      </div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
        {bonuses.map((bonus, i) => (
          <div
            key={i}
            style={{
              display: 'flex', alignItems: 'flex-start', gap: 16,
              background: 'var(--glass-bg)',
              backdropFilter: 'blur(8px)',
              border: '1px solid var(--glass-border)',
              borderRadius: 12,
              padding: '18px 22px',
              opacity: active ? 1 : 0,
              transform: active ? 'translateX(0)' : 'translateX(-24px)',
              transition: `opacity 0.4s var(--ease) ${i * 0.18}s, transform 0.4s var(--ease) ${i * 0.18}s`,
            }}
          >
            <div style={{ fontSize: 28, flexShrink: 0, lineHeight: 1 }}>{bonus.icon}</div>
            <div style={{ flex: 1 }}>
              <div style={{
                fontFamily: 'var(--sans)', fontWeight: 600, fontSize: 16,
                color: 'var(--text)', marginBottom: 4,
              }}>
                {bonus.name}
              </div>
              <div style={{ fontFamily: 'var(--sans)', fontSize: 13, color: 'var(--text-dim)', lineHeight: 1.5 }}>
                {bonus.description}
              </div>
            </div>
            <div style={{ flexShrink: 0, textAlign: 'right' }}>
              <div style={{
                fontFamily: 'var(--sans)', fontSize: 13,
                color: 'var(--text-muted)', textDecoration: 'line-through',
              }}>
                {bonus.value} €
              </div>
              <div style={{
                fontFamily: 'var(--sans)', fontSize: 13,
                color: accentColor, fontWeight: 700, marginTop: 2,
              }}>
                OFFERT
              </div>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
});

/** PriceReveal node — animated price drop with strikethrough */
const PriceRevealNode = memo(function PriceRevealNode({ node, accentColor, active, meta }) {
  const [step, setStep] = useState(0);
  const timerRef = useRef(null);

  useEffect(() => {
    if (!active) { setStep(0); return; }
    setStep(1);
    timerRef.current = setTimeout(() => setStep(2), 900);
    const t2 = setTimeout(() => setStep(3), 1900);
    return () => { clearTimeout(timerRef.current); clearTimeout(t2); };
  }, [active]);

  const prices = [
    { label: node.comparison_label || 'Valeur réelle naturopathe', value: node.comparison_value || '1 000 €', strike: true },
    { label: 'Prix catalogue', value: `${meta?.product?.original_price || 197} €`, strike: true },
    { label: 'Votre prix aujourd\'hui', value: `${meta?.product?.price || 67} €`, strike: false, highlight: true },
  ];

  return (
    <div style={{ textAlign: 'center', padding: '0 24px' }}>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 16, alignItems: 'center' }}>
        {prices.map((p, i) => (
          <div
            key={i}
            style={{
              opacity: step > i ? 1 : 0,
              transform: step > i ? 'translateY(0)' : 'translateY(12px)',
              transition: 'opacity 0.5s var(--ease), transform 0.5s var(--ease)',
            }}
          >
            <div style={{
              fontFamily: 'var(--sans)', fontSize: 12,
              color: 'var(--text-muted)', letterSpacing: '0.1em',
              textTransform: 'uppercase', marginBottom: 4,
            }}>
              {p.label}
            </div>
            <div style={{
              fontFamily: 'var(--serif)',
              fontSize: p.highlight ? 'clamp(44px, 6vw, 72px)' : 'clamp(22px, 3vw, 34px)',
              color: p.highlight ? accentColor : 'var(--text-muted)',
              position: 'relative', display: 'inline-block',
              fontWeight: 400,
            }}>
              {p.strike && (
                <span style={{
                  position: 'absolute', top: '52%', left: 0, right: 0,
                  height: 3, background: '#FF4757', borderRadius: 2,
                  animation: step > i ? 'priceStrike 0.4s var(--ease) forwards' : 'none',
                  transformOrigin: 'left',
                }} />
              )}
              {p.value}
            </div>
          </div>
        ))}
      </div>
      {step >= 3 && (
        <div style={{
          marginTop: 20,
          fontFamily: 'var(--sans)', fontSize: 14,
          color: 'var(--text-dim)',
          animation: 'slideIn 0.4s var(--ease)',
        }}>
          Accès immédiat · Paiement unique · Garantie 90 jours
        </div>
      )}
    </div>
  );
});

/** Comparison node — two columns: without vs with */
const ComparisonNode = memo(function ComparisonNode({ node, accentColor, active, meta }) {
  const left = node.left_items || [
    'Continuer les statines à 200 €/mois',
    'Jambes lourdes, fatigue persistante',
    'Rendez-vous médicaux sans fin',
    'Vivre dans l\'inquiétude permanente',
    'Manquer les promenades et les petits-enfants',
  ];
  const right = node.right_items || [
    `Un investissement unique de ${meta?.product?.price || 67} €`,
    'Énergie retrouvée en 3 à 6 semaines',
    'Résultats visibles sur le bilan sanguin',
    'Sérénité et confiance en votre corps',
    'Profiter pleinement de votre vie',
  ];
  return (
    <div style={{ maxWidth: 780, margin: '0 auto', padding: '0 20px', width: '100%' }}>
      <div style={{
        fontFamily: 'var(--serif)', fontSize: 'clamp(20px, 2.5vw, 28px)',
        textAlign: 'center', color: 'var(--text)', marginBottom: 28,
      }}>
        {node.display}
      </div>
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
        {/* Left — WITHOUT */}
        <div style={{
          background: 'rgba(255,71,87,0.08)',
          border: '1px solid rgba(255,71,87,0.25)',
          borderRadius: 12, padding: '20px 18px',
          opacity: active ? 1 : 0,
          transition: 'opacity 0.5s var(--ease) 0.1s',
        }}>
          <div style={{
            fontFamily: 'var(--sans)', fontWeight: 700, fontSize: 13,
            color: '#FF4757', letterSpacing: '0.1em', textTransform: 'uppercase',
            marginBottom: 16,
          }}>
            {node.left_label || 'Sans le protocole'}
          </div>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
            {left.map((item, i) => (
              <div key={i} style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
                <span style={{ color: '#FF4757', flexShrink: 0, marginTop: 1 }}>✗</span>
                <span style={{ fontFamily: 'var(--sans)', fontSize: 14, color: 'var(--text-dim)', lineHeight: 1.45 }}>
                  {item}
                </span>
              </div>
            ))}
          </div>
        </div>
        {/* Right — WITH */}
        <div style={{
          background: `${accentColor}10`,
          border: `1px solid ${accentColor}33`,
          borderRadius: 12, padding: '20px 18px',
          opacity: active ? 1 : 0,
          transition: 'opacity 0.5s var(--ease) 0.35s',
        }}>
          <div style={{
            fontFamily: 'var(--sans)', fontWeight: 700, fontSize: 13,
            color: accentColor, letterSpacing: '0.1em', textTransform: 'uppercase',
            marginBottom: 16,
          }}>
            {node.right_label || 'Avec le protocole'}
          </div>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
            {right.map((item, i) => (
              <div key={i} style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
                <span style={{ color: accentColor, flexShrink: 0, marginTop: 1 }}>✓</span>
                <span style={{ fontFamily: 'var(--sans)', fontSize: 14, color: 'var(--text)', lineHeight: 1.45 }}>
                  {item}
                </span>
              </div>
            ))}
          </div>
        </div>
      </div>
    </div>
  );
});

/** Analogy node — metaphor with decorative icon block */
const AnalogyNode = memo(function AnalogyNode({ node, accentColor }) {
  const highlights = node.highlight || [];
  return (
    <div style={{ textAlign: 'center', padding: '0 24px', maxWidth: 720, margin: '0 auto' }}>
      <div style={{ fontSize: 52, marginBottom: 28, filter: `drop-shadow(0 0 16px ${accentColor}66)` }}>🚰</div>
      <div style={{
        fontFamily: 'var(--serif)', fontStyle: 'italic',
        fontSize: 'clamp(19px, 2.5vw, 30px)', lineHeight: 1.55, color: 'var(--text)', marginBottom: 20,
      }}>
        <HighlightText text={node.display} highlights={highlights} accentColor={accentColor} />
      </div>
      <div style={{
        width: 80, height: 1, margin: '0 auto', opacity: 0.5,
        background: `linear-gradient(90deg, transparent, ${accentColor}, transparent)`,
      }} />
    </div>
  );
});

// ─── SLIDE RENDERER — TYPES RESTANTS ─────────────────────────────────────────
// visual_report · interactive_poll · countdown · villain_stat
// expert_card · guarantee_badge · cta

/** VisualReport — SVG gauge arcs */
const VisualReportNode = memo(function VisualReportNode({ node, active, meta }) {
  const metrics = node.metrics || [];
  const R = 44, C = 2 * Math.PI * R;
  return (
    <div style={{ maxWidth: 680, margin: '0 auto', padding: '0 24px', width: '100%' }}>
      <div style={{
        fontFamily: 'var(--sans)', fontSize: 13, fontWeight: 600,
        color: 'var(--text-dim)', textAlign: 'center', letterSpacing: '0.1em',
        textTransform: 'uppercase', marginBottom: 32,
      }}>
        {node.display}
      </div>
      <div style={{
        display: 'grid',
        gridTemplateColumns: `repeat(${Math.min(metrics.length, 2)}, 1fr)`,
        gap: 20,
      }}>
        {metrics.map((m, i) => {
          const pct = m.value / m.max;
          const dashOffset = C - pct * C;
          return (
            <div key={i} style={{
              background: 'var(--glass-bg)',
              border: '1px solid var(--glass-border)',
              borderRadius: 12, padding: '20px 16px',
              display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12,
            }}>
              <svg width={100} height={100} viewBox="0 0 100 100">
                <circle cx={50} cy={50} r={R} fill="none" stroke="rgba(255,255,255,0.06)" strokeWidth={8} />
                <circle
                  cx={50} cy={50} r={R} fill="none"
                  stroke={m.color} strokeWidth={8}
                  strokeDasharray={C}
                  strokeDashoffset={active ? dashOffset : C}
                  strokeLinecap="round"
                  transform="rotate(-90 50 50)"
                  style={{ transition: `stroke-dashoffset 1.4s var(--ease) ${i * 0.2}s` }}
                />
                <text x={50} y={54} textAnchor="middle"
                  style={{ fontFamily: 'var(--serif)', fontSize: 18, fill: m.color, fontWeight: 400 }}>
                  {m.value}{m.suffix || ''}
                </text>
              </svg>
              <div style={{
                fontFamily: 'var(--sans)', fontSize: 13, color: 'var(--text-dim)',
                textAlign: 'center', lineHeight: 1.3,
              }}>
                {m.label}
              </div>
            </div>
          );
        })}
      </div>
      {/* Total value */}
      {metrics.length > 0 && (
        <div style={{
          textAlign: 'center', marginTop: 24,
          fontFamily: 'var(--sans)', fontSize: 15, color: 'var(--text-dim)',
        }}>
          Valeur totale :{' '}
          <span style={{ fontFamily: 'var(--serif)', fontSize: 24, color: '#FFD93D', fontWeight: 400 }}>
            {metrics.reduce((s, m) => s + m.value, 0)} €
          </span>
          {' '}→ votre prix :{' '}
          <span style={{ fontFamily: 'var(--serif)', fontSize: 24, color: '#2DD4A8', fontWeight: 400 }}>
            {meta?.product?.price || 67} €
          </span>
        </div>
      )}
    </div>
  );
});

/** InteractivePoll node */
const InteractivePollNode = memo(function InteractivePollNode({ node, accentColor, onInteract }) {
  const poll = node.poll;
  const [selected, setSelected] = useState(null);
  const [revealed, setRevealed] = useState(false);

  const handleSelect = useCallback((i) => {
    setSelected(i);
    setTimeout(() => { setRevealed(true); onInteract?.(); }, 600);
  }, [onInteract]);

  if (!poll) return null;
  const total = poll.results_fake.reduce((a, b) => a + b, 0);

  return (
    <div style={{ maxWidth: 640, margin: '0 auto', padding: '0 24px', width: '100%' }}>
      <div style={{
        fontFamily: 'var(--sans)', fontSize: 13, fontWeight: 600,
        color: accentColor, letterSpacing: '0.12em', textTransform: 'uppercase',
        textAlign: 'center', marginBottom: 14,
      }}>
        Sondage en direct
      </div>
      <div style={{
        fontFamily: 'var(--serif)', fontSize: 'clamp(18px, 2.3vw, 26px)',
        lineHeight: 1.45, color: 'var(--text)', textAlign: 'center', marginBottom: 28,
      }}>
        {poll.question}
      </div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
        {poll.options.map((opt, i) => {
          const pct = Math.round((poll.results_fake[i] / total) * 100);
          const isSelected = selected === i;
          return (
            <button
              key={i}
              onClick={() => !revealed && handleSelect(i)}
              style={{
                position: 'relative', overflow: 'hidden',
                background: isSelected ? `${accentColor}22` : 'var(--glass-bg)',
                border: `1px solid ${isSelected ? accentColor : 'var(--glass-border)'}`,
                borderRadius: 10, padding: '14px 18px',
                cursor: revealed ? 'default' : 'pointer',
                textAlign: 'left', width: '100%',
                transition: 'all 0.3s var(--ease)',
              }}
            >
              {/* Bar fill */}
              {revealed && (
                <div style={{
                  position: 'absolute', inset: 0,
                  background: isSelected ? `${accentColor}18` : 'rgba(255,255,255,0.03)',
                  width: `${pct}%`,
                  transition: 'width 0.8s var(--ease)',
                  borderRadius: 10,
                }} />
              )}
              <div style={{
                position: 'relative', display: 'flex',
                justifyContent: 'space-between', alignItems: 'center',
              }}>
                <span style={{
                  fontFamily: 'var(--sans)', fontSize: 15,
                  color: isSelected ? accentColor : 'var(--text)',
                  fontWeight: isSelected ? 600 : 400,
                }}>
                  {opt}
                </span>
                {revealed && (
                  <span style={{
                    fontFamily: 'var(--sans)', fontWeight: 700, fontSize: 15,
                    color: isSelected ? accentColor : 'var(--text-dim)',
                  }}>
                    {pct} %
                  </span>
                )}
              </div>
            </button>
          );
        })}
      </div>
      {revealed && (
        <div style={{
          textAlign: 'center', marginTop: 16,
          fontFamily: 'var(--sans)', fontSize: 13, color: 'var(--text-dim)',
          animation: 'slideIn 0.4s var(--ease)',
        }}>
          Basé sur {(Math.floor(Math.random() * 400) + 600).toLocaleString('fr-FR')} réponses
        </div>
      )}
    </div>
  );
});

/** Countdown node — urgency timer full-screen */
const CountdownNode = memo(function CountdownNode({ node, accentColor, urgencySeconds }) {
  const mins = Math.floor(Math.max(0, urgencySeconds) / 60);
  const secs = Math.floor(Math.max(0, urgencySeconds) % 60);
  const critical = urgencySeconds < 60;
  return (
    <div style={{ textAlign: 'center', padding: '0 24px' }}>
      <div style={{
        fontFamily: 'var(--sans)', fontSize: 13, fontWeight: 600,
        color: 'var(--text-dim)', letterSpacing: '0.15em', textTransform: 'uppercase',
        marginBottom: 20,
      }}>
        {node.display}
      </div>
      <div style={{
        display: 'flex', gap: 16, justifyContent: 'center', alignItems: 'center',
      }}>
        {[
          { val: String(mins).padStart(2,'0'), label: 'min' },
          { val: ':', label: null },
          { val: String(secs).padStart(2,'0'), label: 'sec' },
        ].map((item, i) => item.label === null ? (
          <div key={i} style={{
            fontFamily: 'var(--serif)',
            fontSize: 'clamp(48px, 7vw, 80px)',
            color: critical ? '#FF3838' : accentColor,
            animation: critical ? 'urgencyPulse 0.8s ease-in-out infinite' : 'none',
            lineHeight: 1,
          }}>:</div>
        ) : (
          <div key={i} style={{ textAlign: 'center' }}>
            <div style={{
              fontFamily: 'var(--serif)',
              fontSize: 'clamp(56px, 8vw, 96px)',
              fontWeight: 400, lineHeight: 1,
              color: critical ? '#FF3838' : accentColor,
              animation: critical ? 'urgencyPulse 0.8s ease-in-out infinite' : 'none',
              background: 'var(--glass-bg)',
              border: '1px solid var(--glass-border)',
              borderRadius: 12, padding: '8px 20px',
              minWidth: 100, display: 'inline-block',
            }}>
              {item.val}
            </div>
            <div style={{ fontFamily: 'var(--sans)', fontSize: 11, color: 'var(--text-muted)', marginTop: 6, letterSpacing: '0.1em' }}>
              {item.label.toUpperCase()}
            </div>
          </div>
        ))}
      </div>
    </div>
  );
});

/** VillainStat node — shocking stat with red background */
const VillainStatNode = memo(function VillainStatNode({ node, active }) {
  // Only apply count-up when display contains exactly one numeric token
  const numTokens = (node.display.match(/\d+/g) || []);
  const isSingleStat = numTokens.length === 1;
  const rawNum  = isSingleStat ? (parseFloat(numTokens[0]) || 0) : 0;
  const prefix  = isSingleStat ? (node.display.match(/^[^\d]*/)?.[0] || '') : '';
  const suffix  = isSingleStat ? node.display.slice(node.display.search(/\d/) + numTokens[0].length) : '';
  const count   = useCountUp(rawNum, 1600, active && isSingleStat);
  const displayVal = isSingleStat
    ? (rawNum >= 1000 ? count.toLocaleString('fr-FR') : String(count))
    : node.display;
  return (
    <div style={{ textAlign: 'center', padding: '0 24px' }}>
      <div style={{
        display: 'inline-block',
        background: 'rgba(255,56,56,0.08)',
        border: '1px solid rgba(255,56,56,0.3)',
        borderRadius: 16, padding: '24px 48px',
      }}>
        <div style={{
          fontFamily: 'var(--serif)',
          fontSize: isSingleStat ? 'clamp(52px, 8vw, 100px)' : 'clamp(36px, 5vw, 68px)',
          fontWeight: 400, lineHeight: 1.1, color: '#FF4757',
          textShadow: '0 0 40px rgba(255,71,87,0.4)',
        }}>
          {isSingleStat ? <>{prefix}{displayVal}{suffix}</> : displayVal}
        </div>
        {node.subtext && (
          <div style={{
            fontFamily: 'var(--sans)', fontSize: 'clamp(13px, 1.4vw, 16px)',
            color: 'var(--text-dim)', marginTop: 14, maxWidth: 480,
          }}>
            {node.subtext}
          </div>
        )}
      </div>
    </div>
  );
});

/** ExpertCard node — node.expert takes priority over meta?.expert (host) */
const ExpertCardNode = memo(function ExpertCardNode({ node, accentColor, meta }) {
  const expert = node.expert || meta?.expert;
  if (!expert) return null;
  const avatarIsUrl = expert.avatar && expert.avatar.startsWith('http');
  return (
    <div style={{ maxWidth: 580, margin: '0 auto', padding: '0 24px', width: '100%' }}>
      <div style={{
        background: 'var(--glass-bg)', backdropFilter: 'blur(12px)',
        border: '1px solid var(--glass-border)', borderRadius: 'var(--radius-card)',
        padding: 'clamp(28px, 3vw, 44px)', textAlign: 'center',
      }}>
        {/* Avatar — photo or initials */}
        <div style={{
          width: 80, height: 80, borderRadius: '50%', margin: '0 auto 20px',
          background: `${accentColor}22`,
          border: `3px solid ${accentColor}55`,
          overflow: 'hidden',
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          fontFamily: 'var(--sans)', fontWeight: 700, fontSize: 26, color: accentColor,
        }}>
          {avatarIsUrl
            ? <img src={expert.avatar} alt={expert.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
            : (expert.avatar || expert.name.split(' ').map(w => w[0]).join(''))
          }
        </div>
        <div style={{ fontFamily: 'var(--serif)', fontSize: 'clamp(22px, 2.8vw, 30px)', color: 'var(--text)', marginBottom: 6 }}>
          {expert.name}
        </div>
        <div style={{ fontFamily: 'var(--sans)', fontSize: 14, color: accentColor, marginBottom: 20, fontWeight: 500 }}>
          {expert.title}
        </div>
        {/* Credentials */}
        <div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}>
          {(expert.credentials || []).map((cred, i) => (
            <div key={i} style={{
              fontFamily: 'var(--sans)', fontSize: 13, color: 'var(--text-dim)',
              display: 'flex', alignItems: 'center', gap: 8,
            }}>
              <span style={{ color: accentColor, fontSize: 10 }}>◆</span>
              {cred}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
});

/** GuaranteeBadge node */
const GuaranteeBadgeNode = memo(function GuaranteeBadgeNode({ node, active }) {
  return (
    <div style={{ textAlign: 'center', padding: '0 24px' }}>
      <div style={{
        display: 'inline-block',
        animation: active ? 'badgePop 0.6s var(--ease) forwards' : 'none',
        opacity: 0,
      }}>
        <div style={{
          width: 160, height: 160, borderRadius: '50%', margin: '0 auto 24px',
          background: 'rgba(45,212,168,0.1)',
          border: '3px solid rgba(45,212,168,0.5)',
          display: 'flex', flexDirection: 'column',
          alignItems: 'center', justifyContent: 'center', gap: 4,
        }}>
          <svg width={52} height={52} viewBox="0 0 52 52">
            <polyline
              points="10,26 22,38 42,14"
              fill="none" stroke="#2DD4A8" strokeWidth={4}
              strokeLinecap="round" strokeLinejoin="round"
              strokeDasharray={60}
              strokeDashoffset={active ? 0 : 60}
              style={{ transition: 'stroke-dashoffset 0.6s var(--ease) 0.4s' }}
            />
          </svg>
          <div style={{ fontFamily: 'var(--sans)', fontWeight: 700, fontSize: 15, color: '#2DD4A8' }}>
            GARANTIE
          </div>
          <div style={{ fontFamily: 'var(--serif)', fontSize: 22, color: '#2DD4A8', lineHeight: 1 }}>
            90 jours
          </div>
        </div>
        <div style={{
          fontFamily: 'var(--serif)', fontSize: 'clamp(18px, 2.2vw, 26px)',
          color: 'var(--text)', lineHeight: 1.4, maxWidth: 520,
        }}>
          {node.display}
        </div>
        <div style={{
          fontFamily: 'var(--sans)', fontSize: 14, color: 'var(--text-dim)', marginTop: 12,
        }}>
          Remboursement en 48h · Sans question · Sans justification
        </div>
      </div>
    </div>
  );
});

/** CTA slide node — full CTA with button */
const CTASlideNode = memo(function CTASlideNode({ node, accentColor, meta, onCTAClick }) {
  const highlights = node.highlight || [];
  return (
    <div style={{ textAlign: 'center', padding: '0 24px', maxWidth: 680, margin: '0 auto', width: '100%' }}>
      <div style={{
        fontFamily: 'var(--serif)',
        fontSize: 'clamp(22px, 3vw, 36px)',
        color: 'var(--text)', lineHeight: 1.4, marginBottom: 32,
      }}>
        <HighlightText text={node.display} highlights={highlights} accentColor={accentColor} />
      </div>
      <button
        onClick={onCTAClick}
        style={{
          background: 'linear-gradient(135deg, #2DD4A8 0%, #1a9e7a 100%)',
          color: '#fff', border: 'none', borderRadius: 99,
          padding: '18px 40px', cursor: 'pointer',
          fontFamily: 'var(--sans)', fontWeight: 700, fontSize: 18,
          letterSpacing: '0.02em',
          animation: 'ctaPulse 2.5s ease-in-out infinite',
          transition: 'transform 0.2s var(--ease)',
          boxShadow: '0 8px 32px rgba(45,212,168,0.3)',
        }}
        onMouseEnter={e => e.target.style.transform = 'scale(1.04)'}
        onMouseLeave={e => e.target.style.transform = 'scale(1)'}
      >
        {meta?.cta_buy_label || `Oui, je protège mes artères — ${meta?.product?.price || 67} € →`}
      </button>
      <div style={{
        fontFamily: 'var(--sans)', fontSize: 13, color: 'var(--text-muted)', marginTop: 12,
      }}>
        🔒 Paiement sécurisé · Garantie {meta?.product?.guarantee_days || 90} jours
      </div>
    </div>
  );
});

// ─── STICKY CTA ──────────────────────────────────────────────────────────────

const StickyCTA = memo(function StickyCTA({
  visible, meta, urgencySeconds, engagementScore, onCTAClick,
}) {
  if (!visible) return null;

  const critical = urgencySeconds > 0 && urgencySeconds < 300;
  const urgencyColor = urgencySeconds < 60 ? '#FF3838' : '#FF6B4A';
  const mins = Math.floor(Math.max(0, urgencySeconds) / 60);
  const secs = Math.floor(Math.max(0, urgencySeconds) % 60);

  // CTA text adapts to engagement score
  const ctaText = engagementScore >= 4
    ? (meta?.cta_start_label || `Commencer maintenant — ${meta?.product?.price || 67} € →`)
    : (meta?.cta_buy_label   || `Oui, je protège mes artères — ${meta?.product?.price || 67} € →`);

  return (
    <div style={{
      position: 'absolute',
      bottom: 58, left: 0, right: 0,
      zIndex: 18,
      background: 'rgba(10,10,15,0.9)',
      backdropFilter: 'blur(16px)',
      borderTop: '1px solid rgba(255,255,255,0.07)',
      padding: '10px 20px',
      display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap',
      animation: 'ctaReveal 0.5s var(--ease) forwards',
    }}>
      {/* Price */}
      <div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
        <span style={{
          fontFamily: 'var(--sans)', fontSize: 13,
          color: 'var(--text-muted)', textDecoration: 'line-through',
        }}>
          {meta?.product?.original_price || 197} €
        </span>
        <span style={{
          fontFamily: 'var(--serif)', fontSize: 22,
          color: '#2DD4A8', fontWeight: 400,
        }}>
          {meta?.product?.price || 67} €
        </span>
      </div>

      {/* Urgency */}
      {urgencySeconds > 0 && (
        <div style={{
          fontFamily: 'var(--sans)', fontSize: 12, fontWeight: 600,
          color: urgencyColor,
          animation: critical ? 'urgencyPulse 0.8s ease-in-out infinite' : 'none',
          flexShrink: 0,
        }}>
          ⏱ {String(mins).padStart(2,'0')}:{String(secs).padStart(2,'0')}
        </div>
      )}

      {/* CTA Button */}
      <button
        onClick={onCTAClick}
        style={{
          flex: 1, minWidth: 200,
          background: 'linear-gradient(135deg, #2DD4A8 0%, #1a9e7a 100%)',
          color: '#fff', border: 'none', borderRadius: 99,
          padding: '11px 24px', cursor: 'pointer',
          fontFamily: 'var(--sans)', fontWeight: 700, fontSize: 14,
          letterSpacing: '0.02em',
          animation: 'ctaPulse 2.5s ease-in-out infinite',
          boxShadow: '0 4px 20px rgba(45,212,168,0.3)',
          transition: 'transform 0.18s var(--ease)',
          whiteSpace: 'nowrap',
        }}
        onMouseEnter={e => e.target.style.transform = 'scale(1.03)'}
        onMouseLeave={e => e.target.style.transform = 'scale(1)'}
      >
        {ctaText}
      </button>

      {/* Trust signals */}
      <div style={{
        fontFamily: 'var(--sans)', fontSize: 11,
        color: 'var(--text-muted)', flexShrink: 0, lineHeight: 1.4,
      }}>
        🔒 Sécurisé<br />
        ✅ Garanti {meta?.product?.guarantee_days || 90}j
      </div>
    </div>
  );
});

// ─── PLAYER BAR ──────────────────────────────────────────────────────────────

const PlayerBar = memo(function PlayerBar({
  currentIdx, totalNodes, isPlaying, isMuted,
  onPrev, onNext, onPlayPause, onToggleMute,
  onSeek, accentColor,
  elapsed, nodeElapsed, nodeDuration,
  onToggleFullscreen, isFullscreen,
}) {
  const totalPct   = totalNodes > 1 ? (currentIdx / (totalNodes - 1)) * 100 : 0;
  const nodePct    = nodeDuration > 0 ? Math.min((nodeElapsed / nodeDuration) * 100, 100) : 0;
  const barRef     = useRef(null);

  const handleBarClick = useCallback((e) => {
    if (!barRef.current) return;
    const rect = barRef.current.getBoundingClientRect();
    const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
    onSeek(Math.round(pct * (totalNodes - 1)));
  }, [totalNodes, onSeek]);

  const btnStyle = (active) => ({
    background: active ? accentColor : 'rgba(255,255,255,0.08)',
    border: 'none', borderRadius: 8,
    width: 40, height: 40, cursor: 'pointer',
    display: 'flex', alignItems: 'center', justifyContent: 'center',
    fontSize: 16, color: active ? '#000' : 'var(--text)',
    transition: 'all 0.18s var(--ease)',
    flexShrink: 0,
  });

  return (
    <div id="vsl-player-bar" style={{
      background: 'rgba(10,10,15,0.97)',
      backdropFilter: 'blur(12px)',
      borderTop: '1px solid rgba(255,255,255,0.06)',
      padding: '8px 16px 10px',
      userSelect: 'none',
    }}>
      {/* Progress bar */}
      <div
        ref={barRef}
        onClick={handleBarClick}
        style={{
          height: 4, borderRadius: 2, marginBottom: 10,
          background: 'rgba(255,255,255,0.1)',
          cursor: 'pointer', position: 'relative',
        }}
      >
        {/* Overall progress */}
        <div style={{
          position: 'absolute', inset: 0,
          width: `${totalPct}%`,
          background: `${accentColor}55`,
          borderRadius: 2,
          transition: 'width 0.3s var(--ease)',
        }} />
        {/* Current node sub-progress */}
        <div style={{
          position: 'absolute',
          left: `${Math.max(0, totalPct - (1 / (totalNodes - 1)) * 100)}%`,
          width: `${(1 / Math.max(1, totalNodes - 1)) * nodePct}%`,
          top: 0, bottom: 0,
          background: accentColor,
          borderRadius: 2,
          transition: 'width 0.25s linear',
        }} />
        {/* Thumb */}
        <div style={{
          position: 'absolute', top: '50%',
          left: `${totalPct}%`,
          transform: 'translate(-50%, -50%)',
          width: 12, height: 12, borderRadius: '50%',
          background: accentColor,
          boxShadow: `0 0 8px ${accentColor}88`,
          transition: 'left 0.3s var(--ease)',
        }} />
      </div>

      {/* Controls row */}
      <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
        {/* Time */}
        <div style={{
          fontFamily: 'var(--sans)', fontSize: 12, color: 'var(--text-muted)',
          minWidth: 64, flexShrink: 0,
        }}>
          {formatTime(elapsed)}
        </div>

        {/* Navigation */}
        <div style={{ display: 'flex', gap: 6, alignItems: 'center', flex: 1, justifyContent: 'center' }}>
          <button
            onClick={onPrev}
            disabled={currentIdx === 0}
            style={{ ...btnStyle(false), opacity: currentIdx === 0 ? 0.35 : 1 }}
            title="Précédent (←)"
          >
            ◁
          </button>
          <button
            onClick={onPlayPause}
            style={{ ...btnStyle(true), width: 48, height: 48, fontSize: 18 }}
            title="Lecture/Pause (Espace)"
          >
            {isPlaying ? '❚❚' : '▶'}
          </button>
          <button
            onClick={onNext}
            disabled={currentIdx >= totalNodes - 1}
            style={{ ...btnStyle(false), opacity: currentIdx >= totalNodes - 1 ? 0.35 : 1 }}
            title="Suivant (→)"
          >
            ▷
          </button>
          {/* Fullscreen — centered, prominent */}
          <button
            onClick={onToggleFullscreen}
            title="Plein écran (F)"
            style={{
              display: 'flex', alignItems: 'center', gap: 7,
              background: isFullscreen ? accentColor : 'rgba(255,255,255,0.10)',
              border: 'none', borderRadius: 8,
              padding: '0 14px', height: 40, cursor: 'pointer',
              fontFamily: 'var(--sans)', fontSize: 13, fontWeight: 600,
              color: isFullscreen ? '#000' : 'var(--text)',
              transition: 'all 0.18s var(--ease)', flexShrink: 0,
            }}
          >
            <span style={{ fontSize: 16, lineHeight: 1 }}>{isFullscreen ? '⊠' : '⊡'}</span>
            <span>{isFullscreen ? 'Quitter' : 'Plein écran'}</span>
          </button>
        </div>

        {/* Right controls */}
        <div style={{
          display: 'flex', gap: 6, alignItems: 'center',
          minWidth: 64, justifyContent: 'flex-end', flexShrink: 0,
        }}>
          <button
            onClick={onToggleMute}
            style={btnStyle(false)}
            title="Son (M)"
          >
            {isMuted ? '🔇' : '🔊'}
          </button>
          <div style={{
            fontFamily: 'var(--sans)', fontSize: 11,
            color: 'var(--text-muted)',
          }}>
            {currentIdx + 1}/{totalNodes}
          </div>
        </div>
      </div>
    </div>
  );
});

// ─── SUBTITLE BAR ────────────────────────────────────────────────────────────

const SubtitleBar = memo(function SubtitleBar({ text, visible }) {
  if (!visible || !text) return null;
  return (
    <div style={{
      position: 'absolute',
      bottom: 68,
      left: '50%',
      transform: 'translateX(-50%)',
      zIndex: 13,
      width: 'min(90%, 760px)',
      pointerEvents: 'none',
    }}>
      <div style={{
        background: 'rgba(0,0,0,0.72)',
        backdropFilter: 'blur(8px)',
        borderRadius: 8,
        padding: '8px 16px',
        textAlign: 'center',
        fontFamily: 'var(--sans)',
        fontSize: 'clamp(13px, 1.5vw, 16px)',
        lineHeight: 1.5,
        color: 'rgba(255,255,255,0.92)',
        border: '1px solid rgba(255,255,255,0.06)',
      }}>
        {text}
      </div>
    </div>
  );
});

// ─── RETENTION HOOK ───────────────────────────────────────────────────────────

const RetentionHook = memo(function RetentionHook({ text }) {
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    if (!text) return;
    setVisible(true);
    const t = setTimeout(() => setVisible(false), 3800);
    return () => clearTimeout(t);
  }, [text]);

  if (!visible || !text) return null;
  return (
    <div style={{
      position: 'absolute',
      bottom: 110, right: 16,
      zIndex: 14,
      background: 'rgba(240,194,122,0.12)',
      border: '1px solid rgba(240,194,122,0.35)',
      borderRadius: 99,
      padding: '8px 16px',
      fontFamily: 'var(--sans)', fontWeight: 600, fontSize: 13,
      color: '#F0C27A',
      animation: 'retentionSlide 0.3s var(--ease)',
      pointerEvents: 'none',
      backdropFilter: 'blur(6px)',
    }}>
      {text}
    </div>
  );
});

// ─── SOCIAL TOAST ─────────────────────────────────────────────────────────────

const SocialToast = memo(function SocialToast({ toast, onDismiss }) {
  const [leaving, setLeaving] = useState(false);
  const timerRef = useRef(null);

  useEffect(() => {
    timerRef.current = setTimeout(() => {
      setLeaving(true);
      setTimeout(onDismiss, 350);
    }, 4200);
    return () => clearTimeout(timerRef.current);
  }, [onDismiss]);

  const renderContent = () => {
    switch (toast.type) {
      case 'viewers':
        return (
          <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
            <span style={{
              width: 7, height: 7, borderRadius: '50%', background: '#FF3838', flexShrink: 0,
              animation: 'livePulse 1.4s ease-in-out infinite',
            }} />
            <span style={{ fontWeight: 700, color: 'var(--text)' }}>
              {toast.count?.toLocaleString('fr-FR')}
            </span>
            <span style={{ color: 'var(--text-dim)', fontSize: 13 }}>{toast.text}</span>
          </div>
        );
      case 'reaction':
        return (
          <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
            <span style={{ fontSize: 16 }}>{toast.emoji}</span>
            <span style={{ fontWeight: 700, color: 'var(--text)' }}>{toast.count}</span>
            <span style={{ color: 'var(--text-dim)', fontSize: 13 }}>{toast.text}</span>
          </div>
        );
      case 'purchase':
        return (
          <div>
            <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
              <span style={{ fontSize: 14 }}>🛒</span>
              <span style={{ fontWeight: 700, color: 'var(--text)', fontSize: 14 }}>
                {toast.name}
              </span>
              {toast.location && (
                <span style={{ color: 'var(--text-dim)', fontSize: 13 }}>({toast.location})</span>
              )}
            </div>
            <div style={{ color: 'var(--text-dim)', fontSize: 12, paddingLeft: 20 }}>
              vient de s'inscrire{toast.time_ago ? ` · il y a ${toast.time_ago}` : ''}
            </div>
          </div>
        );
      case 'signup':
        return (
          <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
            <span style={{ fontSize: 14 }}>✅</span>
            <span style={{ fontWeight: 600, color: 'var(--text)', fontSize: 14 }}>{toast.name}</span>
            <span style={{ color: 'var(--text-dim)', fontSize: 13 }}>{toast.text}</span>
          </div>
        );
      case 'country':
        return (
          <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
            <span style={{ fontSize: 14 }}>🇫🇷</span>
            <span style={{ color: 'var(--text)', fontSize: 14 }}>{toast.text}</span>
          </div>
        );
      default:
        return <span>{toast.text}</span>;
    }
  };

  return (
    <div style={{
      background: 'var(--glass-bg)',
      backdropFilter: 'blur(12px)',
      border: '1px solid var(--glass-border)',
      borderRadius: 12,
      padding: '12px 16px',
      maxWidth: 300,
      minWidth: 220,
      fontFamily: 'var(--sans)',
      fontSize: 14,
      animation: leaving
        ? 'toastOut 0.35s var(--ease) forwards'
        : 'toastIn 0.35s var(--ease) forwards',
      boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
    }}>
      {renderContent()}
    </div>
  );
});

// ─── CHAT ZONE ────────────────────────────────────────────────────────────────

const ChatZone = memo(function ChatZone({ messages }) {
  const endRef = useRef(null);

  useEffect(() => {
    endRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages.length]);

  return (
    <div style={{
      position: 'absolute', bottom: 82, left: 16, zIndex: 12,
      width: 'min(260px, 46vw)',
      display: 'flex', flexDirection: 'column', gap: 8,
      pointerEvents: 'none',
    }}>
      {messages.slice(-3).map((msg, i) => (
        <div
          key={msg.id}
          style={{
            display: 'flex', gap: 8, alignItems: 'flex-end',
            animation: 'chatBubbleIn 0.35s var(--ease)',
            opacity: i < messages.slice(-3).length - 1 ? 0.65 : 1,
            transition: 'opacity 0.3s var(--ease)',
          }}
        >
          {/* Avatar */}
          <div style={{
            width: 26, height: 26, borderRadius: '50%',
            background: msg.avatar_color || '#4ECDC4',
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            fontFamily: 'var(--sans)', fontWeight: 700, fontSize: 11,
            color: '#000', flexShrink: 0,
          }}>
            {msg.avatar_initial}
          </div>
          {/* Bubble */}
          <div style={{
            background: 'rgba(20,20,30,0.88)',
            backdropFilter: 'blur(8px)',
            border: '1px solid rgba(255,255,255,0.08)',
            borderRadius: '12px 12px 12px 2px',
            padding: '8px 12px',
            maxWidth: '100%',
          }}>
            <div style={{
              fontFamily: 'var(--sans)', fontWeight: 700, fontSize: 11,
              color: msg.avatar_color || '#4ECDC4', marginBottom: 2,
            }}>
              {msg.name}
            </div>
            <div style={{
              fontFamily: 'var(--sans)', fontSize: 13, color: 'var(--text)',
              lineHeight: 1.4,
            }}>
              {msg.message}
            </div>
          </div>
        </div>
      ))}
      <div ref={endRef} />
    </div>
  );
});

// ─── SECTION PROGRESS ────────────────────────────────────────────────────────

const SectionProgress = memo(function SectionProgress({
  sections, currentIdx, flatNodes, onNavigate, accentColor,
}) {
  // Build section boundaries: for each section, start index and count in flatNodes
  const sectionMeta = useMemo(() => {
    const result = [];
    let ptr = 0;
    for (const sec of sections) {
      result.push({ id: sec.id, label: sec.label, start: ptr, count: sec.nodes.length });
      ptr += sec.nodes.length;
    }
    return result;
  }, [sections]);

  const currentSection = sectionMeta.find(s =>
    currentIdx >= s.start && currentIdx < s.start + s.count
  ) || sectionMeta[0];

  return (
    <div id="vsl-section-progress" style={{
      position: 'relative', zIndex: 15,
      padding: '8px 16px 6px',
      background: 'rgba(10,10,15,0.9)',
      backdropFilter: 'blur(8px)',
      borderBottom: '1px solid rgba(255,255,255,0.04)',
    }}>
      {/* Section label */}
      <div style={{
        fontFamily: 'var(--sans)', fontSize: 11,
        color: 'var(--text-muted)', letterSpacing: '0.08em',
        textAlign: 'center', marginBottom: 6,
      }}>
        <span style={{ color: accentColor, fontWeight: 600 }}>{currentSection?.label || ''}</span>
        <span style={{ marginLeft: 8 }}>
          {currentIdx + 1} / {flatNodes.length}
        </span>
      </div>

      {/* Segment track */}
      <div style={{ display: 'flex', gap: 3, height: 4 }}>
        {sectionMeta.map((sec, i) => {
          const isCurrent  = sec.id === currentSection?.id;
          const isPast     = sectionMeta.indexOf(currentSection) > i;
          const pctFilled  = isCurrent
            ? ((currentIdx - sec.start + 1) / sec.count) * 100
            : isPast ? 100 : 0;

          return (
            <div
              key={sec.id}
              title={sec.label}
              onClick={() => onNavigate(sec.start)}
              style={{
                flex: sec.count,
                height: '100%',
                borderRadius: 2,
                background: 'rgba(255,255,255,0.08)',
                position: 'relative',
                cursor: 'pointer',
                overflow: 'hidden',
              }}
            >
              <div style={{
                position: 'absolute', inset: 0,
                background: accentColor,
                width: `${pctFilled}%`,
                opacity: isPast ? 0.45 : 1,
                borderRadius: 2,
                transition: 'width 0.3s var(--ease)',
              }} />
            </div>
          );
        })}
      </div>
    </div>
  );
});

// ─── TOP BAR ──────────────────────────────────────────────────────────────────

const TopBar = memo(function TopBar({
  expert, viewerCount, urgencySeconds, urgencyMode,
  showSubtitles, onToggleSubtitles,
  speed, onCycleSpeed,
  onToggleFullscreen, isFullscreen,
  accentColor,
}) {
  const critical = urgencySeconds < 60;
  const warn = urgencySeconds < 300;
  const urgencyColor = critical ? '#FF3838' : warn ? '#FF6B4A' : '#F0C27A';

  const mins = Math.floor(Math.max(0, urgencySeconds) / 60);
  const secs = Math.floor(Math.max(0, urgencySeconds) % 60);
  const timeStr = `${String(mins).padStart(2,'0')}:${String(secs).padStart(2,'0')}`;

  return (
    <div style={{
      position: 'relative', zIndex: 20,
      display: 'flex', alignItems: 'center', gap: 12,
      padding: '10px 18px',
      background: 'rgba(10,10,15,0.95)',
      backdropFilter: 'blur(12px)',
      borderBottom: '1px solid rgba(255,255,255,0.06)',
    }}>
      {/* Fullscreen — centered absolutely */}
      <button
        onClick={onToggleFullscreen}
        title="Plein écran (F)"
        style={{
          position: 'absolute', left: '50%', top: '50%',
          transform: 'translate(-50%, -50%)',
          display: 'flex', alignItems: 'center', gap: 6,
          background: isFullscreen ? accentColor : 'rgba(255,255,255,0.08)',
          border: 'none', borderRadius: 6, padding: '6px 14px', cursor: 'pointer',
          fontFamily: 'var(--sans)', fontWeight: 700, fontSize: 12,
          color: isFullscreen ? '#000' : 'var(--text)',
          transition: 'all 0.2s var(--ease)', whiteSpace: 'nowrap', zIndex: 1,
        }}
      >
        <span style={{ fontSize: 14 }}>{isFullscreen ? '⊠' : '⊡'}</span>
        <span>{isFullscreen ? 'Quitter' : 'Plein écran'}</span>
      </button>
      {/* Expert info */}
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, flex: 1, minWidth: 180 }}>
        <div style={{
          width: 36, height: 36, borderRadius: '50%',
          background: `${accentColor}22`,
          border: `2px solid ${accentColor}55`,
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          fontFamily: 'var(--sans)', fontWeight: 700, fontSize: 13, color: accentColor,
          flexShrink: 0,
        }}>
          {expert?.avatar || 'MB'}
        </div>
        <div>
          <div style={{
            fontFamily: 'var(--sans)', fontWeight: 600, fontSize: 13, color: 'var(--text)',
            lineHeight: 1, display: 'flex', alignItems: 'center', gap: 6,
          }}>
            {expert?.name}
            <span style={{
              background: accentColor, color: '#000', borderRadius: 99,
              fontSize: 9, fontWeight: 700, padding: '1px 6px', letterSpacing: '0.05em',
            }}>
              VÉRIFIÉ
            </span>
          </div>
          <div style={{
            fontFamily: 'var(--sans)', fontSize: 11, color: 'var(--text-muted)', marginTop: 2,
          }}>
            {expert?.title}
          </div>
        </div>
      </div>

      {/* Viewer count */}
      <div style={{
        display: 'flex', alignItems: 'center', gap: 7,
        fontFamily: 'var(--sans)', fontSize: 13, color: 'var(--text-dim)',
        flexShrink: 0,
      }}>
        <span style={{
          width: 8, height: 8, borderRadius: '50%', background: '#FF3838', flexShrink: 0,
          animation: 'livePulse 1.4s ease-in-out infinite',
          boxShadow: '0 0 6px #FF3838',
        }} />
        <span style={{ fontWeight: 600, color: 'var(--text)' }}>
          {viewerCount.toLocaleString('fr-FR')}
        </span>
        <span style={{ display: 'none' }}>en direct</span>
      </div>

      {/* Urgency timer */}
      {urgencySeconds > 0 && (
        <div style={{
          display: 'flex', alignItems: 'center', gap: 6,
          background: `${urgencyColor}18`,
          border: `1px solid ${urgencyColor}44`,
          borderRadius: 99, padding: '5px 12px',
          flexShrink: 0,
        }}>
          <span style={{ fontSize: 12 }}>⏱</span>
          <span style={{
            fontFamily: 'var(--sans)', fontWeight: 700, fontSize: 13,
            color: urgencyColor,
            animation: critical ? 'urgencyPulse 0.8s ease-in-out infinite' : 'none',
            letterSpacing: '0.05em',
          }}>
            {timeStr}
          </span>
        </div>
      )}

      {/* Controls */}
      <div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
        {/* CC */}
        <button
          onClick={onToggleSubtitles}
          title="Sous-titres (S)"
          style={{
            background: showSubtitles ? accentColor : 'rgba(255,255,255,0.08)',
            border: 'none', borderRadius: 6, padding: '6px 10px', cursor: 'pointer',
            fontFamily: 'var(--sans)', fontWeight: 700, fontSize: 11,
            color: showSubtitles ? '#000' : 'var(--text-dim)',
            transition: 'all 0.2s var(--ease)',
          }}
        >
          CC
        </button>
        {/* Speed */}
        <button
          id="vsl-speed-btn"
          onClick={onCycleSpeed}
          title="Vitesse"
          style={{
            background: 'rgba(255,255,255,0.08)', border: 'none', borderRadius: 6,
            padding: '6px 10px', cursor: 'pointer',
            fontFamily: 'var(--sans)', fontWeight: 700, fontSize: 11, color: 'var(--text)',
            transition: 'all 0.2s var(--ease)', minWidth: 40, textAlign: 'center',
          }}
        >
          {speed}×
        </button>
      </div>
    </div>
  );
});

// ─── MASTER SLIDE RENDERER ────────────────────────────────────────────────────

const SlideRenderer = memo(function SlideRenderer({
  node, accentColor, active, meta, onInteract, onCTAClick, urgencySeconds,
}) {
  const sharedProps = { node, accentColor, active, meta, onInteract, onCTAClick, urgencySeconds };
  switch (node.type) {
    case 'headline':         return <HeadlineNode {...sharedProps} />;
    case 'text':             return <TextNode {...sharedProps} />;
    case 'stat':             return <StatNode {...sharedProps} />;
    case 'chapter_title':    return <ChapterTitleNode {...sharedProps} />;
    case 'analogy':          return <AnalogyNode {...sharedProps} />;
    case 'testimonial':
    case 'testimonial_audio':return <TestimonialNode {...sharedProps} />;
    case 'proof':            return <ProofNode {...sharedProps} />;
    case 'value_stack':      return <ValueStackNode {...sharedProps} />;
    case 'price_reveal':     return <PriceRevealNode {...sharedProps} />;
    case 'comparison':       return <ComparisonNode {...sharedProps} />;
    case 'visual_report':    return <VisualReportNode {...sharedProps} />;
    case 'interactive_poll': return <InteractivePollNode {...sharedProps} />;
    case 'countdown':        return <CountdownNode {...sharedProps} />;
    case 'villain_stat':     return <VillainStatNode {...sharedProps} />;
    case 'expert_card':      return <ExpertCardNode {...sharedProps} />;
    case 'guarantee_badge':  return <GuaranteeBadgeNode {...sharedProps} />;
    case 'cta':              return <CTASlideNode {...sharedProps} />;
    default:                 return <TextNode {...sharedProps} />;
  }
});

// ─── VSL PLAYER — STATE & EFFECTS ────────────────────────────────────────────

const SPEED_CYCLE = [0.75, 1, 1.25, 1.5, 2];
const INIT_URGENCY = SCENARIO.meta.timing.urgency_minutes * 60;

window.__VSLPlayer = function VSLPlayer() {
  // ── Core state ────────────────────────────────────────────────────────────
  const [currentIdx,     setCurrentIdx]     = useState(0);
  const [isPlaying,      setIsPlaying]      = useState(false);
  const [speedIdx,       setSpeedIdx]       = useState(1);   // index into SPEED_CYCLE
  const [showSubtitles,  setShowSubtitles]  = useState(false);
  const [ctaRevealed,    setCtaRevealed]    = useState(false);
  const [isFullscreen,   setIsFullscreen]   = useState(false);
  const [isMuted,        setIsMuted]        = useState(false);

  // ── TTS re-speak revision counter (bumped on speed/mute change) ──────────
  const [ttsRev,         setTtsRev]         = useState(0);
  const [audioMapRev,    setAudioMapRev]    = useState(0); // increments when KV audio map loads
  const [dbgSpeedRev,    setDbgSpeedRev]    = useState(0); // increments when debug speed changes → restarts audio effect

  // ── Timers ────────────────────────────────────────────────────────────────
  const [elapsed,        setElapsed]        = useState(0);   // total seconds
  const [nodeElapsed,    setNodeElapsed]    = useState(0);   // seconds on current node
  const [urgencySeconds, setUrgencySeconds] = useState(INIT_URGENCY);

  // ── Social proof ─────────────────────────────────────────────────────────
  const [viewerCount,    setViewerCount]    = useState(847);
  const [toastQueue,     setToastQueue]     = useState([]);  // pending toasts
  const [activeToast,    setActiveToast]    = useState(null);
  const [chatMessages,   setChatMessages]   = useState([]);
  const [retentionText,  setRetentionText]  = useState('');

  // ── Engagement scoring ───────────────────────────────────────────────────
  const [interactions,   setInteractions]   = useState(0);
  const [backCount,      setBackCount]      = useState(0);
  const seenSlidesRef    = useRef(new Set());
  const firedSocialRef   = useRef(new Set());   // social event IDs already shown
  const firedChatRef     = useRef(new Set());   // chat trigger_at already shown
  const purchaseTimerRef = useRef(null);

  // ── Refs ─────────────────────────────────────────────────────────────────
  const autoAdvanceRef   = useRef(null);
  const nodeTimerRef     = useRef(null);
  const viewerTimerRef   = useRef(null);
  const randomChatRef    = useRef(null);
  const urgencyRef       = useRef(null);
  const elapsedRef       = useRef(null);
  const containerRef     = useRef(null);

  // ── TTS refs ─────────────────────────────────────────────────────────────
  const utterRef         = useRef(null);   // active SpeechSynthesisUtterance
  const ttsVoiceRef      = useRef(null);   // selected fr-FR voice
  const ttsPauseRef      = useRef(null);   // setTimeout for pause_after gap
  const speedRef         = useRef(1);      // mirror of speed for closures
  const dbgSpeedRef      = useRef(null);   // debug override speed (null = use normal speed)
  const mutedRef         = useRef(false);  // mirror of isMuted for closures
  const ttsSupported     = typeof window !== 'undefined' && 'speechSynthesis' in window;

  // ── MP3 audio ref (PLAYER#7) ─────────────────────────────────────────────
  const audioRef             = useRef(null);   // HTMLAudioElement for audio_url playback
  const nodeDurationOverride = useRef(null);   // actual MP3 duration in ms (set on loadedmetadata)
  const audioMapRef          = useRef({});     // { nodeId: { url, duration_ms } } from /api/audio-map

  // Load audio map from KV (generated via admin panel)
  // Retries every 4s if map is still empty (cold-start Vercel latency)
  useEffect(() => {
    const scenario = window.__SCENARIO_SLUG || 'arteres';
    let retryTimer = null;
    let cancelled  = false;

    const load = () => {
      fetch(`/api/audio-map?scenario=${scenario}&_t=${Date.now()}`, { cache: 'no-store' })
        .then(r => r.ok ? r.json() : {})
        .then(map => {
          if (cancelled) return;
          audioMapRef.current = map || {};
          setAudioMapRev(r => r + 1);
          // If still empty, retry once after 4s (cold start)
          if (!Object.keys(audioMapRef.current).length) {
            retryTimer = setTimeout(load, 4000);
          }
        })
        .catch(() => { if (!cancelled) retryTimer = setTimeout(load, 4000); });
    };
    load();
    return () => { cancelled = true; clearTimeout(retryTimer); };
  }, []);

  // Select best fr-FR voice once voices are loaded
  useEffect(() => {
    if (!ttsSupported) return;
    const pickVoice = () => {
      const voices = window.speechSynthesis.getVoices();
      // Prefer female French voice, then any French, then default
      ttsVoiceRef.current =
        voices.find(v => v.lang === 'fr-FR' && /female|femme|thomas|amelie|julie|marie/i.test(v.name)) ||
        voices.find(v => v.lang === 'fr-FR') ||
        voices.find(v => v.lang.startsWith('fr')) ||
        null;
    };
    pickVoice();
    window.speechSynthesis.addEventListener('voiceschanged', pickVoice);
    return () => window.speechSynthesis.removeEventListener('voiceschanged', pickVoice);
  }, [ttsSupported]);

  const speed = SPEED_CYCLE[speedIdx];
  const node  = FLAT_NODES[currentIdx];

  // ── Derived: engagement score (0–6) ──────────────────────────────────────
  const engagementScore = useMemo(() => {
    let score = 0;
    // Duration: > 7 min = +1
    if (elapsed > 420) score += 1;
    // Slides seen
    const pct = seenSlidesRef.current.size / FLAT_NODES.length;
    if (pct >= 0.8)      score += 2;
    else if (pct >= 0.5) score += 1;
    // Interactions
    if (interactions >= 3) score += 2;
    else if (interactions >= 1) score += 1;
    // Back navigations
    if (backCount >= 2) score += 1;
    return Math.min(6, score);
  }, [elapsed, interactions, backCount]);

  // ── CTA: unlock once we reach a price_reveal or show_cta node ────────────
  useEffect(() => {
    if (!ctaRevealed && (node?.show_cta || node?.type === 'price_reveal')) {
      setCtaRevealed(true);
    }
  }, [currentIdx, node, ctaRevealed]);

  // ── Track seen slides ─────────────────────────────────────────────────────
  useEffect(() => {
    seenSlidesRef.current.add(currentIdx);
    setNodeElapsed(0);
    // Trigger retention hook
    if (node?.retention_hook) {
      setRetentionText('');
      setTimeout(() => setRetentionText(node.retention_hook), 300);
    }
    // Notify viewer page (webinar chat panel, reaction bar)
    if (typeof window.__onVSLSlideChange === 'function') window.__onVSLSlideChange(node, currentIdx);
    // Debug instrumentation
    if (window.__DBG) {
      window.__DBG.log('SLIDE', '#' + currentIdx + ' [' + (node?.sectionId || '') + '] ' + (node?.type || '') + ' — ' + (node?.id || ''), { mood: node?.mood });
      if (typeof window.__DBG._setSlideIdx === 'function') window.__DBG._setSlideIdx(currentIdx);
    }
  }, [currentIdx, node]);

  // ── Debug bridge: jump + speed override (zero-impact when __DBG inactive) ─
  useEffect(() => {
    const onJump  = (e) => {
      const idx = Math.min(Math.max(0, e.detail?.idx ?? 0), FLAT_NODES.length - 1);
      setCurrentIdx(idx);
      // elapsed + urgencySeconds are synced by the currentIdx effect below
    };
    const onSpeed = (e) => {
      dbgSpeedRef.current = (e.detail?.speed > 0) ? e.detail.speed : null;
      setDbgSpeedRev(r => r + 1); // triggers audio effect re-run with new speed
    };
    // __vsl:play — dispatched by goLive() and debug panel "▶ Lancer" button
    const onPlay  = () => setIsPlaying(true);
    document.addEventListener('__dbg:jump',  onJump);
    document.addEventListener('__dbg:speed', onSpeed);
    document.addEventListener('__vsl:play',  onPlay);
    window.__dbg_setPlayerSpeed = (n) => {
      dbgSpeedRef.current = n > 0 ? n : null;
      setDbgSpeedRev(r => r + 1);
    };
    window.__vsl_play = () => setIsPlaying(true);
    return () => {
      document.removeEventListener('__dbg:jump',  onJump);
      document.removeEventListener('__dbg:speed', onSpeed);
      document.removeEventListener('__vsl:play',  onPlay);
      delete window.__dbg_setPlayerSpeed;
      delete window.__vsl_play;
    };
  }, []);

  // ── TTS: sync mirrors + re-speak when speed or mute changes (PLAYER#5) ───
  useEffect(() => {
    speedRef.current = speed;
    if (ttsSupported) setTtsRev(r => r + 1); // re-speak with new rate
  }, [speed, ttsSupported]);
  useEffect(() => {
    mutedRef.current = isMuted;
    if (ttsSupported) setTtsRev(r => r + 1); // re-speak with new volume
  }, [isMuted, ttsSupported]);

  // ── TTS: stop on component unmount ───────────────────────────────────────
  useEffect(() => {
    return () => {
      if (ttsSupported) window.speechSynthesis.cancel();
      clearTimeout(ttsPauseRef.current);
      clearTimeout(autoAdvanceRef.current);
    };
  }, [ttsSupported]);

  // ── Auto-advance + narration (PLAYER#2/#3/#6/#7) ─────────────────────────
  // Priority: 1) audio_url (MP3) → 2) TTS voice → 3) Timer fallback
  useEffect(() => {
    clearTimeout(autoAdvanceRef.current);
    clearTimeout(ttsPauseRef.current);
    if (ttsSupported) window.speechSynthesis.cancel();
    utterRef.current = null;
    // Stop any previous MP3
    if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; }

    if (!isPlaying) return;

    const eff = dbgSpeedRef.current ?? speedRef.current; // debug-aware effective speed
    let ttsSafetyTimer = null; // safety timer for TTS hang (cleared in cleanup)
    let mp3SafetyTimer = null; // safety timer for MP3 onended not firing

    const advance = () => {
      if (window.__DBG) window.__DBG.log('VOICE', '✓ ended → advance');
      if (currentIdx < FLAT_NODES.length - 1) setCurrentIdx(i => i + 1);
      else setIsPlaying(false);
    };

    nodeDurationOverride.current = null; // reset for new node

    // Resolve audio URL: KV map (admin-generated) takes priority over hardcoded audio_url
    const audioEntry = node?.id ? audioMapRef.current[node.id] : null;
    const resolvedUrl = audioEntry?.url || node?.audio_url || null;
    if (audioEntry?.duration_ms) nodeDurationOverride.current = audioEntry.duration_ms;

    // Always log resolution so we can diagnose audio issues
    const mapSize = Object.keys(audioMapRef.current).length;
    if (window.__DBG) window.__DBG.log('VOICE',
      `🔍 node=${node?.id} mapSize=${mapSize} entry=${audioEntry ? '✅' : '❌'} url=${resolvedUrl ? resolvedUrl.split('/').pop().slice(0,40) : 'null'}`
    );
    console.log('[VSL AUDIO]', 'node:', node?.id, '| mapSize:', mapSize, '| resolvedUrl:', resolvedUrl);

    if (resolvedUrl) {
      // ── MP3 path (highest priority) ───────────────────────────────────────
      if (window.__DBG) window.__DBG.log('VOICE', '🎵 MP3 — ' + resolvedUrl.split('/').pop(), { id: node.id });
      const audio        = new Audio(resolvedUrl);
      audio.playbackRate = eff;
      audio.volume       = mutedRef.current ? 0 : 1;
      audio.onloadedmetadata = () => {
        nodeDurationOverride.current = audio.duration * 1000;
      };
      // Safety timer: if onended never fires (Vercel Blob stream bug), advance after duration × 1.15
      const knownDurMs = audioEntry?.duration_ms || node?.timing?.duration || 6000;
      mp3SafetyTimer = setTimeout(() => {
        if (window.__DBG) window.__DBG.log('VOICE', '⚠️ MP3 safety timeout — advance', { id: node.id });
        audio.pause();
        advance();
      }, (knownDurMs * 1.15) / eff);
      const advanceOnce = () => { clearTimeout(mp3SafetyTimer); advance(); };
      audio.onended = advanceOnce;
      audio.onerror = advanceOnce;
      audioRef.current   = audio;
      audio.play().catch(advanceOnce);
    } else if (ttsSupported && node?.voice) {
      // ── TTS path ──────────────────────────────────────────────────────────
      if (window.__DBG) window.__DBG.log('VOICE', '🗣 TTS — "' + node.voice.slice(0, 60) + '"', { id: node.id });
      const utt     = new SpeechSynthesisUtterance(node.voice);
      utt.lang      = 'fr-FR';
      utt.rate      = eff;
      utt.volume    = mutedRef.current ? 0 : 1;
      if (ttsVoiceRef.current) utt.voice = ttsVoiceRef.current;
      // Safety timer: if TTS hangs (known Chrome bug), advance after duration × 1.5
      const ttsSafetyMs = ((node?.timing?.duration || 6000) * 1.5) / eff;
      ttsSafetyTimer = setTimeout(() => {
        if (window.__DBG) window.__DBG.log('VOICE', '⚠️ TTS timeout safety — advance', { id: node.id });
        window.speechSynthesis.cancel();
        advance();
      }, ttsSafetyMs);
      const advanceOnce = () => { clearTimeout(ttsSafetyTimer); advance(); };
      utt.onend     = advanceOnce;
      utt.onerror   = (e) => {
        if (e.error === 'interrupted' || e.error === 'canceled') return;
        clearTimeout(ttsSafetyTimer);
        advance();
      };
      utterRef.current = utt;
      // Chrome bug: speechSynthesis can enter a suspended state silently — resume() first
      if (window.speechSynthesis.paused) window.speechSynthesis.resume();
      window.speechSynthesis.speak(utt);
    } else {
      // ── Timer fallback (no audio, no TTS) ────────────────────────────────
      if (window.__DBG) window.__DBG.log('VOICE', '⏱ Timer fallback — ttsSupported=' + ttsSupported + ' hasVoice=' + !!node?.voice + ' url=' + resolvedUrl, { id: node?.id });
      console.warn('[VSL] Timer fallback on node', node?.id, '| ttsSupported:', ttsSupported, '| voice:', !!node?.voice, '| url:', resolvedUrl);
      const duration   = (node?.timing?.duration    || 4000) / eff;
      const pauseAfter = (node?.timing?.pause_after || 0)    / eff;
      autoAdvanceRef.current = setTimeout(() => {
        if (currentIdx < FLAT_NODES.length - 1) setCurrentIdx(i => i + 1);
        else setIsPlaying(false);
      }, duration + pauseAfter);
    }

    return () => {
      if (ttsSupported) window.speechSynthesis.cancel();
      if (audioRef.current) { audioRef.current.pause(); audioRef.current = null; }
      clearTimeout(autoAdvanceRef.current);
      clearTimeout(ttsPauseRef.current);
      clearTimeout(ttsSafetyTimer);
      clearTimeout(mp3SafetyTimer);
    };
  }, [currentIdx, isPlaying, ttsSupported, node, ttsRev, audioMapRev, dbgSpeedRev]);

  // ── dms: scale a millisecond value by the current debug speed ───────────
  // All wall-clock timers call dms(ms) so they all accelerate together.
  const dms = (ms) => Math.max(50, ms / (dbgSpeedRef.current ?? 1));

  // ── Node elapsed counter (for PlayerBar sub-progress) ────────────────────
  useEffect(() => {
    clearInterval(nodeTimerRef.current);
    setNodeElapsed(0);
    if (!isPlaying) return;
    nodeTimerRef.current = setInterval(() => {
      setNodeElapsed(n => n + 0.25);
    }, dms(250));
    return () => clearInterval(nodeTimerRef.current);
  }, [currentIdx, isPlaying, dbgSpeedRev]); // eslint-disable-line react-hooks/exhaustive-deps

  // ── Total elapsed counter — anchored to cumulative audio duration ─────────
  // On every slide change, reset elapsed to the exact cumulative duration so
  // the timer badge always matches the actual webinar position, regardless of
  // TTS/audio drift. The interval fills in sub-slide progress smoothly.
  useEffect(() => {
    const base = CUMULATIVE_SECS[currentIdx] ?? 0;
    setElapsed(base);
    setUrgencySeconds(Math.max(0, INIT_URGENCY - base));
  }, [currentIdx]); // eslint-disable-line react-hooks/exhaustive-deps

  useEffect(() => {
    clearInterval(elapsedRef.current);
    if (!isPlaying) return;
    elapsedRef.current = setInterval(() => {
      setElapsed(e => e + 1);
    }, dms(1000));
    return () => clearInterval(elapsedRef.current);
  }, [isPlaying, currentIdx, dbgSpeedRev]); // eslint-disable-line react-hooks/exhaustive-deps

  // ── Urgency countdown ─────────────────────────────────────────────────────
  useEffect(() => {
    clearInterval(urgencyRef.current);
    urgencyRef.current = setInterval(() => {
      setUrgencySeconds(s => Math.max(0, s - 1));
    }, dms(1000));
    return () => clearInterval(urgencyRef.current);
  }, [dbgSpeedRev]); // eslint-disable-line react-hooks/exhaustive-deps

  // ── Viewer count fluctuation ─────────────────────────────────────────────
  useEffect(() => {
    clearInterval(viewerTimerRef.current);
    viewerTimerRef.current = setInterval(() => {
      setViewerCount(v => {
        const section = node?.sectionId;
        const trend = (section === 'hook' || section === 'mechanism') ? 1 : -0.3;
        const delta = rand(-3, 5) + (trend > 0 ? rand(0, 3) : 0);
        return Math.max(400, Math.min(2000, v + delta));
      });
    }, dms(rand(5000, 9000)));
    return () => clearInterval(viewerTimerRef.current);
  }, [node, dbgSpeedRev]); // eslint-disable-line react-hooks/exhaustive-deps

  // ── Social timeline events ────────────────────────────────────────────────
  useEffect(() => {
    SCENARIO.social_timeline.forEach(ev => {
      if (
        ev.trigger_mode === 'slide' &&
        ev.trigger_at === currentIdx &&
        !firedSocialRef.current.has(ev.id)
      ) {
        firedSocialRef.current.add(ev.id);
        setToastQueue(q => [...q, { ...ev, _key: ev.id }]);
        if (window.__DBG) window.__DBG.log('SOCIAL', ev.type + ' — ' + (ev.count || ev.name || ev.text || ev.id || ''));
      }
    });
  }, [currentIdx]);

  // ── Toast queue processor (one at a time) ─────────────────────────────────
  useEffect(() => {
    if (activeToast || toastQueue.length === 0) return;
    const [next, ...rest] = toastQueue;
    setActiveToast(next);
    setToastQueue(rest);
  }, [toastQueue, activeToast]);

  const dismissToast = useCallback(() => {
    setActiveToast(null);
  }, []);

  // ── Chat timeline ─────────────────────────────────────────────────────────
  useEffect(() => {
    SCENARIO.chat_timeline.forEach(msg => {
      const key = `${msg.trigger_at}-${msg.name}`;
      if (msg.trigger_at === currentIdx && !firedChatRef.current.has(key)) {
        firedChatRef.current.add(key);
        setChatMessages(m => [
          ...m,
          { ...msg, id: `${key}-${Date.now()}` },
        ]);
      }
    });
  }, [currentIdx]);

  // ── Random chat messages ──────────────────────────────────────────────────
  useEffect(() => {
    clearTimeout(randomChatRef.current);
    const scheduleRandom = () => {
      randomChatRef.current = setTimeout(() => {
        const name    = pick(ALL_NAMES);
        const message = pick(CHAT_POOL);
        const color   = pick(AVATAR_COLORS);
        setChatMessages(m => [
          ...m,
          {
            id: `rnd-${Date.now()}`,
            name, message,
            avatar_initial: name.charAt(0),
            avatar_color: color,
          },
        ]);
        scheduleRandom();
      }, dms(rand(28000, 58000)));
    };
    if (isPlaying) scheduleRandom();
    return () => clearTimeout(randomChatRef.current);
  }, [isPlaying, dbgSpeedRev]); // eslint-disable-line react-hooks/exhaustive-deps

  // ── Post-price-reveal: purchase toasts every 45–90s ───────────────────────
  useEffect(() => {
    clearTimeout(purchaseTimerRef.current);
    if (!ctaRevealed || !isPlaying) return;
    const schedulePurchase = () => {
      purchaseTimerRef.current = setTimeout(() => {
        const [fn, ln] = pick(PURCHASE_NAMES);
        const city = pick(CITIES);
        setToastQueue(q => [
          ...q,
          {
            _key: `purchase-${Date.now()}`,
            type: 'purchase',
            name: `${fn} ${ln}`,
            location: city,
            time_ago: `${rand(1, 4)} min`,
          },
        ]);
        schedulePurchase();
      }, dms(rand(45000, 90000)));
    };
    schedulePurchase();
    return () => clearTimeout(purchaseTimerRef.current);
  }, [ctaRevealed, isPlaying, dbgSpeedRev]); // eslint-disable-line react-hooks/exhaustive-deps

  // ── Handlers ──────────────────────────────────────────────────────────────
  const handlePrev = useCallback(() => {
    setCurrentIdx(i => {
      if (i <= 0) return i;
      setBackCount(b => b + 1);
      return i - 1;
    });
  }, []);

  const handleNext = useCallback(() => {
    setCurrentIdx(i => Math.min(FLAT_NODES.length - 1, i + 1));
  }, []);

  const handleSeek = useCallback((idx) => {
    setCurrentIdx(Math.max(0, Math.min(FLAT_NODES.length - 1, idx)));
  }, []);

  const handleCTAClick = useCallback(() => {
    setInteractions(n => n + 1);
    if (window.__DBG) window.__DBG.log('CTA', 'CTA click — node #' + currentIdx + ' [' + (node?.id || '') + ']');
    // Open checkout modal in viewer (defined in viewer.html)
    // Falls back to direct link if modal is not available (standalone player embed)
    if (typeof window.openOrderModal === 'function') {
      window.openOrderModal();
    } else {
      const url = SCENARIO.meta.product.checkout_url;
      const { utm_source, utm_campaign } = SCENARIO.meta.tracking;
      window.open(`${url}?utm_source=${utm_source}&utm_campaign=${utm_campaign}`, '_blank');
    }
  }, []);

  const handleInteract = useCallback(() => {
    setInteractions(n => n + 1);
  }, []);

  const cycleSpeed = useCallback(() => {
    setSpeedIdx(i => (i + 1) % SPEED_CYCLE.length);
  }, []);

  const toggleFullscreen = useCallback(() => {
    if (!document.fullscreenElement) {
      // Use .webinar-layout so chat stays visible in fullscreen
      const target = document.querySelector('.webinar-layout') || document.documentElement;
      target.requestFullscreen?.().then(() => setIsFullscreen(true)).catch(() => {});
    } else {
      document.exitFullscreen?.().then(() => setIsFullscreen(false)).catch(() => {});
    }
  }, []);

  // ── Keyboard shortcuts ────────────────────────────────────────────────────
  useEffect(() => {
    const onKey = (e) => {
      if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
      switch (e.code) {
        case 'Space':       e.preventDefault(); setIsPlaying(p => !p); break;
        case 'ArrowRight':  handleNext();   break;
        case 'ArrowLeft':   handlePrev();   break;
        case 'KeyS':        setShowSubtitles(v => !v); break;
        case 'KeyF':        toggleFullscreen(); break;
        case 'KeyM':        setIsMuted(v => !v); break;
        case 'Equal':
        case 'NumpadAdd':   setSpeedIdx(i => Math.min(SPEED_CYCLE.length - 1, i + 1)); break;
        case 'Minus':
        case 'NumpadSubtract': setSpeedIdx(i => Math.max(0, i - 1)); break;
        default: break;
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [handleNext, handlePrev, toggleFullscreen]);

  // ── Touch swipe ───────────────────────────────────────────────────────────
  const touchStartX = useRef(null);
  const onTouchStart = useCallback((e) => {
    touchStartX.current = e.touches[0].clientX;
  }, []);
  const onTouchEnd = useCallback((e) => {
    if (touchStartX.current === null) return;
    const delta = touchStartX.current - e.changedTouches[0].clientX;
    if (Math.abs(delta) > 50) {
      delta > 0 ? handleNext() : handlePrev();
    }
    touchStartX.current = null;
  }, [handleNext, handlePrev]);

  // ── Ambient gradient color ────────────────────────────────────────────────
  const accentColor = MOOD_COLORS[node?.mood] || MOOD_COLORS.confident;

  // ── Node total duration (ms) for PlayerBar ────────────────────────────────
  // When an MP3 is playing, use the actual audio duration (set on loadedmetadata)
  // otherwise fall back to the hardcoded timing estimate.
  const nodeDurationMs = node
    ? ((nodeDurationOverride.current ?? (node.timing.duration + (node.timing.pause_after || 0))) / speed)
    : 4000;

  // ── RENDER ────────────────────────────────────────────────────────────────
  return (
    <>
      <style dangerouslySetInnerHTML={{ __html: GLOBAL_CSS }} />
      <div
        ref={containerRef}
        onTouchStart={onTouchStart}
        onTouchEnd={onTouchEnd}
        style={{
          width: '100%', maxWidth: '100%',
          margin: '0 auto',
          background: 'var(--bg)',
          borderRadius: isFullscreen ? 0 : 16,
          overflow: 'hidden',
          fontFamily: 'var(--sans)',
          position: 'relative',
          boxShadow: isFullscreen ? 'none' : '0 24px 80px rgba(0,0,0,0.6)',
          border: isFullscreen ? 'none' : '1px solid rgba(255,255,255,0.06)',
          display: 'flex', flexDirection: 'column',
          minHeight: isFullscreen ? '100vh' : 'auto',
        }}
      >
        {/* ── Top bar ── */}
        <TopBar
          expert={SCENARIO.meta.expert}
          viewerCount={viewerCount}
          urgencySeconds={urgencySeconds}
          showSubtitles={showSubtitles}
          onToggleSubtitles={() => setShowSubtitles(v => !v)}
          speed={speed}
          onCycleSpeed={cycleSpeed}
          onToggleFullscreen={toggleFullscreen}
          isFullscreen={isFullscreen}
          accentColor={accentColor}
        />

        {/* ── Section progress ── */}
        <SectionProgress
          sections={SCENARIO.sections}
          currentIdx={currentIdx}
          flatNodes={FLAT_NODES}
          onNavigate={handleSeek}
          accentColor={accentColor}
        />

        {/* ── Stage ── */}
        <div
          style={{
            position: 'relative',
            flex: 1,
            minHeight: 'clamp(320px, 50vw, 520px)',
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            overflow: 'hidden',
          }}
        >
          {/* Ambient gradient background */}
          <div style={{
            position: 'absolute', inset: 0, zIndex: 0, overflow: 'hidden', pointerEvents: 'none',
          }}>
            <div style={{
              position: 'absolute',
              width: '80%', height: '80%',
              top: '10%', left: '10%',
              borderRadius: '50%',
              background: `radial-gradient(circle, ${accentColor}12 0%, transparent 70%)`,
              transition: 'background 1.5s var(--ease)',
              animation: 'ambientDrift 12s ease-in-out infinite',
            }} />
            <div style={{
              position: 'absolute',
              width: '50%', height: '50%',
              bottom: '5%', right: '5%',
              borderRadius: '50%',
              background: `radial-gradient(circle, ${accentColor}08 0%, transparent 70%)`,
              transition: 'background 1.5s var(--ease)',
            }} />
          </div>

          {/* Slide content */}
          <div
            key={currentIdx}
            style={{
              position: 'relative', zIndex: 1,
              width: '100%',
              animation: 'slideIn 0.5s var(--ease)',
              padding: '32px 24px',
              display: 'flex', alignItems: 'center', justifyContent: 'center',
            }}
          >
            {node && (
              <SlideRenderer
                node={node}
                accentColor={accentColor}
                active={true}
                meta={SCENARIO.meta}
                onInteract={handleInteract}
                onCTAClick={handleCTAClick}
                urgencySeconds={urgencySeconds}
              />
            )}
          </div>

          {/* Overlays */}
          <ChatZone messages={chatMessages} />

          <RetentionHook text={retentionText} />

          <SubtitleBar
            text={node?.voice || ''}
            visible={showSubtitles}
          />

          {/* Social toast (top-right) */}
          {activeToast && (
            <div style={{
              position: 'absolute', top: 12, right: 12, zIndex: 20,
            }}>
              <SocialToast toast={activeToast} onDismiss={dismissToast} />
            </div>
          )}

          {/* Sticky CTA above player bar */}
          <StickyCTA
            visible={ctaRevealed && node?.type !== 'cta'}
            meta={SCENARIO.meta}
            urgencySeconds={urgencySeconds}
            engagementScore={engagementScore}
            onCTAClick={handleCTAClick}
          />
        </div>

        {/* ── Player bar ── */}
        <PlayerBar
          currentIdx={currentIdx}
          totalNodes={FLAT_NODES.length}
          isPlaying={isPlaying}
          isMuted={isMuted}
          onPrev={handlePrev}
          onNext={handleNext}
          onPlayPause={() => setIsPlaying(p => !p)}
          onToggleMute={() => setIsMuted(v => !v)}
          onSeek={handleSeek}
          accentColor={accentColor}
          elapsed={elapsed}
          nodeElapsed={nodeElapsed}
          nodeDuration={nodeDurationMs / 1000}
          onToggleFullscreen={toggleFullscreen}
          isFullscreen={isFullscreen}
        />
      </div>
    </>
  );
}

