// solution-engine.jsx — main player for the Solución animation.
// Renders the full-bleed cinema container with the 1280×720 canvas,
// chips/controls bar, persistent iPhone, and 5 acts (currently stubs).
//
// Mounts to #sol-root.

const CANVAS_W = 1280;
const CANVAS_H = 720;

// ── Timeline plan ────────────────────────────────────────────────────────────
// Build acts and title cards with clean offsets.
// STRUCTURE (5 actos · ~211s = 3:31):
//   Act 1 Onboarding  (0-9)
//   Act 2 Taxes       (12.25-74.25)  +1s extra al hold del "Mil gracias"
//   Act 3 Migración   (77-139.5, 62.5s)  WhatsApp + case creation + contrato +
//                                         Sophia Importar 7.5s + Cascade 12.5s
//                                         (con cross-fill banners iluminados) +
//                                         Zoom 6.5s
//   Act 4 Paquete     (140.75-167)    +3s on Print scene so impresora 4D respira
//   Phone payoff      (167-180)       13s 4-msg progressive: info → cliente gracias → office "gracias a ti" → 3s breath
//   Act 5 Explain     (186-206)       re-choreographed: phone WhatsApp → fade+slide → letter LARGE → Sophia scan → analysis → dark violet card
//   Closing           (206-211)       extends total to 3:31
// `chipNum` controls which chip in the bottom bar this act belongs to —
//   migración (3) + paquete (4 internal) collapse into chip 3 (one long orange).
//   explain (5 internal) renders as chip 4.
// `showTitle` controls whether to render a title card before this act —
//   paquete has NO title card (it's part of the migración module flow).
const ACTS = [
  { num: 1, key: 'onboarding', start: 0,      end: 9,      color: '#007AFF', titleBuffer: 3.55, nameKey: 'act1_name', subKey: 'act1_sub', chipNum: 1, showTitle: false, Scene: () => window.Act1Onboarding && <Act1Onboarding /> },
  { num: 2, key: 'taxes',      start: 12.25,  end: 74.25,  color: '#007AFF', titleBuffer: 3.55, nameKey: 'act2_name', subKey: 'act2_sub', chipNum: 2, showTitle: true,  Scene: () => window.Act2Taxes && <Act2Taxes />, focusEnd: 73.5 },
  { num: 3, key: 'migracion',  start: 77,     end: 139.5,  color: '#FF9500', titleBuffer: 5.00, nameKey: 'act3_name', subKey: 'act3_sub', chipNum: 3, showTitle: true,  Scene: () => window.Act3Migracion && <Act3Migracion />, focusEnd: 180 },
  { num: 4, key: 'paquete',    start: 140.75, end: 167,     color: '#FF9500', titleBuffer: 3.55, nameKey: 'act4_name', subKey: 'act4_sub', chipNum: 3, showTitle: false, Scene: () => window.Act4Paquete && <Act4Paquete /> },
  { num: 5, key: 'explain',    start: 186,    end: 206,    color: '#8b5cf6', titleBuffer: 6.00, nameKey: 'act5_name', subKey: 'act5_sub', chipNum: 4, showTitle: true,  Scene: () => window.Act5Explain && <Act5Explain /> },
];

// Chips: merge ACTS that share chipNum into a single chip spanning their combined range.
// Used by the bottom progress bar.
function buildChips(acts) {
  const byNum = {};
  for (const a of acts) {
    if (!byNum[a.chipNum]) {
      byNum[a.chipNum] = { num: a.chipNum, key: a.key, start: a.start, end: a.end, color: a.color, nameKey: a.nameKey };
    } else {
      const g = byNum[a.chipNum];
      g.start = Math.min(g.start, a.start);
      g.end   = Math.max(g.end, a.end);
    }
  }
  return Object.values(byNum).sort((a, b) => a.num - b.num);
}
const CHIPS = buildChips(ACTS);
const CLOSING_START = 206;
const CLOSING_END = 211;
const FULL_DURATION = CLOSING_END;

// ── Focus mode ───────────────────────────────────────────────────────────────
// `?focus=<actKey>` recorta el player a ese chapter solamente. Útil para
// embedar el video en páginas de módulos (taxes.html, immigration.html, etc.)
// donde solo queremos mostrar el tramo correspondiente.
//   - Inicia un poco antes del act start para capturar el title card
//   - Para al final del chip de ese act
//   - El bottom bar muestra solo el chip del module
const FOCUS_KEY = (function() {
  try {
    const p = new URL(window.location.href).searchParams;
    return p.get('focus') || null;
  } catch (e) { return null; }
})();
const FOCUS_ACT = FOCUS_KEY ? ACTS.find(a => a.key === FOCUS_KEY) : null;
const FOCUS_CHIP = FOCUS_ACT ? CHIPS.find(c => c.num === FOCUS_ACT.chipNum) : null;
const FOCUS_ACTIVE = !!FOCUS_ACT;
const FOCUS_START = FOCUS_ACTIVE
  ? Math.max(0, FOCUS_CHIP.start - (FOCUS_ACT.titleBuffer || 0))
  : 0;
const FOCUS_END = FOCUS_ACTIVE
  ? (FOCUS_ACT.focusEnd != null ? FOCUS_ACT.focusEnd : FOCUS_CHIP.end)
  : FULL_DURATION;
const RENDERED_CHIPS = FOCUS_ACTIVE ? [FOCUS_CHIP] : CHIPS;
const DURATION = FOCUS_END;

// Title cards live in the buffer BEFORE each act start.
// They overlay on a black backdrop, so a slight overlap with the previous act is fine —
// the fade-in masks the transition. Per-act `titleBuffer` lets long subtitles
// (e.g. Migración, Explain) hold longer on screen.
// Acts with showTitle=false (act 1, paquete) do NOT get a title card.
const TITLE_CARDS = ACTS.filter(a => a.showTitle).map(a => ({
  actNum: a.num,
  nameKey: a.nameKey,
  subKey: a.subKey,
  color: a.color,
  start: a.start - (a.titleBuffer || 3.55),
  end: a.start - 0.05,
}));

// In focus mode, only render the focused act's title card. Otherwise the next
// act's title card (e.g. Migración's "MÓDULO 3" backdrop spanning 72→77) leaks
// into the end of the focused range and reveals chapters we want hidden.
const RENDERED_TITLE_CARDS = FOCUS_ACTIVE
  ? TITLE_CARDS.filter(tc => tc.actNum === FOCUS_ACT.num)
  : TITLE_CARDS;

// ── Hooks ────────────────────────────────────────────────────────────────────

function useLang() {
  const [lang, setLang] = React.useState(window.SOL_LANG_INITIAL || 'es');
  React.useEffect(() => {
    try { localStorage.setItem('ep:sol:lang', lang); } catch {}
    document.documentElement.setAttribute('data-sol-lang', lang);
  }, [lang]);
  return [lang, setLang];
}

function useT(lang) {
  return (window.SOL_I18N && window.SOL_I18N[lang]) || window.SOL_I18N.es;
}

// ── Player ───────────────────────────────────────────────────────────────────

function SolutionPlayer() {
  const [lang, setLang] = useLang();
  const t = useT(lang);

  const [time, setTime] = React.useState(FOCUS_START);
  const [playing, setPlaying] = React.useState(false);
  const [hasStarted, setHasStarted] = React.useState(false);
  const [ended, setEnded] = React.useState(false);

  // Expose setTime for dev/debugging (programmatic seek from console / eval).
  React.useEffect(() => {
    window.__solSetTime = (t) => { setTime(t); setEnded(false); setPlaying(false); };
    window.__solGetTime = () => time;
  }, [time]);
  const rafRef = React.useRef(null);
  const lastTsRef = React.useRef(null);

  const shellRef = React.useRef(null);
  const canvasRef = React.useRef(null);
  const [scale, setScale] = React.useState(1);

  // Fit-to-shell scaling
  React.useEffect(() => {
    if (!shellRef.current) return;
    const el = shellRef.current;
    const measure = () => {
      const s = Math.min(el.clientWidth / CANVAS_W, el.clientHeight / CANVAS_H);
      setScale(Math.max(0.05, s));
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    window.addEventListener('resize', measure);
    return () => { ro.disconnect(); window.removeEventListener('resize', measure); };
  }, []);

  // Auto-play when cinema enters viewport (only once)
  React.useEffect(() => {
    if (hasStarted) return;
    if (!shellRef.current) return;
    const obs = new IntersectionObserver((entries) => {
      entries.forEach(e => {
        if (e.isIntersecting && e.intersectionRatio > 0.35) {
          setPlaying(true);
          setHasStarted(true);
          obs.disconnect();
        }
      });
    }, { threshold: [0.35] });
    obs.observe(shellRef.current);
    return () => obs.disconnect();
  }, [hasStarted]);

  // RAF loop
  React.useEffect(() => {
    if (!playing) { lastTsRef.current = null; return; }
    const step = (ts) => {
      if (lastTsRef.current == null) lastTsRef.current = ts;
      const dt = (ts - lastTsRef.current) / 1000;
      lastTsRef.current = ts;
      setTime(prev => {
        const next = prev + dt;
        if (next >= DURATION) {
          // In focus mode, auto-loop back to start instead of ending.
          if (FOCUS_ACTIVE) {
            return FOCUS_START;
          }
          setPlaying(false);
          setEnded(true);
          return DURATION;
        }
        return next;
      });
      rafRef.current = requestAnimationFrame(step);
    };
    rafRef.current = requestAnimationFrame(step);
    return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
  }, [playing]);

  // Keyboard controls
  React.useEffect(() => {
    const onKey = (e) => {
      if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) return;
      if (e.code === 'Space') {
        e.preventDefault();
        if (ended) { handleReplay(); return; }
        setPlaying(p => !p);
      } else if (e.code === 'ArrowLeft') {
        setTime(t => Math.max(FOCUS_START, t - (e.shiftKey ? 2 : 0.5)));
        setEnded(false);
      } else if (e.code === 'ArrowRight') {
        setTime(t => Math.min(DURATION, t + (e.shiftKey ? 2 : 0.5)));
      }
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [ended]);

  function handleReplay() {
    setTime(FOCUS_START);
    setEnded(false);
    setPlaying(true);
  }

  function seekToChip(chip) {
    setTime(chip.start);
    setEnded(false);
    setPlaying(true);
  }

  const inClosing = !FOCUS_ACTIVE && time >= CLOSING_START;

  // ── Phone state coordinator ────────────────────────────────────────────
  // Returns props for <IPhone> based on global time, or null to hide.
  // Phone appears ONLY when there's active client intervention.
  // When visible, the phone renders CENTERED on canvas as protagonist (with dim backdrop).
  function getPhoneAt(time) {
    // Hidden until first client intervention (Act 2 Taxes — contrato delivery)
    // Act 2 starts at 12.25, contract delivery at local ~21 = global ~33.25
    if (time < 33.25) return null;

    // ── Act 2 TAXES — moment 1: Contrato Taxes 2026 → firma ────────────
    // Progressive reveal: phone enters → office types → msg1 → types → msg2 →
    // user opens sign → steps 0-3 → back to chat with client typing → confirm →
    // office typing → "Perfecto". ~11s total (was 5.75s) so the reader can read
    // the SOPHIA callout above + see each message being typed and replied to.
    const buildChat = (subtitle, msgs, typing) => ({
      state: 'chat',
      contactName: 'EasyPro Plus',
      contactSubtitle: subtitle,
      contactAvatar: 'EP',
      messages: msgs,
      typing,
    });
    const MSG1 = { from: 'office', text: t.phone.a2_c_office_1, time: '4:11 p.m.' };
    const MSG2 = { from: 'office', text: t.phone.a2_c_office_2, time: '4:11 p.m.' };
    const PREVIEW = { from: 'office', text: t.phone.a2_c_preview, time: '4:11 p.m.' };
    const SIGNED = { from: 'client', text: t.phone.a2_c_signed, time: '4:13 p.m.' };
    const PERFECT = { from: 'office', text: t.phone.a2_c_perfect, time: '4:13 p.m.' };

    // 33.25–33.85 (0.6s): chat header only, no bubbles yet
    if (time < 33.85) return buildChat(t.chat_online, []);
    // 33.85–34.85 (1.0s): office typing indicator
    if (time < 34.85) return buildChat(t.chat_typing, [], 'office');
    // 34.85–35.85 (1.0s): msg1 visible, pause
    if (time < 35.85) return buildChat(t.chat_online, [MSG1]);
    // 35.85–36.65 (0.8s): office typing again
    if (time < 36.65) return buildChat(t.chat_typing, [MSG1], 'office');
    // 36.65–37.55 (0.9s): msg2 appears
    if (time < 37.55) return buildChat(t.chat_online, [MSG1, MSG2]);
    // 37.55–41.05 (3.5s): client opens link → sign 4 steps (longer per step)
    if (time < 41.05) {
      const tt = time - 37.55;
      // 0–0.8 step 0 (notification), 0.8–1.7 step 1, 1.7–2.6 step 2, 2.6–3.5 step 3
      const tapStep = tt < 0.8 ? 0 : (tt < 1.7 ? 1 : (tt < 2.6 ? 2 : 3));
      return { state: 'sign', tapStep };
    }
    // 41.05–41.85 (0.8s): back to chat — office sends contract preview bubble
    if (time < 41.85) return buildChat(t.chat_contract_signed, [PREVIEW]);
    // 41.85–42.75 (0.9s): client typing indicator
    if (time < 42.75) return buildChat(t.chat_typing, [PREVIEW], 'client');
    // 42.75–43.55 (0.8s): client confirm bubble appears
    if (time < 43.55) return buildChat(t.chat_contract_signed, [PREVIEW, SIGNED]);
    // 43.55–44.25 (0.7s): office typing
    if (time < 44.25) return buildChat(t.chat_typing, [PREVIEW, SIGNED], 'office');
    // 44.25–46.0 (1.75s): "¡Perfecto!" appears, hold so reader can absorb the close-out
    if (time < 46.0) return buildChat(t.chat_contract_signed, [PREVIEW, SIGNED, PERFECT]);

    // Hide during intake doc-drop + revisar-firmar modal
    if (time < 56.25) return null;

    // ── Act 2 TAXES — moment 2: Intake listo → firma intake → cliente da las gracias ──
    // Progressive reveal mirroring moment 1, ending with client gratitude so the
    // emotional arc closes (firma + interacción + agradecimiento).
    // Total ~11s (was 4s).
    const MSG1B = { from: 'office', text: t.phone.a2_i_office_1, time: '4:15 p.m.' };
    const MSG2B = { from: 'office', text: t.phone.a2_i_office_2, time: '4:15 p.m.' };
    const PREVIEW2 = { from: 'office', text: t.phone.a2_i_preview, time: '4:15 p.m.' };
    const SIGNED2 = { from: 'client', text: t.phone.a2_i_signed, time: '4:16 p.m.' };
    const READY2 = { from: 'office', text: t.phone.a2_i_ready, time: '4:16 p.m.' };
    const THANKS2 = { from: 'client', text: t.phone.a2_i_thanks, time: '4:17 p.m.' };

    // Phone moment 2 starts at global 58.25 (revisar still fading out — nice crossfade)
    // 58.25–58.85 (0.6s): empty chat header
    if (time < 58.85) return buildChat(t.chat_online, []);
    // 58.85–59.85 (1.0s): office typing
    if (time < 59.85) return buildChat(t.chat_typing, [], 'office');
    // 59.85–60.7 (0.85s): msg1 visible
    if (time < 60.7) return buildChat(t.chat_online, [MSG1B]);
    // 60.7–61.5 (0.8s): office typing again
    if (time < 61.5) return buildChat(t.chat_typing, [MSG1B], 'office');
    // 61.5–62.35 (0.85s): msg2 visible
    if (time < 62.35) return buildChat(t.chat_online, [MSG1B, MSG2B]);
    // 62.35–65.65 (3.3s): sign 4 steps × 0.825s each
    if (time < 65.65) {
      const tt = time - 62.35;
      const tapStep = tt < 0.825 ? 0 : (tt < 1.65 ? 1 : (tt < 2.475 ? 2 : 3));
      return { state: 'sign', tapStep };
    }
    // 65.65–66.45 (0.8s): back to chat — office preview bubble
    if (time < 66.45) return buildChat(t.chat_intake_signed, [PREVIEW2]);
    // 66.45–67.3 (0.85s): client typing
    if (time < 67.3) return buildChat(t.chat_typing, [PREVIEW2], 'client');
    // 67.3–68.15 (0.85s): client signs off
    if (time < 68.15) return buildChat(t.chat_intake_signed, [PREVIEW2, SIGNED2]);
    // 68.15–68.75 (0.6s): office typing
    if (time < 68.75) return buildChat(t.chat_typing, [PREVIEW2, SIGNED2], 'office');
    // 68.75–69.55 (0.8s): office "¡Listo!"
    if (time < 69.55) return buildChat(t.chat_intake_signed, [PREVIEW2, SIGNED2, READY2]);
    // 69.55–70.25 (0.7s): client typing (thanks coming)
    if (time < 70.25) return buildChat(t.chat_typing, [PREVIEW2, SIGNED2, READY2], 'client');
    // 70.25–72.15 (1.9s): client thank-you message lingers — “Intake firmado” subtitle (+1s)
    if (time < 72.15) return buildChat(t.chat_intake_signed, [PREVIEW2, SIGNED2, READY2, THANKS2]);
    // 72.15–74.0 (1.85s): hold the closing state before transitioning to Migración
    if (time < 74.0) return buildChat(t.chat_online, [PREVIEW2, SIGNED2, READY2, THANKS2]);

    // Hide during transition to Migración act (74.0 → 77.0 = title card window)
    if (time < 77) return null;

    // ── Act 3 MIGRACIÓN — moment 1: WhatsApp "Me casé!" (77 → 89, 12s) ──
    // Tomás announces his marriage, asks for Residencia for Carla. Amelia
    // congratulates and tells him she has almost everything from the intake.
    // Progressive reveal with typing indicators so the conversation reads.
    const buildChatTomas = (subtitle, msgs, typing) => ({
      state: 'chat',
      contactName: 'TOMÁS SMITH',
      contactSubtitle: subtitle,
      contactAvatar: 'T',
      messages: msgs,
      typing,
    });
    const A3_M1_TOMAS  = { from: 'client', text: t.phone.a3_m_tomas_1, time: '4:25 p.m.' };
    const A3_M2_OFFICE = { from: 'office', text: t.phone.a3_m_office_1, time: '4:26 p.m.' };
    const A3_M3_TOMAS  = { from: 'client', text: t.phone.a3_m_tomas_2, time: '4:26 p.m.' };
    const A3_M4_OFFICE = { from: 'office', text: t.phone.a3_m_office_2, time: '4:27 p.m.' };

    // 77.0-77.6 (0.6s): chat header only
    if (time < 77.6) return buildChatTomas(t.chat_online, []);
    // 77.6-78.6 (1.0s): client typing
    if (time < 78.6) return buildChatTomas(t.chat_typing, [], 'client');
    // 78.6-80.8 (2.2s): M1 visible — read time for long message
    if (time < 80.8) return buildChatTomas(t.chat_online, [A3_M1_TOMAS]);
    // 80.8-81.8 (1.0s): office typing
    if (time < 81.8) return buildChatTomas(t.chat_typing, [A3_M1_TOMAS], 'office');
    // 81.8-83.9 (2.1s): M2 office (felicidades + contrato coming)
    if (time < 83.9) return buildChatTomas(t.chat_online, [A3_M1_TOMAS, A3_M2_OFFICE]);
    // 83.9-84.7 (0.8s): client typing
    if (time < 84.7) return buildChatTomas(t.chat_typing, [A3_M1_TOMAS, A3_M2_OFFICE], 'client');
    // 84.7-86.0 (1.3s): M3 (¿qué info?)
    if (time < 86.0) return buildChatTomas(t.chat_online, [A3_M1_TOMAS, A3_M2_OFFICE, A3_M3_TOMAS]);
    // 86.0-86.8 (0.8s): office typing
    if (time < 86.8) return buildChatTomas(t.chat_typing, [A3_M1_TOMAS, A3_M2_OFFICE, A3_M3_TOMAS], 'office');
    // 86.8-89.0 (2.2s): M4 (tenemos casi todo) + hold for read
    if (time < 89.0) return buildChatTomas(t.chat_online, [A3_M1_TOMAS, A3_M2_OFFICE, A3_M3_TOMAS, A3_M4_OFFICE]);

    // Phone hides — Amelia creates the case on desktop (89 → 101)
    if (time < 101) return null;

    // ── Act 3 MIGRACIÓN — moment 2: Contrato Residencia + firma + thanks
    //    (101 → 112, 11s) progressive like the Taxes contracts in Act 2.
    const buildChatEP = (subtitle, msgs, typing) => ({
      state: 'chat',
      contactName: 'EasyPro Plus',
      contactSubtitle: subtitle,
      contactAvatar: 'EP',
      messages: msgs,
      typing,
    });
    const A3_C_M1      = { from: 'office', text: t.phone.a3_c_office_1, time: '4:34 p.m.' };
    const A3_C_M2      = { from: 'office', text: t.phone.a2_c_office_2, time: '4:34 p.m.' };
    const A3_C_PREVIEW = { from: 'office', text: t.phone.a3_c_preview, time: '4:34 p.m.' };
    const A3_C_SIGNED  = { from: 'client', text: t.phone.a2_c_signed, time: '4:36 p.m.' };
    const A3_C_PERFECT = { from: 'office', text: t.phone.a3_c_perfect, time: '4:36 p.m.' };
    const A3_C_THANKS  = { from: 'client', text: t.phone.a3_c_thanks, time: '4:37 p.m.' };

    // 101.0-101.6 (0.6s): empty chat header
    if (time < 101.6) return buildChatEP(t.chat_online, []);
    // 101.6-102.6 (1.0s): office typing
    if (time < 102.6) return buildChatEP(t.chat_typing, [], 'office');
    // 102.6-103.7 (1.1s): M1 visible (long contract message — needs read time)
    if (time < 103.7) return buildChatEP(t.chat_online, [A3_C_M1]);
    // 103.7-104.4 (0.7s): office typing
    if (time < 104.4) return buildChatEP(t.chat_typing, [A3_C_M1], 'office');
    // 104.4-105.2 (0.8s): M2 (link to sign)
    if (time < 105.2) return buildChatEP(t.chat_online, [A3_C_M1, A3_C_M2]);
    // 105.2-108.4 (3.2s): sign 4 steps × 0.8s each
    if (time < 108.4) {
      const tt = time - 105.2;
      const tapStep = tt < 0.8 ? 0 : (tt < 1.6 ? 1 : (tt < 2.4 ? 2 : 3));
      return { state: 'sign', tapStep };
    }
    // 108.4-109.1 (0.7s): preview bubble
    if (time < 109.1) return buildChatEP(t.chat_contract_signed, [A3_C_PREVIEW]);
    // 109.1-109.8 (0.7s): client typing
    if (time < 109.8) return buildChatEP(t.chat_typing, [A3_C_PREVIEW], 'client');
    // 109.8-110.5 (0.7s): client signed bubble
    if (time < 110.5) return buildChatEP(t.chat_contract_signed, [A3_C_PREVIEW, A3_C_SIGNED]);
    // 110.5-111.0 (0.5s): office typing
    if (time < 111.0) return buildChatEP(t.chat_typing, [A3_C_PREVIEW, A3_C_SIGNED], 'office');
    // 111.0-111.5 (0.5s): perfect bubble
    if (time < 111.5) return buildChatEP(t.chat_contract_signed, [A3_C_PREVIEW, A3_C_SIGNED, A3_C_PERFECT]);
    // 111.5-111.9 (0.4s): client typing (thanks coming)
    if (time < 111.9) return buildChatEP(t.chat_typing, [A3_C_PREVIEW, A3_C_SIGNED, A3_C_PERFECT], 'client');
    // 111.9-113.0 (1.1s): thanks bubble holds longer — lets reader absorb the closing
    if (time < 113.0) return buildChatEP(t.chat_contract_signed, [A3_C_PREVIEW, A3_C_SIGNED, A3_C_PERFECT, A3_C_THANKS]);

    // Hide during Sophia Importar + Cascade + Zoom AND during PrintOutputs scene
    // (113 → 167). Print scene was extended to 5.75s (161.25-167) so impresora 4D
    // breathes; iPhone re-enters only once Print fully ends.
    if (time < 167) return null;

    // ── Act 4 PAQUETE payoff: "Paquete enviado" progressive chat (167 → 180, 13s) ──
    // 4-msg flow: office saludo+info → office copia → client gracias → office "gracias a ti"
    // + 3s breathing hold before the Explain title card.
    const A4_M1 = { from: 'office', text: t.phone.a4_office_1, time: '4:42 p.m.' };
    const A4_M2 = { from: 'office', text: t.phone.a4_office_2, time: '4:42 p.m.' };
    const A4_M3 = { from: 'client', text: t.phone.a4_client_3, time: '4:43 p.m.' };
    const A4_M4 = { from: 'office', text: t.phone.a4_office_4, time: '4:44 p.m.' };
    const buildChatPaquete = (subtitle, msgs, typing) => ({
      state: 'chat',
      contactName: 'EasyPro Plus',
      contactSubtitle: subtitle,
      contactAvatar: 'EP',
      messages: msgs,
      typing,
    });
    // 167.0-167.5 (0.5s): empty chat header
    if (time < 167.5) return buildChatPaquete(t.chat_package_sent, []);
    // 167.5-168.3 (0.8s): office typing
    if (time < 168.3) return buildChatPaquete(t.chat_typing, [], 'office');
    // 168.3-171.1 (2.8s): saludo + info paquete — long msg, needs read time
    if (time < 171.1) return buildChatPaquete(t.chat_package_sent, [A4_M1]);
    // 171.1-171.6 (0.5s): office typing
    if (time < 171.6) return buildChatPaquete(t.chat_typing, [A4_M1], 'office');
    // 171.6-172.8 (1.2s): copia a tu casa
    if (time < 172.8) return buildChatPaquete(t.chat_package_sent, [A4_M1, A4_M2]);
    // 172.8-173.3 (0.5s): client typing
    if (time < 173.3) return buildChatPaquete(t.chat_typing, [A4_M1, A4_M2], 'client');
    // 173.3-174.8 (1.5s): client "¡Mil gracias Amelia!"
    if (time < 174.8) return buildChatPaquete(t.chat_package_sent, [A4_M1, A4_M2, A4_M3]);
    // 174.8-175.3 (0.5s): office typing
    if (time < 175.3) return buildChatPaquete(t.chat_typing, [A4_M1, A4_M2, A4_M3], 'office');
    // 175.3-177.0 (1.7s): office "Gracias a TI por confiar en nosotros"
    if (time < 177.0) return buildChatPaquete(t.chat_package_sent, [A4_M1, A4_M2, A4_M3, A4_M4]);
    // 177.0-180.0 (3.0s): BREATHING HOLD — all 4 msgs visible, no typing, before title card
    if (time < 180.0) return buildChatPaquete(t.chat_package_sent, [A4_M1, A4_M2, A4_M3, A4_M4]);

    // 180-186: Explain title card window — hide phone (clean read of subtitle)
    if (time < 186) return null;

    // ── Act 5 EXPLAIN — Phase A (186-191): iPhone WhatsApp, cliente envía foto.
    //    Phone slides out (exitProgress 0→1) in last 1.5s as hero shot hands off
    //    to the desktop "RFE letter LARGE" scene.
    const A5_RFE_MSG = { from: 'client', text: t.phone.a5_rfe, time: '5:42 p.m.', photo: true };
    if (time < 189.5) {
      return {
        state: 'chat',
        contactName: 'TOMÁS SMITH',
        contactSubtitle: t.chat_client,
        contactAvatar: 'T',
        messages: [A5_RFE_MSG],
      };
    }
    if (time < 191) {
      // Exit: fade + slide right over 1.5s
      const exitProgress = Math.max(0, Math.min(1, (time - 189.5) / 1.5));
      return {
        state: 'chat',
        contactName: 'TOMÁS SMITH',
        contactSubtitle: t.chat_client,
        contactAvatar: 'T',
        messages: [A5_RFE_MSG],
        exitProgress,
      };
    }

    // 191-206: Sophia analyzes letter on desktop → cierre dark violet card.
    //   Phone stays hidden so the cierre cinemático respira sin overlay.
    return null;
  }

  const phoneProps = getPhoneAt(time);
  const inTitleCard = RENDERED_TITLE_CARDS.some(tc => time >= tc.start && time <= tc.end);
  const showPhone = phoneProps !== null && !inClosing;
  const dimPhone = inTitleCard;

  const ctxValue = React.useMemo(
    () => ({ time, duration: DURATION, playing, lang }),
    [time, playing, lang]
  );
  // Mirror lang to a window global so non-React helpers can read it (used by
  // some inline banner-text helpers that can't call useTimeline).
  React.useEffect(() => { window.__solCurrentLang = lang; }, [lang]);
  const TC = window.TimelineContext;

  return (
    <div className="sol-cinema">
      <div className="sol-stage-wrap">
        <div className="sol-canvas-shell" ref={shellRef}>
          <div
            ref={canvasRef}
            className="sol-canvas"
            style={{
              width: CANVAS_W, height: CANVAS_H,
              transform: `scale(${scale})`,
            }}
          >
            <TC.Provider value={ctxValue}>
              {/* Acts */}
              {ACTS.map(a => (
                <Sprite key={a.key} start={a.start} end={a.end}>
                  {a.Scene()}
                </Sprite>
              ))}

              {/* Title cards (overlay between acts) */}
              {RENDERED_TITLE_CARDS.map((tc, i) => (
                <Sprite key={'tc' + i} start={tc.start} end={tc.end}>
                  <TitleCard
                    actNum={tc.actNum}
                    name={t[tc.nameKey]}
                    sub={t[tc.subKey]}
                    color={tc.color}
                  />
                </Sprite>
              ))}

              {/* Closing scene */}
              <Sprite start={CLOSING_START} end={CLOSING_END}>
                <Act5Closing />
              </Sprite>

              {/* Persistent iPhone on the right (driven by phone-state coordinator) */}
              {showPhone && <IPhone {...phoneProps} lang={lang} dim={dimPhone} />}

              {/* Narrative subtitles (bottom-center, follows lang toggle) */}
              <SubtitleTrack lang={lang} />
            </TC.Provider>
          </div>

          {/* No replay overlay — when video ends, the closing scene's final frame
              (logo + tagline) stays on screen. Users can replay with the play button below. */}
        </div>
      </div>

      {/* Chips bar — 4 chips (migración + paquete are unified).
          In focus mode (?focus=<key>) only the focused chip is rendered. */}
      <div className="sol-chips">
        {RENDERED_CHIPS.map(c => {
          const span = c.end - c.start;
          const localT = Math.max(0, Math.min(span, time - c.start));
          const pct = (localT / span) * 100;
          const isActive = time >= c.start && time <= c.end && !inClosing;
          return (
            <button
              key={c.key}
              className={"sol-chip" + (isActive ? " active" : "")}
              style={{ '--span': span, '--c': c.color }}
              onClick={() => seekToChip(c)}
            >
              <span className="fill" style={{ width: pct + '%' }} />
              <span className="num">{String(c.num).padStart(2, '0')}</span>
              <span className="nm">{t[c.nameKey]}</span>
            </button>
          );
        })}
      </div>

      {/* Controls */}
      <div className="sol-controls">
        <button
          className="btn"
          onClick={() => { setTime(FOCUS_START); setEnded(false); }}
          title="Volver al inicio"
        >
          <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
            <path d="M3 2v10M12 2L5 7l7 5V2z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round"/>
          </svg>
        </button>

        <button
          className="btn primary"
          onClick={() => { if (ended) handleReplay(); else setPlaying(p => !p); }}
          title="Play/pausa"
        >
          {playing ? (
            <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
              <rect x="3" y="2" width="3" height="10" fill="currentColor"/>
              <rect x="8" y="2" width="3" height="10" fill="currentColor"/>
            </svg>
          ) : (
            <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
              <path d="M3 2l9 5-9 5V2z" fill="currentColor"/>
            </svg>
          )}
        </button>

        <div className="time">
          {fmtTime(Math.max(0, time - FOCUS_START))} <span style={{ opacity: 0.4 }}>/ {fmtTime(DURATION - FOCUS_START)}</span>
        </div>

        <div className="spacer" />

        <div className="lang">
          <button
            className={lang === 'es' ? 'on' : ''}
            onClick={() => setLang('es')}
          >ES</button>
          <button
            className={lang === 'en' ? 'on' : ''}
            onClick={() => setLang('en')}
          >EN</button>
        </div>
      </div>
    </div>
  );
}

function fmtTime(s) {
  const total = Math.max(0, s);
  const m = Math.floor(total / 60);
  const sec = Math.floor(total % 60);
  return `${m}:${String(sec).padStart(2, '0')}`;
}

// Error boundary — surface any render crash on-screen so we can see what broke
// instead of getting a silently-empty cinema area.
class SolErrorBoundary extends React.Component {
  constructor(props) { super(props); this.state = { err: null }; }
  static getDerivedStateFromError(err) { return { err }; }
  componentDidCatch(err, info) {
    try {
      console.error('[sol] render crash:', err, info?.componentStack);
    } catch (e) {}
  }
  render() {
    if (this.state.err) {
      return (
        <div style={{
          padding: '24px',
          margin: '20px',
          background: '#2a1215',
          color: '#ffb4b4',
          border: '1px solid #5c2b2e',
          borderRadius: 10,
          fontFamily: 'ui-monospace, monospace',
          fontSize: 12,
          lineHeight: 1.6,
          whiteSpace: 'pre-wrap',
          maxHeight: '60vh',
          overflow: 'auto',
        }}>
          <div style={{ fontWeight: 700, marginBottom: 10, fontSize: 14 }}>
            [sol] cinema crashed — see message + stack below
          </div>
          <div style={{ marginBottom: 8 }}>{String(this.state.err?.message || this.state.err)}</div>
          <div style={{ opacity: 0.8 }}>{String(this.state.err?.stack || '').slice(0, 1500)}</div>
        </div>
      );
    }
    return this.props.children;
  }
}

// Mount
const rootEl = document.getElementById('sol-root');
if (rootEl) {
  try {
    ReactDOM.createRoot(rootEl).render(
      <SolErrorBoundary>
        <SolutionPlayer />
      </SolErrorBoundary>
    );
  } catch (e) {
    rootEl.innerHTML = '<div style="padding:24px;color:#ffb4b4;background:#2a1215;border-radius:10px;font:12px ui-monospace,monospace;white-space:pre-wrap">[sol] mount failed: ' + (e.message || e) + '\n\n' + (e.stack || '').slice(0, 1500) + '</div>';
  }
}

// Render head copy in both languages — bind to lang toggle via custom event
(function bindHead() {
  const initial = window.SOL_LANG_INITIAL || 'es';
  function applyHead(lang) {
    const t = (window.SOL_I18N && window.SOL_I18N[lang]) || window.SOL_I18N.es;
    const eb = document.querySelector('[data-sol-head="eyebrow"]');
    const h2 = document.querySelector('[data-sol-head="h2"]');
    const ld = document.querySelector('[data-sol-head="lede"]');
    if (eb) eb.textContent = t.eyebrow;
    if (h2) h2.innerHTML = `${t.h2_a} ${t.h2_b} <span class="grad">${t.h2_c}</span>`;
    if (ld) ld.textContent = t.lede;
  }
  applyHead(initial);
  // Re-apply when [data-sol-lang] on <html> changes
  const obs = new MutationObserver(() => {
    applyHead(document.documentElement.getAttribute('data-sol-lang') || 'es');
  });
  obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-sol-lang'] });
})();
