/* ════════════════════════════════════════════════════════════
   DASHBOARD KPIs T1 · Vacaciones
   Fase 1 visual + Fase 2 datos reales (HubSpot · GA4 · Gemini)
   Reemplazo operativo durante el periodo de vacaciones
   ════════════════════════════════════════════════════════════ */

const { useState, useEffect } = React;
const { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } = Recharts;

/* ── DESIGN TOKENS · Nexus V2.0 (Manrope · Oxford · Brand Red) ── */
const C = {
  // Brand
  brandRed:    '#DB3B2B', // Botón primario dashboard
  brandRedDk:  '#CC0000', // Hover destructivo
  oxford:      '#4C4C4C', // Texto principal
  // Gray scale
  bg:          '#F8F8F8', // gray-50 → background página
  bgAlt:       '#F3F3F3', // gray-100 → hover suave, table header
  card:        '#FFFFFF', // white
  line:        '#E7E7E7', // gray-200 → borders, dividers
  lineDk:      '#DBDBDB', // gray-300
  sub:         '#6B7280', // gray-700 → texto secundario
  subLt:       '#9CA3AF', // gray-600 → placeholders, eyebrow
  ink:         '#1F2937', // gray-900 → headings
  // Semantic
  verde:       '#16A34A', // green-700
  ambar:       '#CC5200', // orange-700
  rojo:        '#CC0000', // red-700
  azul:        '#2180FF', // blue-500
  // Backgrounds suaves (alertas/banners)
  bgVerde:     '#F0FDF4',
  bgAmbar:     '#FFF0E5',
  bgRojo:      '#FEF4F4',
  bgAzul:      '#F0F8FF',
  bgAmarillo:  '#FFF4BF',
  // Product accents
  tienda:      '#16A34A',
  pagos:       '#2180FF',
  envios:      '#CC5200',
};

const FONT_STACK = "'Manrope', system-ui, -apple-system, BlinkMacSystemFont, sans-serif";

/* ── HELPERS ───────────────────────────────────────────────── */
const fmt = n => n==null||isNaN(n) ? '—' : new Intl.NumberFormat('es-MX').format(Math.round(n));
const mxn = n => n==null||isNaN(n) ? '—' : '$'+new Intl.NumberFormat('es-MX').format(Math.round(n));
// mxnDec: preserva centavos (2 decimales) — útil para inversión donde el reporte de Google Ads viene con $X,XXX.XX
const mxnDec = n => n==null||isNaN(n) ? '—' : '$'+new Intl.NumberFormat('es-MX', { minimumFractionDigits:2, maximumFractionDigits:2 }).format(Number(n));
const pct = n => n==null||isNaN(n) ? '—' : n.toFixed(1)+'%';
const colorEst = {verde:C.verde, ambar:C.ambar, rojo:C.rojo, na:C.subLt};
const bgEst    = {verde:C.bgVerde, ambar:C.bgAmbar, rojo:C.bgRojo, na:C.bgAlt};
const txtEst = {verde:'Sano', ambar:'Atención', rojo:'Crítico', na:'Sin dato'};

/* ── SLICING LOCAL ───────────────────────────────────────────
   Filtra el snapshot ya cargado por un rango visible y re-computa
   los totales. Permite que los presets/date-pickers respondan
   instantáneo sin pegarle al backend. */
const MARGEN_ENVIOS = 0.281;
const PAGOS_COMISION_PCT = 0.045;
const PAGOS_COMISION_FIJA = 1;
const PAGOS_MARGEN = 0.30;

function _suma(arr, key) { return arr.reduce((s,x) => s + (Number(x[key])||0), 0); }

function recomputarTotalEnvios(semanas){
  const t = {
    reg: _suma(semanas,'reg'), calif: _suma(semanas,'calif'), cli: _suma(semanas,'cli'),
    inv: _suma(semanas,'inv'), saldoT1: _suma(semanas,'saldoT1'), saldoPrec: _suma(semanas,'saldoPrec')
  };
  t.cpr = t.reg ? t.inv / t.reg : 0;
  t.cpa = t.cli ? t.inv / t.cli : 0;
  t.pctCalif = t.reg ? (t.calif / t.reg) * 100 : 0;
  t.pctCli   = t.reg ? (t.cli   / t.reg) * 100 : 0;
  t.precProm = t.cli ? t.saldoPrec / t.cli : 0;
  t.roiSemanal = t.inv ? (t.saldoT1 * MARGEN_ENVIOS) / t.inv : 0;
  return t;
}
function recomputarTotalTienda(semanas){
  const t = {
    reg:_suma(semanas,'reg'), calif:_suma(semanas,'calif'), cli:_suma(semanas,'cli'),
    checkIni:_suma(semanas,'checkIni'), visitasPlanes:_suma(semanas,'visitasPlanes'),
    inv:_suma(semanas,'inv'), gmv:_suma(semanas,'gmv'),
    google:_suma(semanas,'google'), meta:_suma(semanas,'meta'),
    tiktok:_suma(semanas,'tiktok'), webinar:_suma(semanas,'webinar'), paginas:_suma(semanas,'paginas')
  };
  t.cpr = t.reg ? t.inv/t.reg : 0;
  t.cpa = t.cli ? t.inv/t.cli : 0;
  t.pctCalif = t.reg ? (t.calif/t.reg)*100 : 0;
  t.pctCheckIni = t.reg ? (t.checkIni/t.reg)*100 : 0;
  t.pctVisitasPlanes = t.reg ? (t.visitasPlanes/t.reg)*100 : 0;
  t.pctCli   = t.reg ? (t.cli  /t.reg)*100 : 0;
  t.roi = t.inv ? t.gmv/t.inv : 0;
  return t;
}
function recomputarTotalPagos(semanas){
  const t = {
    reg:_suma(semanas,'reg'), calif:_suma(semanas,'calif'), cli:_suma(semanas,'cli'),
    inv:_suma(semanas,'inv'), montoTrans:_suma(semanas,'montoTrans'),
    docSinEnviar:_suma(semanas,'docSinEnviar'),
    docEnviada:_suma(semanas,'docEnviada'),
    docRevision:_suma(semanas,'docRevision'),
    docAprobada:_suma(semanas,'docAprobada'),
    docRechazada:_suma(semanas,'docRechazada'),
    primTx:_suma(semanas,'primTx'), primLiq:_suma(semanas,'primLiq')
  };
  t.cpr = t.reg ? t.inv/t.reg : 0;
  t.cpa = t.cli ? t.inv/t.cli : 0;
  t.pctCalif = t.reg ? (t.calif/t.reg)*100 : 0;
  t.pctCli   = t.reg ? (t.cli  /t.reg)*100 : 0;
  const comision = t.montoTrans * PAGOS_COMISION_PCT + t.cli * PAGOS_COMISION_FIJA;
  t.roi = t.inv ? (comision * PAGOS_MARGEN) / t.inv : 0;
  return t;
}

const RECOMPUTAR = { envios: recomputarTotalEnvios, tienda: recomputarTotalTienda, pagos: recomputarTotalPagos };

function sliceKpisProducto(producto, kpis, vistaDesde, vistaHasta){
  if (!kpis?.semanas) return kpis;
  // Una semana queda dentro de la vista si solapa con el rango.
  const semanas = kpis.semanas.filter(s => s.hasta >= vistaDesde && s.desde <= vistaHasta);
  // Cross-product de Envíos: filtra paralelo si existe
  const cross = kpis.cross ? kpis.cross.filter((_, i) => {
    const s = kpis.semanas[i]; return s && s.hasta >= vistaDesde && s.desde <= vistaHasta;
  }) : kpis.cross;
  // Clientes Pagos: filtrar por fecha de creación dentro del rango visible
  const clientes = kpis.clientes
    ? kpis.clientes.filter(c => c.fechaCreacion && c.fechaCreacion >= vistaDesde && c.fechaCreacion <= vistaHasta)
    : kpis.clientes;
  // Usuarios Tienda: idem
  const usuarios = kpis.usuarios
    ? kpis.usuarios.filter(u => u.fechaCreacion && u.fechaCreacion >= vistaDesde && u.fechaCreacion <= vistaHasta)
    : kpis.usuarios;
  const fn = RECOMPUTAR[producto];
  const total = fn ? fn(semanas) : kpis.total;
  return { ...kpis, semanas, cross, clientes, usuarios, total };
}

// ¿El rango visible cabe dentro del rango cargado?
function vistaDentroDeCargado(vistaDesde, vistaHasta, cargadoDesde, cargadoHasta){
  return vistaDesde >= cargadoDesde && vistaHasta <= cargadoHasta;
}

// Etiqueta legible del rango: "15-21 may" / "01 abr-17 may" / "últimos 7 días".
const MESES_ES = ['ene','feb','mar','abr','may','jun','jul','ago','sep','oct','nov','dic'];
function labelRango(desde, hasta){
  if (!desde || !hasta) return '';
  const [y1,m1,d1] = desde.split('-').map(Number);
  const [y2,m2,d2] = hasta.split('-').map(Number);
  const dias = Math.round((Date.UTC(y2,m2-1,d2) - Date.UTC(y1,m1-1,d1)) / 86400000) + 1;
  const fmt = (d,m) => `${String(d).padStart(2,'0')} ${MESES_ES[m-1]}`;
  const rangoStr = m1 === m2
    ? `${String(d1).padStart(2,'0')}-${String(d2).padStart(2,'0')} ${MESES_ES[m1-1]}`
    : `${fmt(d1,m1)}-${fmt(d2,m2)}`;
  return `${rangoStr} · ${dias} día${dias>1?'s':''}`;
}

// Una semana está "en curso" si su `hasta` (Domingo) es >= hoy CDMX.
function isEnCurso(semana, hoyStr) { return semana && semana.hasta >= hoyStr; }
// Última semana COMPLETA del array; si todas están en curso, devuelve la última.
function ultimaSemanaCompleta(semanas, hoyStr){
  for (let i = semanas.length - 1; i >= 0; i--) {
    if (!isEnCurso(semanas[i], hoyStr)) return semanas[i];
  }
  return semanas[semanas.length-1] || {};
}

// Análisis automático de la tendencia. Devuelve observaciones derivadas de los datos.
function analizarTendencia(semanas, hoyStr, { metricaReg = 'reg', metricaCli = 'cli' } = {}){
  const completas = semanas.filter(s => !isEnCurso(s, hoyStr));
  if (completas.length < 2) return { dir: 'sin-datos', narrativa: ['Datos insuficientes para análisis de tendencia.'] };

  const regs = completas.map(s => s[metricaReg] || 0);
  const promReg = regs.reduce((a,b)=>a+b,0) / regs.length;

  // Pico
  let piMax = 0; for (let i = 1; i < regs.length; i++) if (regs[i] > regs[piMax]) piMax = i;
  const pico = { sem: completas[piMax].sem, valor: regs[piMax], factor: regs[piMax] / Math.max(promReg, 1) };

  // Dirección: comparar promedio de las primeras N/2 vs últimas N/2 (excluyendo en curso)
  const mid = Math.floor(regs.length / 2);
  const promIni = regs.slice(0, mid).reduce((a,b)=>a+b,0) / Math.max(mid, 1);
  const promFin = regs.slice(mid).reduce((a,b)=>a+b,0) / (regs.length - mid);
  const deltaPct = promIni ? ((promFin - promIni) / promIni) * 100 : 0;
  let dir = 'estable';
  if (deltaPct > 15) dir = 'subiendo';
  else if (deltaPct < -15) dir = 'bajando';

  // Última completa vs promedio
  const ult = completas[completas.length - 1];
  const ultReg = ult[metricaReg] || 0;
  const deltaUlt = promReg ? ((ultReg - promReg) / promReg) * 100 : 0;

  // Caída/pico fuerte semana a semana
  let mayorCambio = null;
  for (let i = 1; i < regs.length; i++) {
    if (regs[i-1] === 0) continue;
    const d = ((regs[i] - regs[i-1]) / regs[i-1]) * 100;
    if (!mayorCambio || Math.abs(d) > Math.abs(mayorCambio.pct)) {
      mayorCambio = { de: completas[i-1].sem, a: completas[i].sem, pct: d, valDe: regs[i-1], valA: regs[i] };
    }
  }

  // Conversión promedio
  const sumReg = regs.reduce((a,b)=>a+b,0);
  const sumCli = completas.map(s => s[metricaCli]||0).reduce((a,b)=>a+b,0);
  const conv = sumReg ? (sumCli / sumReg) * 100 : 0;

  const narrativa = [];
  // 1) Tendencia general
  if (dir === 'subiendo') narrativa.push(`Tendencia al alza: el promedio de la segunda mitad del periodo (${Math.round(promFin)}/sem) está ${Math.abs(deltaPct).toFixed(0)}% arriba de la primera (${Math.round(promIni)}/sem).`);
  else if (dir === 'bajando') narrativa.push(`Tendencia a la baja: el promedio de la segunda mitad del periodo (${Math.round(promFin)}/sem) está ${Math.abs(deltaPct).toFixed(0)}% abajo de la primera (${Math.round(promIni)}/sem).`);
  else narrativa.push(`Comportamiento estable: el promedio de las dos mitades del periodo varía menos de 15% (${Math.round(promIni)} → ${Math.round(promFin)}/sem).`);

  // 2) Pico
  if (pico.factor > 1.3) narrativa.push(`Pico en ${pico.sem}: ${pico.valor} registros, ${(pico.factor*100-100).toFixed(0)}% sobre el promedio (${Math.round(promReg)}/sem).`);

  // 3) Cambio semana a semana mayor
  if (mayorCambio && Math.abs(mayorCambio.pct) >= 30) {
    const verbo = mayorCambio.pct > 0 ? 'subió' : 'cayó';
    narrativa.push(`Mayor cambio semana a semana: ${verbo} ${Math.abs(mayorCambio.pct).toFixed(0)}% de ${mayorCambio.de} (${mayorCambio.valDe}) a ${mayorCambio.a} (${mayorCambio.valA}).`);
  }

  // 4) Última completa
  if (Math.abs(deltaUlt) >= 15) {
    const verbo = deltaUlt > 0 ? 'arriba' : 'abajo';
    narrativa.push(`Última semana completa (${ult.sem}): ${ultReg} registros, ${Math.abs(deltaUlt).toFixed(0)}% ${verbo} del promedio.`);
  }

  // 5) Conversión
  if (sumCli > 0) {
    narrativa.push(`Conversión global del periodo: ${conv.toFixed(1)}% (${sumCli} clientes en ${sumReg} registros).`);
  } else if (sumReg > 0) {
    narrativa.push(`Sin clientes en el periodo: ${sumReg} registros, 0 conversiones. Revisar funnel post-registro.`);
  }

  return { dir, narrativa, pico, promReg, ult, deltaUlt };
}

function AnalisisTendencia({semanas, hoyStr, color, metricaReg='reg', metricaCli='cli'}){
  const a = analizarTendencia(semanas, hoyStr, { metricaReg, metricaCli });
  const dirColor = a.dir === 'subiendo' ? C.verde : a.dir === 'bajando' ? C.rojo : C.subLt;
  const dirLabel = a.dir === 'subiendo' ? 'AL ALZA' : a.dir === 'bajando' ? 'A LA BAJA' : a.dir === 'estable' ? 'ESTABLE' : 'SIN DATOS';
  return (
    <div style={{background:C.card,border:`1px solid ${C.line}`,borderLeft:`4px solid ${color||dirColor}`,borderRadius:8,padding:'14px 20px',marginTop:10}}>
      <div style={{display:'flex',alignItems:'center',gap:10,marginBottom:8}}>
        <div style={{fontSize:10.5,letterSpacing:'.08em',textTransform:'uppercase',color:C.subLt,fontWeight:700}}>Lectura de la tendencia</div>
        <span style={{fontSize:10,fontWeight:700,color:'#fff',background:dirColor,padding:'2px 8px',borderRadius:99,letterSpacing:'.05em'}}>{dirLabel}</span>
      </div>
      <ul style={{margin:0,paddingLeft:18}}>
        {a.narrativa.map((t,i)=><li key={i} style={{fontSize:13,color:C.sub,lineHeight:1.55,marginBottom:5,fontWeight:500}}>{t}</li>)}
      </ul>
    </div>
  );
}

async function api(path, opts = {}) {
  let r;
  try {
    r = await fetch(`/api/vacaciones${path}`, {
      credentials: 'include',
      headers: { 'Content-Type': 'application/json', ...(opts.headers||{}) },
      ...opts
    });
  } catch (networkErr) {
    // fetch falló sin respuesta (server caído, sin internet, DNS, etc.)
    const e = new Error('No se pudo conectar con el servidor. Verifica que esté arriba.');
    e.status = 0;
    e.networkError = true;
    throw e;
  }
  if (!r.ok) {
    let msg = `Error ${r.status}`;
    try { const j = await r.json(); msg = j.error || j.message || msg; } catch {}
    const e = new Error(msg); e.status = r.status;
    // 401 = sesión expirada. Dispara un evento global para que el App component
    // pueda redirigir a login automáticamente sin importar qué función llamó la API.
    if (r.status === 401) {
      try { window.dispatchEvent(new CustomEvent('t1:session-expired')); } catch {}
    }
    throw e;
  }
  return r.json();
}

async function loginWithGoogle() {
  // 1) Popup de Google (Firebase) → ID token. 2) Lo canjeamos en el backend,
  // que valida el token y que el email esté pre-aprobado, y setea la cookie de sesión.
  let idToken;
  try {
    idToken = await window.signInWithGoogle();
  } catch (e) {
    if (e?.code === 'auth/popup-closed-by-user') throw new Error('Cerraste la ventana de Google');
    throw new Error('No se pudo iniciar sesión con Google');
  }
  const r = await fetch('/api/auth/google', {
    method: 'POST',
    credentials: 'include',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ idToken })
  });
  if (!r.ok) {
    let msg = 'No autorizado';
    try { const j = await r.json(); msg = j.error || msg; if (j.detail) msg += ` — ${j.detail}`; } catch {}
    await window.firebaseSignOut?.();
    throw new Error(msg);
  }
  return r.json();
}

async function logout() {
  await window.firebaseSignOut?.();
  await fetch('/api/auth/logout', { method:'POST', credentials:'include' });
}

/* ── PERSISTENCIA LOCAL (stale-while-revalidate) ──────────────
   Guarda el snap en localStorage para que al refresh del browser
   el dashboard hidrate instantáneamente sin spinner. Si pasa más
   de 5 min, hace fetch en background. */
const STORAGE_KEY = 't1-vacaciones-snap-v1';
// Bump cuando cambia la lógica de cohortes / patches de histórico para invalidar el cache local
// del usuario sin que tenga que limpiar localStorage a mano.
const CACHE_VERSION = '2026-05-29-slicing-fix';
function loadSnapCache() {
  try {
    const c = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
    if (!c?.snap) return null;
    if (c.version !== CACHE_VERSION) return null;
    return c;
  } catch { return null; }
}
function saveSnapCache(snap) {
  try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ snap, savedAt: Date.now(), version: CACHE_VERSION })); } catch {}
}
function clearSnapCache() {
  try { localStorage.removeItem(STORAGE_KEY); } catch {}
}
function tiempoTranscurrido(ts) {
  if (!ts) return '';
  const min = Math.floor((Date.now() - ts) / 60000);
  if (min < 1) return 'recién';
  if (min < 60) return `hace ${min} min`;
  const hr = Math.floor(min / 60);
  if (hr < 24) return `hace ${hr}h`;
  return `hace ${Math.floor(hr/24)}d`;
}

/* ── CURADURÍA (textos analíticos · sección 12.4 PRD) ──────── */
const CURATED = {
  envios: {
    nombre:'T1 Envíos', color:C.envios, periodo:'últimas 6 semanas',
    defCliente:'Saldo T1Envíos > 0', defCalif:'Flag "Realizó cotización" = SI',
    margen:0.281, breakeven:'1.0×',
    salud:{ estado:'verde', titulo:'Rentable · ROI 8 sem por encima del breakeven',
      detalle:'ROI 8 semanas (rolling) = 2.58 → producto rentable, muy por encima del breakeven 1.0×. El ROI semanal de las últimas 2-3 cohortes baja a ~0.77 por maduración (cohorte aún no completó recargas adicionales) — NO es problema de adquisición, es lectura desactualizada esperada.' },
    funciona:[
      'Google Search es el motor estructural: la mayor parte de los clientes con ratio reg→cliente estable.',
      'La calidad del lead se mantiene sana — el ratio calificado→cliente es récord histórico.',
      'Unidad económica cerca del breakeven: brecha de solo 2×, alcanzable con palancas operativas.',
      'Las campañas de Envíos también generan valor cross-ecosistema: una parte de los registros termina en T1 Tienda.'
    ],
    atencion:[
      'Meta no escala como canal de adquisición — múltiples iteraciones de audiencia/creativo sin resultado.',
      'El % de calificación se ve bajo desde el batch de propiedades de abril (medición, no calidad).',
      'Dependencia alta de un solo canal: Google Search concentra el grueso de clientes.',
      'Caída del ratio reg→cliente la semana 27 abr-03 may sin causa identificada.'
    ],
    breakevenTabla:[
      { palanca:'Saldo promedio nuevos clientes', actual:'$1,907', meta:'$3,851', factor:'2.0×' },
      { palanca:'Clientes nuevos por semana',     actual:'34',     meta:'67',     factor:'2.0×' }
    ],
    faq:[
      { q:'¿Cómo cerró Envíos el periodo?',
        a:'Google Search sostiene el grueso del volumen. La última semana fue récord. CPR estable en ~$270, CPA en ~$876, ratio reg→cliente ~31%.' },
      { q:'¿Por qué Meta no escala?',
        a:'Causa estructural, no táctica. T1 Envíos se descubre por búsqueda activa de solución a un dolor operativo, no por exposición. Múltiples iteraciones no han movido la aguja.' },
      { q:'¿Por qué el % de calificación se ve bajo en mayo?',
        a:'No es calidad de lead. El batch de propiedades de Tienda publicado el 20-26 abr probablemente genera rate limit en HubSpot que afecta el flag_cotizacion. Lo confirma que el ratio calificado→cliente subió a récord histórico. Solución: incluir Envíos en el batch.' },
      { q:'¿Es viable Envíos al ROI actual?',
        a:'Sí. ROI semanal ~0.5, brecha al breakeven de 2× — alcanzable con cashback de primera recarga y escalado de Google Search.' },
      { q:'¿Por qué el ROI de las semanas tempranas se ve tan alto?',
        a:'Es aging de cohorte, no performance. El saldo_t1envios es acumulado actual: las cohortes de abril llevan semanas recargando. La lectura comparable es la última semana. El saldo de 1ª recarga sí es comparable.' },
      { q:'¿Qué pasó la semana 27 abr-03 may?',
        a:'Dos efectos: % calificación cayó por el batch (causa identificada); ratio reg→cliente cayó a 22% sin causa identificada — pendiente investigar.' }
    ],
    notas:[
      'Cifras con atribución cross-product (por UTM de campaña de Envíos).',
      'El "Saldo T1Envíos" es acumulado actual — la lectura de ROI comparable es la última semana.',
      'Batch de propiedades pendiente de incluir Envíos — afecta la medición del flag de cotización.',
      'Caída del ratio reg→cliente la semana 27 abr-03 may sin causa identificada.'
    ]
  },
  tienda: {
    nombre:'T1 Tienda', color:C.tienda, periodo:'últimas 6 semanas',
    defCliente:'tienda_plan_estatus = activo', defCalif:'tienda_usuario_calificado = SI',
    salud:{ estado:'ambar', titulo:'Baseline sano, conversión a plan en primera medición',
      detalle:'El always-on (Google + Meta) corre en banda predecible. La conversión a plan pagado entra en su ventana de primera medición real cuando venzan los trials de la cohorte de abril.' },
    funciona:[
      'Baseline predecible: always-on (Google+Meta) corre en banda estrecha de variación.',
      'Atribución limpia: UTMs bien mapeados, sin leaks entre productos ni canales.',
      'El algoritmo de Google aprende solo: conversion rate sube sin intervención manual.',
      'Picos puntuales identificados (TikTok Spark, webinar) — no son riesgo de degradación.'
    ],
    atencion:[
      'Conversión global a cliente muy baja en primera medición — entra ventana post-trial.',
      'Dos perfiles mezclados: curiosos sin negocio real (store_name default) e interesados con fricción.',
      'TikTok Spark: alto volumen de registros sin calificación — awareness, no demanda.',
      'Meta: buena tasa de checkout iniciado pero pagos confirmados muy bajos.'
    ],
    faq:[
      { q:'¿Cómo cerró Tienda el periodo?',
        a:'El always-on corre en banda predecible. Picos puntuales explicados (TikTok Spark, webinar, Smart Bidding). La conversión a plan pagado llega a su primera medición real con vencimiento de trials de abril.' },
      { q:'¿El Spark de TikTok ayudó a Google Search?',
        a:'No hubo halo. Cero búsquedas de marca relacionadas al creador. El Spark genera awareness del influencer, no demanda de producto.' },
      { q:'¿Por qué la conversión a cliente es tan baja?',
        a:'Producto recién entra en su primera ventana de medición: cohortes de abril vencen trial en la segunda quincena de mayo. Si no escala a 5-8% en junio, el problema es de modelo freemium o pricing.' }
    ],
    notas:[
      'El reporte separa baseline always-on (Google+Meta) de iniciativas (TikTok/Webinar/T1 Páginas).',
      'La conversión a plan pagado entra en su primera medición real con vencimiento de trials de abril.',
      'Trial de 30 días: los usuarios no aparecen como clientes hasta el mes siguiente.'
    ]
  },
  pagos: {
    nombre:'T1 Pagos', color:C.pagos, periodo:'últimas 6 semanas',
    defCliente:'Primera transacción / liquidación', defCalif:'pagos_links_creados ≥ 1',
    salud:{ estado:'ambar', titulo:'Funnel detallado pendiente de conexión',
      detalle:'El detalle de funnel (documentación, links, transacciones) se completa al consolidar las propiedades de Pagos en HubSpot.' },
    funciona:[
      'Cohorte paid identificado y limpio en la ventana actual.',
      'Vertical Belleza/Cabello mostró señal positiva en los tests de mayo (CPR ≈$16.75).'
    ],
    atencion:[
      'Margen estructuralmente bajo (1.12%) — brecha al breakeven amplia por diseño del producto.',
      'Backlog de documentos de aprobación sin procesar desde el 22 abr — bloquea clientes.'
    ],
    faq:[
      { q:'¿Cómo cerró Pagos el periodo?',
        a:'Cohorte paid identificado. El detalle de funnel se completa al consolidar propiedades en HubSpot.' },
      { q:'¿Por qué la brecha al breakeven es tan grande?',
        a:'El margen de Pagos es muy bajo (1.12% promedio). Aun con buen volumen de tx, el ROI requiere mucho más monto transado.' },
      { q:'¿Qué vertical mostró señal positiva?',
        a:'Belleza/Cabello — el mejor CPR de los tests de mayo. Volumen aún insuficiente para concluir estadísticamente.' }
    ],
    notas:[
      'Funnel detallado pendiente — se completa al consolidar propiedades de Pagos en HubSpot.',
      'Backlog de documentos de aprobación bloquea clientes adicionales.',
      'Margen bajo (1.12%) hace la brecha al breakeven estructuralmente amplia.'
    ]
  }
};

/* ── COMPONENTES UI · Nexus tokens ─────────────────────────── */
function KPI({label, valor, sub, estado, accent}){
  const accentColor = estado ? colorEst[estado] : (accent || null);
  // Tamaño del valor: si es muy largo (ej. $163,201.76), reduce para que quepa sin truncar.
  const valorStr = String(valor || '');
  const fontSize = valorStr.length > 11 ? 22 : valorStr.length > 9 ? 25 : 28;
  return (
    <div style={{background:C.card, border:`1px solid ${C.line}`, borderRadius:8, padding:'16px 20px', position:'relative', overflow:'hidden', transition:'border-color .15s', minWidth:0}}>
      {accentColor && <div style={{position:'absolute',top:0,left:0,width:3,height:'100%',background:accentColor}}/>}
      <div style={{fontSize:11,letterSpacing:'.08em',textTransform:'uppercase',color:C.subLt,fontWeight:600,whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}}>{label}</div>
      <div title={valorStr} style={{fontSize,fontWeight:700,color:C.ink,lineHeight:1.15,marginTop:8,letterSpacing:'-0.01em',whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}}>{valor}</div>
      {sub && <div style={{fontSize:12,color:C.sub,marginTop:4,fontWeight:500,whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}}>{sub}</div>}
    </div>
  );
}
function FallbackBanner({persistedAt}){
  if (!persistedAt) return null;
  const fecha = new Date(persistedAt).toLocaleString('es-MX', { dateStyle:'short', timeStyle:'short' });
  return (
    <div style={{background:C.bgAmbar,border:`1px solid ${C.ambar}`,borderLeft:`4px solid ${C.ambar}`,borderRadius:8,padding:'12px 18px',marginBottom:14,display:'flex',gap:14,alignItems:'flex-start'}}>
      <div style={{fontSize:18}}>💾</div>
      <div>
        <div style={{fontWeight:700,fontSize:13.5,color:C.ambar,letterSpacing:'.02em'}}>MOSTRANDO DATOS PERSISTIDOS</div>
        <div style={{fontSize:13,color:C.sub,marginTop:4,lineHeight:1.5,fontWeight:500}}>
          La fuente está temporalmente fallando. Estos son los últimos datos exitosos guardados en disco: <strong>{fecha}</strong>.
          Cuando la fuente vuelva, los datos se refrescan automáticamente. Para forzar reintento ahora dale "↻ Refrescar este producto".
        </div>
      </div>
    </div>
  );
}

function ToolbarProducto({fuente, refrescar, cargando, color}){
  return (
    <div style={{display:'flex',gap:10,alignItems:'center',marginBottom:16,padding:'8px 14px',background:C.bgAlt,border:`1px solid ${C.line}`,borderLeft:`3px solid ${color||C.brandRed}`,borderRadius:6,fontSize:12}}>
      <span style={{color:C.subLt,fontWeight:600,textTransform:'uppercase',letterSpacing:'.06em',fontSize:10.5}}>Fuente:</span>
      <span style={{color:C.ink,fontWeight:600}}>{fuente}</span>
      <span style={{flex:1}}/>
      <button onClick={refrescar} disabled={cargando} style={{padding:'6px 14px',background:color||C.brandRed,color:'#fff',border:'none',borderRadius:6,fontSize:12,fontWeight:600,cursor:cargando?'wait':'pointer',opacity:cargando?.6:1,fontFamily:'inherit'}}>
        {cargando ? 'Refrescando…' : '↻ Refrescar este producto'}
      </button>
    </div>
  );
}

function FuenteError({fuente, error}){
  // Mensaje específico según el tipo de error para que la pantalla diga la verdad.
  const esRateLimit = /rate-?limited|status=429/i.test(error || '');
  const esAuth = /no autenticado|401|unauthorized/i.test(error || '') && !esRateLimit;
  const explicacion = esRateLimit
    ? `HubSpot está temporalmente rate-limited (HubSpot Free permite ~4 req/s y excedimos burst). Se cura solo en 30-60 segundos. NO es un error de credenciales.`
    : esAuth
    ? `Tu sesión expiró. Cierra sesión (botón power arriba derecha) y vuelve a entrar.`
    : `${error}. Los otros productos no se ven afectados.`;
  return (
    <div style={{background:C.bgRojo,border:`1px solid ${C.rojo}`,borderLeft:`4px solid ${C.rojo}`,borderRadius:8,padding:'14px 20px',marginBottom:14,display:'flex',gap:14,alignItems:'flex-start'}}>
      <div style={{fontSize:18}}>⚠️</div>
      <div>
        <div style={{fontWeight:700,fontSize:13.5,color:C.rojo,letterSpacing:'.02em'}}>FUENTE NO DISPONIBLE · {fuente}</div>
        <div style={{fontSize:13,color:C.sub,marginTop:4,lineHeight:1.5,fontWeight:500}}>
          {explicacion} {esRateLimit ? 'Espera 1 minuto y dale "Refrescar este producto".' : esAuth ? '' : 'Vuelve a darle Aplicar en unos segundos para reintentar.'}
        </div>
      </div>
    </div>
  );
}

function Banner({estado, titulo, detalle}){
  return (
    <div style={{background:bgEst[estado]||C.card,border:`1px solid ${C.line}`,borderLeft:`4px solid ${colorEst[estado]}`,borderRadius:8,padding:'14px 20px',display:'flex',gap:14,alignItems:'flex-start'}}>
      <div style={{width:10,height:10,borderRadius:'50%',background:colorEst[estado],flexShrink:0,marginTop:5,boxShadow:`0 0 0 4px ${colorEst[estado]}1F`}}/>
      <div>
        <div style={{fontWeight:700,fontSize:13.5,color:C.ink,letterSpacing:'.02em'}}>{txtEst[estado].toUpperCase()} · {titulo}</div>
        <div style={{fontSize:13,color:C.sub,marginTop:4,lineHeight:1.5,fontWeight:500}}>{detalle}</div>
      </div>
    </div>
  );
}
/* ── TENDENCIA MENSUAL ──────────────────────────────────────────
   Reusable en Envíos/Tienda/Pagos. Lee de /api/vacaciones/historico/:producto
   (data congelada en disco; reg/calif/inv inmutables, cli/saldo refrescados c/lunes).
   Muestra:
     • Tabla por mes con promedio semanal de registros + min-max + variación
     • Mini gráfica línea (avg reg/sem por mes)
     • Banner contextual: última semana vs promedio del mes
─────────────────────────────────────────────────────────────── */
function TendenciaMensual({producto, color}){
  const [data, setData] = useState(null);
  const [err, setErr] = useState(null);
  const [cargando, setCargando] = useState(true);
  useEffect(() => {
    api(`/historico/${producto}`)
      .then(setData).catch(e => setErr(e.message)).finally(() => setCargando(false));
  }, [producto]);
  if (cargando) return <div style={{padding:'18px',color:C.sub,fontSize:12.5}}>Cargando histórico…</div>;
  if (err) return <div style={{padding:'18px',color:C.rojo,fontSize:12.5}}>Error: {err}</div>;
  if (!data || !data.meses || data.meses.length === 0) {
    return (
      <div style={{padding:'14px 18px',background:C.bgAlt,border:`1px solid ${C.line}`,borderRadius:6,color:C.sub,fontSize:12.5}}>
        Sin histórico cargado. Corre <code style={{fontFamily:'monospace',background:C.card,padding:'1px 5px',borderRadius:3}}>node backend/scripts/backfill-historico.js</code> para llenar Jan-mayo.
      </div>
    );
  }

  // Etiqueta del mes (ej. "Ene 2026")
  const MESES = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
  const labelMes = (ym) => {
    const [y, m] = ym.split('-').map(Number);
    return `${MESES[m-1]} ${y}`;
  };

  // Banner: comparación de la última semana COMPLETA vs su mes
  const semanasOrdenadas = (data.semanas || []).slice().sort((a,b)=>a.desde.localeCompare(b.desde));
  // Filtrar la "en curso" (hasta >= hoy)
  const hoy = new Date().toISOString().slice(0,10);
  const ultimaCompleta = [...semanasOrdenadas].reverse().find(s => s.hasta < hoy);
  let banner = null;
  if (ultimaCompleta) {
    const mesUlt = ultimaCompleta.desde.slice(0,7);
    const mesData = data.meses.find(m => m.mes === mesUlt);
    if (mesData && mesData.regProm > 0) {
      const diffPct = ((ultimaCompleta.reg - mesData.regProm) / mesData.regProm) * 100;
      const tono = Math.abs(diffPct) < 5 ? 'estable' : diffPct > 0 ? 'sobre' : 'bajo';
      const color2 = tono === 'estable' ? C.sub : diffPct > 0 ? C.verde : C.ambar;
      banner = {
        sem: ultimaCompleta.sem, reg: ultimaCompleta.reg, regProm: mesData.regProm,
        diffPct: diffPct.toFixed(1), mes: labelMes(mesUlt), color: color2, tono
      };
    }
  }

  // Datos del chart (línea de promedio semanal por mes)
  const chartData = data.meses.map(m => ({ mes: labelMes(m.mes).split(' ')[0], promedio: m.regProm, min: m.regMin, max: m.regMax }));

  // Etiqueta de estabilidad legible en lugar de ±X%
  const etiquetaEstabilidad = (regProm, regMin, regMax, nSemanas) => {
    if (nSemanas <= 1) return { txt: 'Una semana', color: C.sub, tooltip: 'Solo 1 semana de datos en este mes — no hay variación que medir' };
    const varPct = regProm ? ((regMax - regMin) / regProm) * 100 : 0;
    if (varPct < 20)  return { txt: 'Estable',     color: C.verde, tooltip: `Las semanas de este mes oscilan en un rango chico (${varPct.toFixed(0)}% de variación entre min y max)` };
    if (varPct < 50)  return { txt: 'Volátil',     color: C.ambar, tooltip: `${varPct.toFixed(0)}% de variación entre la mejor y peor semana del mes` };
    return                    { txt: 'Muy volátil', color: C.rojo, tooltip: `${varPct.toFixed(0)}% de variación — semanas muy disparejas entre sí` };
  };

  return (
    <div>
      {/* Encabezado explicativo */}
      <div style={{padding:'10px 14px',background:C.card,border:`1px solid ${C.line}`,borderRadius:6,marginBottom:12,fontSize:12.5,color:C.sub,lineHeight:1.55}}>
        <strong style={{color:C.ink}}>¿Qué muestra esta sección?</strong> Cuántos registros llegan en promedio por semana en cada mes, qué tan estables son esas semanas entre sí, y cuánto cuesta cada registro. Útil para detectar caídas o saltos por algoritmo, estacionalidad o cambios de estrategia.
      </div>

      {banner && (
        <div style={{padding:'14px 18px',background:C.bgAlt,border:`1px solid ${C.line}`,borderLeft:`5px solid ${banner.color}`,borderRadius:6,marginBottom:14,fontSize:13.5,lineHeight:1.55}}>
          <div style={{display:'flex',alignItems:'center',gap:10,flexWrap:'wrap'}}>
            <span style={{fontSize:18,color:banner.color,fontWeight:700}}>{banner.diffPct > 0 ? '↑' : banner.diffPct < 0 ? '↓' : '→'}</span>
            <span style={{fontWeight:700,color:C.ink}}>Semana {banner.sem}: {banner.reg} registros</span>
          </div>
          <div style={{marginTop:5,color:C.sub,paddingLeft:28}}>
            {Math.abs(banner.diffPct) < 5
              ? <>Está en línea con el promedio de {banner.mes} ({banner.regProm} reg/sem).</>
              : <>Eso es <strong style={{color:banner.color}}>{banner.diffPct > 0 ? '+' : ''}{banner.diffPct}%</strong> {banner.diffPct > 0 ? 'arriba' : 'abajo'} del promedio de {banner.mes} ({banner.regProm} reg/sem).</>}
          </div>
        </div>
      )}

      {/* Semana en curso (si existe) */}
      {data.semanaEnCurso && (
        <div style={{padding:'10px 14px',background:C.card,border:`1px dashed ${C.sub}55`,borderRadius:6,marginBottom:12,fontSize:12.5,color:C.sub,lineHeight:1.5}}>
          <strong style={{color:C.ink}}>Semana en curso {data.semanaEnCurso.sem}:</strong> {data.semanaEnCurso.reg} reg · {data.semanaEnCurso.calif} calificados · {data.semanaEnCurso.cli} clientes
          <span style={{marginLeft:8,fontSize:11,fontStyle:'italic'}}>(no se cuenta en los promedios del mes porque aún no termina)</span>
        </div>
      )}

      {/* Tabla por mes */}
      <div style={{overflowX:'auto',border:`1px solid ${C.line}`,borderRadius:6,marginBottom:14}}>
        <table style={{width:'100%',borderCollapse:'collapse',fontSize:12,background:C.card}}>
          <thead><tr style={{background:C.bgAlt}}>
            <th style={{padding:'10px 12px',textAlign:'left',fontWeight:700,color:C.ink,whiteSpace:'nowrap'}}>Mes</th>
            <th style={{padding:'10px 12px',textAlign:'right',fontWeight:700,color:C.ink,whiteSpace:'nowrap'}} title="Cuántas semanas completas tiene este mes en el histórico">Semanas</th>
            <th style={{padding:'10px 12px',textAlign:'left',fontWeight:700,color:C.ink,whiteSpace:'nowrap',minWidth:170}} title="Promedio de registros por semana en este mes — total del mes ÷ # de semanas">Registros por semana (prom)</th>
            <th style={{padding:'10px 12px',textAlign:'right',fontWeight:700,color:C.ink,whiteSpace:'nowrap'}} title="Semana más baja → semana más alta del mes">Rango (min→max)</th>
            <th style={{padding:'10px 12px',textAlign:'center',fontWeight:700,color:C.ink,whiteSpace:'nowrap'}} title="Qué tan parejas son las semanas entre sí">Estabilidad</th>
            <th style={{padding:'10px 12px',textAlign:'right',fontWeight:700,color:C.ink,whiteSpace:'nowrap'}} title="Inversión paid promedio por semana del mes">Inv/semana</th>
            <th style={{padding:'10px 12px',textAlign:'right',fontWeight:700,color:C.ink,whiteSpace:'nowrap'}} title="Costo por registro promedio (inversión / registros)">CPR</th>
            <th style={{padding:'10px 12px',textAlign:'right',fontWeight:700,color:C.ink,whiteSpace:'nowrap'}} title="Costo por cliente promedio (inversión / clientes)">CPA</th>
            <th style={{padding:'10px 12px',textAlign:'right',fontWeight:700,color:C.ink,whiteSpace:'nowrap'}} title="Porcentaje de registros que se convirtieron en clientes">% a cliente</th>
            <th style={{padding:'10px 12px',textAlign:'right',fontWeight:700,color:C.ink,whiteSpace:'nowrap'}} title="Promedio de clientes nuevos por semana del mes">Clientes/sem (prom)</th>
          </tr></thead>
          <tbody>
            {data.meses.map((m,i)=>{
              const est = etiquetaEstabilidad(m.regProm, m.regMin, m.regMax, m.nSemanas);
              return (
                <tr key={i} style={{borderTop:`1px solid ${C.line}`}}>
                  <td style={{padding:'10px 12px',fontWeight:600,color:C.ink,verticalAlign:'middle'}}>{labelMes(m.mes)}</td>
                  <td style={{padding:'10px 12px',textAlign:'right',color:C.sub,verticalAlign:'middle'}}>{m.nSemanas}</td>
                  <td style={{padding:'10px 12px',textAlign:'left',verticalAlign:'middle'}}>
                    <div style={{fontSize:18,fontWeight:700,color,lineHeight:1.1}}>{fmt(m.regProm)}</div>
                    <div style={{fontSize:10.5,color:C.sub,marginTop:2}}>{fmt(m.regSum)} registros / {m.nSemanas} {m.nSemanas===1?'sem':'sems'}</div>
                  </td>
                  <td style={{padding:'10px 12px',textAlign:'right',color:C.sub,fontVariantNumeric:'tabular-nums',verticalAlign:'middle'}}>{m.nSemanas > 1 ? `${fmt(m.regMin)} → ${fmt(m.regMax)}` : '—'}</td>
                  <td style={{padding:'10px 12px',textAlign:'center',verticalAlign:'middle'}} title={est.tooltip}>
                    <span style={{display:'inline-block',padding:'2px 9px',borderRadius:11,fontSize:11,fontWeight:600,color:est.color,background:est.color+'14',border:`1px solid ${est.color}33`}}>{est.txt}</span>
                  </td>
                  <td style={{padding:'10px 12px',textAlign:'right',color:m.invProm?C.ink:C.sub,verticalAlign:'middle'}}>{m.invProm?mxnDec(m.invProm):'—'}</td>
                  <td style={{padding:'10px 12px',textAlign:'right',color:m.cprPromedio?C.ink:C.sub,verticalAlign:'middle'}}>{m.cprPromedio?mxnDec(m.cprPromedio):'—'}</td>
                  <td style={{padding:'10px 12px',textAlign:'right',color:m.cpaPromedio?C.ink:C.sub,verticalAlign:'middle'}}>{m.cpaPromedio?mxnDec(m.cpaPromedio):'—'}</td>
                  <td style={{padding:'10px 12px',textAlign:'right',color:C.sub,verticalAlign:'middle'}}>{pct(m.pctConvPromedio)}</td>
                  <td style={{padding:'10px 12px',textAlign:'right',color:m.cliProm?C.ink:C.sub,fontWeight:m.cliProm?600:400,verticalAlign:'middle'}}>{m.cliProm?fmt(m.cliProm):'—'}</td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>

      {/* Mini gráfica */}
      {chartData.length >= 2 && (
        <div style={{background:C.card,border:`1px solid ${C.line}`,borderRadius:8,padding:'16px 8px 8px'}}>
          <div style={{fontSize:12,color:C.sub,fontWeight:600,paddingLeft:14,marginBottom:6}}>Evolución del promedio semanal mes a mes</div>
          <ResponsiveContainer width="100%" height={180}>
            <LineChart data={chartData} margin={{top:5,right:18,left:-8,bottom:5}}>
              <CartesianGrid stroke={C.line} strokeDasharray="3 3"/>
              <XAxis dataKey="mes" tick={{fontSize:11,fill:C.sub}}/>
              <YAxis tick={{fontSize:11,fill:C.sub}}/>
              <Tooltip contentStyle={{fontSize:12,borderRadius:6,border:`1px solid ${C.line}`}} formatter={(v)=>[`${v} reg/sem`,'Promedio semanal']}/>
              <Line type="monotone" dataKey="promedio" stroke={color} strokeWidth={2.5} dot={{r:3}}/>
            </LineChart>
          </ResponsiveContainer>
        </div>
      )}

      <div style={{fontSize:11,color:C.sub,marginTop:8,lineHeight:1.5}}>
        <strong>Cómo leer:</strong> Si un mes dice <em>«120 registros/sem · Estable · CPR $224»</em>, significa que cada semana entró un volumen parecido de prospectos a un costo consistente. Si dice <em>«93 reg/sem · Muy volátil · CPR $230»</em>, hay semanas con cero y semanas con 139 — algo (Hot Sale, pausa de pauta, fin de mes) está moviendo fuerte. Registros e inversión se cierran semana a semana con una <strong>ventana de gracia de 14 días</strong> para capturar contactos tardíos.
      </div>
    </div>
  );
}

// Botón "?" reusable. Click muestra popover con explicación contextual.
function HelpIcon({text, label, align='left'}){
  const [open, setOpen] = useState(false);
  if (!text) return null;
  return (
    <span style={{position:'relative',display:'inline-block',marginLeft:6,verticalAlign:'middle'}}>
      <button
        type="button"
        onClick={(e)=>{e.stopPropagation(); setOpen(o=>!o);}}
        onBlur={()=>setTimeout(()=>setOpen(false),180)}
        aria-label={label || 'Más información'}
        title="¿Qué mide?"
        style={{
          background: open ? C.bgAlt : 'transparent',
          border: `1px solid ${open ? C.ink : C.line}`,
          color: open ? C.ink : C.sub,
          width: 17, height: 17,
          borderRadius: '50%',
          fontSize: 10.5, fontWeight: 700,
          cursor: 'pointer', padding: 0, lineHeight: 1,
          fontFamily: 'inherit',
          display: 'inline-flex', alignItems: 'center', justifyContent: 'center'
        }}
      >?</button>
      {open && (
        <div style={{
          position:'absolute',
          top: '100%', [align]: 0,
          marginTop: 7,
          minWidth: 280, maxWidth: 400, width: 'max-content',
          background: C.card,
          border: `1px solid ${C.line}`,
          borderRadius: 7,
          padding: '11px 13px',
          fontSize: 12.5, color: C.ink, lineHeight: 1.55,
          fontWeight: 400, letterSpacing: 'normal',
          textAlign: 'left',
          boxShadow: '0 6px 22px rgba(0,0,0,0.10)',
          zIndex: 200
        }}>
          {text}
        </div>
      )}
    </span>
  );
}

function Sec({titulo, nota, help, children}){
  return (
    <div style={{marginTop:32}}>
      <div style={{display:'flex',alignItems:'baseline',gap:12,marginBottom:14,flexWrap:'wrap'}}>
        <h3 style={{fontSize:18,fontWeight:700,color:C.ink,margin:0,letterSpacing:'-0.01em',display:'inline-flex',alignItems:'center'}}>
          {titulo}
          {help && <HelpIcon text={help} label={`Sobre ${titulo}`}/>}
        </h3>
        {nota && <span style={{fontSize:12,color:C.sub,fontWeight:500}}>{nota}</span>}
      </div>
      {children}
    </div>
  );
}
function ListaSalud({items, color, signo}){
  return (
    <ul style={{margin:0,padding:0,listStyle:'none'}}>
      {items.map((t,i)=>(
        <li key={i} style={{display:'flex',gap:10,fontSize:13,color:C.sub,lineHeight:1.55,marginBottom:8}}>
          <span style={{color,fontWeight:700,flexShrink:0,fontSize:14}}>{signo}</span><span>{t}</span>
        </li>
      ))}
    </ul>
  );
}
function FAQ({items, color}){
  const [open,setOpen] = useState(0);
  return (
    <div style={{border:`1px solid ${C.line}`,borderRadius:8,background:C.card,overflow:'hidden'}}>
      {items.map((it,i)=>(
        <div key={i} style={{borderBottom:i<items.length-1?`1px solid ${C.line}`:'none'}}>
          <button onClick={()=>setOpen(open===i?-1:i)} style={{width:'100%',textAlign:'left',padding:'14px 20px',background:'none',border:'none',cursor:'pointer',display:'flex',justifyContent:'space-between',gap:12,alignItems:'center',fontFamily:'inherit'}}>
            <span style={{fontWeight:600,fontSize:14,color:C.ink}}>{it.q}</span>
            <span style={{color,fontSize:20,flexShrink:0,fontWeight:600}}>{open===i?'−':'+'}</span>
          </button>
          {open===i && <div style={{padding:'0 20px 16px',fontSize:13,lineHeight:1.6,color:C.sub,fontWeight:500}}>{it.a}</div>}
        </div>
      ))}
    </div>
  );
}
function Notas({notas}){
  return (
    <div style={{background:C.bgAmarillo,border:`1px solid #F4E08A`,borderRadius:8,padding:'14px 20px'}}>
      <div style={{fontSize:11,letterSpacing:'.08em',textTransform:'uppercase',color:'#A96A00',fontWeight:700,marginBottom:8}}>Notas de contexto · leer antes de interpretar</div>
      <ul style={{margin:0,paddingLeft:18}}>
        {notas.map((n,i)=><li key={i} style={{fontSize:12.5,color:C.sub,lineHeight:1.55,marginBottom:5,fontWeight:500}}>{n}</li>)}
      </ul>
    </div>
  );
}
function LecturaSalud({funciona, atencion}){
  return (
    <div style={{display:'grid',gridTemplateColumns:'repeat(auto-fit,minmax(280px,1fr))',gap:16}}>
      <div style={{background:C.card,border:`1px solid ${C.line}`,borderRadius:8,padding:'16px 20px'}}>
        <div style={{fontSize:11,fontWeight:700,color:C.verde,marginBottom:12,textTransform:'uppercase',letterSpacing:'.08em'}}>Lo que funciona</div>
        <ListaSalud items={funciona} color={C.verde} signo="▸"/>
      </div>
      <div style={{background:C.card,border:`1px solid ${C.line}`,borderRadius:8,padding:'16px 20px'}}>
        <div style={{fontSize:11,fontWeight:700,color:C.rojo,marginBottom:12,textTransform:'uppercase',letterSpacing:'.08em'}}>Lo que requiere atención</div>
        <ListaSalud items={atencion} color={C.rojo} signo="▸"/>
      </div>
    </div>
  );
}

/* ── VISTA ENVÍOS ──────────────────────────────────────────── */
function VistaEnvios({d, roi8, hoyStr, error, refrescar, cargando, semaforos, periodoLabel, fallbackAt}){
  const k = d.total;
  const ult = ultimaSemanaCompleta(d.semanas, hoyStr);
  const roiUlt = ult.inv ? (ult.saldoT1 * d.margen) / ult.inv : 0;
  const chart = d.semanas.map(s=>({sem:s.sem, Registros:s.reg, Clientes:s.cli, enCurso:isEnCurso(s,hoyStr)}));
  const sem = semaforos || {};
  // Salud dinámica basada en ROI 8 semanas real (no hardcoded).
  // Lectura estable: el rolling 8 sem ya promedia maduración de cohorte.
  const roi8Val = roi8?.total?.roi;
  const saludDinamica = roi8Val == null ? d.salud : (
    roi8Val >= 1.5 ? { estado:'verde', titulo:`Rentable · ROI 8 sem ${roi8Val.toFixed(2)}× por encima del breakeven`,
      detalle:`ROI 8 semanas (rolling) = ${roi8Val.toFixed(2)} → producto rentable, ${(roi8Val).toFixed(1)}× sobre el breakeven 1.0. El ROI semanal de las últimas 2-3 cohortes (${roiUlt.toFixed(2)}) refleja maduración pendiente, NO problema de adquisición.` } :
    roi8Val >= 1.0 ? { estado:'verde', titulo:`En breakeven · ROI 8 sem ${roi8Val.toFixed(2)}`,
      detalle:`ROI 8 semanas = ${roi8Val.toFixed(2)}, justo en o sobre el breakeven 1.0. Hay margen para crecer optimizando saldo prom. por cliente o reduciendo CPA.` } :
    roi8Val >= 0.7 ? { estado:'ambar', titulo:`Cerca del breakeven · ROI 8 sem ${roi8Val.toFixed(2)}`,
      detalle:`ROI 8 semanas = ${roi8Val.toFixed(2)} — bajo breakeven 1.0. Investigar si baja saldo prom. por cliente o aumenta CPA. Verificar cohortes maduras (>6 sem) para confirmar tendencia.` } :
    { estado:'rojo', titulo:`Bajo breakeven · ROI 8 sem ${roi8Val.toFixed(2)}`,
      detalle:`ROI 8 semanas = ${roi8Val.toFixed(2)} — significativamente bajo el breakeven. Acción urgente: revisar canales con peor CPA y considerar pausar inversión en los menos rentables.` }
  );
  return (
    <div>
      <ToolbarProducto fuente="HubSpot directo (pat-na1-...)" refrescar={refrescar} cargando={cargando} color={C.envios}/>
      <FallbackBanner persistedAt={fallbackAt}/>
      {error && <FuenteError fuente="HubSpot directo" error={error}/>}
      <Sec titulo="KPIs del periodo" help="Resumen del rango de fechas seleccionado. Solo cuenta cohortes paid (Meta + Google), no orgánico ni remarketing. Registros = nuevos contactos en HubSpot con UTM de campaña. Calificados = los que prendieron flag_cotizacion=SI. Clientes = los que tienen saldo T1Envíos > 0. CPR = inversión ÷ registros. CPA = inversión ÷ clientes. ROI = saldo total acumulado × margen ÷ inversión." nota={periodoLabel || d.periodo}>
        <div style={{display:'grid',gridTemplateColumns:'repeat(auto-fit,minmax(185px,1fr))',gap:12}}>
          <KPI label="Registros" valor={fmt(k.reg)} sub="cross-product" estado={sem.reg}/>
          <KPI label="Calificados" valor={fmt(k.calif)} sub={pct(k.pctCalif)+' del total'} estado={sem.pctCalif}/>
          <KPI label="Clientes" valor={fmt(k.cli)} sub="saldo > 0" estado={sem.cli}/>
          <KPI label="% Conversión" valor={pct(k.pctCli)} sub="registro → cliente" estado={sem.pctCli} accent={!sem.pctCli || sem.pctCli==='na' ? C.envios : null}/>
          <KPI label="Inversión" valor={mxnDec(k.inv)} sub="Google + Meta"/>
          <KPI label="CPR" valor={mxnDec(k.cpr)} sub="costo / registro" estado={sem.cpr}/>
          <KPI label="CPA" valor={mxnDec(k.cpa)} sub="costo / cliente" estado={sem.cpa}/>
          <KPI label="1ª Recarga prom." valor={mxnDec(k.precProm)} sub="promedio del periodo"/>
          <KPI label="ROI del periodo" valor={k.inv ? k.roiSemanal.toFixed(2) : '—'} sub={`${periodoLabel||''} · sigue el rango`} accent={k.roiSemanal >= 1 ? C.verde : C.ambar}/>
          <KPI label="ROI 8 semanas" valor={roi8?.total?.roi?.toFixed(2) ?? '—'} sub="rolling · lectura estable" estado={sem.roi8 || 'verde'}/>
          <KPI label="ROI última sem" valor={roiUlt.toFixed(2)} sub={`${ult.sem||''} · última completa`} estado="ambar"/>
        </div>
      </Sec>
      <Sec titulo="Salud del producto" help="Semáforo verde/ámbar/rojo del producto basado en el ROI de 8 semanas. Verde = ROI ≥ 1 estable. Ámbar = ROI 0.7-1, bajo breakeven pero manejable. Rojo = ROI < 0.7, acción urgente."><Banner {...saludDinamica}/></Sec>

      <Sec titulo="Tendencia" help="Línea de registros (con UTM paid) y línea de clientes (saldo > 0) por semana en el rango seleccionado. La última barra es la semana en curso — puede verse baja porque aún no termina." nota="registros y clientes por semana · semana en curso al final">
        <div style={{background:C.card,border:`1px solid ${C.line}`,borderRadius:8,padding:'16px 8px 8px'}}>
          <ResponsiveContainer width="100%" height={240}>
            <LineChart data={chart} margin={{top:5,right:18,left:-8,bottom:5}}>
              <CartesianGrid stroke={C.line} strokeDasharray="3 3"/>
              <XAxis dataKey="sem" tick={{fontSize:11,fill:C.sub}}/>
              <YAxis tick={{fontSize:11,fill:C.sub}}/>
              <Tooltip contentStyle={{fontSize:12,borderRadius:6,border:`1px solid ${C.line}`}}/>
              <Line type="monotone" dataKey="Registros" stroke={d.color} strokeWidth={2.5} dot={{r:3}}/>
              <Line type="monotone" dataKey="Clientes" stroke={C.sub} strokeWidth={2} strokeDasharray="4 3" dot={{r:3}}/>
            </LineChart>
          </ResponsiveContainer>
        </div>
        <AnalisisTendencia semanas={d.semanas} hoyStr={hoyStr} color={d.color}/>
      </Sec>

      <Sec titulo="Comportamiento semanal" help="Tabla detallada semana × semana. Cada fila es 1 semana ISO (lunes-domingo). Inversión = paid Meta + Google. Reg = registros con UTM paid. CPR = costo por registro. % Calif = registros que cotizaron. Cli = clientes con saldo > 0. % Conv = clientes / registros. CPA = costo por cliente. ROI sem = saldo T1 × margen ÷ inversión." nota="inversión, CPR, CPA, % calificado, % conversión y ROI por semana">
        <div style={{overflowX:'auto',border:`1px solid ${C.line}`,borderRadius:3}}>
          <table style={{width:'100%',borderCollapse:'collapse',fontSize:11.8,background:C.card}}>
            <thead><tr style={{background:C.bgAlt}}>
              {['Semana','Inversión','Reg','CPR','Calif','% Calif','Cli','% Conv','CPA','ROI sem'].map(h=>(
                <th key={h} style={{padding:'8px 9px',textAlign:h==='Semana'?'left':'right',fontWeight:700,color:C.ink,whiteSpace:'nowrap'}}>{h}</th>))}
            </tr></thead>
            <tbody>
              {d.semanas.map((s,i)=>{
                const cpr  = s.reg ? s.inv/s.reg : 0;
                const cpa  = s.cli ? s.inv/s.cli : 0;
                const roiS = s.inv ? (s.saldoT1*d.margen)/s.inv : 0;
                const pctConv = s.reg ? (s.cli/s.reg)*100 : 0;
                const enCurso = isEnCurso(s, hoyStr);
                return (
                  <tr key={i} style={{borderTop:`1px solid ${C.line}`,background:enCurso?C.bgAlt:'transparent'}}>
                    <td style={{padding:'7px 9px',fontWeight:600}}>{s.sem}{enCurso && <span style={{marginLeft:6,fontSize:9.5,fontWeight:700,color:C.ambar,background:C.bgAmbar,padding:'1px 6px',borderRadius:99,textTransform:'uppercase',letterSpacing:'.05em'}}>En curso</span>}</td>
                    <td style={{padding:'7px 9px',textAlign:'right'}}>{mxnDec(s.inv)}</td>
                    <td style={{padding:'7px 9px',textAlign:'right'}}>{fmt(s.reg)}</td>
                    <td style={{padding:'7px 9px',textAlign:'right',color:C.sub}}>{s.inv?mxnDec(cpr):'—'}</td>
                    <td style={{padding:'7px 9px',textAlign:'right'}}>{fmt(s.calif)}</td>
                    <td style={{padding:'7px 9px',textAlign:'right',color:C.sub}}>{pct(s.reg?s.calif/s.reg*100:0)}</td>
                    <td style={{padding:'7px 9px',textAlign:'right',fontWeight:600}}>{fmt(s.cli)}</td>
                    <td style={{padding:'7px 9px',textAlign:'right',fontWeight:600,color:C.envios}}>{pct(pctConv)}</td>
                    <td style={{padding:'7px 9px',textAlign:'right',color:C.sub}}>{s.cli&&s.inv?mxnDec(cpa):'—'}</td>
                    <td style={{padding:'7px 9px',textAlign:'right',fontWeight:700,color:C.envios}}>{s.inv?roiS.toFixed(2):'—'}</td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
        <div style={{fontSize:11,color:C.sub,marginTop:6,lineHeight:1.5}}>ROI semanal usa saldo acumulado actual (aging de cohorte). Para juzgar rentabilidad usa el ROI de 8 semanas abajo.</div>
      </Sec>

      <Sec titulo="Saldo y ROI por semana" help="Saldo T1Envíos acumulado total (todas las recargas que han hecho los clientes de esa cohorte hasta hoy) + Saldo de la 1ª Recarga (lo que metieron al activarse, comparable entre semanas). ⚠ El ROI de cohortes viejas se ve alto porque llevan más tiempo recargando — eso es aging, no performance. Para juzgar rentabilidad real, usar el ROI 8 semanas abajo." nota="saldo T1Envíos, saldo de 1ª recarga y ROI">
        <div style={{overflowX:'auto',border:`1px solid ${C.line}`,borderRadius:3}}>
          <table style={{width:'100%',borderCollapse:'collapse',fontSize:11.7,background:C.card}}>
            <thead><tr style={{background:C.bgAlt}}>
              {['Semana','Clientes','Saldo T1Envíos','Saldo 1ª Recarga','1ª Recarga prom.','ROI'].map(h=>(
                <th key={h} style={{padding:'8px 9px',textAlign:h==='Semana'?'left':'right',fontWeight:700,color:C.ink,whiteSpace:'nowrap'}}>{h}</th>))}
            </tr></thead>
            <tbody>
              {d.semanas.map((s,i)=>{
                const roi = s.inv ? (s.saldoT1*d.margen)/s.inv : 0;
                const precProm = s.cli ? s.saldoPrec/s.cli : 0;
                const enCurso = isEnCurso(s, hoyStr);
                const ultimaComp = !enCurso && s.sem === ult.sem;
                return (
                  <tr key={i} style={{borderTop:`1px solid ${C.line}`,background:(enCurso||ultimaComp)?C.bgAlt:'transparent'}}>
                    <td style={{padding:'7px 9px',fontWeight:600}}>{s.sem}
                      {enCurso && <span style={{marginLeft:6,fontSize:9.5,fontWeight:700,color:C.ambar,background:C.bgAmbar,padding:'1px 6px',borderRadius:99,textTransform:'uppercase',letterSpacing:'.05em'}}>En curso</span>}
                      {ultimaComp && <span style={{marginLeft:6,fontSize:9.5,fontWeight:700,color:C.envios,background:'#FFEDD5',padding:'1px 6px',borderRadius:99,textTransform:'uppercase',letterSpacing:'.05em'}}>Lectura limpia</span>}
                    </td>
                    <td style={{padding:'7px 9px',textAlign:'right'}}>{fmt(s.cli)}</td>
                    <td style={{padding:'7px 9px',textAlign:'right'}}>{mxnDec(s.saldoT1)}</td>
                    <td style={{padding:'7px 9px',textAlign:'right'}}>{mxnDec(s.saldoPrec)}</td>
                    <td style={{padding:'7px 9px',textAlign:'right',fontWeight:600,color:C.envios}}>{mxnDec(precProm)}</td>
                    <td style={{padding:'7px 9px',textAlign:'right',fontWeight:700,color:ultimaComp?C.ink:C.sub}}>{roi.toFixed(2)}</td>
                  </tr>);
              })}
            </tbody>
          </table>
        </div>
        <div style={{fontSize:11.5,color:'#A96A00',background:C.bgAmarillo,border:`1px solid #F4E08A`,borderRadius:3,padding:'9px 13px',marginTop:9,lineHeight:1.5}}>
          ⚠ El "Saldo T1Envíos" es acumulado actual — las cohortes tempranas llevan semanas recargando, las recientes apenas días. Su ROI alto es <strong>aging de cohorte, no performance</strong>. La lectura comparable es la última semana. El saldo de 1ª recarga sí es comparable entre semanas.
        </div>
      </Sec>

      {roi8 && <Sec titulo="ROI · últimas 8 semanas" help="Promedio del ROI sobre las últimas 8 semanas ISO. Esta ventana móvil suaviza el ruido de cohortes individuales (las recientes no han madurado, las viejas llevan meses recargando) y da una lectura estable del estado del negocio. ROI 1.0 = breakeven, < 1 = perdiendo dinero, > 1 = recuperando con margen." nota="ventana móvil · alimentada por HubSpot">
        <div style={{background:C.card,border:`1px solid ${C.line}`,borderLeft:`4px solid ${C.verde}`,borderRadius:3,padding:'14px 18px',marginBottom:10}}>
          <div style={{display:'flex',gap:24,flexWrap:'wrap',alignItems:'baseline'}}>
            <div>
              <div style={{fontSize:10.5,textTransform:'uppercase',letterSpacing:'.06em',color:C.sub,fontWeight:700}}>ROI últimas 8 semanas</div>
              <div style={{fontSize:32,fontWeight:600,color:C.verde}}>{roi8.total.roi.toFixed(2)}</div>
            </div>
            <div style={{fontSize:12.5,color:C.sub,lineHeight:1.55,flex:1,minWidth:240}}>
              Lectura estable: la ventana de 8 semanas promedia aging de cohorte y refleja el estado real del negocio. El ROI semanal es la foto de la cohorte más reciente sin tiempo de madurar — no usar para juzgar rentabilidad.
            </div>
          </div>
        </div>
        <div style={{overflowX:'auto',border:`1px solid ${C.line}`,borderRadius:3}}>
          <table style={{width:'100%',borderCollapse:'collapse',fontSize:11.7,background:C.card}}>
            <thead><tr style={{background:C.bgAlt}}>
              {['Semana','Inversión','Registros','Clientes','ROI'].map(h=>(
                <th key={h} style={{padding:'8px 10px',textAlign:h==='Semana'?'left':'right',fontWeight:700,color:C.ink}}>{h}</th>))}
            </tr></thead>
            <tbody>
              {roi8.semanas.map((s,i)=>(
                <tr key={i} style={{borderTop:`1px solid ${C.line}`}}>
                  <td style={{padding:'7px 10px',fontWeight:600}}>{s.sem}</td>
                  <td style={{padding:'7px 10px',textAlign:'right'}}>{mxnDec(s.inv)}</td>
                  <td style={{padding:'7px 10px',textAlign:'right'}}>{fmt(s.reg)}</td>
                  <td style={{padding:'7px 10px',textAlign:'right'}}>{fmt(s.cli)}</td>
                  <td style={{padding:'7px 10px',textAlign:'right',color:C.sub}}>{(s.roi||0).toFixed(2)}</td>
                </tr>))}
              <tr style={{borderTop:`2px solid ${C.line}`,background:C.bgAlt}}>
                <td style={{padding:'8px 10px',fontWeight:700}}>Últimas 8 semanas</td>
                <td style={{padding:'8px 10px',textAlign:'right',fontWeight:700}}>{mxnDec(roi8.total.inv)}</td>
                <td style={{padding:'8px 10px',textAlign:'right',fontWeight:700}}>{fmt(roi8.total.reg)}</td>
                <td style={{padding:'8px 10px',textAlign:'right',fontWeight:700}}>{fmt(roi8.total.cli)}</td>
                <td style={{padding:'8px 10px',textAlign:'right',fontWeight:700,color:C.verde}}>{roi8.total.roi.toFixed(2)}</td>
              </tr>
            </tbody>
          </table>
        </div>
      </Sec>}

      {d.cross && <Sec titulo="Atribución cross-product" help="Aunque la campaña sea de Envíos, a veces el usuario termina activando Tienda o Pagos. Esta sección muestra cuántos contactos atribuidos a Envíos en realidad están registrados en otros productos (por su providertype en HubSpot)." nota="dónde se registran los usuarios de campañas de Envíos">
        <div style={{overflowX:'auto',border:`1px solid ${C.line}`,borderRadius:3}}>
          <table style={{width:'100%',borderCollapse:'collapse',fontSize:12,background:C.card}}>
            <thead><tr style={{background:C.bgAlt}}>
              {['Semana','T1Shipping','T1Store','T1Payments','% Cross a Tienda'].map(h=>(
                <th key={h} style={{padding:'8px 10px',textAlign:h==='Semana'?'left':'right',fontWeight:700,color:C.ink,whiteSpace:'nowrap'}}>{h}</th>))}
            </tr></thead>
            <tbody>
              {d.cross.map((s,i)=>{
                const tot=s.ship+s.store+s.pay;
                return (
                  <tr key={i} style={{borderTop:`1px solid ${C.line}`}}>
                    <td style={{padding:'7px 10px',fontWeight:600}}>{s.sem}</td>
                    <td style={{padding:'7px 10px',textAlign:'right'}}>{fmt(s.ship)}</td>
                    <td style={{padding:'7px 10px',textAlign:'right'}}>{fmt(s.store)}</td>
                    <td style={{padding:'7px 10px',textAlign:'right'}}>{fmt(s.pay)}</td>
                    <td style={{padding:'7px 10px',textAlign:'right',color:C.sub}}>{pct(tot?s.store/tot*100:0)}</td>
                  </tr>);
              })}
            </tbody>
          </table>
        </div>
      </Sec>}

      <Sec titulo="Brecha al punto de equilibrio" help="Si el ROI 8 semanas es < 1, esta sección calcula qué tan lejos estamos del breakeven y propone palancas: más registros (mismo CPR), menos CPR (mismos registros), o mejor conversión a cliente. Cada palanca te dice cuánto necesitas mover para llegar a ROI = 1." nota="palancas para alcanzar ROI = 1">
        <div style={{overflowX:'auto',border:`1px solid ${C.line}`,borderRadius:3}}>
          <table style={{width:'100%',borderCollapse:'collapse',fontSize:12,background:C.card}}>
            <thead><tr style={{background:C.bgAlt}}>
              {['Palanca','Valor actual','Necesario p/ ROI=1','Factor'].map(h=>(
                <th key={h} style={{padding:'8px 10px',textAlign:h==='Palanca'?'left':'right',fontWeight:700,color:C.ink}}>{h}</th>))}
            </tr></thead>
            <tbody>
              {d.breakevenTabla.map((r,i)=>(
                <tr key={i} style={{borderTop:`1px solid ${C.line}`}}>
                  <td style={{padding:'7px 10px',fontWeight:600}}>{r.palanca}</td>
                  <td style={{padding:'7px 10px',textAlign:'right'}}>{r.actual}</td>
                  <td style={{padding:'7px 10px',textAlign:'right'}}>{r.meta}</td>
                  <td style={{padding:'7px 10px',textAlign:'right',fontWeight:700,color:C.envios}}>{r.factor}</td>
                </tr>))}
            </tbody>
          </table>
        </div>
      </Sec>

      <Sec titulo="Tendencia mensual" help="Cuántos registros entraron en promedio por semana en cada mes, qué tan estables fueron esas semanas entre sí, y cuánto costó cada registro. La semana en curso se reporta aparte para no jalar el promedio hacia abajo. Útil para detectar caídas o saltos por algoritmo, estacionalidad o cambios de estrategia." nota="promedio semanal por mes · histórico desde Ene 2026"><TendenciaMensual producto="envios" color={C.envios}/></Sec>
      <Sec titulo="Lectura de salud" help="Resumen ejecutivo dividido en dos: lo que está funcionando bien y debe mantenerse, vs lo que requiere atención y posible ajuste. Generado automáticamente desde los KPIs del periodo."><LecturaSalud funciona={d.funciona} atencion={d.atencion}/></Sec>
      <Sec titulo="Preguntas frecuentes" help="Q&A automático generado desde los datos del periodo. Responde las preguntas más comunes sobre el estado actual del producto: ¿cómo vamos? ¿qué está pasando con la conversión? ¿cuánto cuesta cada cliente? etc."><FAQ items={d.faq} color={d.color}/></Sec>
      <Sec titulo="Notas de contexto" help="Eventos, lanzamientos, pausas de pauta, festivos y otros factores externos del periodo que pueden explicar las variaciones de los KPIs. Si una semana se ve rara, esta sección suele tener el porqué."><Notas notas={d.notas}/></Sec>
    </div>
  );
}

/* ── VISTA TIENDA ──────────────────────────────────────────── */
function VistaTienda({d, hoyStr, error, refrescar, cargando, semaforos, periodoLabel, fallbackAt}){
  const t = d.total;
  const chart = d.semanas.map(s=>({sem:s.sem,Baseline:s.google+s.meta,Total:s.google+s.meta+s.tiktok+s.webinar+s.paginas,enCurso:isEnCurso(s,hoyStr)}));
  const ult = ultimaSemanaCompleta(d.semanas, hoyStr);
  const sem = semaforos || {};
  // Modal de usuarios al click en celda. tipo = 'reg' | 'calif' | 'checkIni' | 'visitasPlanes' | 'cli'
  const [usuariosModal, setUsuariosModal] = useState(null);
  const abrirModalUsuarios = (semana, tipo) => {
    const all = (d.usuarios || []).filter(u => u.fechaCreacion && u.fechaCreacion >= semana.desde && u.fechaCreacion <= semana.hasta);
    const filtros = {
      reg:           { titulo:'Registros',          filtrar: () => all, csv:'registros-tienda' },
      calif:         { titulo:'Calificados',        filtrar: () => all.filter(u => u.calificado), csv:'calificados-tienda' },
      checkIni:      { titulo:'Checkout iniciado',  filtrar: () => all.filter(u => u.checkoutIniciado), csv:'checkout-tienda' },
      visitasPlanes: { titulo:'Visitas a planes',   filtrar: () => all.filter(u => u.visitasPlanes), csv:'visitas-planes-tienda' },
      cli:           { titulo:'Clientes (plan activo)', filtrar: () => all.filter(u => u.cliente), csv:'clientes-tienda' }
    };
    const f = filtros[tipo];
    setUsuariosModal({ semana, tituloTipo: f.titulo, usuarios: f.filtrar(), csvPrefix: f.csv, tipo });
  };
  return (
    <div>
      <ToolbarProducto fuente="T1 API (dev.t1api.com/identity-api)" refrescar={refrescar} cargando={cargando} color={C.tienda}/>
      <FallbackBanner persistedAt={fallbackAt}/>
      {error && <FuenteError fuente="T1 API" error={error}/>}
      <Sec titulo="KPIs del periodo" help="Resumen de Tienda en el rango seleccionado. Registros = nuevos contactos vía cohort paid + iniciativas (TikTok/Webinar/T1 Páginas). Calificados = tienda_usuario_calificado=SI. Clientes = tienda_plan_estatus=activo. GMV = ventas totales generadas (tienda_pagos_acumulado). Baseline = always-on (Google + Meta). Iniciativas = especiales (TikTok, Webinar, T1 Páginas). CPR/CPA solo se calculan sobre paid." nota={periodoLabel || d.periodo}>
        <div style={{display:'grid',gridTemplateColumns:'repeat(auto-fit,minmax(185px,1fr))',gap:12}}>
          <KPI label="Registros total" valor={fmt(t.reg)} sub={`${d.semanas.length} semanas`} estado={sem.reg}/>
          <KPI label="Calificados" valor={fmt(t.calif)} sub={pct(t.pctCalif)+' del total'} estado={sem.pctCalif}/>
          <KPI label="Clientes (plan)" valor={fmt(t.cli)} sub="plan activo" estado={sem.cli || (t.cli>0?'ambar':'rojo')}/>
          <KPI label="% Conversión" valor={pct(t.pctCli)} sub="registro → cliente" estado={sem.pctCli} accent={!sem.pctCli || sem.pctCli==='na' ? C.tienda : null}/>
          <KPI label="Baseline (G+M)" valor={fmt(t.google+t.meta)} sub="always-on"/>
          <KPI label="Iniciativas" valor={fmt(t.tiktok+t.webinar+t.paginas)} sub="TikTok+Webinar+Páginas"/>
          <KPI label={`Inv. ${ult.sem||'última sem'}`} valor={mxnDec(ult.inv||0)} sub="Google + Meta"/>
          <KPI label="CPR última sem" valor={mxnDec(ult.reg?ult.inv/ult.reg:0)} sub={`${fmt(ult.reg||0)} registros`} estado={sem.cpr}/>
        </div>
      </Sec>
      <Sec titulo="Salud del producto" help="Estado del producto basado en KPIs del periodo vs baseline histórica. Verde = en rango. Ámbar = desviación moderada. Rojo = atención urgente."><Banner {...d.salud}/></Sec>

      <Sec titulo="Comportamiento semanal" help="Distribución de registros por fuente de tráfico cada semana. Baseline = Google + Meta (always-on, son la línea base). Iniciativas = TikTok + Webinar + T1 Páginas (especiales, suben en eventos). Total = todo junto. Permite ver si una iniciativa especial está moviendo el volumen o solo está canibalizando el baseline." nota="baseline always-on vs iniciativas especiales">
        <div style={{overflowX:'auto',border:`1px solid ${C.line}`,borderRadius:3}}>
          <table style={{width:'100%',borderCollapse:'collapse',fontSize:11.7,background:C.card}}>
            <thead><tr style={{background:C.bgAlt}}>
              {['Semana','Google','Meta','Baseline','TikTok','Webinar','T1 Páginas','Total'].map(h=>(
                <th key={h} style={{padding:'8px 9px',textAlign:h==='Semana'?'left':'right',fontWeight:700,color:C.ink,whiteSpace:'nowrap'}}>{h}</th>))}
            </tr></thead>
            <tbody>
              {d.semanas.map((s,i)=>{
                const bl=s.google+s.meta; const tot=bl+s.tiktok+s.webinar+s.paginas;
                const enCurso = isEnCurso(s, hoyStr);
                return (
                  <tr key={i} style={{borderTop:`1px solid ${C.line}`,background:enCurso?C.bgAlt:'transparent'}}>
                    <td style={{padding:'7px 9px',fontWeight:600}}>{s.sem}{enCurso && <span style={{marginLeft:6,fontSize:9.5,fontWeight:700,color:C.ambar,background:C.bgAmbar,padding:'1px 6px',borderRadius:99,textTransform:'uppercase',letterSpacing:'.05em'}}>En curso</span>}</td>
                    <td style={{padding:'7px 9px',textAlign:'right'}}>{fmt(s.google)}</td>
                    <td style={{padding:'7px 9px',textAlign:'right'}}>{fmt(s.meta)}</td>
                    <td style={{padding:'7px 9px',textAlign:'right',fontWeight:700,color:C.tienda}}>{fmt(bl)}</td>
                    <td style={{padding:'7px 9px',textAlign:'right',color:C.sub}}>{s.tiktok||'—'}</td>
                    <td style={{padding:'7px 9px',textAlign:'right',color:C.sub}}>{s.webinar||'—'}</td>
                    <td style={{padding:'7px 9px',textAlign:'right',color:C.sub}}>{s.paginas||'—'}</td>
                    <td style={{padding:'7px 9px',textAlign:'right',fontWeight:700}}>{fmt(tot)}</td>
                  </tr>);
              })}
            </tbody>
          </table>
        </div>
      </Sec>

      <Sec titulo="KPIs semanales" help="Tabla semana × semana de Tienda. Reg = registros con UTM paid. CPR = costo por registro. % Calif = registros que prendieron tienda_usuario_calificado. Check.Ini = checkout iniciado (banderazo en HubSpot). Visitas Pl. = visitas a planes (banderazo). Cli = clientes con plan activo. CPA = inversión ÷ clientes. Pagos Acum. = GMV total generado por esa cohorte hasta hoy. ROI = pagos acum. ÷ inversión." nota="click en Check.Ini o Cli para ver y copiar los ShopIDs · ROI = pagos acum. / inversión">
        <div style={{overflowX:'auto',border:`1px solid ${C.line}`,borderRadius:3}}>
          <table style={{width:'100%',borderCollapse:'collapse',fontSize:11.8,background:C.card}}>
            <thead><tr style={{background:C.bgAlt}}>
              {['Semana','Inversión','Reg','CPR','Calif','% Calif','Check.Ini','Visitas Pl.','Cli','CPA','Pagos Acum.','ROI'].map(h=>(
                <th key={h} style={{padding:'8px 9px',textAlign:h==='Semana'?'left':'right',fontWeight:700,color:C.ink,whiteSpace:'nowrap'}}>{h}</th>))}
            </tr></thead>
            <tbody>
              {d.semanas.map((s,i)=>{
                const cpr = s.reg ? s.inv/s.reg : 0;
                const cpa = s.cli ? s.inv/s.cli : 0;
                const roi = s.inv ? s.gmv/s.inv : 0;
                const enCurso = isEnCurso(s, hoyStr);
                const checkIni = s.checkIni || 0;
                return (
                  <tr key={i} style={{borderTop:`1px solid ${C.line}`,background:enCurso?C.bgAlt:'transparent'}}>
                    <td style={{padding:'7px 9px',fontWeight:600}}>{s.sem}{enCurso && <span style={{marginLeft:6,fontSize:9.5,fontWeight:700,color:C.ambar,background:C.bgAmbar,padding:'1px 6px',borderRadius:99,textTransform:'uppercase',letterSpacing:'.05em'}}>En curso</span>}</td>
                    <td style={{padding:'7px 9px',textAlign:'right'}}>{mxnDec(s.inv)}</td>
                    <td style={{padding:'7px 9px',textAlign:'right'}}>{fmt(s.reg)}</td>
                    <td style={{padding:'7px 9px',textAlign:'right',color:C.sub}}>{s.inv?mxnDec(cpr):'—'}</td>
                    <td style={{padding:'7px 9px',textAlign:'right'}}>{fmt(s.calif)}</td>
                    <td style={{padding:'7px 9px',textAlign:'right',color:C.sub}}>{pct(s.reg?s.calif/s.reg*100:0)}</td>
                    <td style={{padding:'7px 9px',textAlign:'right'}}>
                      {checkIni > 0
                        ? <button onClick={()=>abrirModalUsuarios(s, 'checkIni')} style={{background:'transparent',border:'none',padding:'2px 7px',color:C.tienda,fontWeight:700,cursor:'pointer',textDecoration:'underline',textUnderlineOffset:3,fontSize:'inherit',fontFamily:'inherit'}} title={`Ver ShopIDs · ${s.sem}`}>{fmt(checkIni)}</button>
                        : fmt(checkIni)}
                    </td>
                    <td style={{padding:'7px 9px',textAlign:'right'}}>{fmt(s.visitasPlanes || 0)}</td>
                    <td style={{padding:'7px 9px',textAlign:'right',fontWeight:600}}>
                      {s.cli > 0
                        ? <button onClick={()=>abrirModalUsuarios(s, 'cli')} style={{background:'transparent',border:'none',padding:'2px 7px',color:C.tienda,fontWeight:700,cursor:'pointer',textDecoration:'underline',textUnderlineOffset:3,fontSize:'inherit',fontFamily:'inherit'}} title={`Ver ShopIDs · ${s.sem}`}>{fmt(s.cli)}</button>
                        : fmt(s.cli)}
                    </td>
                    <td style={{padding:'7px 9px',textAlign:'right',color:C.sub}}>{s.cli&&s.inv?mxnDec(cpa):'—'}</td>
                    <td style={{padding:'7px 9px',textAlign:'right',color:C.sub}}>{s.gmv?mxnDec(s.gmv):'—'}</td>
                    <td style={{padding:'7px 9px',textAlign:'right',fontWeight:700,color:C.tienda}}>{s.inv?roi.toFixed(2):'—'}</td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
        <div style={{fontSize:11,color:C.sub,marginTop:6,lineHeight:1.5}}>ROI = tienda_pagos_acumulado / inversión (ingreso directo, sin margen). Las cohortes recientes pueden tener ROI bajo porque el plan se cobra mensual.</div>
      </Sec>

      <Sec titulo="Tendencia" help="Dos líneas: Total (todos los registros del periodo) vs Baseline (solo Google + Meta always-on). La distancia entre ambas líneas es lo que aporta cada iniciativa especial. Si Total y Baseline se mueven igual, las iniciativas no están aportando volumen incremental." nota="baseline vs total (con iniciativas)">
        <div style={{background:C.card,border:`1px solid ${C.line}`,borderRadius:8,padding:'16px 8px 8px'}}>
          <ResponsiveContainer width="100%" height={240}>
            <LineChart data={chart} margin={{top:5,right:18,left:-8,bottom:5}}>
              <CartesianGrid stroke={C.line} strokeDasharray="3 3"/>
              <XAxis dataKey="sem" tick={{fontSize:11,fill:C.sub}}/>
              <YAxis tick={{fontSize:11,fill:C.sub}}/>
              <Tooltip contentStyle={{fontSize:12,borderRadius:6,border:`1px solid ${C.line}`}}/>
              <Line type="monotone" dataKey="Total" stroke={d.color} strokeWidth={2.5} dot={{r:3}}/>
              <Line type="monotone" dataKey="Baseline" stroke={C.sub} strokeWidth={2} strokeDasharray="4 3" dot={{r:3}}/>
            </LineChart>
          </ResponsiveContainer>
        </div>
        <AnalisisTendencia semanas={d.semanas} hoyStr={hoyStr} color={d.color}/>
      </Sec>

      <Sec titulo="Conversión a cliente" help="% de registros que se convierten en clientes activos de Tienda (plan activo). En Tienda el ciclo es lento: el trial dura 30 días, así que las cohortes recientes tienen 0% conversión por diseño. La métrica es comparable solo en cohortes de hace 1+ mes.">
        <div style={{background:C.card,border:`1px solid ${C.line}`,borderLeft:`4px solid ${C.rojo}`,borderRadius:3,padding:'14px 18px'}}>
          <div style={{fontSize:13,fontWeight:700,color:C.ink}}>{`${fmt(t.cli)} clientes en ${fmt(t.reg)} registros (${pct(t.pctCli)})`}</div>
          <div style={{fontSize:12.5,color:C.sub,marginTop:4,lineHeight:1.55}}>
            El producto entra en su ventana de primera medición real: las cohortes de abril vencen trial en la segunda quincena de mayo. Si el % no escala a 5-8% en junio, el problema es de modelo freemium o pricing.
          </div>
        </div>
      </Sec>

      <Sec titulo="Tendencia mensual" help="Cuántos registros entraron en promedio por semana en cada mes, qué tan estables fueron esas semanas entre sí, y cuánto costó cada registro. La semana en curso se reporta aparte para no jalar el promedio hacia abajo. Útil para detectar caídas o saltos por algoritmo, estacionalidad o cambios de estrategia." nota="promedio semanal por mes · histórico desde Ene 2026"><TendenciaMensual producto="tienda" color={C.tienda}/></Sec>
      <Sec titulo="Lectura de salud" help="Resumen ejecutivo dividido en dos: lo que está funcionando bien y debe mantenerse, vs lo que requiere atención y posible ajuste. Generado automáticamente desde los KPIs del periodo."><LecturaSalud funciona={d.funciona} atencion={d.atencion}/></Sec>
      <Sec titulo="Preguntas frecuentes" help="Q&A automático generado desde los datos del periodo. Responde las preguntas más comunes sobre el estado actual del producto: ¿cómo vamos? ¿qué está pasando con la conversión? ¿cuánto cuesta cada cliente? etc."><FAQ items={d.faq} color={d.color}/></Sec>
      <Sec titulo="Notas de contexto" help="Eventos, lanzamientos, pausas de pauta, festivos y otros factores externos del periodo que pueden explicar las variaciones de los KPIs. Si una semana se ve rara, esta sección suele tener el porqué."><Notas notas={d.notas}/></Sec>
      {usuariosModal && (
        <UsuariosSemanaModal
          semana={usuariosModal.semana}
          tituloTipo={usuariosModal.tituloTipo}
          usuarios={usuariosModal.usuarios}
          csvPrefix={usuariosModal.csvPrefix}
          columnasExtra={[
            { header:'Calif', render:(u)=>u.calificado?'✓':'—' },
            { header:'Check.Ini', render:(u)=>u.checkoutIniciado?'✓':'—' },
            { header:'Visitas Pl.', render:(u)=>u.visitasPlanes?'✓':'—' },
            { header:'Cliente', render:(u)=>u.cliente?'✓':'—' },
            { header:'GMV', render:(u)=>u.gmv ? '$'+u.gmv.toLocaleString('es-MX',{minimumFractionDigits:2,maximumFractionDigits:2}) : '—' }
          ]}
          cerrar={()=>setUsuariosModal(null)}/>
      )}
    </div>
  );
}

/* ── VISTA PAGOS ───────────────────────────────────────────── */
// Modal genérico de usuarios de UNA semana — se abre al click en una celda numérica de KPIs semanales.
// Props:
//   - semana: { sem, desde, hasta }
//   - tituloTipo: "Clientes" | "Calificados" | "Checkout iniciado" | "Visitas a planes" | "Registros"
//   - usuarios: lista filtrada al rango+flag de esa celda
//   - prefix: nombre del CSV (ej "clientes-pagos", "calif-tienda")
//   - columnas extras: array opcional de { header, render } para mostrar atributos específicos
function UsuariosSemanaModal({semana, tituloTipo, usuarios, csvPrefix='usuarios', columnasExtra=[], cerrar}){
  const [copiado, setCopiado] = useState(false);
  useEffect(() => {
    const h = (e) => { if (e.key === 'Escape') cerrar(); };
    window.addEventListener('keydown', h);
    return () => window.removeEventListener('keydown', h);
  }, [cerrar]);
  const shopIds = usuarios.map(u => u.shopid).filter(Boolean);
  const copiarShopIDs = () => {
    navigator.clipboard.writeText(shopIds.join('\n')).then(() => {
      setCopiado(true);
      setTimeout(() => setCopiado(false), 2000);
    });
  };
  const descargarCSV = () => {
    const headersBase = ['ShopID','Email','Fecha creación','Campaña','Canal'];
    const headersExtra = columnasExtra.map(c => c.header);
    const headers = [...headersBase, ...headersExtra];
    const rows = usuarios.map(u => {
      const base = [u.shopid || '', u.email || '', u.fechaCreacion || '', u.campana || '', u.canal || ''];
      const extra = columnasExtra.map(c => c.render(u));
      return [...base, ...extra];
    });
    const csv = [headers, ...rows].map(r => r.map(v => `"${String(v).replace(/"/g,'""')}"`).join(',')).join('\n');
    const blob = new Blob([csv], {type:'text/csv;charset=utf-8'});
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url; a.download = `${csvPrefix}-${semana.desde}_${semana.hasta}.csv`;
    a.click();
    URL.revokeObjectURL(url);
  };
  return (
    <div onClick={cerrar} style={{position:'fixed',inset:0,background:'rgba(0,0,0,0.45)',zIndex:1000,display:'flex',alignItems:'center',justifyContent:'center',padding:20}}>
      <div onClick={e => e.stopPropagation()} style={{background:C.card,borderRadius:8,maxWidth:960,width:'100%',maxHeight:'88vh',display:'flex',flexDirection:'column',boxShadow:'0 12px 40px rgba(0,0,0,0.25)'}}>
        <div style={{padding:'16px 22px',borderBottom:`1px solid ${C.line}`,display:'flex',alignItems:'center',gap:14}}>
          <div style={{flex:1}}>
            <div style={{fontWeight:700,fontSize:15,color:C.ink}}>{tituloTipo} · {semana.sem}</div>
            <div style={{fontSize:12,color:C.sub,marginTop:2}}>{usuarios.length} usuario{usuarios.length!==1?'s':''} · {shopIds.length} con ShopID</div>
          </div>
          <button onClick={cerrar} style={{border:'none',background:'transparent',fontSize:22,color:C.sub,cursor:'pointer',padding:'4px 10px',borderRadius:4,fontFamily:'inherit'}} aria-label="Cerrar">×</button>
        </div>
        <div style={{padding:'12px 22px',display:'flex',gap:10,borderBottom:`1px solid ${C.line}`,background:C.bgAlt}}>
          <button onClick={copiarShopIDs} disabled={!shopIds.length} style={{padding:'8px 16px',border:`1px solid ${C.line}`,background:copiado?C.bgVerde:C.card,color:copiado?C.verde:C.ink,borderRadius:6,fontSize:12.5,fontWeight:600,cursor:shopIds.length?'pointer':'not-allowed',fontFamily:'inherit',opacity:shopIds.length?1:0.5}}>
            {copiado ? `✓ ${shopIds.length} ShopIDs copiados` : `📋 Copiar ${shopIds.length} ShopID${shopIds.length!==1?'s':''}`}
          </button>
          <button onClick={descargarCSV} style={{padding:'8px 16px',border:`1px solid ${C.line}`,background:C.card,color:C.ink,borderRadius:6,fontSize:12.5,fontWeight:600,cursor:'pointer',fontFamily:'inherit'}}>
            ⬇ CSV de la semana
          </button>
        </div>
        <div style={{overflowY:'auto',flex:1}}>
          {usuarios.length === 0 ? (
            <div style={{padding:'40px 22px',textAlign:'center',color:C.sub,fontSize:13}}>Sin usuarios en esta semana.</div>
          ) : (
            <table style={{width:'100%',borderCollapse:'collapse',fontSize:11.8,background:C.card}}>
              <thead><tr style={{background:C.bgAlt,position:'sticky',top:0}}>
                {['ShopID','Email','Fecha','Campaña (adquisición)', ...columnasExtra.map(c=>c.header)].map(h=>(
                  <th key={h} style={{padding:'10px 14px',textAlign:'left',fontWeight:700,color:C.ink,whiteSpace:'nowrap',borderBottom:`1px solid ${C.line}`}}>{h}</th>))}
              </tr></thead>
              <tbody>
                {usuarios.map((u,i)=>(
                  <tr key={i} style={{borderTop:`1px solid ${C.line}`}}>
                    <td style={{padding:'8px 14px',fontWeight:700,fontFamily:'monospace',color:C.ink}}>{u.shopid || '—'}</td>
                    <td style={{padding:'8px 14px',color:C.sub}}>{u.email || '—'}</td>
                    <td style={{padding:'8px 14px',color:C.sub,fontFamily:'monospace',fontSize:11}}>{u.fechaCreacion || '—'}</td>
                    <td style={{padding:'8px 14px',fontSize:11.3,color:C.sub}}>{u.campana || '—'}</td>
                    {columnasExtra.map((c,j)=>(
                      <td key={j} style={{padding:'8px 14px',fontSize:11.3,color:C.sub}}>{c.render(u)}</td>
                    ))}
                  </tr>
                ))}
              </tbody>
            </table>
          )}
        </div>
      </div>
    </div>
  );
}

function VistaPagos({d, hoyStr, error, refrescar, cargando, semaforos, periodoLabel, fallbackAt}){
  const t = d.total;
  const chart = d.semanas.map(s=>({sem:s.sem, Registros:s.reg, Clientes:s.cli, enCurso:isEnCurso(s,hoyStr)}));
  const sem = semaforos || {};
  const [clientesModal, setClientesModal] = useState(null); // legacy clientes para Álvaro
  const [usuariosModal, setUsuariosModal] = useState(null); // funnel usuarios por status

  // Filtra usuarios de la semana por bucket. tipo = 'reg' | 'calif' | 'sinEnviar' | 'revision' | 'aprobada' | 'rechazada' | 'tx' | 'liq'
  const abrirModalUsuarios = (semana, tipo) => {
    const all = (d.usuarios || []).filter(u => u.fechaCreacion && u.fechaCreacion >= semana.desde && u.fechaCreacion <= semana.hasta);
    const filtros = {
      reg:       { titulo:'Registros',         filtrar:()=>all,                                  csv:'reg-pagos' },
      calif:     { titulo:'Calificados',       filtrar:()=>all.filter(u=>u.calificado),          csv:'calif-pagos' },
      sinEnviar: { titulo:'Doc sin enviar',    filtrar:()=>all.filter(u=>u.docBucket==='sinEnviar'), csv:'doc-sin-enviar' },
      revision:  { titulo:'Doc en revisión',   filtrar:()=>all.filter(u=>u.docBucket==='revision'),  csv:'doc-revision' },
      aprobada:  { titulo:'Doc aprobada',      filtrar:()=>all.filter(u=>u.docBucket==='aprobada'),  csv:'doc-aprobada' },
      rechazada: { titulo:'Doc rechazada',     filtrar:()=>all.filter(u=>u.docBucket==='rechazada'), csv:'doc-rechazada' },
      tx:        { titulo:'1ª Transacción',    filtrar:()=>all.filter(u=>u.primTx),              csv:'1a-tx' },
      liq:       { titulo:'1ª Liquidación',    filtrar:()=>all.filter(u=>u.primLiq),             csv:'1a-liq' }
    };
    const f = filtros[tipo];
    if (!f) return;
    setUsuariosModal({ semana, tituloTipo: f.titulo, usuarios: f.filtrar(), csvPrefix: f.csv });
  };

  const funnel = [
    { e:'Registros paid',         n:t.reg,         p:100, key:'reg' },
    { e:'Doc sin enviar',         n:t.docSinEnviar, p:t.reg?t.docSinEnviar/t.reg*100:0, key:'sinEnviar' },
    { e:'Doc enviada (total)',    n:t.docEnviada,  p:t.reg?t.docEnviada/t.reg*100:0,   key:null },
    { e:'  ↳ En revisión',        n:t.docRevision, p:t.reg?t.docRevision/t.reg*100:0,  key:'revision', color:C.ambar },
    { e:'  ↳ Aprobada ⭐',         n:t.docAprobada, p:t.reg?t.docAprobada/t.reg*100:0,  key:'aprobada', color:C.verde },
    { e:'  ↳ Rechazada ⚠',        n:t.docRechazada, p:t.reg?t.docRechazada/t.reg*100:0, key:'rechazada', color:C.rojo },
    { e:'Calificado (link creado)', n:t.calif,    p:t.reg?t.calif/t.reg*100:0,         key:'calif' },
    { e:'1ª Transacción',         n:t.primTx,      p:t.reg?t.primTx/t.reg*100:0,       key:'tx' },
    { e:'1ª Liquidación',         n:t.primLiq,     p:t.reg?t.primLiq/t.reg*100:0,      key:'liq' }
  ];
  return (
    <div>
      <ToolbarProducto fuente="T1 API (dev.t1api.com/identity-api)" refrescar={refrescar} cargando={cargando} color={C.pagos}/>
      <FallbackBanner persistedAt={fallbackAt}/>
      {error && <FuenteError fuente="T1 API" error={error}/>}
      <Sec titulo="KPIs del periodo" help="Resumen de Pagos en el rango seleccionado. Registros = nuevos contactos paid en HubSpot. Calificados = pagos_links_creados ≥ 1 (crearon un link de cobro). Clientes = quienes ya tienen 1ª transacción o 1ª liquidación. % Conversión = clientes ÷ registros. CPR = inversión ÷ registros. CPA = inversión ÷ clientes. El ROI de Pagos lo calcula Álvaro aparte con los montos transados." nota={periodoLabel || d.periodo}>
        <div style={{display:'grid',gridTemplateColumns:'repeat(auto-fit,minmax(185px,1fr))',gap:12}}>
          <KPI label="Registros" valor={fmt(t.reg)} sub="cohorte paid" estado={sem.reg}/>
          <KPI label="Calificados" valor={fmt(t.calif)} sub={pct(t.pctCalif)+' del total'} estado={sem.pctCalif}/>
          <KPI label="Clientes" valor={fmt(t.cli)} sub="primera tx/liq" estado={sem.cli || d.salud.estado}/>
          <KPI label="% Conversión" valor={pct(t.pctCli)} sub="registro → cliente" estado={sem.pctCli} accent={!sem.pctCli || sem.pctCli==='na' ? C.pagos : null}/>
          <KPI label="Inversión" valor={t.inv?mxnDec(t.inv):'cargar'} sub={t.inv?'cargada':'sin cargar'}/>
          <KPI label="CPR" valor={t.cpr?mxnDec(t.cpr):'—'} sub="costo / registro" estado={sem.cpr}/>
          <KPI label="CPA" valor={t.cpa?mxnDec(t.cpa):'—'} sub="costo / cliente" estado={sem.cpa}/>
        </div>
      </Sec>
      <Sec titulo="Salud del producto" help="Estado de Pagos basado en KPIs del periodo. Verde = en rango. Ámbar = desviación moderada. Rojo = atención urgente. Pagos es el producto con mayor latencia (el alta operativa requiere documentación), por eso muchas cohortes recientes pueden verse en rojo solo por aging."><Banner {...d.salud}/></Sec>

      <Sec titulo="KPIs semanales" help="Tabla semanal completa con el funnel de Pagos. Reg = registros paid. CPR = costo por registro. Calif = link creado. SinEnv = docs sin enviar (estatus vacío o pendiente). Rev = documentos en revisión. Apr ⭐ = aprobada. Rec ⚠ = rechazada. 1ªTx = primera transacción. 1ªLiq = primera liquidación. Cli = tiene tx O liq. CPA = costo por cliente. Click en cualquier número abre modal con los ShopIDs." nota="click en cualquier número (Reg/Calif/SinEnv/Rev/Apr/Rec/Tx/Liq/Cli) para ver y copiar los ShopIDs · ROI lo calcula Álvaro con los montos transados">
        <div style={{overflowX:'auto',border:`1px solid ${C.line}`,borderRadius:3}}>
          <table style={{width:'100%',borderCollapse:'collapse',fontSize:11.5,background:C.card}}>
            <thead><tr style={{background:C.bgAlt}}>
              {['Semana','Inv.','Reg','CPR','Calif','% Cal','SinEnv','Rev','Apr','Rec','1ªTx','1ªLiq','Cli','CPA'].map(h=>(
                <th key={h} style={{padding:'8px 7px',textAlign:h==='Semana'?'left':'right',fontWeight:700,color:C.ink,whiteSpace:'nowrap'}}>{h}</th>))}
            </tr></thead>
            <tbody>
              {d.semanas.map((s,i)=>{
                const cpr = s.reg ? s.inv/s.reg : 0;
                const cpa = s.cli ? s.inv/s.cli : 0;
                const enCurso = isEnCurso(s, hoyStr);
                const cellBtn = (val, tipo, color = C.pagos) => val > 0
                  ? <button onClick={()=>abrirModalUsuarios(s, tipo)} style={{background:'transparent',border:'none',padding:'2px 5px',color,fontWeight:700,cursor:'pointer',textDecoration:'underline',textUnderlineOffset:3,fontSize:'inherit',fontFamily:'inherit'}} title={`Ver ShopIDs · ${s.sem}`}>{fmt(val)}</button>
                  : fmt(val);
                return (
                  <tr key={i} style={{borderTop:`1px solid ${C.line}`,background:enCurso?C.bgAlt:'transparent'}}>
                    <td style={{padding:'7px 7px',fontWeight:600}}>{s.sem}{enCurso && <span style={{marginLeft:6,fontSize:9.5,fontWeight:700,color:C.ambar,background:C.bgAmbar,padding:'1px 6px',borderRadius:99,textTransform:'uppercase',letterSpacing:'.05em'}}>En curso</span>}</td>
                    <td style={{padding:'7px 7px',textAlign:'right'}}>{mxnDec(s.inv)}</td>
                    <td style={{padding:'7px 7px',textAlign:'right'}}>{cellBtn(s.reg, 'reg')}</td>
                    <td style={{padding:'7px 7px',textAlign:'right',color:C.sub}}>{s.inv?mxnDec(cpr):'—'}</td>
                    <td style={{padding:'7px 7px',textAlign:'right'}}>{cellBtn(s.calif, 'calif')}</td>
                    <td style={{padding:'7px 7px',textAlign:'right',color:C.sub}}>{pct(s.reg?s.calif/s.reg*100:0)}</td>
                    <td style={{padding:'7px 7px',textAlign:'right',color:C.sub}}>{cellBtn(s.docSinEnviar||0, 'sinEnviar', C.sub)}</td>
                    <td style={{padding:'7px 7px',textAlign:'right'}}>{cellBtn(s.docRevision||0, 'revision', C.ambar)}</td>
                    <td style={{padding:'7px 7px',textAlign:'right'}}>{cellBtn(s.docAprobada||0, 'aprobada', C.verde)}</td>
                    <td style={{padding:'7px 7px',textAlign:'right'}}>{cellBtn(s.docRechazada||0, 'rechazada', C.rojo)}</td>
                    <td style={{padding:'7px 7px',textAlign:'right'}}>{cellBtn(s.primTx||0, 'tx')}</td>
                    <td style={{padding:'7px 7px',textAlign:'right'}}>{cellBtn(s.primLiq||0, 'liq')}</td>
                    <td style={{padding:'7px 7px',textAlign:'right',fontWeight:600}}>{fmt(s.cli)}</td>
                    <td style={{padding:'7px 7px',textAlign:'right',color:C.sub}}>{s.cli&&s.inv?mxnDec(cpa):'—'}</td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
        <div style={{fontSize:11,color:C.sub,marginTop:6,lineHeight:1.5}}>
          <strong>Columnas:</strong> SinEnv = sin enviar docs (estatus vacío o "pendiente") · Rev = en revisión · Apr = aprobada ⭐ · Rec = rechazada ⚠ · 1ªTx = primera transacción · 1ªLiq = primera liquidación · Cli = tx O liq (= cliente).
          El monto transado vive en el Excel que Álvaro mantiene.
        </div>
      </Sec>

      <Sec titulo="Tendencia" help="Línea de registros y línea de clientes (con 1ª tx o 1ª liq) por semana del rango seleccionado. Las cohortes nuevas siempre muestran 0 clientes porque el alta operativa lleva semanas — son normal hasta que pasa ~1 mes." nota="registros y clientes por semana · semana en curso al final">
        <div style={{background:C.card,border:`1px solid ${C.line}`,borderRadius:8,padding:'16px 8px 8px'}}>
          <ResponsiveContainer width="100%" height={240}>
            <LineChart data={chart} margin={{top:5,right:18,left:-8,bottom:5}}>
              <CartesianGrid stroke={C.line} strokeDasharray="3 3"/>
              <XAxis dataKey="sem" tick={{fontSize:11,fill:C.sub}}/>
              <YAxis tick={{fontSize:11,fill:C.sub}}/>
              <Tooltip contentStyle={{fontSize:12,borderRadius:6,border:`1px solid ${C.line}`}}/>
              <Line type="monotone" dataKey="Registros" stroke={d.color} strokeWidth={2.5} dot={{r:3}}/>
              <Line type="monotone" dataKey="Clientes" stroke={C.sub} strokeWidth={2} strokeDasharray="4 3" dot={{r:3}}/>
            </LineChart>
          </ResponsiveContainer>
        </div>
        <AnalisisTendencia semanas={d.semanas} hoyStr={hoyStr} color={d.color}/>
      </Sec>

      <Sec titulo="Funnel de documentación" help="Funnel completo de Pagos: cuántos registros llegan en cada etapa del proceso. NO es secuencial 100% — refleja el estado actual del registro (un usuario puede saltarse pasos según cómo le toque). Verde = aprobada (camino feliz). Ámbar = en revisión (cuello de botella). Rojo = rechazada (problema). Click en cualquier paso muestra los ShopIDs." nota="click en cualquier paso para ver y copiar los ShopIDs · el flujo de documentos no es secuencial 100%, refleja estado actual del registro">
        <div style={{border:`1px solid ${C.line}`,borderRadius:3,background:C.card,padding:'4px 0'}}>
          {funnel.map((f,i)=>{
            const ind = f.e.startsWith('  ');
            const colorBar = f.color || d.color;
            // Last semana cerrada del rango — usamos el rango total para los conteos del funnel
            const rangoCompleto = { sem: 'periodo completo', desde: d.semanas[0]?.desde, hasta: d.semanas[d.semanas.length-1]?.hasta };
            const clickable = f.key && f.n > 0;
            return (
              <div key={i} style={{display:'flex',alignItems:'center',gap:13,padding:'10px 17px',borderTop:i>0?`1px solid ${C.line}`:'none'}}>
                <div style={{width:230,fontWeight:ind?500:600,fontSize:12.5,color:ind?C.sub:C.ink,paddingLeft:ind?12:0}}>{f.e}</div>
                <div style={{flex:1,height:18,background:C.bgAlt,borderRadius:2,overflow:'hidden'}}>
                  {f.p!=null && <div style={{width:`${Math.max(f.p,1.5)}%`,height:'100%',background:colorBar,opacity:.85}}/>}
                </div>
                <div style={{width:60,textAlign:'right',fontSize:12,color:C.sub}}>{f.p!=null?f.p.toFixed(1)+'%':'—'}</div>
                <div style={{width:70,textAlign:'right',fontSize:15,fontWeight:700,color:C.ink}}>
                  {clickable
                    ? <button onClick={()=>abrirModalUsuarios(rangoCompleto, f.key)} style={{background:'transparent',border:'none',padding:'2px 7px',color:colorBar,fontWeight:700,cursor:'pointer',textDecoration:'underline',textUnderlineOffset:3,fontSize:'inherit',fontFamily:'inherit'}} title={`Ver ShopIDs · ${f.e.trim()}`}>{fmt(f.n)}</button>
                    : (f.n!=null?fmt(f.n):'—')}
                </div>
              </div>
            );
          })}
        </div>
      </Sec>
      <Sec titulo="Registros por canal" help="Distribución de los registros del periodo por fuente de tráfico (Meta, Google, etc.) según el utm_source. Permite ver qué canales están aportando volumen y dónde se debe ajustar el mix.">
        <div style={{background:C.card,border:`1px solid ${C.line}`,borderRadius:3,padding:'14px 6px 6px'}}>
          <ResponsiveContainer width="100%" height={180}>
            <BarChart data={d.canales} margin={{top:5,right:18,left:-8,bottom:5}}>
              <CartesianGrid stroke={C.line} strokeDasharray="3 3"/>
              <XAxis dataKey="c" tick={{fontSize:11,fill:C.sub}}/>
              <YAxis tick={{fontSize:11,fill:C.sub}}/>
              <Tooltip contentStyle={{fontSize:12,borderRadius:3}}/>
              <Bar dataKey="n" fill={d.color} name="Registros" radius={[2,2,0,0]}/>
            </BarChart>
          </ResponsiveContainer>
        </div>
      </Sec>
      <Sec titulo="Tendencia mensual" help="Cuántos registros entraron en promedio por semana en cada mes, qué tan estables fueron esas semanas entre sí, y cuánto costó cada registro. La semana en curso se reporta aparte para no jalar el promedio hacia abajo. Útil para detectar caídas o saltos por algoritmo, estacionalidad o cambios de estrategia." nota="promedio semanal por mes · histórico desde Ene 2026"><TendenciaMensual producto="pagos" color={C.pagos}/></Sec>
      <Sec titulo="Lectura de salud" help="Resumen ejecutivo dividido en dos: lo que está funcionando bien y debe mantenerse, vs lo que requiere atención y posible ajuste. Generado automáticamente desde los KPIs del periodo."><LecturaSalud funciona={d.funciona} atencion={d.atencion}/></Sec>
      <Sec titulo="Preguntas frecuentes" help="Q&A automático generado desde los datos del periodo. Responde las preguntas más comunes sobre el estado actual del producto: ¿cómo vamos? ¿qué está pasando con la conversión? ¿cuánto cuesta cada cliente? etc."><FAQ items={d.faq} color={d.color}/></Sec>
      <Sec titulo="Notas de contexto" help="Eventos, lanzamientos, pausas de pauta, festivos y otros factores externos del periodo que pueden explicar las variaciones de los KPIs. Si una semana se ve rara, esta sección suele tener el porqué."><Notas notas={d.notas}/></Sec>
      {clientesModal && (
        <UsuariosSemanaModal
          semana={clientesModal.semana}
          tituloTipo="Clientes Pagos"
          usuarios={clientesModal.clientes}
          csvPrefix="clientes-pagos"
          columnasExtra={[
            { header:'Estado', render:(c)=>c.estado || '—' },
            { header:'Fecha 1ª tx', render:(c)=>c.fechaPrimeraTx || '—' },
            { header:'Fecha 1ª liq', render:(c)=>c.fechaPrimeraLiq || '—' }
          ]}
          cerrar={()=>setClientesModal(null)}/>
      )}
      {usuariosModal && (
        <UsuariosSemanaModal
          semana={usuariosModal.semana}
          tituloTipo={usuariosModal.tituloTipo}
          usuarios={usuariosModal.usuarios}
          csvPrefix={usuariosModal.csvPrefix}
          columnasExtra={[
            { header:'Doc estatus', render:(u)=>u.docStatus || '—' },
            { header:'Calif', render:(u)=>u.calificado?'✓':'—' },
            { header:'Links', render:(u)=>u.linksCreados || '—' },
            { header:'1ª Tx', render:(u)=>u.primTx?'✓':'—' },
            { header:'1ª Liq', render:(u)=>u.primLiq?'✓':'—' }
          ]}
          cerrar={()=>setUsuariosModal(null)}/>
      )}
    </div>
  );
}

/* ── VISTA RESUMEN ─────────────────────────────────────────── */
function VistaResumen({irA, snap, roi8}){
  const cards = ['envios','tienda','pagos'].map(k => ({
    key: k,
    d: { ...CURATED[k], total: snap[k].total },
    reg: snap[k].total.reg,
    cli: snap[k].total.cli,
    pctCli: snap[k].total.pctCli,
    roi: k==='envios' ? (roi8?.total?.roi?.toFixed(2) ?? '—')
       : (snap[k].total.roi?.toFixed(2) ?? '—'),
    nota: CURATED[k].salud.detalle
  }));
  return (
    <div>
      <Sec titulo="Estado de los tres productos" help="Vista consolidada de Envíos + Tienda + Pagos al cierre del día. Para cada producto muestra: registros del periodo, % calificado, clientes, ROI/conversión y semáforo de salud. Click en cualquier card para ir al detalle del producto." nota={`datos al corte · ${snap.corte}`}>
        <div style={{display:'grid',gridTemplateColumns:'repeat(auto-fit,minmax(255px,1fr))',gap:13}}>
          {cards.map(p=>(
            <div key={p.key} onClick={()=>irA(p.key)} style={{background:C.card,border:`1px solid ${C.line}`,borderTop:`4px solid ${p.d.color}`,borderRadius:3,padding:'17px 19px',cursor:'pointer',transition:'transform .1s'}}
              onMouseEnter={e=>e.currentTarget.style.transform='translateY(-2px)'}
              onMouseLeave={e=>e.currentTarget.style.transform='translateY(0)'}>
              <div style={{display:'flex',justifyContent:'space-between',alignItems:'center'}}>
                <span style={{fontSize:18,fontWeight:600,color:C.ink}}>{p.d.nombre}</span>
                <span style={{width:9,height:9,borderRadius:'50%',background:colorEst[p.d.salud.estado]}}/>
              </div>
              <div style={{display:'flex',gap:20,marginTop:14,flexWrap:'wrap'}}>
                {[['Registros',fmt(p.reg)],['Clientes',fmt(p.cli)],['% Conv',pct(p.pctCli)],['ROI',p.roi]].map(([l,v])=>(
                  <div key={l}>
                    <div style={{fontSize:10.5,textTransform:'uppercase',letterSpacing:'.08em',color:C.subLt,fontWeight:600}}>{l}</div>
                    <div style={{fontSize:22,fontWeight:700,color:C.ink,letterSpacing:'-0.01em',marginTop:2}}>{v}</div>
                  </div>))}
              </div>
              <div style={{fontSize:11.8,color:C.sub,marginTop:11,lineHeight:1.5}}>{p.nota}</div>
              <div style={{fontSize:11.3,color:p.d.color,marginTop:9,fontWeight:700}}>Ver detalle →</div>
            </div>))}
        </div>
      </Sec>
      <Sec titulo="Cómo leer este dashboard" help="Guía rápida para usuarios nuevos. Explica los semáforos, la diferencia entre baseline e iniciativas, qué significa cohort por UTM, ventana móvil de 8 semanas, etc.">
        <div style={{background:C.card,border:`1px solid ${C.line}`,borderRadius:3,padding:'15px 19px',fontSize:12.8,lineHeight:1.65,color:C.sub}}>
          Cada producto tiene su pestaña con KPIs, comportamiento semanal, lectura de salud y un bloque de <strong>preguntas frecuentes</strong> que responde lo que se le preguntaría a Marketing.
          Los <strong>semáforos</strong> indican salud: verde sano, ámbar atención, rojo crítico.
          Las <strong>notas de contexto</strong> explican lo que los números no dicen solos — conviene leerlas antes de concluir.
          Las cifras se actualizan desde HubSpot vía API. La pestaña Tráfico se alimenta de GA4 Data API; la inversión se carga subiendo el export de Google Ads o Meta.
        </div>
      </Sec>
    </div>
  );
}

/* ── VISTA TRÁFICO (GA4) ───────────────────────────────────── */
// Calcula desde a partir de un preset y hasta dado
function rangoDesdePreset(preset, hastaYmd){
  const [y,m,d] = hastaYmd.split('-').map(Number);
  const dt = new Date(Date.UTC(y, m-1, d));
  const dias = preset === '7d' ? 6 : preset === '14d' ? 13 : preset === '30d' ? 29 : preset === '60d' ? 59 : preset === '90d' ? 89 : null;
  if (dias === null) return null;
  dt.setUTCDate(dt.getUTCDate() - dias);
  return `${dt.getUTCFullYear()}-${String(dt.getUTCMonth()+1).padStart(2,'0')}-${String(dt.getUTCDate()).padStart(2,'0')}`;
}

// Chips de preset + date pickers calendario. Cambia un rango (desde,hasta) según preset relativo a `hastaRef`
// o por selección directa de fechas calendario (cualquier rango arbitrario).
function PresetRangoChips({rango, onChange, hastaRef, presets = ['7d','14d','30d','60d','global'], defaultGlobal}){
  // Estado local para los inputs date (para no disparar fetch en cada tecla, solo al click Aplicar)
  const [localDesde, setLocalDesde] = useState(rango.desde);
  const [localHasta, setLocalHasta] = useState(rango.hasta);
  // Sync local con prop cuando cambia desde fuera (ej. global reset)
  useEffect(() => { setLocalDesde(rango.desde); setLocalHasta(rango.hasta); }, [rango.desde, rango.hasta]);

  // Detecta cuál preset matchea el rango actual
  const match = (() => {
    if (defaultGlobal && rango.desde === defaultGlobal.desde && rango.hasta === defaultGlobal.hasta) return 'global';
    for (const p of presets) {
      if (p === 'global') continue;
      const d = rangoDesdePreset(p, rango.hasta);
      if (d === rango.desde) return p;
    }
    return null;
  })();
  const inputStyle = { padding:'4px 8px', border:`1px solid ${C.line}`, borderRadius:5, fontSize:11.5, fontFamily:'inherit', color:C.ink, background:C.card };
  const pendiente = localDesde !== rango.desde || localHasta !== rango.hasta;
  const aplicarFechas = () => {
    if (localDesde && localHasta && localDesde <= localHasta) onChange({ desde: localDesde, hasta: localHasta });
  };
  return (
    <div style={{display:'inline-flex',gap:6,alignItems:'center',flexWrap:'wrap'}}>
      <span style={{fontSize:11,color:C.sub,marginRight:4}}>Rango:</span>
      {presets.map(p => {
        const active = match === p;
        return (
          <button key={p} onClick={() => {
            if (p === 'global') onChange({ desde: defaultGlobal.desde, hasta: defaultGlobal.hasta });
            else {
              const d = rangoDesdePreset(p, hastaRef || rango.hasta);
              if (d) onChange({ desde: d, hasta: hastaRef || rango.hasta });
            }
          }} style={{
            padding:'4px 10px', background: active ? C.ink : C.card, color: active ? C.bg : C.ink,
            border:`1px solid ${active ? C.ink : C.line}`, borderRadius: 99, fontSize: 11.5, fontWeight: 600,
            cursor:'pointer', fontFamily:'inherit', whiteSpace:'nowrap'
          }}>{p === 'global' ? 'global' : p}</button>
        );
      })}
      <span style={{width:1,height:18,background:C.line,marginLeft:4,marginRight:2}}/>
      <input type="date" value={localDesde} onChange={e => setLocalDesde(e.target.value)} style={inputStyle} title="Desde"/>
      <span style={{fontSize:11,color:C.sub}}>→</span>
      <input type="date" value={localHasta} onChange={e => setLocalHasta(e.target.value)} style={inputStyle} title="Hasta"/>
      <button onClick={aplicarFechas} disabled={!pendiente} style={{
        padding:'4px 10px', background: pendiente ? C.brandRed : C.card, color: pendiente ? '#fff' : C.subLt,
        border:`1px solid ${pendiente ? C.brandRed : C.line}`, borderRadius: 5, fontSize: 11.5, fontWeight: 600,
        cursor: pendiente ? 'pointer' : 'not-allowed', fontFamily:'inherit'
      }}>Aplicar</button>
      <span style={{fontSize:10.5,color:C.sub,marginLeft:6,fontVariantNumeric:'tabular-nums'}}>{labelRango(rango.desde, rango.hasta)}</span>
    </div>
  );
}

function VistaTrafico({desde: globalDesde, hasta: globalHasta}){
  // Cada panel tiene su propio rango — default = global, pero Nat puede cambiar independiente.
  const [rangoTendencia, setRangoTendencia] = useState({ desde: globalDesde, hasta: globalHasta });
  const [rangoAgregado, setRangoAgregado]   = useState({ desde: globalDesde, hasta: globalHasta });
  const [rangoBarras, setRangoBarras]       = useState({ desde: globalDesde, hasta: globalHasta });
  // Si el global cambia, reseteamos cada panel al global (UX: Aplicar global = mover todo)
  useEffect(() => {
    setRangoTendencia({ desde: globalDesde, hasta: globalHasta });
    setRangoAgregado({ desde: globalDesde, hasta: globalHasta });
    setRangoBarras({ desde: globalDesde, hasta: globalHasta });
  }, [globalDesde, globalHasta]);

  const [intervalo,setIntervalo] = useState('day');
  const [tendenciaData, setTendenciaData] = useState(null);
  const [agregadoData,  setAgregadoData]  = useState(null);
  const [barrasData,    setBarrasData]    = useState(null);
  const [err,setErr] = useState(null);

  // Fetch tendencia (depende de intervalo + rangoTendencia)
  useEffect(() => {
    setTendenciaData(null); setErr(null);
    api(`/trafico/serie?desde=${rangoTendencia.desde}&hasta=${rangoTendencia.hasta}&intervalo=${intervalo}`)
      .then(setTendenciaData).catch(e => setErr(e.message));
  }, [rangoTendencia.desde, rangoTendencia.hasta, intervalo]);

  // Fetch agregado
  useEffect(() => {
    setAgregadoData(null);
    api(`/trafico?desde=${rangoAgregado.desde}&hasta=${rangoAgregado.hasta}`)
      .then(setAgregadoData).catch(e => setErr(e.message));
  }, [rangoAgregado.desde, rangoAgregado.hasta]);

  // Fetch barras
  useEffect(() => {
    setBarrasData(null);
    api(`/trafico?desde=${rangoBarras.desde}&hasta=${rangoBarras.hasta}`)
      .then(setBarrasData).catch(e => setErr(e.message));
  }, [rangoBarras.desde, rangoBarras.hasta]);

  if (err) return <div style={{padding:20,color:C.rojo}}>Error GA4: {err}</div>;

  const defaultGlobal = { desde: globalDesde, hasta: globalHasta };

  // Adaptadores para que el resto del componente siga funcionando
  const data = agregadoData;
  const serie = tendenciaData;

  // Pivot la serie tendencia
  let serieChart = [], landings = [];
  if (tendenciaData) {
    const pivote = {};
    const landingsSet = new Set();
    for (const r of tendenciaData.rows) {
      if (!pivote[r.fecha]) pivote[r.fecha] = { fecha: r.fecha };
      pivote[r.fecha][r.landing] = r.sesiones;
      landingsSet.add(r.landing);
    }
    serieChart = Object.values(pivote).sort((a,b) => a.fecha.localeCompare(b.fecha));
    landings = [...landingsSet];
  }
  const seriesColors = [C.envios, C.tienda, C.pagos, C.brandRed, C.sub];

  return (
    <div>
      {/* Panel 1: Tendencia */}
      <Sec titulo="Tendencia de tráfico" help="Sesiones de adquisición en www.t1.com (landings /mx, /mx/envios, /mx/tienda, /mx/pagos). Se filtran por hostName=www.t1.com para excluir el uso de la app shipping.t1.com de clientes ya logueados, y se usa landingPage (primera vista de la sesión) en lugar de pagePath." nota={`sesiones por ${intervalo==='day'?'día':'semana ISO'}`}>
        <div style={{display:'flex',gap:8,marginBottom:12,alignItems:'center',flexWrap:'wrap'}}>
          {[['day','Diario'],['week','Semanal']].map(([k,label]) => (
            <button key={k} onClick={()=>setIntervalo(k)} style={{
              padding:'7px 14px',background:intervalo===k?C.ink:C.card,color:intervalo===k?C.bg:C.ink,
              border:`1px solid ${intervalo===k?C.ink:C.line}`,borderRadius:6,fontSize:12.5,fontWeight:600,
              cursor:'pointer',fontFamily:'inherit'
            }}>{label}</button>
          ))}
          <span style={{width:1,height:22,background:C.line,marginLeft:6,marginRight:2}}/>
          <PresetRangoChips rango={rangoTendencia} onChange={setRangoTendencia} hastaRef={globalHasta} defaultGlobal={defaultGlobal}/>
        </div>
        {!tendenciaData ? (
          <div style={{padding:20,color:C.sub,fontSize:13}}>Cargando…</div>
        ) : (
          <div style={{background:C.card,border:`1px solid ${C.line}`,borderRadius:8,padding:'16px 8px 8px'}}>
            <ResponsiveContainer width="100%" height={280}>
              <LineChart data={serieChart} margin={{top:5,right:18,left:-8,bottom:5}}>
                <CartesianGrid stroke={C.line} strokeDasharray="3 3"/>
                <XAxis dataKey="fecha" tick={{fontSize:10,fill:C.sub}}/>
                <YAxis tick={{fontSize:11,fill:C.sub}}/>
                <Tooltip contentStyle={{fontSize:12,borderRadius:6,border:`1px solid ${C.line}`}}/>
                {landings.map((l,i) => (
                  <Line key={l} type="monotone" dataKey={l} stroke={seriesColors[i % seriesColors.length]} strokeWidth={2} dot={false}/>
                ))}
              </LineChart>
            </ResponsiveContainer>
            <div style={{display:'flex',gap:14,flexWrap:'wrap',padding:'10px 14px 4px',fontSize:11.5,color:C.sub}}>
              {landings.map((l,i) => (
                <span key={l} style={{display:'inline-flex',alignItems:'center',gap:5}}>
                  <span style={{width:10,height:2,background:seriesColors[i % seriesColors.length],display:'inline-block'}}/>
                  {l}
                </span>
              ))}
            </div>
          </div>
        )}
      </Sec>

      {/* Panel 2: Agregado (tabla) */}
      <Sec titulo="Tráfico de landings · agregado" help="Tabla agregada por landing: sesiones, usuarios únicos, vistas de página y % de rebote. Útil para detectar qué landings reciben más volumen (paid + orgánico) y cuáles tienen problemas (rebote alto = la página no engancha)." nota={`GA4 · sesiones, usuarios, vistas, %rebote`}>
        <div style={{marginBottom:12}}>
          <PresetRangoChips rango={rangoAgregado} onChange={setRangoAgregado} hastaRef={globalHasta} defaultGlobal={defaultGlobal}/>
        </div>
        {!agregadoData ? (
          <div style={{padding:20,color:C.sub,fontSize:13}}>Cargando…</div>
        ) : (
          <div style={{overflowX:'auto',border:`1px solid ${C.line}`,borderRadius:3}}>
            <table style={{width:'100%',borderCollapse:'collapse',fontSize:12,background:C.card}}>
              <thead><tr style={{background:C.bgAlt}}>
                {['Landing','Propiedad','Sesiones','Usuarios','Vistas','% Rebote'].map(h=>(
                  <th key={h} style={{padding:'8px 11px',textAlign:h==='Landing'||h==='Propiedad'?'left':'right',fontWeight:700,color:C.ink}}>{h}</th>))}
              </tr></thead>
              <tbody>
                {agregadoData.landings.map((r,i)=>(
                  <tr key={i} style={{borderTop:`1px solid ${C.line}`}}>
                    <td style={{padding:'7px 11px',fontWeight:600}}>{r.landing}</td>
                    <td style={{padding:'7px 11px',color:C.sub}}>{r.propiedad}</td>
                    <td style={{padding:'7px 11px',textAlign:'right'}}>{fmt(r.sesiones)}</td>
                    <td style={{padding:'7px 11px',textAlign:'right'}}>{fmt(r.usuarios)}</td>
                    <td style={{padding:'7px 11px',textAlign:'right'}}>{fmt(r.vistas)}</td>
                    <td style={{padding:'7px 11px',textAlign:'right',color:C.sub}}>{pct(r.rebote)}</td>
                  </tr>))}
              </tbody>
            </table>
          </div>
        )}
      </Sec>

      {/* Panel 3: Barras por landing */}
      <Sec titulo="Tráfico por landing" help="Mismos datos del agregado pero como gráfico de barras: sesiones (todas las visitas, incluye repetidos) vs usuarios únicos (cuentas distintas). Si las barras son muy parecidas, casi nadie repite. Si la barra de sesiones es mucho más alta, hay alta recurrencia (bueno) o tráfico de bots (malo)." nota="comparativo visual sesiones vs usuarios">
        <div style={{marginBottom:12}}>
          <PresetRangoChips rango={rangoBarras} onChange={setRangoBarras} hastaRef={globalHasta} defaultGlobal={defaultGlobal}/>
        </div>
        {!barrasData ? (
          <div style={{padding:20,color:C.sub,fontSize:13}}>Cargando…</div>
        ) : (
          <div style={{background:C.card,border:`1px solid ${C.line}`,borderRadius:3,padding:'14px 6px 6px'}}>
            <ResponsiveContainer width="100%" height={220}>
              <BarChart data={barrasData.landings} margin={{top:5,right:18,left:-8,bottom:5}}>
                <CartesianGrid stroke={C.line} strokeDasharray="3 3"/>
                <XAxis dataKey="landing" tick={{fontSize:10,fill:C.sub}}/>
                <YAxis tick={{fontSize:11,fill:C.sub}}/>
                <Tooltip contentStyle={{fontSize:12,borderRadius:3}}/>
                <Bar dataKey="sesiones" fill={C.envios} name="Sesiones" radius={[2,2,0,0]}/>
                <Bar dataKey="usuarios" fill={C.tienda} name="Usuarios" radius={[2,2,0,0]}/>
              </BarChart>
            </ResponsiveContainer>
          </div>
        )}
      </Sec>
    </div>
  );
}

/* ── VISTA AYUDA · instructivo para Natalia ────────────────── */
function VistaAyuda(){
  const Q = ({titulo, children}) => (
    <div style={{background:C.card,border:`1px solid ${C.line}`,borderRadius:8,padding:'18px 22px',marginBottom:14}}>
      <div style={{fontSize:14,fontWeight:700,color:C.ink,marginBottom:8,letterSpacing:'-0.01em'}}>{titulo}</div>
      <div style={{fontSize:13,color:C.sub,lineHeight:1.65,fontWeight:500}}>{children}</div>
    </div>
  );
  return (
    <div>
      <Sec titulo="Cómo usar este dashboard" help="Información general del proyecto: cobertura de vacaciones de Natalia, fuentes de datos (HubSpot + T1 API + GA4), reglas de cohort por UTM, contacto técnico (Bryant), y políticas de saneamiento. Lee esto antes de tomar decisiones grandes basadas en los KPIs." nota="guía rápida · proyecto temporal de vacaciones">
        <Q titulo="¿Qué hago si llego en la mañana?">
          1. Verifica el rango en el header (por default: últimos 42 días terminando hoy).
          2. Mira la pestaña <strong>Resumen</strong>: las 3 cards te dicen el estado de cada producto.
          3. Si un producto tiene la salud en ámbar o rojo, entra a su pestaña para ver el detalle.
          4. La sección <strong>Lectura de la tendencia</strong> debajo de cada gráfica te explica lo que está pasando.
        </Q>

        <Q titulo="¿Cómo cargo la inversión?">
          Pestaña <strong>Inversión</strong> → botón <strong>📂 Subir CSV</strong> (o "o pegar texto"). Tres formatos auto-detectados:
          <ul style={{margin:'6px 0',paddingLeft:18}}>
            <li><strong>Google Ads (recomendado):</strong> exporta el reporte de Google Ads tal cual. Si incluye la dimensión <strong>Día</strong>, se guarda con granularidad diaria — el chat puede responder CPR/CPA por fecha exacta.</li>
            <li><strong>Long:</strong> 3 columnas — Campaña, Fecha, Gasto.</li>
            <li><strong>Wide:</strong> filas = campañas en orden de la tabla, columnas = semanas.</li>
          </ul>
          Cuando guardas, los CPR/CPA/ROI se recalculan al instante.
        </Q>
        <Q titulo="¿Cómo exporto Google Ads con la dimensión Día?">
          En la UI de Google Ads → vista de Campañas → arriba haz click en <strong>Día</strong> en la barra de Periodo/Segmento (o agrega "Día" como segmento). Exporta el CSV. Cada campaña va a aparecer un row por día con su Costo. Sube ese archivo y el parser distribuye automáticamente.
        </Q>

        <Q titulo="¿Qué significa 'En curso' vs 'Última completa'?">
          Las semanas corren Lunes-Domingo. La <strong>semana en curso</strong> (si hoy es martes, sería esta semana) está marcada con un pill naranja "EN CURSO" y los datos están incompletos por definición — su ROI puede verse bajo simplemente porque no han terminado las recargas.
          La <strong>última completa</strong> es la lectura limpia para juzgar performance.
        </Q>

        <Q titulo="¿Qué significan los colores de los KPIs?">
          <ul style={{margin:'6px 0 0',paddingLeft:18}}>
            <li><strong style={{color:C.verde}}>Verde</strong>: dentro de banda normal (±15% del promedio reciente).</li>
            <li><strong style={{color:C.ambar}}>Ámbar</strong>: variación entre 15-30%. Vale la pena revisar.</li>
            <li><strong style={{color:C.rojo}}>Rojo</strong>: variación &gt; 30%. Atender.</li>
          </ul>
          Para CPR/CPA, "subida" es lo malo (más caro). Para Registros/Clientes, "bajada" es lo malo.
          El semáforo se calcula contra el promedio de las 3-4 semanas previas (excluyendo la en curso).
        </Q>

        <Q titulo="¿Por qué T1 Envíos y los demás cargan a velocidades distintas?">
          Son fuentes diferentes:
          <ul style={{margin:'6px 0 0',paddingLeft:18}}>
            <li><strong>T1 Envíos</strong> → HubSpot directo. Tarda 30-90s la primera carga (paginado).</li>
            <li><strong>T1 Tienda + Pagos</strong> → T1 API. Mucho más rápido (5-20s).</li>
            <li><strong>Tráfico</strong> → GA4 Data API. Rápido pero solo carga al entrar a esa pestaña.</li>
          </ul>
          Si rate-limita una fuente, las otras siguen funcionando. Cada vista de producto tiene su propio botón <strong>"↻ Refrescar este producto"</strong>.
        </Q>

        <Q titulo="¿Y si veo un número que no me cuadra?">
          1. Verifica el rango en el header — los filtros aplican a todo.
          2. Ve a la pestaña <strong>UTMs sin mapear</strong> — si aparece una UTM con `T1*` en el nombre, es una campaña nueva que falta agregar al catálogo.
          3. Compara contra HubSpot UI (segmento de Rebeca u otro) y mándame el número que esperabas vs el que se muestra.
        </Q>

        <Q titulo="¿Cómo le pregunto algo en lenguaje natural?">
          Botón <strong>"Consultar ✦"</strong> arriba a la derecha. Abre el chat de Gemini, que responde con base en los KPIs cargados.
          Si la pregunta no se puede responder con los datos, te lo dice claramente ("no tengo ese dato") — no inventa.
        </Q>

        <Q titulo="Fuentes y caché">
          Cada producto cachea su data por 5 minutos. El botón <strong>"Aplicar (3 productos)"</strong> del header fuerza fresh fetch.
          Hay también un cron automático que pre-calienta el snapshot a las 8 AM CDMX y cada 2h en horario laboral, así que cuando llegues a trabajar ya estará listo.
        </Q>

        <Q titulo="Caveats permanentes (no son bugs)">
          <ul style={{margin:'6px 0 0',paddingLeft:18,lineHeight:1.7}}>
            <li>T1 Envíos sí tiene campañas paid activas. No es baseline operativo.</li>
            <li>El saldo_t1envios es acumulado actual — las cohortes tempranas reflejan aging.</li>
            <li>El funnel comercial no es secuencial: los flags son eventos independientes.</li>
            <li>Variaciones &lt;±20% pueden ser ruido normal.</li>
            <li>Atribución por UTM de campaña (cross-product).</li>
          </ul>
        </Q>
      </Sec>
    </div>
  );
}

/* ── VISTA UTMs SIN MAPEAR (interactiva) ──────────────────────
   Permite al usuario asignar UTMs sin clasificar a un producto T1 o
   descartarlas. La asignación se guarda en cohortes-overrides.json y
   dispara un rebuild del snap + sync histórico (background, ~2 min).
   Match exacto por los 5 campos UTM. */
const CANALES = ['meta','google','tiktok','youtube','webinar','otro'];

function FilaAsignar({utm, n, providers, sampleEmails, catalogo, onAplicar, busy}) {
  const [producto, setProducto] = useState('');
  const [canal, setCanal] = useState('');
  const [campana, setCampana] = useState('');

  const campanasFiltradas = catalogo.filter(c =>
    (!producto || c.producto === producto) && (!canal || c.canal === canal)
  );

  const puedeAsignar = producto && canal && campana && !busy;
  const puedeDescartar = !busy;

  const reset = () => { setProducto(''); setCanal(''); setCampana(''); };
  const asignar = async () => {
    await onAplicar({ utm, accion: 'asignar', producto, canal, campana });
    reset();
  };
  const descartar = async () => {
    if (!confirm(`¿Descartar ${n} contactos con esta UTM? No contarán en ningún producto.`)) return;
    await onAplicar({ utm, accion: 'descartar' });
  };

  const provStr = Object.entries(providers||{}).map(([k,v])=>`${k}=${v}`).join(' · ');
  return (
    <tr style={{borderTop:`1px solid ${C.line}`,verticalAlign:'top'}}>
      <td style={{padding:'9px 10px',width:60,textAlign:'right',fontWeight:700,color:C.ink,fontSize:13}}>{fmt(n)}</td>
      <td style={{padding:'9px 10px',fontSize:11.5,lineHeight:1.5,color:C.sub,maxWidth:340}}>
        <div><span style={{color:C.subLt}}>source:</span> <code style={{color:C.ink,fontWeight:600}}>{utm.utm_source || '—'}</code></div>
        <div><span style={{color:C.subLt}}>medium:</span> <code>{utm.utm_medium || '—'}</code></div>
        <div><span style={{color:C.subLt}}>campaign:</span> <code style={{color:C.ink}}>{utm.utm_campaign || '—'}</code></div>
        <div><span style={{color:C.subLt}}>content:</span> <code style={{color:C.ink}}>{utm.utm_content || '—'}</code></div>
        <div><span style={{color:C.subLt}}>term:</span> <code>{utm.utm_term || '—'}</code></div>
        <div style={{marginTop:5,color:C.subLt,fontSize:10.5}}>providers: {provStr}</div>
        {sampleEmails?.length > 0 && <div style={{marginTop:2,color:C.subLt,fontSize:10.5}}>ejemplos: {sampleEmails.slice(0,2).join(' · ')}</div>}
      </td>
      <td style={{padding:'9px 10px'}}>
        <select value={producto} onChange={e=>{setProducto(e.target.value); setCampana('');}}
          style={{padding:'5px 7px',fontSize:11.5,border:`1px solid ${C.line}`,borderRadius:4,background:C.card,minWidth:90}} disabled={busy}>
          <option value="">— producto —</option>
          <option value="envios">envíos</option>
          <option value="tienda">tienda</option>
          <option value="pagos">pagos</option>
        </select>
      </td>
      <td style={{padding:'9px 10px'}}>
        <select value={canal} onChange={e=>{setCanal(e.target.value); setCampana('');}}
          style={{padding:'5px 7px',fontSize:11.5,border:`1px solid ${C.line}`,borderRadius:4,background:C.card,minWidth:90}} disabled={busy}>
          <option value="">— canal —</option>
          {CANALES.map(c => <option key={c} value={c}>{c}</option>)}
        </select>
      </td>
      <td style={{padding:'9px 10px'}}>
        <select value={campana} onChange={e=>setCampana(e.target.value)}
          style={{padding:'5px 7px',fontSize:11.5,border:`1px solid ${C.line}`,borderRadius:4,background:C.card,minWidth:180,maxWidth:220}} disabled={busy || !producto || !canal}>
          <option value="">— campaña canónica —</option>
          {campanasFiltradas.map((c,i) => <option key={i} value={c.campana}>{c.campana}</option>)}
        </select>
      </td>
      <td style={{padding:'9px 10px',whiteSpace:'nowrap'}}>
        <button onClick={asignar} disabled={!puedeAsignar}
          style={{padding:'6px 12px',fontSize:11.5,fontWeight:700,color:'#fff',background:puedeAsignar?C.envios:C.subLt,border:'none',borderRadius:4,cursor:puedeAsignar?'pointer':'not-allowed',marginRight:6}}>
          Asignar
        </button>
        <button onClick={descartar} disabled={!puedeDescartar}
          style={{padding:'6px 10px',fontSize:11.5,fontWeight:600,color:C.rojo,background:'transparent',border:`1px solid ${C.rojo}`,borderRadius:4,cursor:puedeDescartar?'pointer':'not-allowed'}}>
          Descartar
        </button>
      </td>
    </tr>
  );
}

function VistaUtms({desde, hasta}){
  const [data,setData] = useState(null);
  const [err,setErr] = useState(null);
  const [busy,setBusy] = useState(false);
  const [msg,setMsg] = useState(null);
  const [overridesData,setOverridesData] = useState({overrides: [], catalogo: []});

  const cargar = async () => {
    setErr(null);
    try {
      const [vivo, ov] = await Promise.all([
        api(`/utms-sinclasificar-vivo?desde=${desde}&hasta=${hasta}`),
        api(`/cohortes-overrides`)
      ]);
      setData(vivo);
      setOverridesData(ov);
    } catch (e) { setErr(e.message); }
  };

  useEffect(() => { setData(null); cargar(); }, [desde, hasta]);

  // Después de POST/DELETE NO refetcheamos /utms-sinclasificar-vivo (es lento).
  // Actualizamos el estado local: la fila desaparece de items y el override aparece en la lista.
  // El rebuild en background del backend ya está actualizando snap + histórico.
  const aplicarOverride = async ({utm, accion, producto, canal, campana}) => {
    setBusy(true); setMsg(null);
    try {
      const body = { ...utm, accion, producto, canal, campana };
      const resp = await api('/cohortes-overrides', { method:'POST', body: JSON.stringify(body) });
      // Quitar la fila de items locales (match por los 5 campos UTM)
      const keyOf = u => `${u.utm_source||''}|||${u.utm_medium||''}|||${u.utm_campaign||''}|||${u.utm_content||''}|||${u.utm_term||''}`;
      const k = keyOf(utm);
      setData(prev => prev ? { ...prev, items: prev.items.filter(x => keyOf(x.utm) !== k) } : prev);
      // Agregar override a la lista local
      setOverridesData(prev => ({ ...prev, overrides: [...(prev.overrides||[]), resp.override] }));
      setMsg({ tipo: 'ok', txt: `${accion === 'descartar' ? 'Descartada' : 'Asignada'} ✓ — el rebuild del snap+histórico corre en background (2-3 min). Los KPIs viejos se recalculan automático.` });
    } catch (e) {
      setMsg({ tipo: 'err', txt: e.message });
    } finally { setBusy(false); }
  };

  const eliminarOverride = async (idx) => {
    if (!confirm('¿Quitar este override? Los contactos que matcheaban con la regla manual volverán a su clasificación original.')) return;
    setBusy(true); setMsg(null);
    try {
      await api(`/cohortes-overrides/${idx}`, { method:'DELETE' });
      // Quitar de la lista local
      setOverridesData(prev => ({ ...prev, overrides: (prev.overrides||[]).filter((_, i) => i !== idx) }));
      setMsg({ tipo: 'ok', txt: 'Override removido — rebuild en background. La UTM volverá a aparecer en la tabla al próximo refresh.' });
    } catch (e) { setMsg({ tipo: 'err', txt: e.message }); } finally { setBusy(false); }
  };

  // Botón opcional: forzar refetch desde HubSpot/T1 (lento, ~30-60s).
  const refrescarUtms = async () => {
    setData(null);
    setMsg(null);
    try {
      const fresh = await api(`/utms-sinclasificar-vivo?desde=${desde}&hasta=${hasta}&fresh=1`);
      setData(fresh);
    } catch (e) { setMsg({ tipo: 'err', txt: e.message }); }
  };

  if (err) return <div style={{padding:20,color:C.rojo}}>Error: {err}</div>;
  if (!data) return <div style={{padding:20,color:C.sub}}>Cargando UTMs sin clasificar en vivo (~30-60s)…</div>;

  const items = data.items || [];
  const overrides = overridesData.overrides || [];
  const catalogo = overridesData.catalogo || [];

  return (
    <div>
      {msg && (
        <div style={{padding:'10px 14px',marginBottom:12,borderRadius:6,fontSize:12.5,fontWeight:500,
          background: msg.tipo==='ok' ? '#ECFDF5' : C.bgRojo,
          color: msg.tipo==='ok' ? '#065F46' : C.rojo,
          border: `1px solid ${msg.tipo==='ok' ? '#A7F3D0' : C.rojo}`}}>
          {msg.txt}
        </div>
      )}

      <Sec titulo="UTMs sin clasificar — asignar a producto"
        help="Lista de combinaciones UTM (5 campos) que no caen en ninguna regla del catálogo. Asigna cada una al producto T1 que le corresponda (con su canal y campaña canónica) o descártala si es ruido. El match es por los 5 campos EXACTOS. El cambio dispara un rebuild del snap + histórico en background (2-3 min). Fuente: envíos viene de HubSpot, tienda+pagos vienen de T1 API."
        nota={`${data?.fromCache ? 'cache instantáneo · ' : 'fresco · '}rebuild autom. del histórico al asignar`}
      >
        <div style={{display:'flex',justifyContent:'flex-end',marginBottom:10,gap:8}}>
          <button onClick={refrescarUtms} disabled={!data} title="Forza re-fetch HubSpot+T1 API (30-60s). Úsalo solo si crees que el cache está desactualizado."
            style={{padding:'6px 14px',fontSize:11.5,fontWeight:600,color:C.envios,background:'transparent',border:`1px solid ${C.envios}`,borderRadius:4,cursor:data?'pointer':'wait'}}>
            ↻ Refrescar UTMs (lento)
          </button>
        </div>
        <div style={{display:'grid',gridTemplateColumns:'repeat(auto-fit,minmax(160px,1fr))',gap:12,marginBottom:16}}>
          <KPI label="Combinaciones únicas" valor={fmt(items.length)} sub="con al menos 1 UTM"/>
          <KPI label="Contactos recuperables" valor={fmt(items.reduce((s,x)=>s+x.n, 0))} sub="suma de todos"/>
          <KPI label="sinClasificar total" valor={fmt(data.totalSinClasificar)} sub="incluye organic/direct sin UTM"/>
          <KPI label="Overrides activos" valor={fmt(overrides.length)} sub="reglas manuales del usuario"/>
        </div>
        {items.length === 0 ? (
          <div style={{padding:30,textAlign:'center',color:C.sub,background:C.card,border:`1px solid ${C.line}`,borderRadius:8}}>
            No hay UTMs con señal sin clasificar en este rango — todo lo paid ya está mapeado.
          </div>
        ) : (
          <div style={{overflowX:'auto',border:`1px solid ${C.line}`,borderRadius:8,background:C.card}}>
            <table style={{width:'100%',borderCollapse:'collapse',fontSize:12}}>
              <thead><tr style={{background:C.bgAlt}}>
                <th style={{padding:'10px 10px',textAlign:'right',fontWeight:700,color:C.ink,whiteSpace:'nowrap',width:60}}>Contactos</th>
                <th style={{padding:'10px 10px',textAlign:'left',fontWeight:700,color:C.ink}}>UTM (5 campos)</th>
                <th style={{padding:'10px 10px',textAlign:'left',fontWeight:700,color:C.ink,minWidth:110}}>Producto</th>
                <th style={{padding:'10px 10px',textAlign:'left',fontWeight:700,color:C.ink,minWidth:110}}>Canal</th>
                <th style={{padding:'10px 10px',textAlign:'left',fontWeight:700,color:C.ink,minWidth:200}}>Campaña canónica</th>
                <th style={{padding:'10px 10px',textAlign:'left',fontWeight:700,color:C.ink,minWidth:170}}>Acción</th>
              </tr></thead>
              <tbody>
                {items.map((x,i) => (
                  <FilaAsignar key={i} utm={x.utm} n={x.n} providers={x.providers} sampleEmails={x.sampleEmails}
                    catalogo={catalogo} onAplicar={aplicarOverride} busy={busy}/>
                ))}
              </tbody>
            </table>
          </div>
        )}
      </Sec>

      <Sec titulo={`Overrides activos (${overrides.length})`} help="Reglas manuales que el usuario ha creado. Cada una hace match exacto de los 5 campos UTM y manda los contactos al producto/canal/campaña indicado. Si quieres deshacer una asignación, dale al botón Quitar — los contactos vuelven a su clasificación original (probablemente sinClasificar)." nota="cada regla matchea UTM exacto · el rebuild histórico también se dispara al quitar">
        {overrides.length === 0 ? (
          <div style={{padding:20,textAlign:'center',color:C.sub,background:C.card,border:`1px solid ${C.line}`,borderRadius:8,fontSize:12}}>
            Sin overrides activos. Cuando asignes una UTM arriba, aparecerá aquí.
          </div>
        ) : (
          <div style={{overflowX:'auto',border:`1px solid ${C.line}`,borderRadius:8,background:C.card}}>
            <table style={{width:'100%',borderCollapse:'collapse',fontSize:12}}>
              <thead><tr style={{background:C.bgAlt}}>
                {['Acción','UTM','→ Producto','→ Canal','→ Campaña','Asignado por','Fecha','Quitar'].map(h => (
                  <th key={h} style={{padding:'9px 11px',textAlign:'left',fontWeight:700,color:C.ink}}>{h}</th>
                ))}
              </tr></thead>
              <tbody>
                {overrides.map((o,i) => (
                  <tr key={i} style={{borderTop:`1px solid ${C.line}`}}>
                    <td style={{padding:'8px 11px'}}>
                      <span style={{display:'inline-block',padding:'2px 8px',borderRadius:99,fontSize:10.5,fontWeight:700,color:o.accion==='descartar'?C.rojo:C.envios,background:o.accion==='descartar'?C.bgRojo:'#FFEDD5'}}>
                        {o.accion}
                      </span>
                    </td>
                    <td style={{padding:'8px 11px',fontSize:11,color:C.sub,maxWidth:280}}>
                      <code style={{fontSize:10.5}}>{[o.utm_source,o.utm_medium,o.utm_campaign,o.utm_content,o.utm_term].map(x=>x||'—').join(' / ')}</code>
                    </td>
                    <td style={{padding:'8px 11px',fontWeight:600,color:C.ink}}>{o.producto || '—'}</td>
                    <td style={{padding:'8px 11px',color:C.sub}}>{o.canal || '—'}</td>
                    <td style={{padding:'8px 11px',fontSize:11,color:C.ink}}>{o.campana || '—'}</td>
                    <td style={{padding:'8px 11px',fontSize:11,color:C.sub}}>{o.asignadoPor || '—'}</td>
                    <td style={{padding:'8px 11px',fontSize:10.5,color:C.subLt}}>{(o.asignadoEn||'').slice(0,16).replace('T',' ')}</td>
                    <td style={{padding:'8px 11px'}}>
                      <button onClick={()=>eliminarOverride(i)} disabled={busy}
                        style={{padding:'4px 10px',fontSize:11,fontWeight:600,color:C.rojo,background:'transparent',border:`1px solid ${C.rojo}`,borderRadius:4,cursor:busy?'not-allowed':'pointer'}}>
                        Quitar
                      </button>
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        )}
      </Sec>
    </div>
  );
}

/* ── VISTA INVERSIÓN (hoja de admin) ───────────────────────── */
const MESES_LARGO = ['ene','feb','mar','abr','may','jun','jul','ago','sep','oct','nov','dic'];
function labelMes(ym){ const [y,m] = ym.split('-').map(Number); return `${MESES_LARGO[m-1]} ${y}`; }

// Input de moneda: muestra $X,XXX.XX cuando no tiene foco, raw (con decimales) al editar.
// Preserva centavos — el reporte de Google Ads viene con dos decimales y los queremos exactos.
function CurrencyCell({value, onChange, width=90}){
  const [focus, setFocus] = useState(false);
  const n = Number(value);
  const tieneVal = value !== '' && value != null && !isNaN(n) && n !== 0;
  // En modo edición mostramos el número con decimales si los tiene, sin notación científica.
  const rawStr = (() => {
    if (!tieneVal) return '';
    // Evita 0.04 → "0.040000000001" type artifacts: redondea a 2 decimales
    const rounded = Math.round(n * 100) / 100;
    return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(2);
  })();
  const display = focus ? rawStr : (tieneVal ? mxnDec(n) : '');
  return (
    <input
      type="text"
      inputMode="decimal"
      value={display}
      onChange={e => onChange(e.target.value.replace(/[^\d.]/g, ''))}
      onFocus={()=>setFocus(true)}
      onBlur={()=>setFocus(false)}
      placeholder="$0.00"
      style={{width,padding:'5px 7px',border:`1px solid ${C.line}`,borderRadius:4,fontSize:12,textAlign:'right',fontFamily:'inherit',background:C.card,color:tieneVal?C.ink:C.subLt}}
    />
  );
}

function VistaInversion({desde, hasta, onSaved}){
  const [data,setData] = useState(null);
  const [err,setErr] = useState(null);
  const [cargando,setCargando] = useState(false);
  const [guardando,setGuardando] = useState(false);
  const [valores,setValores] = useState({});
  const [pegando,setPegando] = useState(false);
  const [pasteText,setPasteText] = useState('');
  const [modo,setModo] = useState('semanal'); // 'semanal' | 'mensual'

  useEffect(() => {
    setData(null); setErr(null); setCargando(true);
    api(`/inversion/grid?desde=${desde}&hasta=${hasta}&futuras=5`)
      .then(d => { setData(d); setValores(d.valores || {}); })
      .catch(e => setErr(e.message))
      .finally(() => setCargando(false));
  }, [desde, hasta]);

  // Construye las columnas según el modo. En mensual, agrupa por YYYY-MM del Lunes de la semana.
  const columnas = (() => {
    if (!data?.semanas) return [];
    if (modo === 'semanal') return data.semanas.map(s => ({ ...s, key: s.desde, semanasDelMes: [s] }));
    const meses = new Map();
    data.semanas.forEach(s => {
      const ym = s.desde.slice(0,7);
      if (!meses.has(ym)) meses.set(ym, { key: ym, sem: labelMes(ym), desde: s.desde, hasta: s.hasta, semanasDelMes: [] });
      const m = meses.get(ym);
      m.semanasDelMes.push(s);
      if (s.hasta > m.hasta) m.hasta = s.hasta;
    });
    return [...meses.values()];
  })();

  // Obtiene el valor de una celda según modo (suma de semanas si mensual).
  const getValor = (campKey, col) => {
    if (modo === 'semanal') return valores[campKey]?.[col.desde] ?? '';
    const total = col.semanasDelMes.reduce((s, w) => s + (Number(valores[campKey]?.[w.desde]) || 0), 0);
    return total > 0 ? total : '';
  };

  // Limpia una sola columna (semana/mes) para TODAS las campañas — útil para borrar un upload mal hecho sin perder otras semanas.
  const limpiarColumna = (col) => {
    if (!confirm(`¿Borrar la inversión de "${col.sem}" para TODAS las campañas? Solo afecta esta columna; las demás semanas se conservan. No se guarda hasta que le des Guardar.`)) return;
    setValores(prev => {
      const next = { ...prev };
      for (const campKey of Object.keys(next)) {
        if (!next[campKey]) continue;
        const camp = { ...next[campKey] };
        // En modo semanal: borrar la celda de col.desde y los __dias dentro de la semana.
        // En modo mensual: borrar todas las semanas que componen el mes.
        const semanasABorrar = modo === 'mensual' ? (col.semanasDelMes || []) : [col];
        for (const sem of semanasABorrar) {
          delete camp[sem.desde];
          if (camp.__dias) {
            for (const fecha of Object.keys(camp.__dias)) {
              if (fecha >= sem.desde && fecha <= sem.hasta) delete camp.__dias[fecha];
            }
          }
        }
        next[campKey] = camp;
      }
      return next;
    });
  };

  // Descartar cambios: vuelve al estado del backend (lo último guardado).
  const descartarCambios = () => {
    if (!data) return;
    if (!confirm('¿Descartar las ediciones no guardadas? Los valores vuelven al último estado guardado.')) return;
    setValores(data.valores || {});
  };

  // En semanal: setea la celda directo. En mensual: distribuye el total equitativamente entre las semanas del mes.
  const setCelda = (campKey, col, valor) => {
    if (modo === 'semanal') {
      setValores(prev => ({ ...prev, [campKey]: { ...(prev[campKey]||{}), [col.desde]: valor } }));
      return;
    }
    const num = Number(valor) || 0;
    const N = col.semanasDelMes.length;
    const porSem = N > 0 ? num / N : 0;
    setValores(prev => {
      const next = { ...prev, [campKey]: { ...(prev[campKey]||{}) } };
      col.semanasDelMes.forEach(w => { next[campKey][w.desde] = porSem; });
      return next;
    });
  };

  const guardar = async () => {
    if (!data) return;
    setGuardando(true); setErr(null);
    const rows = [];
    for (const camp of data.campanas) {
      const dias = valores[camp.key]?.__dias;
      if (dias && Object.keys(dias).length > 0) {
        // Modo DIARIO: limpia la semana completa (rangoDesde→rangoHasta) y escribe entradas por día.
        // Para limpiar usamos un row con rangoDesde/rangoHasta + gasto=0 (no se inserta porque gasto≤0).
        for (const sem of data.semanas) {
          rows.push({ campanaKey: camp.key, producto: camp.producto, canal: camp.canal,
                      fecha: sem.desde, rangoDesde: sem.desde, rangoHasta: sem.hasta, gasto: 0 });
        }
        for (const [fechaDia, gasto] of Object.entries(dias)) {
          rows.push({ campanaKey: camp.key, producto: camp.producto, canal: camp.canal,
                      fecha: fechaDia, gasto: Number(gasto) || 0 });
        }
      } else {
        // Modo SEMANAL: cada row representa la semana completa [sem.desde, sem.hasta].
        // El backend borrará TODAS las filas existentes en ese rango antes de insertar.
        for (const sem of data.semanas) {
          const v = valores[camp.key]?.[sem.desde];
          rows.push({
            campanaKey: camp.key,
            producto: camp.producto,
            canal: camp.canal,
            fecha: sem.desde,
            rangoDesde: sem.desde, rangoHasta: sem.hasta,
            gasto: Number(v) || 0
          });
        }
      }
    }
    try {
      const r = await api('/inversion/grid', { method:'POST', body: JSON.stringify({ rows }) });
      onSaved && onSaved();
      const fresh = await api(`/inversion/grid?desde=${desde}&hasta=${hasta}&futuras=5`);
      setData(fresh); setValores(fresh.valores || {});
      alert(`Guardado: ${r.guardadas} celdas · KPIs recalculados`);
    } catch (e) {
      setErr(e.message);
    } finally {
      setGuardando(false);
    }
  };

  // Parser de fechas en español: "11 de mayo de 2026" → "2026-05-11"
  const MES_NUM = { enero:1, febrero:2, marzo:3, abril:4, mayo:5, junio:6, julio:7, agosto:8, septiembre:9, octubre:10, noviembre:11, diciembre:12 };
  const parseFechaES = (s) => {
    const m = /(\d{1,2})\s*de\s*(\w+)\s*de\s*(\d{4})/i.exec(s);
    if (!m) return null;
    const dia = parseInt(m[1], 10);
    const mes = MES_NUM[m[2].toLowerCase()];
    const ano = parseInt(m[3], 10);
    if (!mes) return null;
    return `${ano}-${String(mes).padStart(2,'0')}-${String(dia).padStart(2,'0')}`;
  };
  const normalizarNombre = (s) => (s||'').replace(/\s+/g,' ').trim().toLowerCase();

  // Auto-clasifica una campaña por prefijo de nombre (cuando no está en el catálogo).
  // Ejemplo: 'T1Envios_Meta_Adquisicion_Conversion_Q1-26' → {producto:'envios', canal:'meta'}
  function autoClasificarCampaña(nombre){
    if (!nombre) return null;
    const n = nombre.toLowerCase();
    let producto = null;
    if (/^t1envios|^t1\s*env|t1envios_|env[ií]os_/.test(n)) producto = 'envios';
    else if (/^t1tienda|^t1\s*tien|t1tienda_|tienda_/.test(n)) producto = 'tienda';
    else if (/^t1pagos|^t1\s*pag|t1pagos_|pagos_/.test(n)) producto = 'pagos';
    if (!producto) return null;
    let canal = 'otro';
    if (/_meta_|_fb_|_facebook_|_instagram_/.test(n) || /\bmeta\b/.test(n)) canal = 'meta';
    else if (/_google_|_search_|_pmax_|_adwords_/.test(n) || /google/.test(n)) canal = 'google';
    else if (/_tiktok_|tiktok/.test(n)) canal = 'tiktok';
    else if (/_youtube_|_yt_|youtube/.test(n)) canal = 'youtube';
    return { producto, canal };
  }

  // Parser específico para reporte de Google Ads.
  // Detecta: línea con "Informe de campaña" + rango "X de mes de año - Y de mes de año"
  // + header con "Campaña" y "Costo" (o "Presupuesto" como fallback).
  // Parser de fecha más flexible (Google Ads usa varios formatos)
  const parseFechaCelda = (s) => {
    if (!s) return null;
    const t = String(s).trim();
    // YYYY-MM-DD o YYYY/MM/DD
    let m = /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/.exec(t);
    if (m) return `${m[1]}-${String(m[2]).padStart(2,'0')}-${String(m[3]).padStart(2,'0')}`;
    // DD/MM/YYYY o DD-MM-YYYY
    m = /^(\d{1,2})[-/](\d{1,2})[-/](\d{4})$/.exec(t);
    if (m) return `${m[3]}-${String(m[2]).padStart(2,'0')}-${String(m[1]).padStart(2,'0')}`;
    // ES "11 de mayo de 2026"
    const esFecha = parseFechaES(t);
    if (esFecha) return esFecha;
    return null;
  };

  const parseGoogleAds = (lineas, sep) => {
    // Buscar línea con rango de fechas (encabezado del reporte). Detecta start y end.
    let fechaRango = null, fechaRangoFin = null;
    for (const linea of lineas) {
      for (const celda of linea) {
        const m = /(\d{1,2}\s*de\s*\w+\s*de\s*\d{4})\s*-\s*(\d{1,2}\s*de\s*\w+\s*de\s*\d{4})/i.exec(celda);
        if (m) { fechaRango = parseFechaES(m[1]); fechaRangoFin = parseFechaES(m[2]); break; }
      }
      if (fechaRango) break;
    }
    // Buscar header con "Campaña" / "Nombre de la campaña" + columna de costo + opcional fecha.
    // Maneja Google Ads (header simple) y Meta Ads (headers más largos).
    let headerRow = -1, idxCamp = -1, idxCosto = -1, idxDia = -1;
    for (let i = 0; i < lineas.length; i++) {
      const lc = lineas[i].map(c => (c||'').toLowerCase().trim());
      const _idxCamp = lc.findIndex(h =>
        h === 'campaña' || h === 'campana' ||
        h === 'nombre de la campaña' || h === 'nombre de la campana' ||
        h === 'nombre del conjunto de anuncios' || h === 'conjunto de anuncios' ||
        h === 'campaign' || h === 'campaign name' || h === 'ad set name' || h === 'ad set'
      );
      const _idxCosto = lc.findIndex(h =>
        h === 'costo' || h === 'coste' ||
        h === 'importe gastado' || h === 'importe gastado (mxn)' ||
        h === 'gasto' || h === 'amount spent' || h === 'cost'
      );
      const _idxPres = lc.findIndex(h => h === 'presupuesto');
      const _idxDia  = lc.findIndex(h =>
        h === 'día' || h === 'dia' || h === 'date' || h === 'fecha' ||
        h === 'inicio del informe' || h === 'fin del informe' ||
        h === 'reporting starts' || h === 'reporting ends' || h === 'day'
      );
      if (_idxCamp >= 0 && (_idxCosto >= 0 || _idxPres >= 0)) {
        headerRow = i; idxCamp = _idxCamp; idxCosto = _idxCosto >= 0 ? _idxCosto : _idxPres; idxDia = _idxDia;
        break;
      }
    }
    // Detectar si el export es de Meta Ads (headers en español típicos de Meta).
    // Cuando es Meta, forzamos canal='meta' para todas las filas auto-clasificadas,
    // independiente de si el nombre del ad set incluye '_Meta_' o no.
    const esMetaExport = headerRow >= 0 && lineas[headerRow].some(h => {
      const lh = (h||'').toLowerCase().trim();
      return lh === 'importe gastado (mxn)' || lh === 'nombre del conjunto de anuncios' ||
             lh === 'inicio del informe' || lh === 'amount spent (mxn)';
    });
    if (headerRow < 0) return null; // No es formato Google Ads
    if (idxDia < 0 && !fechaRango) {
      return { error: 'Reporte de Google Ads sin fecha. Agrega la dimensión "Día" o asegúrate que el reporte tenga el rango de fechas en el encabezado.' };
    }

    // Detectar si el rango global cubre varias semanas (>7 días) sin desglose diario.
    const spanDias = (fechaRango && fechaRangoFin)
      ? Math.round((Date.UTC(...fechaRangoFin.split('-').map((v,i)=> i===1?+v-1:+v)) - Date.UTC(...fechaRango.split('-').map((v,i)=> i===1?+v-1:+v)))/86400000) + 1
      : 1;
    const multiSemana = idxDia < 0 && spanDias > 7;
    // Si el reporte cubre >7 días sin Día, distribuiremos cada valor equitativamente entre las semanas que toca el rango.
    const semanasEnRangoReporte = multiSemana
      ? data.semanas.filter(s => s.hasta >= fechaRango && s.desde <= fechaRangoFin)
      : null;

    const nuevo = { ...valores };
    const mapped = [];
    const unmatched = [];
    const excluded = []; // YouTube / Remarketing — no son adquisición, no entran al grid
    const fechasUsadas = new Set();
    let llenadas = 0;

    // Patrones de campañas NO adquisición que vienen del backend (cohortes.js).
    // Filtramos ANTES de matchear contra catálogo o autoClasificarCampaña para que
    // no terminen como filas "dinámicas" en el grid.
    const excludedPatterns = (data.excluidasPatterns || []).map(src => new RegExp(src, 'i'));
    const esExcluida = (nombre) => excludedPatterns.some(rx => rx.test(nombre));

    // Determinar el rango del reporte para limpiar entradas previas dentro de ese rango.
    // Esto evita que re-subir el mismo reporte duplique valores.
    const rangoLimpiezaDesde = fechaRango;
    const rangoLimpiezaHasta = fechaRangoFin || fechaRango;
    const semanasEnLimpieza = (rangoLimpiezaDesde && rangoLimpiezaHasta)
      ? data.semanas.filter(s => s.hasta >= rangoLimpiezaDesde && s.desde <= rangoLimpiezaHasta)
      : [];
    const campsEnUpload = new Set(); // se llena durante el parseo

    for (let i = headerRow + 1; i < lineas.length; i++) {
      const row = lineas[i];
      let nombre = (row[idxCamp] || '').trim();
      // Saltar filas de Total / vacías / "--" típicas de Google Ads
      if (!nombre || /^total/i.test(nombre) || nombre === '--' || nombre === '—') continue;
      // Normalizar espacios duplicados (typos como "Competidores  -Always On" → "Competidores -Always On")
      nombre = nombre.replace(/\s+/g, ' ').trim();
      const valor = Number((row[idxCosto] || '').replace(/[$,\s]/g, '')) || 0;
      if (valor <= 0) continue;

      // Excluir campañas NO adquisición (YouTube/Remarketing) — no entran al grid.
      if (esExcluida(nombre)) { excluded.push({ nombre, valor }); continue; }

      // Determinar fecha del row: si hay columna Día, úsala; si no, usa el rango.
      const fechaRow = idxDia >= 0 ? parseFechaCelda(row[idxDia]) : fechaRango;
      if (!fechaRow) continue;

      // Canonizar alias antes de matchear contra el catálogo.
      // aliasCampana: variantes de utm_campaign históricas → nombre canónico.
      // aliasAdset:   ad set Meta (lo que Meta exporta en CSV) → campaña Meta canónica.
      const aliases = data.aliasCampana || {};
      const adsetMap = data.aliasAdset || {};
      const nombreCanonico = adsetMap[nombre] || aliases[nombre] || nombre;
      const target = normalizarNombre(nombreCanonico);
      let camp = data.campanas.find(c => normalizarNombre(c.campana) === target) ||
                 data.campanas.find(c => normalizarNombre(c.campana).startsWith(target.slice(0, Math.max(25, target.length-5)))) ||
                 data.campanas.find(c => target.startsWith(normalizarNombre(c.campana).slice(0, 25)));

      // Auto-clasificar por prefijo del nombre si no matchea el catálogo.
      // Patrones: T1Envios_* → envíos, T1Tienda_* → tienda, T1Pagos_* → pagos.
      // Canal inferido del nombre: _Meta_ → meta, _Google_/_Search_ → google, etc.
      // Si el export es Meta, forzamos canal='meta' aunque el nombre no lo diga.
      if (!camp) {
        const auto = autoClasificarCampaña(nombreCanonico);
        if (auto) {
          const canalFinal = esMetaExport ? 'meta' : auto.canal;
          camp = { campana: nombreCanonico, canal: canalFinal, producto: auto.producto, key: nombreCanonico, _auto: true };
        } else {
          unmatched.push({ nombre, fecha: fechaRow, valor });
          continue;
        }
      }

      if (!nuevo[camp.key]) nuevo[camp.key] = {};

      // En el primer toque de esta campaña en este upload, limpiamos sus entradas
      // existentes dentro del rango del reporte. Evita duplicación al re-subir.
      if (!campsEnUpload.has(camp.key)) {
        campsEnUpload.add(camp.key);
        for (const sem of semanasEnLimpieza) {
          delete nuevo[camp.key][sem.desde];
        }
        if (nuevo[camp.key].__dias) {
          for (const fecha of Object.keys(nuevo[camp.key].__dias)) {
            if (fecha >= rangoLimpiezaDesde && fecha <= rangoLimpiezaHasta) {
              delete nuevo[camp.key].__dias[fecha];
            }
          }
        }
      }

      if (idxDia >= 0) {
        // Granularidad diaria: una entrada por día exacto
        const sem = data.semanas.find(s => fechaRow >= s.desde && fechaRow <= s.hasta);
        if (!sem) continue;
        const prev = Number(nuevo[camp.key][sem.desde]) || 0;
        nuevo[camp.key][sem.desde] = prev + valor;
        if (!nuevo[camp.key].__dias) nuevo[camp.key].__dias = {};
        nuevo[camp.key].__dias[fechaRow] = (Number(nuevo[camp.key].__dias[fechaRow])||0) + valor;
        mapped.push({ camp: camp.campana, fecha: fechaRow, valor });
        fechasUsadas.add(fechaRow);
        llenadas++;
      } else if (multiSemana && semanasEnRangoReporte && semanasEnRangoReporte.length > 0) {
        // Rango global de varias semanas sin desglose → distribuir equitativo
        const porSem = valor / semanasEnRangoReporte.length;
        for (const sem of semanasEnRangoReporte) {
          const prev = Number(nuevo[camp.key][sem.desde]) || 0;
          nuevo[camp.key][sem.desde] = prev + porSem;
          fechasUsadas.add(sem.desde);
        }
        mapped.push({ camp: camp.campana, fecha: `${fechaRango}→${fechaRangoFin}`, valor, distribuido: semanasEnRangoReporte.length });
        llenadas++;
      } else {
        // Rango de una sola semana: todo va a la semana que lo contiene
        const sem = data.semanas.find(s => fechaRow >= s.desde && fechaRow <= s.hasta);
        if (!sem) continue;
        nuevo[camp.key][sem.desde] = valor;
        mapped.push({ camp: camp.campana, fecha: fechaRow, valor });
        fechasUsadas.add(fechaRow);
        llenadas++;
      }
    }
    const fechasOrden = [...fechasUsadas].sort();
    return {
      tipo: idxDia >= 0 ? 'googleAdsDiario' : (multiSemana ? 'googleAdsMultiSemana' : 'googleAds'),
      llenadas, nuevo,
      mapped, unmatched, excluded,
      fechaInicio: fechaRango || fechasOrden[0],
      fechaFin: fechaRangoFin || fechasOrden[fechasOrden.length-1],
      diasUnicos: fechasUsadas.size,
      spanDias,
      semanasCubiertas: semanasEnRangoReporte ? semanasEnRangoReporte.length : 1
    };
  };

  // Parser permisivo: detecta separador (tab o coma) y formato (long o wide).
  // Long: 3 columnas con headers como campaña/fecha/gasto.
  // Wide: filas = campañas (en el orden de la tabla), columnas = semanas (en el orden de la tabla).
  const parseTexto = (texto) => {
    if (!data || !texto?.trim()) return { llenadas: 0, nuevo: valores };
    const sep = texto.includes('\t') ? '\t' : ',';
    const lineas = texto.trim().split(/\r?\n/).map(l => l.split(sep).map(c => c.trim()));

    // Intento 1: formato Google Ads (header "Campaña" + "Costo/Presupuesto" + línea de fecha).
    const ga = parseGoogleAds(lineas, sep);
    if (ga) return ga;

    const nuevo = { ...valores };
    let llenadas = 0;

    // Detectar formato long: header tiene 'campa'/'camp', 'fecha'/'date'/'semana', 'gasto'/'costo'/'amount'
    const head = (lineas[0] || []).map(s => s.toLowerCase());
    const idxCamp  = head.findIndex(h => /camp|nombre/i.test(h));
    const idxFecha = head.findIndex(h => /fecha|date|semana|week/i.test(h));
    const idxGasto = head.findIndex(h => /gasto|costo|spend|amount|cost|importe/i.test(h));
    const esLong = idxCamp >= 0 && idxFecha >= 0 && idxGasto >= 0;

    if (esLong) {
      // Long format: skip header
      for (let i = 1; i < lineas.length; i++) {
        const row = lineas[i];
        const campNombre = (row[idxCamp] || '').trim();
        const fecha = (row[idxFecha] || '').trim();
        const gasto = Number((row[idxGasto] || '').replace(/[$,\s]/g, '')) || 0;
        if (!campNombre || !fecha || gasto <= 0) continue;
        // Match campaña por nombre (case-insensitive, prefix match)
        const camp = data.campanas.find(c => c.campana.toLowerCase() === campNombre.toLowerCase()) ||
                     data.campanas.find(c => c.campana.toLowerCase().includes(campNombre.toLowerCase().slice(0, 20)));
        if (!camp) continue;
        // Match fecha a la semana que la contiene
        const sem = data.semanas.find(s => fecha >= s.desde && fecha <= s.hasta);
        if (!sem) continue;
        if (!nuevo[camp.key]) nuevo[camp.key] = {};
        nuevo[camp.key][sem.desde] = gasto;
        llenadas++;
      }
    } else {
      // Wide: filas = campañas en orden, columnas = semanas en orden. Detecta y omite header si existe.
      const filaInicio = lineas[0]?.[0] && isNaN(Number(lineas[0][0].replace(/[$,\s]/g,''))) && lineas.length > 1 ? 1 : 0;
      const filas = lineas.slice(filaInicio);
      filas.forEach((celdas, i) => {
        if (i >= data.campanas.length) return;
        const camp = data.campanas[i];
        // Si primera celda es texto (nombre campaña), skip
        const startJ = celdas[0] && isNaN(Number(celdas[0].replace(/[$,\s]/g,''))) ? 1 : 0;
        if (!nuevo[camp.key]) nuevo[camp.key] = {};
        celdas.slice(startJ).forEach((c, j) => {
          if (j >= data.semanas.length) return;
          const num = Number(c.replace(/[$,\s]/g, '')) || 0;
          if (num > 0) { nuevo[camp.key][data.semanas[j].desde] = num; llenadas++; }
        });
      });
    }
    return { llenadas, nuevo, esLong };
  };

  const aplicarTexto = (texto) => {
    const r = parseTexto(texto);
    if (r.error) { alert(r.error); return; }
    if (!r.llenadas) {
      alert('No se detectaron celdas con valor. Formatos soportados:\n• Reporte de Google Ads (auto-detecta fecha + columna Costo)\n• Long: 3 columnas con headers Campaña, Fecha, Gasto\n• Wide: filas = campañas en orden de la tabla');
      return;
    }
    setValores(r.nuevo);
    setPegando(false); setPasteText('');
    let msg = `✓ ${r.llenadas} celdas cargadas`;
    if (r.tipo === 'googleAdsDiario') {
      msg += ` (Google Ads diario · ${r.diasUnicos} día${r.diasUnicos>1?'s':''}: ${r.fechaInicio}${r.fechaFin!==r.fechaInicio?` → ${r.fechaFin}`:''}).`;
    } else if (r.tipo === 'googleAdsMultiSemana') {
      msg += ` (Google Ads sin desglose por Día · rango ${r.fechaInicio} → ${r.fechaFin} = ${r.spanDias} días.`+
             `\n\n⚠ Tu reporte cubre ${r.semanasCubiertas} semanas pero no incluye la columna "Día". Distribuí cada valor EQUITATIVAMENTE entre las ${r.semanasCubiertas} semanas.`+
             `\nPara que cada semana tenga su gasto exacto, vuelve a exportar Google Ads con la dimensión "Día" agregada.`;
    } else if (r.tipo === 'googleAds') {
      msg += ` (Google Ads · semana ${r.fechaInicio}).`;
    } else {
      msg += ` (formato ${r.esLong?'long':'wide'}).`;
    }
    if (r.excluded?.length) {
      const excludedAgg = new Map();
      for (const e of r.excluded) {
        if (!excludedAgg.has(e.nombre)) excludedAgg.set(e.nombre, 0);
        excludedAgg.set(e.nombre, excludedAgg.get(e.nombre) + e.valor);
      }
      const items = [...excludedAgg.entries()].sort((a,b) => b[1] - a[1]);
      msg += `\n\n— ${items.length} campañas EXCLUIDAS (YouTube/Remarketing — no adquisición):\n` +
        items.slice(0, 6).map(([n, v]) => `  • ${n} → ${mxnDec(v)}`).join('\n') +
        (items.length > 6 ? `\n  …y ${items.length - 6} más` : '');
    }
    if (r.unmatched?.length) {
      msg += `\n\n⚠ ${r.unmatched.length} campañas no mapeadas (no están en el catálogo):\n` +
        r.unmatched.slice(0, 8).map(u => `  • ${u.nombre} → ${mxnDec(u.valor)}`).join('\n') +
        (r.unmatched.length > 8 ? `\n  …y ${r.unmatched.length - 8} más` : '') +
        '\n\n(Si son campañas paid nuevas, avisar a Bryant para agregarlas a cohortes.js.)';
    }
    msg += '\n\nDale Guardar para persistir.';
    alert(msg);
  };

  // Upload de archivo: POST al backend /inversion/importar (parser robusto server-side).
  // Acepta CSV/TSV/XLSX. Backend valida, parsea, mapea contra catálogo, escribe.
  // Si no encuentra header válido → 400 con detalle (NO escribe nada).
  // Esto reemplaza el parser client-side anterior que podía caer a "wide" y corromper.
  const subirCSV = async (file) => {
    if (!file) { alert('Selecciona un archivo.'); return; }
    const fd = new FormData();
    fd.append('archivo', file);
    try {
      const resp = await fetch('/api/vacaciones/inversion/importar', {
        method: 'POST', body: fd, credentials: 'include'
      });
      const r = await resp.json();
      if (!resp.ok || r.error) {
        alert(`❌ Error procesando "${file.name}":\n\n${r.error || 'Error desconocido'}\n\nVerifica que el archivo tenga las columnas:\n• Nombre de la campaña (o Conjunto de anuncios)\n• Importe gastado (MXN) o Costo\n• Inicio del informe o Día`);
        return;
      }
      let msg = `✓ ${r.importadas} filas importadas (total $${(r.totalImportado||0).toLocaleString('es-MX',{minimumFractionDigits:2,maximumFractionDigits:2})})`;
      if (r.excluidas > 0) {
        msg += `\n\n— ${r.excluidas} campañas EXCLUIDAS (YouTube/Remarketing/Páginas):\n` +
          r.excludedSample.map(n => `  • ${n}`).join('\n');
      }
      if (r.sinMapear > 0) {
        msg += `\n\n⚠ ${r.sinMapear} campañas no mapeadas (no están en el catálogo):\n` +
          r.sinMapearSample.map(u => `  • ${u.nombre} → $${(u.gasto||0).toFixed(2)}`).join('\n') +
          `\n\n(Si son campañas paid nuevas, avisar para agregarlas a cohortes.js.)`;
      }
      msg += `\n\nLos datos ya están guardados (server-side). Recarga el dashboard.`;
      alert(msg);
      // Refrescar el grid con los nuevos valores
      const fresh = await api(`/inversion/grid?desde=${desde}&hasta=${hasta}&futuras=5`);
      setData(fresh); setValores(fresh.valores || {});
      onSaved && onSaved();
    } catch (err) {
      alert(`❌ Error en el upload:\n${err.message}`);
    }
  };

  if (err) return <div style={{padding:20,color:C.rojo}}>Error: {err}</div>;
  if (cargando || !data) return <div style={{padding:20,color:C.sub}}>Cargando inversión…</div>;

  const productos = ['envios','tienda','pagos'];
  const labels = { envios:'T1 Envíos', tienda:'T1 Tienda', pagos:'T1 Pagos' };
  const colors = { envios:C.envios, tienda:C.tienda, pagos:C.pagos };

  // Totales por columna y por campaña (usa getValor para respetar el modo)
  const totalCol = columnas.map(col =>
    data.campanas.reduce((sum, c) => sum + (Number(getValor(c.key, col)) || 0), 0)
  );
  const grandTotal = totalCol.reduce((a,b) => a+b, 0);

  return (
    <div>
      <Sec titulo="Inversión por campaña y semana" help="Grid editable de inversión paid: campañas (filas) × semanas (columnas). Cada celda en MXN. Las primeras columnas son históricas, las últimas 5 son futuras para pre-cargar. Al guardar, recalcula CPR/CPA/ROI de los 3 productos. Las campañas vienen del catálogo de cohortes (core/cohortes.js). Importar CSV de Meta/Google Ads también funciona desde aquí." nota="edita los montos en MXN · guarda para recalcular CPR/CPA/ROI">
        <div style={{display:'flex',gap:10,marginBottom:12,flexWrap:'wrap',alignItems:'center'}}>
          <label style={{padding:'9px 16px',background:C.card,color:C.ink,border:`1px solid ${C.line}`,borderRadius:6,fontSize:13,fontWeight:600,cursor:'pointer',fontFamily:'inherit',display:'inline-flex',alignItems:'center',gap:6}}>
            📂 Subir CSV o XLSX
            <input type="file" accept=".csv,.tsv,.txt,.xlsx,.xls,.xlsm" onChange={e=>subirCSV(e.target.files[0])} style={{display:'none'}}/>
          </label>
          <button onClick={()=>setPegando(true)} style={{padding:'9px 16px',background:'transparent',color:C.sub,border:`1px solid ${C.line}`,borderRadius:6,fontSize:13,fontWeight:500,cursor:'pointer',fontFamily:'inherit'}}>o pegar texto</button>
          <span style={{width:1,height:24,background:C.line,marginLeft:6,marginRight:2}}/>
          <div style={{display:'inline-flex',border:`1px solid ${C.line}`,borderRadius:6,overflow:'hidden'}}>
            {[['semanal','Semanal'],['mensual','Mensual']].map(([k,label]) => (
              <button key={k} onClick={()=>setModo(k)} style={{
                padding:'9px 14px',background:modo===k?C.ink:C.card,color:modo===k?C.bg:C.ink,
                border:'none',fontSize:12.5,fontWeight:600,cursor:'pointer',fontFamily:'inherit'
              }}>{label}</button>
            ))}
          </div>
          <button onClick={guardar} disabled={guardando} style={{padding:'9px 16px',background:C.brandRed,color:'#fff',border:'none',borderRadius:6,fontSize:13,fontWeight:600,cursor:guardando?'wait':'pointer',opacity:guardando?.6:1,fontFamily:'inherit'}}>
            {guardando ? 'Guardando…' : 'Guardar inversión'}
          </button>
          <button onClick={descartarCambios} style={{padding:'9px 14px',background:'transparent',color:C.sub,border:`1px solid ${C.line}`,borderRadius:6,fontSize:12.5,fontWeight:600,cursor:'pointer',fontFamily:'inherit'}}>↺ Descartar cambios</button>
          <button onClick={()=>{ if(confirm('⚠ BORRA TODA LA INVERSIÓN del periodo visible (todas las semanas y campañas). Si solo quieres limpiar una columna específica, usa el botón × en el encabezado de esa semana. ¿Continuar?')) setValores({}); }} style={{padding:'9px 14px',background:'transparent',color:C.rojo,border:`1px solid ${C.line}`,borderRadius:6,fontSize:12.5,fontWeight:600,cursor:'pointer',fontFamily:'inherit'}}>Limpiar todo</button>
          <div style={{fontSize:12,color:C.sub,fontWeight:500,display:'flex',alignItems:'center',gap:6}}>
            Total del periodo: <strong style={{color:C.ink,fontSize:14}}>{mxnDec(grandTotal)}</strong>
          </div>
        </div>

        <div style={{overflowX:'auto',border:`1px solid ${C.line}`,borderRadius:8,background:C.card}}>
          <table style={{width:'100%',borderCollapse:'collapse',fontSize:12}}>
            <thead>
              <tr style={{background:C.bgAlt}}>
                <th style={{padding:'9px 11px',textAlign:'left',fontWeight:700,color:C.ink,position:'sticky',left:0,background:C.bgAlt,zIndex:1,minWidth:280}}>Campaña</th>
                {columnas.map(col => (
                  <th key={col.key} style={{padding:'9px 11px',textAlign:'right',fontWeight:700,color:C.ink,whiteSpace:'nowrap'}}>
                    <div style={{display:'inline-flex',alignItems:'center',gap:6}}>
                      {col.sem}
                      <button onClick={()=>limpiarColumna(col)} title={`Limpiar ${col.sem}`} style={{padding:'0 6px',fontSize:12,lineHeight:1,background:'transparent',color:C.subLt,border:'none',cursor:'pointer',fontFamily:'inherit',borderRadius:3}}>×</button>
                    </div>
                  </th>
                ))}
                <th style={{padding:'9px 11px',textAlign:'right',fontWeight:700,color:C.ink}}>Total fila</th>
              </tr>
            </thead>
            <tbody>
              {productos.map(prod => {
                const camps = data.campanas.filter(c => c.producto === prod);
                if (camps.length === 0) return null;
                return (
                  <React.Fragment key={prod}>
                    <tr style={{background:colors[prod]+'18'}}>
                      <td colSpan={columnas.length + 2} style={{padding:'7px 11px',fontWeight:700,color:colors[prod],textTransform:'uppercase',letterSpacing:'.05em',fontSize:11,position:'sticky',left:0,background:colors[prod]+'18'}}>
                        {labels[prod]} · {camps.length} campañas
                      </td>
                    </tr>
                    {camps.map(c => {
                      const filaTotal = columnas.reduce((s, col) => s + (Number(getValor(c.key, col)) || 0), 0);
                      return (
                        <tr key={c.key} style={{borderTop:`1px solid ${C.line}`}}>
                          <td style={{padding:'7px 11px',color:C.ink,fontWeight:500,position:'sticky',left:0,background:C.card,zIndex:1}}>{c.campana}</td>
                          {columnas.map(col => (
                            <td key={col.key} style={{padding:'2px 4px',textAlign:'right'}}>
                              <CurrencyCell
                                value={getValor(c.key, col)}
                                onChange={v => setCelda(c.key, col, v)}
                                width={modo==='mensual'?110:90}
                              />
                            </td>
                          ))}
                          <td style={{padding:'7px 11px',textAlign:'right',fontWeight:600,color:C.ink}}>{filaTotal>0?mxnDec(filaTotal):'—'}</td>
                        </tr>
                      );
                    })}
                  </React.Fragment>
                );
              })}
              <tr style={{borderTop:`2px solid ${C.ink}`,background:C.bgAlt}}>
                <td style={{padding:'9px 11px',fontWeight:700,color:C.ink,position:'sticky',left:0,background:C.bgAlt,zIndex:1}}>{modo==='mensual'?'TOTAL mes':'TOTAL semana'}</td>
                {totalCol.map((t,i) => (
                  <td key={i} style={{padding:'9px 11px',textAlign:'right',fontWeight:700,color:C.ink}}>{t>0?mxnDec(t):'—'}</td>
                ))}
                <td style={{padding:'9px 11px',textAlign:'right',fontWeight:700,color:C.brandRed,fontSize:13}}>{mxnDec(grandTotal)}</td>
              </tr>
            </tbody>
          </table>
        </div>

        <div style={{fontSize:11.5,color:C.sub,marginTop:8,lineHeight:1.5,fontWeight:500}}>
          Las campañas mostradas son las del catálogo paid del PRD. Si una campaña nueva no aparece, será detectada automáticamente al refrescar el snapshot — y aparecerá aquí para asignarle inversión.
        </div>
      </Sec>

      {pegando && (
        <div onClick={()=>setPegando(false)} style={{position:'fixed',inset:0,background:'rgba(34,31,24,.5)',display:'flex',alignItems:'center',justifyContent:'center',zIndex:50,padding:20}}>
          <div onClick={e=>e.stopPropagation()} style={{background:C.card,borderRadius:12,padding:28,maxWidth:560,width:'100%',border:`1px solid ${C.line}`}}>
            <h3 style={{margin:0,color:C.ink,fontSize:18,fontWeight:700}}>Pegar inversión</h3>
            <div style={{fontSize:12.5,color:C.sub,marginTop:6,lineHeight:1.55,fontWeight:500}}>
              Tres formatos auto-detectados:
              <ul style={{margin:'6px 0',paddingLeft:18}}>
                <li><strong>Google Ads (recomendado):</strong> pega/sube el export directo. Si tiene la dimensión <em>Día</em>, se guarda diario (mejor para chat por fecha y para CPR/CPA por día).</li>
                <li><strong>Long:</strong> headers Campaña, Fecha (YYYY-MM-DD), Gasto. Una fila por (campaña, día).</li>
                <li><strong>Wide:</strong> filas = campañas en orden de la tabla, columnas = semanas.</li>
              </ul>
              Separador: tab o coma. Símbolos $ y , de miles se ignoran.
            </div>
            <textarea
              value={pasteText} onChange={e=>setPasteText(e.target.value)}
              placeholder="Pega aquí el contenido…"
              style={{width:'100%',height:200,marginTop:12,padding:10,border:`1px solid ${C.line}`,borderRadius:6,fontSize:12,fontFamily:'monospace',boxSizing:'border-box',background:C.card,color:C.ink}}
            />
            <div style={{display:'flex',gap:9,marginTop:14}}>
              <button onClick={()=>aplicarTexto(pasteText)} style={{flex:1,padding:'10px',background:C.brandRed,color:'#fff',border:'none',borderRadius:6,fontSize:13,fontWeight:600,cursor:'pointer',fontFamily:'inherit'}}>Aplicar</button>
              <button onClick={()=>{setPegando(false);setPasteText('');}} style={{padding:'10px 16px',background:'none',color:C.ink,border:`1px solid ${C.line}`,borderRadius:6,fontSize:13,cursor:'pointer',fontFamily:'inherit'}}>Cancelar</button>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}


/* ── CHAT DE CONSULTA (local · reglas + plantillas) ────────── */
// Detecta producto, métrica y periodo en la pregunta, lee del snap cargado
// y responde con texto plantillado. Sin LLM, sin latencia, sin costo.

const _MESES_NUM = { enero:1, febrero:2, marzo:3, abril:4, mayo:5, junio:6, julio:7, agosto:8, septiembre:9, octubre:10, noviembre:11, diciembre:12 };
function detectarFecha(q){
  // YYYY-MM-DD
  let m = /(\d{4})-(\d{1,2})-(\d{1,2})/.exec(q);
  if (m) return `${m[1]}-${String(m[2]).padStart(2,'0')}-${String(m[3]).padStart(2,'0')}`;
  // DD/MM/YYYY o DD-MM-YYYY
  m = /(\d{1,2})[/-](\d{1,2})[/-](\d{4})/.exec(q);
  if (m) return `${m[3]}-${String(m[2]).padStart(2,'0')}-${String(m[1]).padStart(2,'0')}`;
  // "DD de MES de YYYY" / "DD de MES" / "DD MES"
  m = /(\d{1,2})\s*(?:de\s*)?(enero|febrero|marzo|abril|mayo|junio|julio|agosto|septiembre|octubre|noviembre|diciembre)(?:\s*(?:de\s*)?(\d{4}))?/i.exec(q);
  if (m) {
    const mes = _MESES_NUM[m[2].toLowerCase()];
    const ano = m[3] ? parseInt(m[3],10) : new Date().getFullYear();
    return `${ano}-${String(mes).padStart(2,'0')}-${String(m[1]).padStart(2,'0')}`;
  }
  return null;
}

function responderChat(pregunta, snap, viewDesde, viewHasta){
  if (!snap) return 'Cargando datos… espera unos segundos y vuelve a preguntar.';
  const q = pregunta.toLowerCase().trim();
  if (!q) return '¿En qué te ayudo?';

  // Detectar producto
  let producto = null;
  if (/env[ií]os?|env/.test(q)) producto = 'envios';
  else if (/tienda/.test(q)) producto = 'tienda';
  else if (/pagos?/.test(q)) producto = 'pagos';

  // Detectar canal (alias agrupados al canónico)
  const CANAL_ALIAS = {
    google: /\b(google|adwords|search|google ads)\b/i,
    meta: /\b(meta|facebook|fb|instagram)\b/i,
    tiktok: /\btiktok\b/i,
    webinar: /\bwebinar|pato\s*madrazo\b/i,
    paginas: /\bpaginas|p[aá]ginas|t1\s*p[aá]ginas\b/i,
    youtube: /\byoutube|yt\b/i
  };
  let canal = null;
  for (const [k, re] of Object.entries(CANAL_ALIAS)) if (re.test(q)) { canal = k; break; }

  // Detectar campaña por substring (después de "campaña X" o "utm X")
  const campMatch = /(?:campa[ñn]a|utm)\s+([\w\s\-_.]+?)(?:\s+(?:el|del|en|de|a|por)\b|\?|$)/i.exec(q);
  const campSubstring = campMatch ? campMatch[1].trim() : null;

  // Detectar métrica
  let metrica = null;
  if (/clientes?|clientes nuevos/.test(q)) metrica = 'cli';
  else if (/registros?|leads?/.test(q)) metrica = 'reg';
  else if (/calificad|calif/.test(q)) metrica = 'calif';
  else if (/convers/.test(q)) metrica = 'pctCli';
  else if (/cpr|costo.*registro/.test(q)) metrica = 'cpr';
  else if (/cpa|costo.*cliente/.test(q)) metrica = 'cpa';
  else if (/roi/.test(q)) metrica = 'roi';
  else if (/inversi[oó]n|gasto|presupuesto/.test(q)) metrica = 'inv';

  const periodoLabel = labelRango(viewDesde, viewHasta);
  const productos = producto ? [producto] : ['envios','tienda','pagos'];
  const nombres = { envios: 'T1 Envíos', tienda: 'T1 Tienda', pagos: 'T1 Pagos' };

  // ── Substring de campaña: "campaña BRAND TERMS" ───────────
  if (campSubstring && campSubstring.length >= 3) {
    const productosArr = producto ? [producto] : ['envios','tienda','pagos'];
    const term = campSubstring.toLowerCase();
    const partes = productosArr.map(p => {
      const utms = (snap[p]?.utmsTop || []).filter(u => (u.campana||'').toLowerCase().includes(term) || (u.content||'').toLowerCase().includes(term));
      if (!utms.length) return null;
      const totReg = utms.reduce((s,u) => s+u.reg, 0);
      const totCli = utms.reduce((s,u) => s+u.cli, 0);
      return `${nombres[p]} · "${campSubstring}" · ${fmt(totReg)} reg · ${fmt(totCli)} cli\n` +
        utms.slice(0, 6).map(u => `  • ${u.campana}${u.content?` · ${u.content}`:''} (${u.canal||'?'}): ${fmt(u.reg)} reg${u.cli?` · ${fmt(u.cli)} cli`:''}`).join('\n');
    }).filter(Boolean);
    if (partes.length) return partes.join('\n\n');
    return `Ninguna campaña matchea "${campSubstring}" en el periodo cargado. Prueba con menos caracteres o checa la tab UTMs sin mapear.`;
  }

  // ── Canal + Día específico ────────────────────────────────
  if (canal) {
    const fechaC = detectarFecha(q);
    const productosArr = producto ? [producto] : ['envios','tienda','pagos'];
    if (fechaC) {
      if (fechaC < snap.desde || fechaC > snap.hasta) {
        return `El ${fechaC} está fuera del periodo cargado.`;
      }
      const partes = productosArr.map(p => {
        const dia = snap[p]?.dias?.find(d => d.dia === fechaC);
        const canalDia = dia?.canales?.[canal];
        if (!canalDia) return `${nombres[p]} · ${canal} · ${fechaC}: 0 registros.`;
        return `${nombres[p]} · ${canal} · ${fechaC}: ${fmt(canalDia.reg)} reg${canalDia.cli?` · ${fmt(canalDia.cli)} cli`:''}`;
      });
      return partes.join('\n');
    }
    // Canal sin día: usa canalesBreakdown agregado del periodo
    if (metrica) {
      // canal + métrica (sin día) - todavía solo total
      const partes = productosArr.map(p => {
        const c = snap[p]?.canalesBreakdown?.find(c => c.canal === canal);
        if (!c) return `${nombres[p]} · ${canal}: sin datos paid en el periodo.`;
        const v = metrica === 'cli' ? `${fmt(c.cli)} clientes` :
                  metrica === 'reg' ? `${fmt(c.reg)} registros` :
                  metrica === 'pctCli' ? `${pct(c.reg?c.cli/c.reg*100:0)} conversión (${fmt(c.cli)}/${fmt(c.reg)})` :
                  `${fmt(c.reg)} reg · ${fmt(c.cli)} cli`;
        return `${nombres[p]} · ${canal}: ${v}`;
      });
      return partes.join('\n');
    }
    // Solo canal+producto sin día ni métrica → resumen del canal
    const partes = productosArr.map(p => {
      const c = snap[p]?.canalesBreakdown?.find(c => c.canal === canal);
      if (!c) return `${nombres[p]} · ${canal}: sin datos paid en el periodo.`;
      return `${nombres[p]} · ${canal}: ${fmt(c.reg)} reg · ${fmt(c.cli)} cli · ${pct(c.reg?c.cli/c.reg*100:0)} conv`;
    });
    return partes.join('\n');
  }

  // ── Breakdown por canal o UTM ──────────────────────────
  const esCanal = /\b(canal(es)?|por canal)\b/i.test(q);
  const esUtm = /\butm|campa[ñn]a|por campa[ñn]a|top campa[ñn]as?/i.test(q);
  if (esCanal || esUtm) {
    const productosArr = producto ? [producto] : ['envios','tienda','pagos'];
    const partes = productosArr.map(p => {
      const k = snap[p];
      if (!k) return null;
      if (esUtm) {
        const top = (k.utmsTop || []).slice(0, 8);
        if (!top.length) return `${nombres[p]}: sin UTMs paid en el periodo.`;
        return `${nombres[p]} · top UTMs por registros:\n` +
          top.map((u,i) => {
            const camp = u.campana === '(sin)' ? '(sin campaña)' : u.campana;
            const cnt = u.content ? ` · ${u.content}` : '';
            return `  ${i+1}. ${camp}${cnt} → ${fmt(u.reg)} reg${u.cli?` · ${fmt(u.cli)} cli`:''}`;
          }).join('\n');
      }
      // Por canal
      const canales = k.canalesBreakdown || [];
      if (!canales.length) return `${nombres[p]}: sin breakdown por canal.`;
      const tot = canales.reduce((s,c) => s + c.reg, 0);
      return `${nombres[p]} · registros por canal:\n` +
        canales.map(c => `  • ${c.canal}: ${fmt(c.reg)} reg (${pct(tot?c.reg/tot*100:0)})${c.cli?` · ${fmt(c.cli)} cli`:''}`).join('\n');
    }).filter(Boolean);
    return partes.join('\n\n');
  }

  // ── Tráfico GA4 ─────────────────────────────────────────
  const esTrafico = /tr[aá]fico|sesi[oó]n|usuario|visita|landing|p[aá]gina|view|view[s]?/i.test(q);
  if (esTrafico) {
    const fechaT = detectarFecha(q);
    const dias = snap.trafico?.dias || [];
    if (!dias.length) return 'GA4 no devolvió datos para el periodo cargado.';
    if (fechaT) {
      if (fechaT < snap.desde || fechaT > snap.hasta) {
        return `El ${fechaT} está fuera del periodo cargado (${snap.desde} → ${snap.hasta}).`;
      }
      const filas = dias.filter(d => d.fecha === fechaT);
      if (!filas.length) return `Sin datos de tráfico para ${fechaT}.`;
      return `Tráfico GA4 · ${fechaT}:\n` + filas
        .sort((a,b) => b.sesiones - a.sesiones)
        .map(f => `  • ${f.landing}: ${fmt(f.sesiones)} sesiones · ${fmt(f.usuarios)} usuarios · ${fmt(f.vistas)} vistas`)
        .join('\n');
    }
    // Sin fecha: total agregado del periodo
    const tot = {};
    dias.forEach(d => {
      if (!tot[d.landing]) tot[d.landing] = { sesiones:0, usuarios:0, vistas:0 };
      tot[d.landing].sesiones += d.sesiones;
      tot[d.landing].usuarios += d.usuarios;
      tot[d.landing].vistas += d.vistas;
    });
    return `Tráfico GA4 · ${periodoLabel}:\n` + Object.entries(tot)
      .sort((a,b) => b[1].sesiones - a[1].sesiones)
      .map(([landing, v]) => `  • ${landing}: ${fmt(v.sesiones)} sesiones · ${fmt(v.usuarios)} usuarios`)
      .join('\n');
  }

  // Si la pregunta menciona un día específico, responder con la granularidad diaria.
  const fecha = detectarFecha(q);
  if (fecha) {
    const productosArr = producto ? [producto] : ['envios','tienda','pagos'];
    if (fecha < snap.desde || fecha > snap.hasta) {
      return `El ${fecha} está fuera del periodo cargado (${snap.desde} → ${snap.hasta}). Cambia el rango y dale Aplicar para cargar.`;
    }
    const partes = productosArr.map(p => {
      const dia = snap[p]?.dias?.find(d => d.dia === fecha);
      const invDiaProd = (snap.inversionDiaria || [])
        .filter(x => x.producto === p && x.fecha === fecha)
        .reduce((s, x) => s + (Number(x.gasto)||0), 0);
      if (!dia) {
        return `${nombres[p]} · ${fecha}: 0 registros ese día.${invDiaProd?` Inversión: ${mxnDec(invDiaProd)}.`:''}`;
      }
      const conv = dia.reg ? (dia.cli/dia.reg)*100 : 0;
      const cpr = invDiaProd && dia.reg ? invDiaProd/dia.reg : 0;
      const cpa = invDiaProd && dia.cli ? invDiaProd/dia.cli : 0;
      return `${nombres[p]} · ${fecha}:\n` +
        `  • ${fmt(dia.reg)} registros · ${fmt(dia.calif)} calificados · ${fmt(dia.cli)} clientes${dia.reg?` (${pct(conv)} conv)`:''}` +
        (invDiaProd
          ? `\n  • Inversión: ${mxnDec(invDiaProd)} · CPR ${cpr?mxnDec(cpr):'—'} · CPA ${cpa?mxnDec(cpa):'—'}`
          : `\n  • Inversión: sin cargar diaria (cargas semanales pueden estar disponibles en la tab Inversión)`);
    });
    return partes.join('\n\n');
  }

  // ── Funnel de documentación Pagos ──────────────────────────
  if (/(documentaci[oó]n|docs?|enviad|aprobad|rechazad|revisi[oó]n|pendient)/i.test(q) && (producto === 'pagos' || !producto)) {
    const t = snap.pagos?.total;
    if (!t) return 'No tengo datos cargados de T1 Pagos.';
    const r = t.reg || 0;
    const enviadaPct = (n) => r ? `(${pct(n/r*100)})` : '';
    if (/aprobad/i.test(q)) return `T1 Pagos · ${periodoLabel}: ${fmt(t.docAprobada||0)} aprobada${(t.docAprobada||0)!==1?'s':''} ${enviadaPct(t.docAprobada||0)} sobre ${fmt(r)} registros paid. Click en "Apr" en la tabla semanal para ver shopIDs.`;
    if (/rechazad/i.test(q)) return `T1 Pagos · ${periodoLabel}: ${fmt(t.docRechazada||0)} rechazada${(t.docRechazada||0)!==1?'s':''} ${enviadaPct(t.docRechazada||0)}. Click en "Rec" para ver shopIDs.`;
    if (/revisi/i.test(q)) return `T1 Pagos · ${periodoLabel}: ${fmt(t.docRevision||0)} en revisión ${enviadaPct(t.docRevision||0)}. Click en "Rev" para ver shopIDs.`;
    if (/sin enviar|pendient/i.test(q)) return `T1 Pagos · ${periodoLabel}: ${fmt(t.docSinEnviar||0)} sin enviar docs ${enviadaPct(t.docSinEnviar||0)} (mayoritariamente leads que no completaron onboarding).`;
    // General: funnel completo
    return `T1 Pagos · funnel de documentación · ${periodoLabel}:\n` +
      `  Registros paid:    ${fmt(r)}  (100%)\n` +
      `  Sin enviar docs:   ${fmt(t.docSinEnviar||0)}  ${enviadaPct(t.docSinEnviar||0)}\n` +
      `  Doc enviada:       ${fmt(t.docEnviada||0)}  ${enviadaPct(t.docEnviada||0)}\n` +
      `    ↳ En revisión:   ${fmt(t.docRevision||0)}\n` +
      `    ↳ Aprobada ⭐:    ${fmt(t.docAprobada||0)}\n` +
      `    ↳ Rechazada ⚠:   ${fmt(t.docRechazada||0)}\n` +
      `  Calificado:        ${fmt(t.calif||0)}  ${enviadaPct(t.calif||0)}\n` +
      `  1ª Transacción:    ${fmt(t.primTx||0)}  ${enviadaPct(t.primTx||0)}\n` +
      `  1ª Liquidación:    ${fmt(t.primLiq||0)}  ${enviadaPct(t.primLiq||0)}`;
  }

  // ── Saldo / dinero acumulado de Envíos ─────────────────────
  if (/saldo|recarg|saldo.*t1env|saldo.*envios|cu[aá]nto.*generad|cu[aá]nto.*dinero/i.test(q) && (producto === 'envios' || !producto)) {
    const t = snap.envios?.total;
    if (!t) return 'No tengo datos cargados de T1 Envíos.';
    return `T1 Envíos · ${periodoLabel}:\n` +
      `  • Saldo T1Envíos acumulado: ${mxnDec(t.saldoT1 || 0)} (de ${fmt(t.cli||0)} clientes con saldo > 0)\n` +
      `  • Saldo 1ª recarga: ${mxnDec(t.saldoPrec || 0)} · prom por cliente: ${t.cli ? mxnDec(t.saldoPrec/t.cli) : '—'}\n` +
      `  • ROI 8 sem (rolling): ${snap.roi8 ? snap.roi8.total.roi.toFixed(2) : '—'} · margen aplicado 28.1%`;
  }

  // ── Checkout iniciado / Visitas a planes (Tienda) ──────────
  if (/(checkout|check.*ini|visit.*plan|plan.*visit)/i.test(q) && (producto === 'tienda' || !producto)) {
    const t = snap.tienda?.total;
    if (!t) return 'No tengo datos cargados de T1 Tienda.';
    if (/visit/i.test(q)) return `T1 Tienda · ${periodoLabel}: ${fmt(t.visitasPlanes||0)} contactos con visitas a planes (flag = SI). Click en "Visitas Pl." en la tabla semanal para ver shopIDs.`;
    return `T1 Tienda · ${periodoLabel}: ${fmt(t.checkIni||0)} iniciaron checkout (flag = SI) sobre ${fmt(t.reg||0)} reg paid (${pct(t.reg?t.checkIni/t.reg*100:0)}). Click en "Check.Ini" para ver shopIDs.`;
  }

  // ── Tendencia mensual / promedio semanal ───────────────────
  if (/(tendencia|promedio.*sem|promedio.*mes|por mes|mes a mes|histor)/i.test(q)) {
    const productosArr = producto ? [producto] : ['envios','tienda','pagos'];
    return `Para ver tendencia mensual completa abre el panel "Tendencia mensual" en ${productosArr.map(p => nombres[p]).join(', ')}. Muestra promedio semanal de registros por mes, min-max, variación y mini-chart. Backfill desde enero 2026.`;
  }

  // Respuestas curadas para preguntas frecuentes (atajos antes de la lógica general)
  if (/meta.*(no|por qu).*(escala|funciona|genera)/.test(q) || /por qu.*meta/.test(q)) {
    return 'Meta no escala en Envíos por causa estructural, no táctica. T1 Envíos se descubre por búsqueda activa de solución a un dolor operativo (Google Search), no por exposición. Múltiples iteraciones de audiencia/creativo no han movido la aguja.';
  }
  if (/batch|calificaci[oó]n.*baj|por qu.*calif/.test(q)) {
    return 'El % de calificación de Envíos se ve bajo desde mayo por el batch de propiedades de Tienda publicado el 20-26 abr, que probablemente genera rate limit en HubSpot afectando flag_cotizacion. No es calidad del lead — el ratio calificado→cliente subió a récord histórico.';
  }
  if (/breakeven|punto.*equilibrio|cuanto.*falta.*roi/.test(q)) {
    return 'T1 Envíos: ROI semanal cerca de 0.5, brecha al breakeven de 2× — alcanzable con cashback de 1ª recarga y escalado de Google Search. T1 Pagos: margen estructuralmente bajo (1.12%), brecha amplia por diseño. T1 Tienda: en primera medición real con vencimiento de trials de abril.';
  }
  if (/c[oó]mo.*cerr|c[oó]mo.*va|c[oó]mo.*estamos/.test(q)) {
    const partes = productos.map(p => {
      const t = snap[p]?.total;
      if (!t) return null;
      return `${nombres[p]}: ${fmt(t.reg)} reg · ${fmt(t.cli)} clientes (${pct(t.pctCli)} conv)`;
    }).filter(Boolean);
    return `Periodo ${periodoLabel}.\n` + partes.join('\n');
  }

  // Si no hay métrica detectada y solo un producto, lanza el resumen.
  if (!metrica && producto) {
    const t = snap[producto]?.total;
    if (!t) return `No tengo datos cargados de ${nombres[producto]}.`;
    return `${nombres[producto]} en el periodo ${periodoLabel}:\n` +
      `• Registros: ${fmt(t.reg)}\n` +
      `• Calificados: ${fmt(t.calif)} (${pct(t.pctCalif)})\n` +
      `• Clientes: ${fmt(t.cli)} (${pct(t.pctCli)} conversión)\n` +
      `• Inversión: ${t.inv?mxnDec(t.inv):'sin cargar'}\n` +
      `• CPR: ${t.inv && t.reg?mxnDec(t.cpr):'—'}  · CPA: ${t.inv && t.cli?mxnDec(t.cpa):'—'}\n` +
      (producto === 'envios' && snap.roi8 ? `• ROI 8 semanas: ${snap.roi8.total.roi.toFixed(2)} (lectura estable)` :
       t.roi ? `• ROI: ${t.roi.toFixed(2)}` : '');
  }

  // Si hay métrica y producto
  if (metrica && producto) {
    const t = snap[producto]?.total;
    if (!t) return `No tengo datos cargados de ${nombres[producto]}.`;
    const valor = {
      cli: `${fmt(t.cli)} clientes (${pct(t.pctCli)} de conversión)`,
      reg: `${fmt(t.reg)} registros`,
      calif: `${fmt(t.calif)} calificados (${pct(t.pctCalif)} del total)`,
      pctCli: `${pct(t.pctCli)} (${fmt(t.cli)} clientes en ${fmt(t.reg)} registros)`,
      cpr: t.inv && t.reg ? `${mxnDec(t.cpr)} (inversión ${mxnDec(t.inv)} ÷ ${fmt(t.reg)} registros)` : 'no calculable — sin inversión cargada',
      cpa: t.inv && t.cli ? `${mxnDec(t.cpa)} (inversión ${mxnDec(t.inv)} ÷ ${fmt(t.cli)} clientes)` : 'no calculable — sin inversión cargada',
      inv: t.inv ? mxnDec(t.inv) : 'sin cargar para este periodo',
      roi: producto === 'envios'
        ? (snap.roi8 ? `ROI 8 semanas estable: ${snap.roi8.total.roi.toFixed(2)} · ROI semanal: ${(t.roiSemanal||0).toFixed(2)}` : '—')
        : (t.roi ? t.roi.toFixed(2) : 'no calculable — sin inversión cargada')
    }[metrica];
    return `${nombres[producto]} · ${periodoLabel}:\n${valor}`;
  }

  // Si hay métrica pero sin producto, responde para los 3
  if (metrica && !producto) {
    const partes = ['envios','tienda','pagos'].map(p => {
      const t = snap[p]?.total;
      if (!t) return null;
      const v = {
        cli: `${fmt(t.cli)} clientes`,
        reg: `${fmt(t.reg)} registros`,
        pctCli: pct(t.pctCli),
        calif: `${fmt(t.calif)} (${pct(t.pctCalif)})`,
        cpr: t.inv && t.reg ? mxnDec(t.cpr) : '—',
        cpa: t.inv && t.cli ? mxnDec(t.cpa) : '—',
        inv: t.inv ? mxnDec(t.inv) : 'sin cargar',
        roi: p === 'envios' ? (snap.roi8?.total?.roi?.toFixed(2) || '—') : (t.roi?.toFixed(2) || '—')
      }[metrica];
      return `• ${nombres[p]}: ${v}`;
    }).filter(Boolean);
    return `${{cli:'Clientes',reg:'Registros',pctCli:'% Conversión',calif:'Calificados',cpr:'CPR',cpa:'CPA',inv:'Inversión',roi:'ROI'}[metrica]} · ${periodoLabel}:\n` + partes.join('\n');
  }

  // Sin match — guía completa de lo que sabe responder
  return 'No entendí. Prueba con algo como:\n\n' +
    '📊 KPIs por producto:\n' +
    '  • "¿cuántos clientes hubo en envíos?"\n' +
    '  • "CPR de tienda"\n' +
    '  • "ROI de envíos"\n' +
    '  • "% conversión de pagos"\n' +
    '  • "inversión de tienda esta semana"\n\n' +
    '🎯 Por canal o campaña:\n' +
    '  • "google envios"  /  "meta tienda"  /  "tiktok"\n' +
    '  • "top campañas envíos"\n' +
    '  • "campaña BRAND TERMS"\n\n' +
    '📅 Por fecha o día:\n' +
    '  • "envios 11 de mayo"  /  "tráfico 18 may"\n\n' +
    '💵 Saldo y dinero (Envíos):\n' +
    '  • "saldo envios"  /  "cuánto generamos en envíos"\n\n' +
    '📄 Documentación (Pagos):\n' +
    '  • "docs aprobadas"  /  "rechazadas"  /  "en revisión"\n' +
    '  • "funnel pagos"\n\n' +
    '🛒 Tienda funnel:\n' +
    '  • "checkout iniciado tienda"  /  "visitas a planes"\n\n' +
    '🌍 Tráfico GA4:\n' +
    '  • "tráfico landings"  /  "sesiones envios"\n\n' +
    '📈 Tendencia mensual:\n' +
    '  • "promedio semanal por mes"\n\n' +
    '❓ Resumen general:\n' +
    '  • "cómo cerró envíos"  /  "cómo va el negocio"  /  "por qué meta no escala"';
}

/* ── BURBUJA FLOTANTE DE CHAT ───────────────────────────────
   Burbuja siempre accesible abajo-derecha. Cuando se abre, expande a un
   panel lateral (no modal — no bloquea el dashboard). Llama al backend
   /api/vacaciones/chat que usa Gemini con TODO el contexto del dashboard.
   Historial persistido en localStorage. Si GEMINI_API_KEY no está
   configurada, muestra mensaje claro con instrucción para conseguir una. */

const CHAT_HISTORY_KEY = 't1-chat-historial-v1';
function leerHistorialChat() {
  try { return JSON.parse(localStorage.getItem(CHAT_HISTORY_KEY) || '[]'); }
  catch { return []; }
}
function guardarHistorialChat(historial) {
  try { localStorage.setItem(CHAT_HISTORY_KEY, JSON.stringify(historial.slice(-30))); } catch {}
}

// Mini parser de markdown para los mensajes del bot.
// Convierte **texto** → <strong>, `texto` → <code>, URLs → <a>, preserva saltos de línea.
function renderMarkdownChat(texto) {
  if (!texto) return null;
  const lineas = String(texto).split('\n');
  return lineas.map((linea, li) => {
    const partes = [];
    let i = 0;
    const re = /(\*\*([^*\n]+)\*\*)|(`([^`\n]+)`)|((?:https?:\/\/)[^\s]+)/g;
    let match;
    while ((match = re.exec(linea)) !== null) {
      if (match.index > i) partes.push(linea.slice(i, match.index));
      if (match[2] !== undefined) {
        partes.push(<strong key={`b${li}-${match.index}`} style={{fontWeight: 700, color: 'inherit'}}>{match[2]}</strong>);
      } else if (match[4] !== undefined) {
        partes.push(<code key={`c${li}-${match.index}`} style={{background: 'rgba(0,0,0,0.06)', padding: '1px 5px', borderRadius: 3, fontSize: '0.92em', fontFamily: 'ui-monospace,Menlo,monospace'}}>{match[4]}</code>);
      } else if (match[5] !== undefined) {
        partes.push(<a key={`a${li}-${match.index}`} href={match[5]} target="_blank" rel="noreferrer" style={{color: 'inherit', textDecoration: 'underline'}}>{match[5]}</a>);
      }
      i = match.index + match[0].length;
    }
    if (i < linea.length) partes.push(linea.slice(i));
    return (
      <React.Fragment key={li}>
        {partes.length ? partes : linea}
        {li < lineas.length - 1 && <br/>}
      </React.Fragment>
    );
  });
}

function ChatBurbuja({snap}) {
  const [abierto, setAbierto] = useState(false);
  const [msgs, setMsgs] = useState(() => leerHistorialChat());
  const [input, setInput] = useState('');
  const [cargando, setCargando] = useState(false);
  const [err, setErr] = useState(null);
  const refMsgs = React.useRef(null);
  const refInput = React.useRef(null);

  useEffect(() => { guardarHistorialChat(msgs); }, [msgs]);
  useEffect(() => {
    if (abierto && refMsgs.current) {
      refMsgs.current.scrollTop = refMsgs.current.scrollHeight;
    }
    if (abierto && refInput.current) refInput.current.focus();
  }, [abierto, msgs.length]);

  const enviar = async () => {
    const q = input.trim();
    if (!q || cargando) return;
    setErr(null);
    setInput('');
    // Convertir msgs (rol/texto) al formato {role, content} para el backend
    const historial = msgs.map(m => ({
      role: m.rol === 'user' ? 'user' : 'assistant',
      content: m.texto
    }));
    setMsgs(prev => [...prev, { rol: 'user', texto: q }]);
    setCargando(true);
    try {
      const resp = await api('/chat', {
        method: 'POST',
        body: JSON.stringify({ pregunta: q, historial })
      });
      setMsgs(prev => [...prev, { rol: 'bot', texto: resp.respuesta }]);
    } catch (e) {
      const msg = e.message || 'Error desconocido';
      if (e.status === 503 || /GEMINI_API_KEY|OPENROUTER|CHAT_SIN_CONFIGURAR/i.test(msg)) {
        setMsgs(prev => [...prev, {
          rol: 'bot',
          texto: `⚠ Falta configurar la API key del chat en el .env del servidor.\n\nDos opciones (cualquiera funciona):\n\n**Opción A · OpenRouter** (recomendado, acceso a varios modelos)\n1. https://openrouter.ai/keys → crear key (sk-or-v1-...)\n2. Agregar al .env de producción:\n   OPENROUTER_API_KEY=sk-or-v1-...\n   OPENROUTER_MODEL=openai/gpt-oss-120b:free\n\n**Opción B · Gemini directo** (gratis pero solo Gemini)\n1. https://aistudio.google.com/app/apikey → crear key\n2. Agregar al .env: GEMINI_API_KEY=...\n\nEn producción el .env vive en S3 — actualízalo ahí y redeploy.`
        }]);
      } else {
        setMsgs(prev => [...prev, { rol: 'bot', texto: `❌ ${msg}` }]);
      }
      setErr(msg);
    } finally { setCargando(false); }
  };

  const limpiar = () => {
    if (!confirm('¿Limpiar toda la conversación?')) return;
    setMsgs([]);
  };

  // Burbuja cerrada (botón circular)
  if (!abierto) {
    return (
      <button onClick={() => setAbierto(true)} aria-label="Abrir chat de consulta"
        style={{
          position: 'fixed', bottom: 22, right: 22, width: 58, height: 58, borderRadius: '50%',
          background: C.brandRed || '#DB3B2B', color: '#fff', border: 'none', cursor: 'pointer',
          boxShadow: '0 6px 20px rgba(0,0,0,0.18), 0 2px 6px rgba(0,0,0,0.12)',
          fontSize: 22, display: 'flex', alignItems: 'center', justifyContent: 'center',
          zIndex: 100, transition: 'transform .15s ease, box-shadow .15s ease'
        }}
        onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.06)'; }}
        onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; }}
      >
        <span style={{fontSize: 22, lineHeight: 1, fontWeight: 700}}>✦</span>
        {msgs.length > 0 && (
          <span style={{
            position: 'absolute', top: -4, right: -4, background: '#fff', color: C.brandRed,
            borderRadius: '50%', width: 22, height: 22, fontSize: 11, fontWeight: 700,
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            border: `2px solid ${C.brandRed}`
          }}>{Math.min(msgs.length, 99)}</span>
        )}
      </button>
    );
  }

  // Burbuja abierta (panel)
  const ejemplos = [
    '¿Cómo va envíos esta semana?',
    'Compara abril vs mayo en tienda',
    'Top campañas Meta por inversión',
    'Cuál es el ROI 8 semanas',
    'Cuántos calificados de pagos esta semana'
  ];

  return (
    <div style={{
      position: 'fixed', bottom: 22, right: 22, width: 'min(420px, calc(100vw - 44px))',
      height: 'min(620px, calc(100vh - 44px))', background: C.bg || '#fff',
      borderRadius: 14, boxShadow: '0 12px 36px rgba(0,0,0,0.18), 0 4px 12px rgba(0,0,0,0.10)',
      border: `1px solid ${C.line}`, display: 'flex', flexDirection: 'column',
      zIndex: 100, overflow: 'hidden'
    }}>
      {/* Header */}
      <div style={{padding: '12px 16px', borderBottom: `1px solid ${C.line}`, display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: C.brandRed || '#DB3B2B', color: '#fff'}}>
        <div>
          <div style={{fontSize: 14, fontWeight: 700, letterSpacing: '.01em'}}>Asistente del dashboard ✦</div>
          <div style={{fontSize: 10.5, opacity: 0.85, marginTop: 1}}>responde sobre los KPIs del corte vigente</div>
        </div>
        <div style={{display: 'flex', gap: 4}}>
          {msgs.length > 0 && (
            <button onClick={limpiar} title="Limpiar conversación"
              style={{background: 'rgba(255,255,255,0.18)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 11, padding: '4px 8px', borderRadius: 4, fontWeight: 600}}>
              limpiar
            </button>
          )}
          <button onClick={() => setAbierto(false)} aria-label="Minimizar chat"
            style={{background: 'transparent', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 22, padding: 0, lineHeight: 1, width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
            ✕
          </button>
        </div>
      </div>

      {/* Mensajes */}
      <div ref={refMsgs} style={{flex: 1, overflowY: 'auto', padding: '14px 16px', display: 'flex', flexDirection: 'column', gap: 10, background: C.bgAlt || '#F8F8F8'}}>
        {msgs.length === 0 && (
          <div style={{padding: '8px 4px'}}>
            <div style={{fontSize: 12.5, color: C.ink, fontWeight: 600, marginBottom: 8}}>
              👋 Pregúntame lo que quieras sobre el dashboard
            </div>
            <div style={{fontSize: 11.5, color: C.sub, lineHeight: 1.5, marginBottom: 10}}>
              Tengo acceso a: totales por producto, semanas, breakdowns por canal y campaña, histórico mensual, ROI 8 semanas, inversión por campaña, tráfico GA4, semáforos, UTMs sin clasificar y overrides.
            </div>
            <div style={{fontSize: 11, color: C.subLt, fontWeight: 600, marginBottom: 5, textTransform: 'uppercase', letterSpacing: '.05em'}}>Sugerencias:</div>
            {ejemplos.map((ej, i) => (
              <button key={i} onClick={() => setInput(ej)}
                style={{display: 'block', width: '100%', textAlign: 'left', background: C.card || '#fff', border: `1px solid ${C.line}`, borderRadius: 6, padding: '8px 11px', fontSize: 12, color: C.ink, cursor: 'pointer', marginBottom: 6, fontFamily: 'inherit'}}>
                {ej}
              </button>
            ))}
          </div>
        )}
        {msgs.map((m, i) => (
          <div key={i} style={{alignSelf: m.rol === 'user' ? 'flex-end' : 'flex-start', maxWidth: '88%'}}>
            <div style={{
              background: m.rol === 'user' ? (C.brandRed || '#DB3B2B') : (C.card || '#fff'),
              color: m.rol === 'user' ? '#fff' : C.ink,
              border: m.rol === 'user' ? 'none' : `1px solid ${C.line}`,
              borderRadius: 10, padding: '10px 13px', fontSize: 12.8, lineHeight: 1.55,
              wordBreak: 'break-word'
            }}>
              {m.rol === 'bot' ? renderMarkdownChat(m.texto) : <span style={{whiteSpace: 'pre-wrap'}}>{m.texto}</span>}
            </div>
          </div>
        ))}
        {cargando && (
          <div style={{alignSelf: 'flex-start', maxWidth: '88%'}}>
            <div style={{background: C.card || '#fff', border: `1px solid ${C.line}`, borderRadius: 10, padding: '10px 13px', fontSize: 13, color: C.sub, display: 'flex', gap: 4, alignItems: 'center'}}>
              <span className="va-spin" style={{width: 12, height: 12, border: '2px solid #E7E7E7', borderTopColor: C.brandRed || '#DB3B2B', borderRadius: '50%', animation: 'spin 0.7s linear infinite', display: 'inline-block'}}/>
              <span style={{marginLeft: 6}}>pensando…</span>
            </div>
          </div>
        )}
      </div>

      {/* Input */}
      <div style={{padding: '12px 14px', borderTop: `1px solid ${C.line}`, background: C.bg || '#fff'}}>
        <div style={{display: 'flex', gap: 8, alignItems: 'flex-end'}}>
          <textarea ref={refInput} value={input} onChange={e => setInput(e.target.value)}
            onKeyDown={e => {
              if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); enviar(); }
            }}
            placeholder="Pregunta sobre los KPIs… (Shift+Enter para nueva línea)"
            rows={1}
            style={{
              flex: 1, padding: '9px 12px', border: `1px solid ${C.line}`, borderRadius: 8,
              fontSize: 13, background: C.bgAlt || '#F8F8F8', resize: 'none', fontFamily: 'inherit',
              color: C.ink, maxHeight: 120, lineHeight: 1.4, outline: 'none'
            }}
            onFocus={e => e.currentTarget.style.borderColor = C.brandRed || '#DB3B2B'}
            onBlur={e => e.currentTarget.style.borderColor = C.line}
            disabled={cargando}/>
          <button onClick={enviar} disabled={cargando || !input.trim()}
            style={{
              padding: '9px 16px', background: C.brandRed || '#DB3B2B', color: '#fff',
              border: 'none', borderRadius: 8, fontSize: 13, fontWeight: 700,
              cursor: (cargando || !input.trim()) ? 'not-allowed' : 'pointer',
              opacity: (cargando || !input.trim()) ? 0.5 : 1, fontFamily: 'inherit'
            }}>
            ↑
          </button>
        </div>
        <div style={{fontSize: 10, color: C.subLt, marginTop: 6, textAlign: 'right'}}>
          Gemini · contexto del corte vigente
        </div>
      </div>
    </div>
  );
}

function ChatConsulta({cerrar, snap, viewDesde, viewHasta}){
  const [msgs,setMsgs] = useState([
    { rol:'bot', texto:'Hola. Puedo responder sobre cualquier KPI del dashboard:\n• Registros, clientes, CPR, CPA, ROI, conversión\n• Por producto (envíos/tienda/pagos), canal (google/meta/tiktok), o campaña\n• Por fecha o semana específica\n• Saldo Envíos, Funnel de docs Pagos, Checkout Tienda\n• Tráfico GA4 y tendencia mensual\n\nEscribe "ayuda" para ver todos los ejemplos.' }
  ]);
  const [input,setInput] = useState('');

  const enviar = ()=>{
    if (!input.trim()) return;
    const q = input.trim();
    const respuesta = responderChat(q, snap, viewDesde, viewHasta);
    setMsgs(m=>[...m, {rol:'user',texto:q}, {rol:'bot',texto:respuesta}]);
    setInput('');
  };
  // Para mantener API uniforme con la version anterior
  const cargando = false;

  return (
    <div onClick={cerrar} style={{position:'fixed',inset:0,background:'rgba(34,31,24,.45)',display:'flex',alignItems:'flex-end',justifyContent:'flex-end',zIndex:50,padding:20}}>
      <div onClick={e=>e.stopPropagation()} style={{background:C.bg,borderRadius:4,width:'100%',maxWidth:420,height:'min(560px, 80vh)',border:`1px solid ${C.line}`,display:'flex',flexDirection:'column'}}>
        <div style={{padding:'14px 18px',borderBottom:`1px solid ${C.line}`,display:'flex',justifyContent:'space-between',alignItems:'center'}}>
          <div>
            <div style={{fontSize:16,fontWeight:600}}>Consultar al dashboard</div>
            <div style={{fontSize:10.5,color:C.sub}}>respuestas curadas sobre los datos cargados</div>
          </div>
          <button onClick={cerrar} style={{background:'none',border:'none',fontSize:20,color:C.sub,cursor:'pointer'}}>×</button>
        </div>
        <div style={{flex:1,overflowY:'auto',padding:'14px 16px',display:'flex',flexDirection:'column',gap:10}}>
          {msgs.map((m,i)=>(
            <div key={i} style={{alignSelf:m.rol==='user'?'flex-end':'flex-start',maxWidth:'85%',background:m.rol==='user'?C.ink:C.card,color:m.rol==='user'?C.bg:C.ink,border:m.rol==='bot'?`1px solid ${C.line}`:'none',borderRadius:8,padding:'9px 12px',fontSize:12.5,lineHeight:1.5,whiteSpace:'pre-wrap'}}>
              {m.texto}
            </div>))}
          {cargando && <div style={{alignSelf:'flex-start',color:C.sub,fontSize:12}}>…</div>}
        </div>
        <div style={{padding:'12px 14px',borderTop:`1px solid ${C.line}`,display:'flex',gap:8}}>
          <input value={input} onChange={e=>setInput(e.target.value)} onKeyDown={e=>e.key==='Enter'&&enviar()}
            placeholder="Pregunta sobre los KPIs..."
            style={{flex:1,padding:'9px 11px',border:`1px solid ${C.line}`,borderRadius:3,fontSize:12.5,background:C.card}}/>
          <button onClick={enviar} disabled={cargando} style={{padding:'9px 15px',background:C.ink,color:C.bg,border:'none',borderRadius:3,fontSize:12.5,fontWeight:600,cursor:cargando?'wait':'pointer',opacity:cargando?.5:1}}>Enviar</button>
        </div>
      </div>
    </div>
  );
}

/* ── LOGIN SCREEN ──────────────────────────────────────────── */
function LoginScreen({ onSuccess }){
  const [err,setErr] = useState(null);
  const [cargando,setCargando] = useState(false);
  const entrar = async ()=>{
    setErr(null); setCargando(true);
    try { await loginWithGoogle(); onSuccess(); }
    catch (e) { setErr(e.message); }
    finally { setCargando(false); }
  };
  return (
    <div style={{minHeight:'100vh',background:C.bg,display:'flex',alignItems:'center',justifyContent:'center',padding:24,fontFamily:FONT_STACK,color:C.oxford}}>
      <div style={{background:C.card,border:`1px solid ${C.line}`,borderRadius:12,padding:'40px 36px',maxWidth:380,width:'100%',boxShadow:'0 1px 3px rgba(0,0,0,0.04)'}}>
        <div style={{textAlign:'center',marginBottom:28}}>
          <div style={{width:48,height:48,borderRadius:10,background:C.brandRed,display:'inline-flex',alignItems:'center',justifyContent:'center',color:'#fff',fontWeight:700,fontSize:22,letterSpacing:'-0.02em'}}>T1</div>
          <h1 style={{fontSize:22,fontWeight:700,color:C.ink,margin:'18px 0 4px',letterSpacing:'-0.01em'}}>Dashboard KPIs · T1</h1>
          <div style={{fontSize:13,color:C.sub,fontWeight:500}}>Proyecto temporal · cobertura de vacaciones</div>
        </div>
        {err && <div style={{background:C.bgRojo,border:`1px solid ${C.rojo}`,color:C.rojo,borderRadius:6,padding:'10px 12px',marginBottom:14,fontSize:13,fontWeight:500}}>{err}</div>}
        <button type="button" onClick={entrar} disabled={cargando}
          style={{width:'100%',padding:'11px',background:C.card,color:C.ink,border:`1px solid ${C.line}`,borderRadius:6,fontSize:14,fontWeight:600,cursor:cargando?'wait':'pointer',opacity:cargando?.7:1,fontFamily:'inherit',display:'flex',alignItems:'center',justifyContent:'center',gap:10}}>
          <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path fill="#4285F4" d="M17.64 9.2c0-.64-.06-1.25-.16-1.84H9v3.48h4.84a4.14 4.14 0 0 1-1.8 2.72v2.26h2.92c1.7-1.57 2.68-3.88 2.68-6.62z"/><path fill="#34A853" d="M9 18c2.43 0 4.47-.8 5.96-2.18l-2.92-2.26c-.8.54-1.84.86-3.04.86-2.34 0-4.32-1.58-5.03-3.7H.96v2.34A9 9 0 0 0 9 18z"/><path fill="#FBBC05" d="M3.97 10.72a5.4 5.4 0 0 1 0-3.44V4.94H.96a9 9 0 0 0 0 8.12l3.01-2.34z"/><path fill="#EA4335" d="M9 3.58c1.32 0 2.5.45 3.44 1.35l2.58-2.58A9 9 0 0 0 .96 4.94l3.01 2.34C4.68 5.16 6.66 3.58 9 3.58z"/></svg>
          {cargando ? 'Entrando…' : 'Iniciar sesión con Google'}
        </button>
        <div style={{textAlign:'center',marginTop:16,fontSize:12,color:C.sub}}>Acceso sólo para cuentas autorizadas</div>
      </div>
    </div>
  );
}

/* ── APP RAÍZ ──────────────────────────────────────────────── */
function hoyYmd(){
  const d = new Date();
  return new Date(d.getTime() - 6*3600*1000).toISOString().slice(0,10);
}
function diasAtrasYmd(n){
  const d = new Date();
  d.setUTCDate(d.getUTCDate() - n);
  return new Date(d.getTime() - 6*3600*1000).toISOString().slice(0,10);
}

function App(){
  // Hidrata desde localStorage si existe — render instantáneo en browser refresh.
  const _cached = loadSnapCache();
  const [snap,setSnap] = useState(_cached?.snap || null);
  const [savedAt,setSavedAt] = useState(_cached?.savedAt || null);
  const [err,setErr] = useState(null);
  const [cargando,setCargando] = useState(false);
  const [cargandoProd,setCargandoProd] = useState({});
  const [needLogin,setNeedLogin] = useState(false);
  const [vista,setVista] = useState('resumen');
  // Rango VISIBLE. Cambia al apretar presets/date pickers. No dispara fetch — slicing local.
  // Default: hereda el rango cargado en cache, o últimos 60 días.
  // Default: 8 semanas (56 días) — alinea Comportamiento Semanal con ROI 8 sem
  const [desde,setDesde] = useState(_cached?.snap?.desde || diasAtrasYmd(55));
  const [hasta,setHasta] = useState(_cached?.snap?.hasta || hoyYmd());
  // chat: el componente ChatBurbuja maneja su propio estado (abierto/cerrado, historial)

  const recargar = async (d = desde, h = hasta, force = false)=>{
    setErr(null); setCargando(true);
    try {
      const q = `desde=${d}&hasta=${h}${force?'&refresh=1':''}`;
      const data = await api(`/resumen?${q}`);
      setSnap(data);
      saveSnapCache(data);
      setSavedAt(Date.now());
      setNeedLogin(false);
    } catch (e) {
      if (e.status === 401) {
        setNeedLogin(true);
      } else if (e.networkError) {
        setErr('🔌 No se pudo conectar con el servidor. Intenta de nuevo en unos segundos.');
      } else {
        setErr(`${e.message}${e.status ? ` (status ${e.status})` : ''}`);
      }
    } finally { setCargando(false); }
  };

  // Refresh silencioso: actualiza el snap pero NO toca el flag global `cargando`.
  // Útil para acciones puntuales (ej. guardar inversión) que solo necesitan recomputar
  // KPIs derivados sin mostrar el spinner global ni cambiar el botón Aplicar.
  const recargarSilencioso = async (d = desde, h = hasta, force = false)=>{
    try {
      const q = `desde=${d}&hasta=${h}${force?'&refresh=1':''}`;
      const data = await api(`/resumen?${q}`);
      setSnap(data);
      saveSnapCache(data);
      setSavedAt(Date.now());
    } catch (e) {
      if (e.status === 401) setNeedLogin(true);
      // No mostramos error global: la acción que disparó esto ya mostrará su propio feedback.
    }
  };

  // Política stale-while-revalidate:
  //   • Sin caché → fetch con spinner.
  //   • Con caché fresco (<5min) → no hace fetch.
  //   • Con caché stale → muestra cache y refresca en background (sin spinner).
  useEffect(()=>{
    const ageMs = savedAt ? Date.now() - savedAt : Infinity;
    const STALE = 5 * 60 * 1000;
    if (!snap) {
      recargar();
    } else if (ageMs > STALE) {
      // Background refresh: no toca cargando (que ya está false).
      (async () => {
        try {
          const q = `desde=${snap.desde}&hasta=${snap.hasta}`;
          const data = await api(`/resumen?${q}`);
          setSnap(data); saveSnapCache(data); setSavedAt(Date.now());
        } catch (e) {
          if (e.status === 401) setNeedLogin(true);
        }
      })();
    }
    // Si es fresh, no hace nada.
  },[]);

  // Cualquier mutación de snap (incluido refrescarProducto) se persiste.
  useEffect(()=>{ if (snap) { saveSnapCache(snap); setSavedAt(prev => prev || Date.now()); } }, [snap]);

  // Listener global de 401: cualquier llamada API que reciba "No autenticado"
  // dispara el evento desde api(). Manda al usuario a login automáticamente,
  // sin que cada función tenga que recordar capturar el 401.
  useEffect(() => {
    const handler = () => { setNeedLogin(true); setCargando(false); };
    window.addEventListener('t1:session-expired', handler);
    return () => window.removeEventListener('t1:session-expired', handler);
  }, []);

  // Aplicar global: SIEMPRE fuerza fetch live (decisión Bryant: que dure lo
  // que dure pero que realmente jale). Si el ALB corta a los 30s con 504,
  // entramos en modo polling: el orchestrator's enVuelo map agarra a la
  // request original que sigue corriendo server-side, y el polling se une a
  // ella. Cuando termina, todos los pollers reciben la data fresca.
  // Total wait máximo: 4 min (60 intentos × ~4s primero, después 8s).
  // ASYNC JOB PATTERN: garantiza que cada click ACTUALICE la data, sin que el
  // ALB de AWS corte (cada request HTTP es <1s; la fetch real corre server-side).
  // Flow: POST inicia job → GET cada 3s polea status → cuando "done", aplica data.
  const aplicarFiltro = async (evt) => {
    if (evt && evt.preventDefault) evt.preventDefault();
    console.log(`[aplicar] CLICK ${new Date().toLocaleTimeString()} · rango=${desde}→${hasta}`);
    setErr(null); setCargando(true);
    const d = desde, h = hasta;
    const tStart = Date.now();
    let jobId;
    try {
      const r = await api(`/resumen-async?desde=${d}&hasta=${h}`, { method: 'POST' });
      jobId = r.jobId;
      console.log(`[aplicar] Job iniciado: ${jobId}`);
    } catch (e) {
      if (e.status === 401) { setNeedLogin(true); setCargando(false); return; }
      setErr(`No se pudo iniciar el job: ${e.message}`);
      setCargando(false);
      return;
    }
    // Polling del status del job (cada request <1s, el ALB no toca esto)
    const maxWaitMs = 4 * 60 * 1000; // 4 min cap
    while (Date.now() - tStart < maxWaitMs) {
      await new Promise(r => setTimeout(r, 3000));
      const elapsed = ((Date.now() - tStart) / 1000).toFixed(0);
      try {
        const job = await api(`/job/${jobId}`);
        console.log(`[aplicar] job ${jobId} status=${job.status} (${elapsed}s)`);
        if (job.status === 'done') {
          setSnap(job.data);
          saveSnapCache(job.data);
          setSavedAt(Date.now());
          setCargando(false);
          console.log(`[aplicar] ✓ DATA FRESCA aplicada después de ${elapsed}s`);
          return;
        }
        if (job.status === 'error') {
          setErr(`Fetch falló: ${job.error}`);
          setCargando(false);
          return;
        }
      } catch (e) {
        if (e.status === 401) { setNeedLogin(true); setCargando(false); return; }
        if (e.status === 404) {
          setErr('El job expiró. Intenta otra vez.');
          setCargando(false);
          return;
        }
        // Otros errores transientes: seguimos polleando
      }
    }
    setErr('Timeout esperando data fresca después de 4 min. El job sigue corriendo server-side, recarga en 1 min.');
    setCargando(false);
  };

  // Refrescar producto — mismo patrón async job que aplicarFiltro.
  // Garantiza data fresca aunque HubSpot tarde 2+ min.
  const refrescarProducto = async (producto)=>{
    console.log(`[refrescar ${producto}] CLICK ${new Date().toLocaleTimeString()}`);
    setCargandoProd(prev => ({ ...prev, [producto]: true }));
    const d = snap?.desde || desde, h = snap?.hasta || hasta;
    const tStart = Date.now();
    const aplicarRes = (r) => {
      setSnap(prev => prev ? {
        ...prev,
        [producto]: r.kpis,
        ...(producto === 'envios' && r.roi8 ? { roi8: r.roi8 } : {}),
        errores: { ...(prev.errores||{}), [producto]: r.error || null },
        semaforos: { ...(prev.semaforos||{}), [producto]: r.semaforos }
      } : prev);
    };
    let jobId;
    try {
      const r = await api(`/producto-async/${producto}?desde=${d}&hasta=${h}`, { method: 'POST' });
      jobId = r.jobId;
      console.log(`[refrescar ${producto}] job iniciado: ${jobId}`);
    } catch (e) {
      if (e.status === 401) { setNeedLogin(true); setCargandoProd(prev => ({ ...prev, [producto]: false })); return; }
      setSnap(prev => prev ? { ...prev, errores: { ...(prev.errores||{}), [producto]: e.message } } : prev);
      setCargandoProd(prev => ({ ...prev, [producto]: false }));
      return;
    }
    const maxWaitMs = 4 * 60 * 1000;
    while (Date.now() - tStart < maxWaitMs) {
      await new Promise(r => setTimeout(r, 3000));
      const elapsed = ((Date.now() - tStart) / 1000).toFixed(0);
      try {
        const job = await api(`/job/${jobId}`);
        console.log(`[refrescar ${producto}] job ${jobId} status=${job.status} (${elapsed}s)`);
        if (job.status === 'done') {
          aplicarRes(job.data);
          console.log(`[refrescar ${producto}] ✓ DATA FRESCA aplicada en ${elapsed}s`);
          setCargandoProd(prev => ({ ...prev, [producto]: false }));
          return;
        }
        if (job.status === 'error') {
          setSnap(prev => prev ? { ...prev, errores: { ...(prev.errores||{}), [producto]: job.error } } : prev);
          setCargandoProd(prev => ({ ...prev, [producto]: false }));
          return;
        }
      } catch (e) {
        if (e.status === 401) { setNeedLogin(true); setCargandoProd(prev => ({ ...prev, [producto]: false })); return; }
      }
    }
    setSnap(prev => prev ? { ...prev, errores: { ...(prev.errores||{}), [producto]: 'Timeout 4 min. Recarga en 1 min.' } } : prev);
    setCargandoProd(prev => ({ ...prev, [producto]: false }));
  };

  const cerrarSesion = async ()=>{ clearSnapCache(); await logout(); setNeedLogin(true); setSnap(null); setSavedAt(null); };

  if (needLogin) return <LoginScreen onSuccess={()=>{ setNeedLogin(false); recargar(); }}/>;

  if (err) return (
    <div style={{padding:30,maxWidth:680,margin:'40px auto',background:C.card,border:`1px solid ${C.line}`,borderRadius:4}}>
      <h2 style={{margin:0,color:C.rojo}}>Error al cargar el dashboard</h2>
      <pre style={{whiteSpace:'pre-wrap',fontSize:12.5,color:C.sub,marginTop:12}}>{err}</pre>
      <div style={{fontSize:12,color:C.sub,marginTop:12}}>Verifica que estés autenticado en el dashboard principal y vuelve a recargar.</div>
    </div>
  );
  if (!snap) return (
    <div style={{display:'flex',alignItems:'center',justifyContent:'center',height:'100vh',flexDirection:'column',gap:14,padding:24,textAlign:'center'}}>
      <div style={{width:32,height:32,border:'2.5px solid #E7E7E7',borderTopColor:C.brandRed,borderRadius:'50%',animation:'spin 0.7s linear infinite'}}/>
      <div style={{fontSize:14,color:C.ink,fontWeight:600,marginTop:4}}>Cargando dashboard…</div>
      <div style={{fontSize:12,color:C.sub,maxWidth:420,lineHeight:1.55}}>Trayendo datos desde HubSpot y T1 API. La primera carga tarda 30-90s; los siguientes refreshes son instantáneos gracias a la caché y persistencia local.</div>
      <style>{`@keyframes spin{to{transform:rotate(360deg)}}`}</style>
    </div>
  );

  // Slicing local: recortamos el snap cargado al rango VISIBLE seleccionado.
  // Si la vista cae fuera del rango cargado, snapVista será vacío y mostramos banner.
  const enRango = vistaDentroDeCargado(desde, hasta, snap.desde, snap.hasta);
  // Si la vista está fuera del rango cargado, recortamos al OVERLAP entre los dos
  // (no mostramos el snap entero, eso confundía: ranges distintos mostraban mismos números).
  const sliceDesde = enRango ? desde : (desde > snap.desde ? desde : snap.desde);
  const sliceHasta = enRango ? hasta : (hasta < snap.hasta ? hasta : snap.hasta);
  const enviosSlice = sliceKpisProducto('envios', snap.envios, sliceDesde, sliceHasta);
  const tiendaSlice = sliceKpisProducto('tienda', snap.tienda, sliceDesde, sliceHasta);
  const pagosSlice  = sliceKpisProducto('pagos',  snap.pagos,  sliceDesde, sliceHasta);

  const envios = { ...CURATED.envios, ...enviosSlice };
  const tienda = { ...CURATED.tienda, ...tiendaSlice };
  const pagos  = { ...CURATED.pagos,  ...pagosSlice  };
  const snapVista = enRango ? { ...snap, envios: enviosSlice, tienda: tiendaSlice, pagos: pagosSlice, desde, hasta } : snap;
  const tabs = [['resumen','Resumen'],['envios','T1 Envíos'],['tienda','T1 Tienda'],['pagos','T1 Pagos'],['trafico','Tráfico (GA4)'],['inversion','Inversión'],['utms','UTMs sin mapear'],['ayuda','Ayuda']];

  return (
    <div style={{background:C.bg,minHeight:'100vh',fontFamily:FONT_STACK,color:C.oxford}}>
      <style>{`@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&display=swap'); body{font-family:${FONT_STACK};-webkit-font-smoothing:antialiased;} *{font-family:inherit;}`}</style>

      <div style={{borderBottom:`1px solid ${C.line}`,background:C.card}}>
        <div style={{maxWidth:1080,margin:'0 auto',padding:'18px 24px',display:'flex',justifyContent:'space-between',alignItems:'flex-end',flexWrap:'wrap',gap:12}}>
          <div>
            <div style={{fontSize:23,fontWeight:600}}>Dashboard KPIs · T1</div>
            <div style={{fontSize:11.5,color:C.sub,marginTop:2}}>
              Datos al corte · {snap.corte} · proyecto temporal
              {savedAt && <span style={{marginLeft:8,padding:'2px 8px',background:C.bgAlt,borderRadius:99,fontSize:10.5,color:C.subLt,fontWeight:600,letterSpacing:'.03em'}}>actualizado {tiempoTranscurrido(savedAt)}</span>}
            </div>
          </div>
          <div style={{display:'flex',gap:8,alignItems:'center'}}>
            <button onClick={cerrarSesion} title="Cerrar sesión" style={{padding:'9px 12px',background:'transparent',color:C.sub,border:`1px solid transparent`,borderRadius:6,fontSize:13,fontWeight:500,cursor:'pointer',fontFamily:'inherit'}}>⏻</button>
          </div>
        </div>
        {/* Filtros: rango calendario libre */}
        <div style={{maxWidth:1080,margin:'0 auto',padding:'0 24px 12px',display:'flex',gap:14,alignItems:'center',flexWrap:'wrap',fontSize:12}}>
          <span style={{color:C.sub,fontWeight:700,textTransform:'uppercase',letterSpacing:'.06em',fontSize:10.5}}>Rango</span>
          <label style={{display:'flex',alignItems:'center',gap:6,color:C.ink}}>
            Desde:
            <input type="date" value={desde} max={hasta} onChange={e=>setDesde(e.target.value)}
              style={{padding:'5px 7px',border:`1px solid ${C.line}`,borderRadius:4,fontSize:12,background:C.card,fontFamily:'inherit'}}/>
          </label>
          <label style={{display:'flex',alignItems:'center',gap:6,color:C.ink}}>
            Hasta:
            <input type="date" value={hasta} min={desde} max={hoyYmd()} onChange={e=>setHasta(e.target.value)}
              style={{padding:'5px 7px',border:`1px solid ${C.line}`,borderRadius:4,fontSize:12,background:C.card,fontFamily:'inherit'}}/>
          </label>
          {/* Presets rápidos — slicing local instantáneo (no fetch).
              Si el rango sale del cargado, aparece banner para Aplicar. */}
          {[['7d',7],['14d',14],['30d',30],['8 sem',56],['60d',60]].map(([label,n]) => {
            const d = diasAtrasYmd(n-1), h = hoyYmd();
            const activo = desde === d && hasta === h;
            return (
              <button key={label} onClick={()=>{ setDesde(d); setHasta(h); }}
                style={{padding:'5px 12px',background:activo?C.ink:C.card,color:activo?C.bg:C.sub,border:`1px solid ${activo?C.ink:C.line}`,borderRadius:99,fontSize:11.5,cursor:'pointer',fontFamily:'inherit',fontWeight:600}}>{label}</button>
            );
          })}
          <button onClick={aplicarFiltro} disabled={cargando} style={{padding:'7px 16px',background:C.brandRed,color:'#fff',border:'none',borderRadius:6,fontSize:12.5,fontWeight:600,cursor:cargando?'wait':'pointer',opacity:cargando?.6:1,fontFamily:'inherit'}}>
            {cargando ? 'Cargando…' : 'Aplicar (3 productos)'}
          </button>
          {cargando && <span style={{color:C.sub,fontSize:11.5,fontWeight:500}}>HubSpot suele tardar 30-90s en primera carga</span>}
        </div>
        <div style={{maxWidth:1080,margin:'0 auto',padding:'0 24px',display:'flex',gap:3,flexWrap:'wrap'}}>
          {tabs.map(([k,label])=>(
            <button key={k} onClick={()=>setVista(k)} style={{
              padding:'10px 16px',background:'none',border:'none',cursor:'pointer',fontSize:12.8,
              fontWeight:vista===k?700:500, color:vista===k?C.ink:C.sub,
              borderBottom:vista===k?`2px solid ${C.ink}`:'2px solid transparent', marginBottom:-1,
            }}>{label}</button>))}
        </div>
      </div>

      <div style={{maxWidth:1080,margin:'0 auto',padding:'8px 24px 50px'}}>
        {!enRango && (
          <div style={{background:C.bgAmbar,border:`1px solid ${C.ambar}`,borderLeft:`4px solid ${C.ambar}`,borderRadius:8,padding:'12px 18px',marginBottom:14,display:'flex',gap:14,alignItems:'center',justifyContent:'space-between',flexWrap:'wrap'}}>
            <div>
              <div style={{fontWeight:700,fontSize:13,color:C.ambar,letterSpacing:'.02em'}}>⚠ RANGO FUERA DE LO CARGADO</div>
              <div style={{fontSize:12.5,color:C.sub,marginTop:3,fontWeight:500}}>
                Cargado: <strong>{snap.desde} → {snap.hasta}</strong> · Pediste: <strong>{desde} → {hasta}</strong>.
                Dale Aplicar para traer el rango nuevo.
              </div>
            </div>
            <button onClick={aplicarFiltro} disabled={cargando} style={{padding:'8px 16px',background:C.ambar,color:'#fff',border:'none',borderRadius:6,fontSize:12.5,fontWeight:600,cursor:cargando?'wait':'pointer',opacity:cargando?.6:1,fontFamily:'inherit'}}>Aplicar y traer datos</button>
          </div>
        )}
        {vista==='resumen' && <VistaResumen irA={setVista} snap={snapVista} roi8={snap.roi8}/>}
        {vista==='envios' && <VistaEnvios d={envios} roi8={snap.roi8} hoyStr={hoyYmd()} error={snap.errores?.envios} refrescar={()=>refrescarProducto('envios')} cargando={!!cargandoProd.envios} semaforos={snap.semaforos?.envios} periodoLabel={labelRango(desde, hasta)} fallbackAt={snap.fallback?.envios}/>}
        {vista==='tienda' && <VistaTienda d={tienda} hoyStr={hoyYmd()} error={snap.errores?.tienda} refrescar={()=>refrescarProducto('tienda')} cargando={!!cargandoProd.tienda} semaforos={snap.semaforos?.tienda} periodoLabel={labelRango(desde, hasta)} fallbackAt={snap.fallback?.tienda}/>}
        {vista==='pagos' && <VistaPagos d={pagos} hoyStr={hoyYmd()} error={snap.errores?.pagos} refrescar={()=>refrescarProducto('pagos')} cargando={!!cargandoProd.pagos} semaforos={snap.semaforos?.pagos} periodoLabel={labelRango(desde, hasta)} fallbackAt={snap.fallback?.pagos}/>}
        {vista==='trafico' && <VistaTrafico desde={desde} hasta={hasta}/>}
        {vista==='inversion' && <VistaInversion desde={desde} hasta={hasta} onSaved={()=>recargarSilencioso(desde,hasta,true)}/>}
        {vista==='utms' && <VistaUtms desde={desde} hasta={hasta}/>}
        {vista==='ayuda' && <VistaAyuda/>}

        <div style={{marginTop:36,paddingTop:18,borderTop:`1px solid ${C.line}`,fontSize:11,color:C.sub,lineHeight:1.6}}>
          <strong style={{color:C.sub}}>Caveats permanentes:</strong> T1 Envíos sí tiene campañas pagadas (Google + Meta) — no es baseline operativo ·
          Mes en curso es parcial · El "Saldo T1Envíos" es acumulado actual ·
          El funnel comercial no es secuencial — los flags son eventos independientes ·
          Variaciones &lt;±20% pueden ser ruido normal · Atribución por UTM de campaña (cross-product).
          <div style={{marginTop:6}}>Datos en vivo desde HubSpot CRM · Tráfico desde GA4 Data API · Análisis curado validado.</div>
          <div style={{marginTop:4}}>Cohortes en ventana: {snap.semanasEnVentana?.join(' · ')}</div>
          <div style={{marginTop:4}}>Saneamiento: descartes prueba {snap.descartes.prueba} · interno @t1.com {snap.descartes.t1Interno}</div>
          {snap.utmsSinMapear?.length > 0 && <div style={{marginTop:4,color:'#A96A00'}}>⚠ {snap.utmsSinMapear.length} UTMs sin mapear detectadas.</div>}
        </div>
      </div>

      <ChatBurbuja snap={snapVista}/>
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(App));
