/* Dashboard "Hoy" */

// Mini formatter: "hace 12 min" / "hace 3h" / "ayer" / "27 abr".
// El backend persiste timestamps UTC pero sin sufijo "Z" (DuckDB TIMESTAMP).
// JS por defecto los interpreta como hora local → resta horas. Forzamos UTC
// agregando "Z" si el string parece ISO sin tz.
function formatRelativeShort(iso) {
  if (!iso) return "—";
  let s = String(iso);
  if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(s) && !/[zZ]|[+-]\d{2}:?\d{2}$/.test(s)) {
    s = s.replace(" ", "T") + "Z";
  }
  const d = new Date(s);
  if (isNaN(d.getTime())) return "—";
  const diffMin = Math.round((Date.now() - d.getTime()) / 60000);
  if (diffMin < 0) return "ahora";
  if (diffMin < 1) return "ahora";
  if (diffMin < 60) return `hace ${diffMin} min`;
  const diffH = Math.round(diffMin / 60);
  if (diffH < 24) return `hace ${diffH}h`;
  if (diffH < 48) return "ayer";
  const months = ["ene","feb","mar","abr","may","jun","jul","ago","sep","oct","nov","dic"];
  return `${d.getDate()} ${months[d.getMonth()]}`;
}

// "sáb 02 may" — fecha corta en español para el eyebrow del hero.
function formatBriefingDateShort(iso) {
  if (!iso) return "";
  let s = String(iso);
  if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(s) && !/[zZ]|[+-]\d{2}:?\d{2}$/.test(s)) {
    s = s.replace(" ", "T") + "Z";
  }
  const d = new Date(s);
  if (isNaN(d.getTime())) return "";
  const months = ["ene","feb","mar","abr","may","jun","jul","ago","sep","oct","nov","dic"];
  const days = ["dom","lun","mar","mié","jue","vie","sáb"];
  return `${days[d.getDay()]} ${String(d.getDate()).padStart(2, "0")} ${months[d.getMonth()]}`;
}

// Trunca a `max` chars sin cortar palabra cuando se puede.
function truncateText(txt, max = 280) {
  if (!txt) return "";
  const s = String(txt).trim();
  if (s.length <= max) return s;
  const cut = s.slice(0, max);
  const lastSpace = cut.lastIndexOf(" ");
  return (lastSpace > max - 40 ? cut.slice(0, lastSpace) : cut).trimEnd() + "…";
}

// Extrae la primera frase del coach reading para el "display" del hero.
// El briefing puede tener `title` (ya corto) o `body` (párrafo). Preferimos
// `title`; si no hay, tomamos la primera frase del body.
function extractCoachHeadline(reading, fallback = "Tu día listo.") {
  if (!reading) return fallback;
  const title = (reading.title || "").trim();
  if (title) return title;
  const body = (reading.body || "").trim();
  if (!body) return fallback;
  const firstSentence = body.split(/(?<=[\.\!\?])\s+/)[0] || body;
  return truncateText(firstSentence, 120);
}

// Tono visual derivado del recovery score (mantiene el lenguaje de color
// "verde / azul / ámbar / rojo" del design original — pero NO inventa el
// adjetivo: el TEXTO viene del LLM).
function toneFromRecovery(score) {
  if (score == null) return "var(--text-3)";
  if (score >= 75) return "var(--good)";
  if (score >= 50) return "var(--info)";
  if (score >= 25) return "var(--warn)";
  return "var(--bad)";
}

// Frases secuenciales que mostramos durante un refresh del briefing/insights.
// Imitan el feedback de la modal de Garmin Connect ("escuchando…", "sincronizando…").
const HERO_REFRESH_PHASES = [
  "Consultando coach…",
  "Leyendo tus últimas 72 h…",
  "Consolidando…",
];
const INSIGHTS_REFRESH_PHASES = [
  "Repasando tendencias…",
  "Identificando patrones…",
  "Escribiendo insights…",
];

const Today = ({ density, dataState, showDisclaimer, setRoute, inDemo }) => {
  // Estado de datos. En demo arrancamos con MOCK; en producción arrancamos
  // VACÍO y sólo poblamos cuando el backend respondió con datos reales.
  // Antes el dashboard mostraba MOCK durante el primer fetch o cuando había
  // error de red — eso confundía a usuarios reales (parecía datos suyos).
  const [daily, setDaily] = React.useState(() => inDemo ? MOCK.daily : []);
  const [lastActivity, setLastActivity] = React.useState(() => inDemo ? MOCK.activities[0] : null);
  const [lastActivityHrStream, setLastActivityHrStream] = React.useState(null);
  const [loaded, setLoaded] = React.useState(inDemo);   // true = ya intentamos fetch o estamos en demo
  // Estado real de los providers para la barra de sync. Cada entrada:
  // { name, auth: "logged_in"|"unknown"|..., last_sync, syncing }
  const [providerStates, setProviderStates] = React.useState([]);

  React.useEffect(() => {
    if (inDemo) { setLoaded(true); return; }
    let alive = true;
    (async () => {
      // /data/daily — últimos 7 días.
      try {
        const today = new Date();
        const from = new Date(today); from.setDate(from.getDate() - 30);
        const fmt = (d) => d.toISOString().slice(0, 10);
        const res = await API.dailyData({ from: fmt(from), to: fmt(today) });
        const items = Array.isArray(res) ? res : (res?.rows || res?.items || res?.daily || []);
        if (alive && items.length > 0) {
          // Backend devuelve DESC (más reciente primero); el resto del
          // screen asume ASC (último elemento = hoy). Ordenamos ASC.
          const normalized = items.map(normalizeDailyRow);
          normalized.sort((a, b) => (a.date || "").localeCompare(b.date || ""));
          setDaily(normalized);
        }
      } catch (e) {
        if (e.status === 401 || e.status === 403 || e.status === 422) return; // bubble
        // network error → quedamos con [] (empty state honesto, no MOCK)
      }
      // /data/activities?limit=1 — última actividad
      let lastActId = null;
      try {
        const res = await API.activitiesList({ limit: 1 });
        const items = Array.isArray(res) ? res : (res?.rows || res?.items || res?.activities || []);
        if (alive && items.length > 0) {
          // Backend devuelve DESC también; el primero ES la más reciente.
          const row = normalizeActivityRow(items[0]);
          setLastActivity(row);
          lastActId = row.id;
        }
      } catch (e) {
        if (e.status === 401 || e.status === 403 || e.status === 422) return;
      }
      // Stream HR real para el sparkline. Si la actividad no tiene streams
      // (FIT no enriquecido), dejamos el state en null y el card oculta el chart.
      if (lastActId) {
        try {
          const streams = await API.activityStreams(lastActId);
          const points = Array.isArray(streams?.points) ? streams.points : [];
          const hr = points.map((p) => p.hr).filter((v) => v != null);
          if (alive && hr.length > 1) setLastActivityHrStream(hr);
        } catch (e) {
          if (e.status === 401 || e.status === 403 || e.status === 422) return;
        }
      }
      // Estado de providers (la sync row del header). Honesto: si Eufy no
      // está configurado, no decimos "Eufy escuchando BLE".
      try {
        const provs = await API.listProviders();
        const list = Array.isArray(provs) ? provs : (provs?.providers || []);
        const states = await Promise.all(
          list.map(async (p) => {
            const name = typeof p === "string" ? p : (p.name || p.id);
            try {
              const s = await API.providerStatus(name);
              return { name, ...s };
            } catch {
              return { name, auth: "unknown" };
            }
          })
        );
        if (alive) setProviderStates(states.filter(s => s && s.name));
      } catch (e) { /* sin providers info → render fallback */ }
      if (alive) setLoaded(true);
    })();
    return () => { alive = false; };
  }, [inDemo]);

  // ───── HOOKS — todos antes de cualquier early-return.
  // Mover hooks debajo de un return condicional viola las rules of hooks
  // (orden cambia entre renders) y React desmonta todo → "pantalla negra".
  const heroRef = React.useRef(null);
  const insightsRef = React.useRef(null);

  // Derivados (no son hooks).
  const hasDaily = daily && daily.length > 0;
  const _emptyDaily = {
    sleepScore: null, sleepHours: null, sleepMinutes: null, sleepPhases: null,
    hrv: null, rhr: null, weight: null, bodyFat: null, energy: null,
    load: null, stress: null, stressAvg: null, stressMax: null, readiness: null,
  };
  const t = hasDaily ? daily[daily.length - 1] : _emptyDaily;
  const last7 = daily.slice(-7);
  const last30 = daily.slice(-30);
  const partial = dataState === "partial";
  const error = dataState === "error";

  // ───── Early returns AHORA, después de los hooks.
  if (!inDemo && !loaded) {
    return (
      <div style={{ padding: "var(--sp-8)", textAlign: "center", color: "var(--text-3)", fontFamily: "var(--font-mono)", fontSize: 13 }}>
        cargando datos…
      </div>
    );
  }
  if (!inDemo && !hasDaily && !lastActivity) {
    return <TodayEmpty setRoute={setRoute} />;
  }
  if (dataState === "empty") return <TodayEmpty setRoute={setRoute} />;

  return (
    <div className="af-padded" style={{ padding: "var(--sp-6) var(--sp-8) 80px", display: "flex", flexDirection: "column", gap: 18 }}>
      {/* sync status row — sólo proveedores conectados. Los desconectados no
       * aportan info útil acá; quien quiera agregar uno va a Configuración. */}
      <div className="af-sync-row" style={{ display: "flex", alignItems: "center", gap: 10, fontSize: 11.5, color: "var(--text-3)", flexWrap: "wrap" }}>
        {(() => {
          if (inDemo || providerStates.length === 0) {
            return (
              <>
                <Dot tone="good" pulse />
                <span style={{ fontFamily: "var(--font-mono)" }}>Garmin sincronizado hace 12 min</span>
                <span style={{ color: "var(--text-4)" }}>·</span>
                <Dot tone="good" />
                <span style={{ fontFamily: "var(--font-mono)" }}>Eufy escuchando BLE</span>
              </>
            );
          }
          const connected = providerStates.filter((s) => s.auth === "logged_in");
          if (connected.length === 0) {
            return (
              <span style={{ fontFamily: "var(--font-mono)", color: "var(--text-3)" }}>
                sin proveedores conectados
              </span>
            );
          }
          return connected.map((s, i) => {
            const last = s.last_sync ? formatRelativeShort(s.last_sync) : null;
            return (
              <React.Fragment key={s.name}>
                {i > 0 && <span style={{ color: "var(--text-4)" }}>·</span>}
                <Dot tone="good" pulse={!!s.syncing} />
                <span style={{ fontFamily: "var(--font-mono)" }}>
                  {s.name} {last ? "sincronizado " + last : "conectado"}
                </span>
              </React.Fragment>
            );
          });
        })()}
        {!inDemo && lastActivity && lastActivity.date && (
          <>
            <span style={{ color: "var(--text-4)" }}>·</span>
            <span>Última actividad: {formatRelativeShort(lastActivity.date)}</span>
          </>
        )}
        <div style={{ flex: 1 }} />
        <Btn size="sm" variant="ghost" icon={<Icon name="sync" size={12} />}>Sincronizar todo</Btn>
      </div>

      {/* HERO: today's verdict (briefing del coach + recovery breakdown) */}
      <Card padded={false} style={{ overflow: "hidden", background: "var(--hero-bg)" }}>
        <div ref={heroRef} className="af-hero af-hero-content" style={{ padding: "26px 28px", display: "grid", gridTemplateColumns: "1fr 1px 380px", gap: 28 }}>
          <HeroVerdict
            inDemo={inDemo}
            recoveryScore={(t.readiness && t.readiness.score) ?? t.energy ?? null}
            setRoute={setRoute}
            scrollTarget={heroRef}
          />

          <div className="af-hero-divider" style={{ background: "var(--line)" }} />

          <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
            <div style={{ display: "flex", alignItems: "center", gap: 14 }}>
              {/* El score real de recovery viene del Training Readiness de
                  Garmin (extras.readiness.score, 0-100). Si no está,
                  caemos a body battery (energy_level). */}
              {(() => {
                const rs = t.readiness || {};
                const recoveryScore = rs.score ?? t.energy ?? null;
                const ringColor = toneFromRecovery(recoveryScore);
                const phrase = rs.feedbackShort
                  ? humanizeFeedback(rs.feedbackShort)
                  : "Combinación de HRV, sueño y carga reciente.";
                return (
                  <>
                    <Ring
                      value={recoveryScore ?? 0}
                      max={100}
                      size={88}
                      stroke={8}
                      color={ringColor}
                      label={recoveryScore ?? "—"}
                      sublabel={rs.level ? rs.level.toLowerCase() : "recovery"}
                    />
                    <div style={{ flex: 1, minWidth: 0 }}>
                      <div className="eyebrow" style={{ marginBottom: 4 }}>RECOVERY SCORE</div>
                      <div style={{ fontSize: 13, color: "var(--text-2)", lineHeight: 1.4, marginBottom: 6 }}>
                        {phrase}
                      </div>
                      {rs.feedbackLong && (
                        <div style={{ fontSize: 10.5, color: "var(--text-3)", fontFamily: "var(--font-mono)" }}>
                          {humanizeFeedback(rs.feedbackLong)}
                        </div>
                      )}
                    </div>
                  </>
                );
              })()}
            </div>
            <div style={{ height: 1, background: "var(--line-soft)" }} />
            {/* 6 indicadores reales del Training Readiness de Garmin:
                puntuación de sueño, tiempo de recuperación, VFC, carga
                aguda, sueño 7d, estrés. Si readiness no está disponible
                para el día, caemos a los campos individuales del daily. */}
            <RecoveryBreakdown
              readiness={t.readiness}
              fallback={{
                sleepScore: t.sleepScore,
                sleepScoreAvg7d: last7.length
                  ? Math.round(last7.filter(d => d.sleepScore != null).reduce((s, d) => s + d.sleepScore, 0) / Math.max(1, last7.filter(d => d.sleepScore != null).length))
                  : null,
                recoveryHours: lastActivity?.recoveryHours,
                hrv: t.hrv,
                acuteLoad: t.load,
                stress: t.stress,
                stressAvg: t.stressAvg,
              }}
            />
          </div>
        </div>
      </Card>

      {/* Vitals row — los providers no siempre rellenan todo (Garmin no
          reporta peso ni body composition; Eufy no reporta HRV). Mostramos
          "—" cuando el valor es null para no inventar datos. */}
      <div className="af-grid-4" style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 14 }}>
        <VitalCard
          icon="moon" label="Sueño" value={t.sleepHours != null ? t.sleepHours.toFixed(1) : "—"} unit={t.sleepHours != null ? "h" : ""}
          subValue={t.sleepScore != null ? `Score ${t.sleepScore}` : "sin datos"}
          delta={+0.4} goodDirection="up" deltaUnit="h"
          spark={last7.map(d => d.sleepHours).filter(v => v != null)}
          color="var(--info)"
          source="Garmin"
        />
        <VitalCard
          icon="heart" label="HRV" value={t.hrv ?? "—"} unit={t.hrv != null ? "ms" : ""}
          subValue={t.hrv != null ? "Máx. del mes" : "sin datos"}
          delta={+8} goodDirection="up"
          spark={last7.map(d => d.hrv).filter(v => v != null)}
          color="var(--good)"
          source="Garmin"
          highlight
        />
        <VitalCard
          icon="flame" label="FC reposo" value={t.rhr ?? "—"} unit={t.rhr != null ? "bpm" : ""}
          subValue={t.rhr != null ? "Mejor que tu media" : "sin datos"}
          delta={-2} goodDirection="down"
          spark={last7.map(d => d.rhr).filter(v => v != null)}
          color="var(--accent)"
          source="Garmin"
        />
        {(() => {
          // Card de peso defensiva: cuando no hay lecturas (báscula desconectada
          // o el usuario aún no se pesó), mostramos sólo la card en estado vacío
          // sin el arrow de tendencia ni la spark line ni la fuente — antes
          // mostraba "—  ↘  -0.6kg  fuente Eufy 7d" todo a la vez, lo cual era
          // contradictorio.
          const weightSeries = last30.map(d => d.weight).filter(v => v != null);
          const hasWeight = t.weight != null;
          const realDelta = weightSeries.length >= 2
            ? Number((weightSeries[weightSeries.length - 1] - weightSeries[0]).toFixed(1))
            : null;
          return (
            <VitalCard
              icon="weight" label="Peso"
              value={hasWeight ? t.weight.toFixed(1) : "—"}
              unit={hasWeight ? "kg" : ""}
              subValue={
                hasWeight
                  ? (t.bodyFat != null ? `${t.bodyFat.toFixed(1)}% grasa` : "")
                  : "Sin lecturas — conectá una báscula (Eufy / Garmin Index)"
              }
              delta={realDelta} goodDirection="down" deltaUnit="kg"
              spark={weightSeries}
              color="var(--warn)"
              source={hasWeight ? "Eufy" : null}
              stale={partial && hasWeight}
            />
          );
        })()}
      </div>

      {/* Two-col: insights + last activity */}
      <div className="af-stack-mobile" style={{ display: "grid", gridTemplateColumns: "1.4fr 1fr", gap: 14 }}>
        {/* Insights — bueno / atención / mejora */}
        <div ref={insightsRef}>
          <InsightsStrip inDemo={inDemo} scrollTarget={insightsRef} />
        </div>

        {/* Last activity — sólo cuando existe. Si no hay actividades aún
         * (cuenta nueva o sin sync), mostramos un empty state honesto. */}
        <Card>
          <div style={{ display: "flex", alignItems: "center", marginBottom: 12 }}>
            <div className="eyebrow">ÚLTIMA ACTIVIDAD</div>
            <div style={{ flex: 1 }} />
            {lastActivity && <Badge tone="accent">{lastActivity.type}</Badge>}
          </div>
          {!lastActivity ? (
            <div style={{
              padding: "30px 10px", textAlign: "center",
              fontSize: 12, color: "var(--text-3)", fontFamily: "var(--font-mono)",
              border: "1px dashed var(--line)", borderRadius: "var(--r-2)",
            }}>
              Sin actividades sincronizadas todavía.<br />
              <span style={{ color: "var(--text-4)", fontSize: 10, marginTop: 6, display: "inline-block" }}>
                Conectá un proveedor (Garmin) o esperá al próximo auto-sync.
              </span>
            </div>
          ) : (
            <>
              <div style={{ display: "flex", alignItems: "baseline", gap: 6, marginBottom: 4 }}>
                <div className="num" style={{ fontSize: 28, fontWeight: 500, letterSpacing: "-0.02em" }}>
                  {lastActivity.dist != null ? Number(lastActivity.dist).toFixed(2) : "—"} km
                </div>
                <div style={{ fontSize: 13, color: "var(--text-3)" }}>
                  en {lastActivity.dur}
                </div>
              </div>
              <div style={{ fontSize: 12.5, color: "var(--text-2)", marginBottom: 14 }}>
                {lastActivity.subtype} · {(lastActivity.date || "").replace("2026-", "")}, {lastActivity.time}
              </div>

              <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 10, fontSize: 11.5, marginBottom: 14 }}>
                <Stat label="Pace" value={lastActivity.pace} unit="/km" />
                <Stat label="FC media" value={lastActivity.hr} unit="bpm" />
                <Stat
                  label="TE aer/anaer"
                  value={
                    (lastActivity.te != null ? lastActivity.te.toFixed(1) : "—")
                    + " / "
                    + (lastActivity.teAnaerobic != null ? lastActivity.teAnaerobic.toFixed(1) : "—")
                  }
                  unit=""
                />
              </div>

              {/* Mini sparkline del stream HR real. fluid=true → SVG al 100% del
               * container; antes se mostraba a media anchura porque width estaba
               * pinneado a 300px y el card era más ancho. */}
              {(() => {
                const hr = inDemo ? MOCK.stream.hr : lastActivityHrStream;
                if (!hr || hr.length < 2) return null;
                return (
                  <div style={{ padding: 10, background: "var(--bg-2)", borderRadius: "var(--r-2)", marginBottom: 12 }}>
                    <div style={{ fontSize: 9.5, color: "var(--text-3)", fontFamily: "var(--font-mono)", letterSpacing: 0.1, marginBottom: 4, textTransform: "uppercase" }}>
                      Pulso · {hr.length} pts
                    </div>
                    <SparkLine data={hr} width={600} height={42} color="var(--bad)" fill fluid />
                  </div>
                );
              })()}
            </>
          )}

          <Btn size="sm" variant="default" style={{ width: "100%", justifyContent: "center" }} onClick={() => setRoute("activities")}>
            Ver detalle <Icon name="chevR" size={11} />
          </Btn>
        </Card>
      </div>

      {/* "EN TU PLAN" — sólo si hay un plan activo guardado en /plans/. */}
      <ActivePlanStrip setRoute={setRoute} inDemo={inDemo} />

      {/* Evolución 90 días: comparativa primera vs última quincena de los
       * datos reales de common.daily. Se oculta si no hay suficientes datos. */}
      <EvolutionCard inDemo={inDemo} />

      {/* HÁBITO EN CONSTRUCCIÓN: feature pendiente, sin backend. Se oculta
       * hasta que tengamos /habits/. No mostrar mock falso. */}

      {showDisclaimer && (
        <div style={{
          padding: "12px 16px", borderRadius: "var(--r-2)",
          background: "var(--warn-soft)",
          border: "1px solid color-mix(in srgb, var(--warn) 30%, transparent)",
          display: "flex", gap: 10, alignItems: "flex-start",
          fontSize: 11.5, color: "var(--text-2)",
        }}>
          <Icon name="alert" size={14} color="var(--warn)" />
          <div>
            <strong style={{ color: "var(--warn)" }}>Esto no es consejo médico.</strong> La información se basa en tendencias de tus datos y conocimiento general. Consulta a un profesional ante cualquier síntoma o duda clínica.
          </div>
        </div>
      )}
    </div>
  );
};

const VitalCard = ({ icon, label, value, unit, subValue, delta, deltaUnit = "", goodDirection = "up", spark, color, source, highlight, stale }) => {
  const hasSpark = Array.isArray(spark) && spark.length >= 2;
  const hasDelta = delta != null && !Number.isNaN(delta);
  return (
    <Card style={{
      position: "relative", overflow: "hidden",
      border: highlight ? `1px solid ${color}` : "1px solid var(--line)",
    }}>
      <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 10 }}>
        <div style={{
          width: 24, height: 24, borderRadius: 5, display: "grid", placeItems: "center",
          background: `color-mix(in srgb, ${color} 14%, transparent)`,
          color,
        }}>
          <Icon name={icon} size={13} color={color} />
        </div>
        <div className="eyebrow" style={{ fontSize: 10 }}>{label}</div>
        <div style={{ flex: 1 }} />
        {stale && <Badge tone="warn"><Dot tone="warn" /> stale</Badge>}
      </div>
      <div style={{ display: "flex", alignItems: "baseline", gap: 4, marginBottom: 4 }}>
        <span className="num" style={{ fontSize: 28, fontWeight: 500, letterSpacing: "-0.02em" }}>{value}</span>
        {unit && <span style={{ fontSize: 12, color: "var(--text-3)" }}>{unit}</span>}
        <div style={{ flex: 1 }} />
        {hasDelta && <Trend delta={delta} unit={deltaUnit} goodDirection={goodDirection} size="sm" />}
      </div>
      {subValue && <div style={{ fontSize: 11.5, color: "var(--text-3)", marginBottom: 10 }}>{subValue}</div>}
      {hasSpark && (
        <div style={{ marginLeft: -4, marginRight: -4 }}>
          <SparkLine data={spark} width={240} height={32} color={color} fill />
        </div>
      )}
      {(source || hasSpark) && (
        <div style={{ marginTop: 8, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
          {source ? <Source sources={[source]} /> : <span />}
          {hasSpark && <span style={{ fontSize: 9, color: "var(--text-4)", fontFamily: "var(--font-mono)" }}>7d</span>}
        </div>
      )}
    </Card>
  );
};

const Stat = ({ label, value, unit }) => (
  <div>
    <div style={{ fontSize: 9.5, color: "var(--text-3)", fontFamily: "var(--font-mono)", letterSpacing: 0.1, textTransform: "uppercase", marginBottom: 3 }}>
      {label}
    </div>
    <div className="num" style={{ fontSize: 16, fontWeight: 500 }}>
      {value}<span style={{ fontSize: 10, color: "var(--text-3)", marginLeft: 2 }}>{unit}</span>
    </div>
  </div>
);

/* ActivePlanStrip — busca el plan training más reciente en /plans/ y muestra
 * la primera semana parseada. Si no hay ninguno, no renderiza nada (sin mock). */
const ActivePlanStrip = ({ setRoute, inDemo }) => {
  const [plan, setPlan] = React.useState(null);
  const [loaded, setLoaded] = React.useState(false);

  React.useEffect(() => {
    if (inDemo) {
      // En demo, mostrar el preview del MOCK para no quedar con la card vacía.
      setPlan({ goal: MOCK.user.goal, weekDays: MOCK.planWeek[0] });
      setLoaded(true);
      return;
    }
    let alive = true;
    (async () => {
      try {
        const list = await API.listPlans();
        const items = Array.isArray(list) ? list : (list?.plans || []);
        const training = items.find((p) => (p.type || p.plan_type) === "training");
        if (!alive) return;
        if (!training) {
          setLoaded(true);
          return;
        }
        const detail = await API.getPlan(training.id);
        if (!alive) return;
        const md = detail?.content?.answer || detail?.content?.text || detail?.content?.markdown;
        const parsed = (typeof window.parseTrainingPlan === "function" && md) ? window.parseTrainingPlan(md) : null;
        if (parsed && parsed.weeks.length > 0) {
          // Convertir el primer "weeks[0]" en formato compatible con PlanDayMini.
          // PlanDayMini lee {day, title, desc, tone, load, today} — usá esos keys.
          const todayDow = (new Date().getDay() + 6) % 7; // 0=Lun ... 6=Dom
          const weekDays = parsed.weeks[0].days.map((d) => {
            const sess = d.sessions[0];
            const isRest = !sess || sess.tone === "rest";
            return {
              day: d.label,
              today: d.dow === todayDow,
              tone: sess?.tone || "rest",
              title: isRest ? "Descanso" : (sess?.name || "Sesión"),
              desc: [
                sess?.intensity,
                sess?.duration_min ? `${sess.duration_min}'` : null,
              ].filter(Boolean).join(" · "),
              load: sess?.load || null,
            };
          });
          setPlan({ goal: detail?.content?.goal || training.goal || parsed.title, weekDays });
        } else {
          setPlan({ goal: detail?.content?.goal || training.goal || "Plan activo", weekDays: null });
        }
      } catch (e) {
        // Silencio: si falla la API mostramos como "sin plan" (más honesto que mock).
      } finally {
        if (alive) setLoaded(true);
      }
    })();
    return () => { alive = false; };
  }, [inDemo]);

  if (!loaded) return null;
  if (!plan) return null;

  return (
    <Card>
      <div style={{ display: "flex", alignItems: "center", marginBottom: 14 }}>
        <div>
          <div className="eyebrow" style={{ marginBottom: 4 }}>EN TU PLAN · ESTA SEMANA</div>
          <div style={{ fontSize: 16, fontWeight: 500 }}>{plan.goal}</div>
        </div>
        <div style={{ flex: 1 }} />
        <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
          <Badge tone="accent"><Dot tone="good" /> activo</Badge>
          <Btn size="sm" variant="ghost" icon={<Icon name="chevR" size={11} />} onClick={() => setRoute("plans")}>Plan completo</Btn>
        </div>
      </div>
      {plan.weekDays ? (
        <div style={{ display: "grid", gridTemplateColumns: "repeat(7, 1fr)", gap: 8, overflowX: "auto" }}>
          {plan.weekDays.map((s, i) => <PlanDayMini key={i} session={s} />)}
        </div>
      ) : (
        <div style={{ fontSize: 12, color: "var(--text-3)", fontFamily: "var(--font-mono)" }}>
          El plan está guardado pero no se pudo parsear el calendario semanal. Abrí "Planes" para ver el detalle.
        </div>
      )}
    </Card>
  );
};

/* EvolutionCard — comparativa primera vs última semana de daily real. */
const EvolutionCard = ({ inDemo }) => {
  const [rows, setRows] = React.useState(null);

  React.useEffect(() => {
    if (inDemo) { setRows(null); return; }
    let alive = true;
    (async () => {
      try {
        const today = new Date();
        const from = new Date(today); from.setDate(from.getDate() - 90);
        const fmt = (d) => d.toISOString().slice(0, 10);
        const res = await API.dailyData({ from: fmt(from), to: fmt(today) });
        const items = Array.isArray(res) ? res : (res?.rows || []);
        if (!alive) return;
        // Backend orden DESC; ordenamos ASC por fecha para tomar primer/último tramo.
        const ordered = [...items].reverse().map(window.normalizeDailyRow);
        if (ordered.length < 4) { setRows([]); return; }
        const headSize = Math.max(3, Math.floor(ordered.length / 6));
        const tailSize = headSize;
        const head = ordered.slice(0, headSize);
        const tail = ordered.slice(-tailSize);
        const avg = (arr, key) => {
          const xs = arr.map((r) => r[key]).filter((v) => v != null && !isNaN(v));
          return xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null;
        };
        const fields = [
          { key: "weight", label: "Peso", unit: "kg", goodDirection: "down", digits: 1 },
          { key: "hrv", label: "HRV media", unit: "ms", goodDirection: "up", digits: 0 },
          { key: "rhr", label: "FC reposo", unit: "bpm", goodDirection: "down", digits: 0 },
          { key: "sleepHours", label: "Sueño medio", unit: "h", goodDirection: "up", digits: 1 },
          { key: "energy", label: "Energía media", unit: "", goodDirection: "up", digits: 0 },
        ];
        const computed = fields.map((f) => {
          const before = avg(head, f.key);
          const after = avg(tail, f.key);
          if (before == null || after == null) return null;
          const diff = after - before;
          const goodSign = f.goodDirection === "up" ? diff > 0 : diff < 0;
          return {
            label: f.label,
            before: `${before.toFixed(f.digits)}${f.unit ? " " + f.unit : ""}`,
            after: `${after.toFixed(f.digits)}${f.unit ? " " + f.unit : ""}`,
            delta: `${diff > 0 ? "+" : ""}${diff.toFixed(f.digits)}${f.unit ? " " + f.unit : ""}`,
            good: goodSign,
          };
        }).filter(Boolean);
        setRows(computed);
      } catch (e) {
        if (alive) setRows([]);
      }
    })();
    return () => { alive = false; };
  }, [inDemo]);

  if (rows === null) return null;
  if (rows.length === 0) return null;

  return (
    <Card style={{ background: "var(--bg-2)" }}>
      <div className="eyebrow" style={{ marginBottom: 10 }}>EVOLUCIÓN · ~90 DÍAS</div>
      <div style={{ fontSize: 13, color: "var(--text-2)", lineHeight: 1.5, marginBottom: 14 }}>
        Comparativa primera vs última quincena dentro de los últimos 90 días con datos.
      </div>
      {rows.map((r, i) => (
        <ChangeRow
          key={r.label}
          label={r.label}
          before={r.before}
          after={r.after}
          delta={r.delta}
          good={r.good}
          last={i === rows.length - 1}
        />
      ))}
    </Card>
  );
};

const ChangeRow = ({ label, before, after, delta, good, last }) => (
  <div style={{
    display: "grid", gridTemplateColumns: "1fr auto auto auto",
    alignItems: "center", gap: 14, padding: "8px 0",
    borderBottom: last ? "none" : "1px dashed var(--line-soft)",
    fontSize: 12.5,
  }}>
    <div style={{ color: "var(--text-2)" }}>{label}</div>
    <div className="num" style={{ color: "var(--text-3)" }}>{before}</div>
    <div className="num" style={{ color: "var(--text-3)" }}>→</div>
    <div style={{ display: "flex", gap: 8, alignItems: "baseline" }}>
      <span className="num" style={{ fontWeight: 500 }}>{after}</span>
      <span className="num" style={{ fontSize: 11, color: good ? "var(--good)" : "var(--bad)", fontWeight: 500 }}>{delta}</span>
    </div>
  </div>
);

const PlanDayMini = ({ session }) => {
  const colors = {
    rest: "var(--text-4)",
    easy: "var(--info)",
    long: "var(--good)",
    hard: "var(--bad)",
    strength: "var(--accent)",
  };
  const c = colors[session.tone] || "var(--text-3)";
  const isRest = session.tone === "rest";
  return (
    <div style={{
      padding: "10px 10px 12px",
      borderRadius: "var(--r-2)",
      background: session.today ? "color-mix(in srgb, var(--accent) 10%, var(--bg-1))" : "var(--bg-2)",
      border: session.today ? `1px solid var(--accent)` : "1px solid var(--line)",
      position: "relative",
      minHeight: 96,
      display: "flex", flexDirection: "column",
    }}>
      <div style={{
        fontSize: 9.5, fontFamily: "var(--font-mono)", letterSpacing: 0.1,
        color: session.today ? "var(--accent)" : "var(--text-3)",
        textTransform: "uppercase", marginBottom: 2, fontWeight: 600,
      }}>
        {session.day}{session.today && " · HOY"}
      </div>
      <div style={{
        height: 3, width: 28, borderRadius: 2, background: c, marginBottom: 8,
      }} />
      <div style={{ fontSize: 12, fontWeight: 500, lineHeight: 1.25, marginBottom: 3 }}>
        {session.title}
      </div>
      <div style={{ fontSize: 10.5, color: "var(--text-3)", lineHeight: 1.3 }}>
        {session.desc}
      </div>
      {session.load && (
        <div style={{ marginTop: "auto", paddingTop: 6, fontSize: 9.5, color: "var(--text-3)", fontFamily: "var(--font-mono)" }}>
          carga {session.load}
        </div>
      )}
    </div>
  );
};

const TodayEmpty = ({ setRoute }) => (
  <div style={{ padding: "var(--sp-8)", display: "grid", placeItems: "center", minHeight: 500 }}>
    <div style={{ maxWidth: 520, textAlign: "center" }}>
      <div className="display" style={{ fontSize: 32, marginBottom: 12, letterSpacing: "-0.025em" }}>
        Conecta tu primer dispositivo.
      </div>
      <div style={{ fontSize: 14, color: "var(--text-2)", lineHeight: 1.6, marginBottom: 24 }}>
        FtiMind no inventa datos. Cuando conectes Garmin o Eufy verás aquí tu energía, sueño y HRV con micro-tendencias de los últimos 7 días.
      </div>
      <Btn variant="primary" size="lg" onClick={() => setRoute("onboarding")} icon={<Icon name="plus" size={14} />}>
        Empezar onboarding
      </Btn>
    </div>
  </div>
);

/* Normalizadores: el backend puede devolver shapes ligeramente distintos al MOCK
 * (snake_case vs camelCase, fechas como ISO, etc). Los mantenemos en este file
 * para que sea fácil ajustar a medida que estabilicemos el contrato. */
function normalizeDailyRow(r) {
  // Garmin manda los detalles de sueño (duración + 4 fases) bajo
  // extras.sleep, y el panel de recovery (training readiness real)
  // bajo extras.readiness. El resto tiene alias por proveedor.
  const sleepBlob = (r.extras && r.extras.sleep) || {};
  const readiness = (r.extras && r.extras.readiness) || null;
  const sleepMinTotal = sleepBlob.minutes_total ?? null;
  const phases = sleepBlob.phases || null;
  return {
    date: r.date || r.day,
    label: r.label || (r.date ? r.date.slice(5).replace("-", "/") : ""),
    sleepScore: r.sleep_score ?? r.sleepScore ?? null,
    sleepHours: r.sleep_hours ?? r.sleepHours ?? (sleepMinTotal != null ? sleepMinTotal / 60 : null),
    sleepMinutes: sleepMinTotal,
    sleepPhases: phases,
    hrv: r.hrv ?? r.hrv_overnight ?? r.hrv_avg ?? null,
    rhr: r.rhr ?? r.resting_hr ?? r.rhr_avg ?? null,
    weight: r.weight ?? r.weight_kg ?? null,
    bodyFat: r.body_fat ?? r.bodyFat ?? r.body_fat_pct ?? null,
    energy: r.energy ?? r.energy_level ?? r.body_battery ?? null,
    load: r.load ?? r.training_load ?? null,
    stress: r.stress ?? r.stress_avg ?? null,
    // 0-100 stress real (avg del día) — viene de get_all_day_stress.
    stressAvg: (r.extras && r.extras.stress && r.extras.stress.avg) ?? r.stress_avg ?? null,
    stressMax: (r.extras && r.extras.stress && r.extras.stress.max) ?? null,
    // Training readiness factors (Garmin) — el panel de recovery los lee.
    readiness,
  };
}

function normalizeActivityRow(a) {
  // duration_s + distance_m vienen del backend; el resto de los campos
  // ricos (TE, average speed, etc.) viven en extras (raw payload de Garmin).
  const ex = a.extras || {};
  const durSec = a.duration_seconds || a.duration_s || (a.durationMinutes ? a.durationMinutes * 60 : 0);
  const distKm = a.dist ?? a.distance_km ?? (a.distance_m != null ? a.distance_m / 1000 : 0);
  // Pace: m:ss/km. Garmin no manda pace explícito, lo computamos del par
  // duration/distance (o de averageSpeed en m/s si está).
  const computePace = () => {
    if (a.pace) return a.pace;
    if (a.avg_pace) return a.avg_pace;
    const speedMs = ex.averageSpeed ?? a.average_speed ?? null;
    let secPerKm = null;
    if (speedMs && speedMs > 0) {
      secPerKm = 1000 / speedMs;
    } else if (durSec > 0 && distKm > 0) {
      secPerKm = durSec / distKm;
    }
    if (!secPerKm || !isFinite(secPerKm)) return "—";
    const m = Math.floor(secPerKm / 60);
    const s = Math.round(secPerKm % 60);
    return `${m}:${String(s).padStart(2, "0")}`;
  };
  // Training effect (aerobic). Garmin's list endpoint NO devuelve el
  // float; sólo manda *Message strings con un sufijo numérico que NO
  // mapea linealmente al TE real (ej. "_12" puede corresponder a 3.5).
  // El número correcto sólo aparece cuando se llama get_activity(id).
  // El backend ahora enriquece eso en el sync; mientras no esté, dejar null.
  const teRaw = a.te
    ?? a.training_effect
    ?? a.training_effect_aerobic
    ?? ex.aerobicTrainingEffect
    ?? ex.training_effect_aerobic
    ?? null;
  const teAnaerobic = ex.anaerobicTrainingEffect ?? a.training_effect_anaerobic ?? null;
  return {
    id: a.id || a.activity_id,
    date: a.date || (a.start_at ? a.start_at.slice(0, 10) : (a.start_time ? a.start_time.slice(0, 10) : "")),
    time: a.time || (a.start_at ? a.start_at.slice(11, 16) : (a.start_time ? a.start_time.slice(11, 16) : "")),
    type: a.type || a.activity_type || "Actividad",
    subtype: a.subtype || ex.activityName || a.name || a.title || "",
    dur: a.dur || a.duration_str || formatDurationFromSeconds(durSec),
    durMin: a.durMin || (durSec ? Math.round(durSec / 60) : 0),
    dist: distKm,
    hr: a.hr ?? a.avg_hr ?? a.heart_rate_avg ?? 0,
    hrMax: a.hrMax ?? a.max_hr ?? null,
    pace: computePace(),
    load: a.load ?? a.training_load ?? ex.activityTrainingLoad ?? null,
    te: teRaw != null ? Number(teRaw) : null,
    teAnaerobic: teAnaerobic != null ? Number(teAnaerobic) : null,
    recoveryHours: a.recovery_time_h ?? ex.recoveryHeartRate ?? null,
    recovery: a.recovery || a.recovery_time || "—",
    calories: a.calories ?? a.kcal ?? 0,
    elevation: a.elevation ?? a.elevation_gain ?? ex.elevationGain ?? 0,
    source: a.source || a.provider || "Garmin",
    // El detail screen necesita los splits crudos (extras.splits enriquecidos
    // del FIT) y el resto del payload Garmin para análisis avanzados.
    extras: ex,
  };
}

// Convierte códigos de Garmin como "FIND_TIME_TO_RELAX" a copy en español.
const FEEDBACK_ES = {
  FIND_TIME_TO_RELAX: "Encontrá tiempo para relajarte.",
  RECOVERED: "Recuperado y listo.",
  WELL_RESTED: "Bien descansado.",
  TIRED: "Cansado, día suave.",
  REACHED_ZERO: "Recuperación completada.",
  LOW_RT_MOD_OR_LOW_SS_POOR: "Recuperación moderada con sueño irregular.",
  GOOD: "Buena",
  VERY_GOOD: "Muy buena",
  POOR: "Pobre",
  MODERATE: "Moderada",
};
const humanizeFeedback = (code) => {
  if (!code) return "";
  return FEEDBACK_ES[code] || code.replaceAll("_", " ").toLowerCase();
};

// Mapea factor (0-100) a tono visual.
const factorTone = (pct) => {
  if (pct == null) return "var(--text-4)";
  if (pct >= 75) return "var(--good)";
  if (pct >= 50) return "var(--info)";
  if (pct >= 25) return "var(--warn)";
  return "var(--bad)";
};

// Buckets directos por valor real de cada métrica (independiente de los
// factorPercent de Garmin, que a veces son confusos).
const sleepScoreTone = (v) => v == null ? "var(--text-4)" : v >= 80 ? "var(--good)" : v >= 60 ? "var(--info)" : v >= 40 ? "var(--warn)" : "var(--bad)";
const recoveryTone = (min) => min == null ? "var(--text-4)" : min < 60 ? "var(--good)" : min < 1440 ? "var(--info)" : min < 4320 ? "var(--warn)" : "var(--bad)";
const stressTone = (v) => v == null ? "var(--text-4)" : v <= 25 ? "var(--good)" : v <= 50 ? "var(--info)" : v <= 75 ? "var(--warn)" : "var(--bad)";

/* Apertura del recovery score: 6 indicadores con icono + valor real +
 * color por bucket. Si readiness no llegó, usa el fallback del daily. */
const RecoveryBreakdown = ({ readiness, fallback }) => {
  const r = readiness || {};
  const fb = fallback || {};
  const rtMin = r.recoveryTime != null ? r.recoveryTime : (fb.recoveryHours != null ? fb.recoveryHours * 60 : null);
  const recoveryDisplay = rtMin == null
    ? "—"
    : rtMin < 1 ? "0 min" : rtMin < 60 ? `${rtMin} min` : `${Math.round(rtMin / 60)}h`;
  const stressVal = fb.stressAvg ?? fb.stress;  // 0-100, MENOR es mejor

  const items = [
    {
      icon: "moon", label: "Sueño",
      value: r.sleepScore ?? fb.sleepScore,
      tone: sleepScoreTone(r.sleepScore ?? fb.sleepScore),
    },
    {
      icon: "clock", label: "Recuperación",
      raw: recoveryDisplay,
      tone: recoveryTone(rtMin),
    },
    {
      icon: "heart", label: "VFC",
      value: r.hrvWeeklyAverage ?? fb.hrv,
      suffix: " ms",
      tone: factorTone(r.hrvFactorPercent),
    },
    {
      icon: "flame", label: "Carga aguda",
      value: r.acuteLoad ?? fb.acuteLoad,
      tone: factorTone(r.acwrFactorPercent),
    },
    {
      icon: "moon", label: "Sueño 7d",
      value: fb.sleepScoreAvg7d,
      tone: sleepScoreTone(fb.sleepScoreAvg7d),
    },
    {
      icon: "zap", label: "Estrés",
      value: stressVal,
      tone: stressTone(stressVal),
    },
  ];

  return (
    <div style={{
      display: "grid",
      gridTemplateColumns: "repeat(3, 1fr)",
      gap: "10px 14px",
      fontSize: 11,
    }}>
      {items.map((it) => {
        const display = it.raw
          || (it.value == null ? "—"
              : (typeof it.value === "number" ? Math.round(it.value) : it.value) + (it.suffix || ""));
        const empty = display === "—";
        const color = empty ? "var(--text-4)" : it.tone;
        return (
          <div key={it.label} style={{ display: "flex", alignItems: "center", gap: 8 }}>
            <div style={{
              width: 26, height: 26, borderRadius: 6,
              background: empty
                ? "transparent"
                : `color-mix(in srgb, ${color} 18%, transparent)`,
              border: `1px solid color-mix(in srgb, ${color} ${empty ? 30 : 40}%, transparent)`,
              display: "grid", placeItems: "center", flexShrink: 0,
            }}>
              <Icon name={it.icon} size={13} color={color} />
            </div>
            <div style={{ display: "flex", flexDirection: "column", minWidth: 0, lineHeight: 1.1 }}>
              <span style={{ color: "var(--text-3)", fontSize: 9.5, letterSpacing: 0.05, textTransform: "uppercase", fontFamily: "var(--font-mono)" }}>
                {it.label}
              </span>
              <span style={{ color: empty ? "var(--text-4)" : "var(--text)", fontSize: 13, fontWeight: 500, fontFamily: "var(--font-mono)" }}>
                {display}
              </span>
            </div>
          </div>
        );
      })}
    </div>
  );
};

// OJO: nombre distinto del de screen-plans.jsx (que toma minutos). Ambos
// archivos se cargan como <script type="text/babel"> y comparten scope global,
// así que un nombre repetido haría que el último cargado pisara al primero —
// causando que duraciones en segundos se renderizaran como minutos
// (ej. 3592s → "59h52" cuando debería ser "59:52").
function formatDurationFromSeconds(sec) {
  if (!sec || isNaN(sec)) return "—";
  const totalMin = Math.floor(sec / 60);
  const h = Math.floor(totalMin / 60);
  const m = totalMin % 60;
  const s = Math.floor(sec % 60);
  if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
  return `${m}:${String(s).padStart(2, "0")}`;
}

/* ────────── HeroVerdict ──────────
 * Cablea /agents/briefing/latest al hero del dashboard.
 *  - demo mode → MOCK estático (idéntico al original).
 *  - live → fetch al montar.
 *      404         → empty state corto + CTA "Generar briefing del día".
 *      412         → CTA "Configurá un LLM…" (no spinner perpetuo).
 *      red caída   → MOCK con badge sutil "datos demo (sin backend)".
 *      ok          → texto del coach reading. Color del adjetivo derivado del
 *                    recovery score; el copy viene del LLM (no inventamos).
 */
const HeroVerdict = ({ inDemo, recoveryScore, setRoute, scrollTarget }) => {
  const [loading, setLoading] = React.useState(!inDemo);
  const [briefing, setBriefing] = React.useState(null);
  const [refreshing, setRefreshing] = React.useState(false);
  const [phaseIdx, setPhaseIdx] = React.useState(0);
  const [llmMissing, setLlmMissing] = React.useState(false);
  const [networkFallback, setNetworkFallback] = React.useState(false);
  const [notFound, setNotFound] = React.useState(false);

  React.useEffect(() => {
    if (inDemo) { setLoading(false); return; }
    let alive = true;
    (async () => {
      try {
        const res = await API.briefingLatest();
        if (!alive) return;
        if (res == null) {
          setNotFound(true);
        } else {
          setBriefing(res);
        }
      } catch (e) {
        if (!alive) return;
        if (e.status === 401 || e.status === 403) return;     // bubble al login
        if (e.status === 412) { setLlmMissing(true); return; }
        if (e.network) { setNetworkFallback(true); return; }  // mock con badge
        setNetworkFallback(true);
      } finally {
        if (alive) setLoading(false);
      }
    })();
    return () => { alive = false; };
  }, [inDemo]);

  // Animación de fases mientras refrescamos.
  React.useEffect(() => {
    if (!refreshing) { setPhaseIdx(0); return; }
    const t = setInterval(() => {
      setPhaseIdx(i => Math.min(i + 1, HERO_REFRESH_PHASES.length - 1));
    }, 4500);
    return () => clearInterval(t);
  }, [refreshing]);

  const triggerRefresh = async () => {
    setRefreshing(true);
    setPhaseIdx(0);
    setLlmMissing(false);
    setNetworkFallback(false);
    try {
      const res = await API.briefingRefresh();
      setBriefing(res);
      setNotFound(false);
      // Scroll suave al card actualizado (después del render).
      setTimeout(() => {
        if (scrollTarget && scrollTarget.current) {
          scrollTarget.current.scrollIntoView({ behavior: "smooth", block: "start" });
        }
      }, 60);
    } catch (e) {
      if (e.llmMissing) { setLlmMissing(true); return; }
      if (e.network) { setNetworkFallback(true); return; }
      setNetworkFallback(true);
    } finally {
      setRefreshing(false);
    }
  };

  // Modo demo: render estático con el copy original (sin tocar el backend).
  if (inDemo) {
    return <HeroVerdictMock setRoute={setRoute} />;
  }

  if (loading) {
    return <HeroVerdictSkeleton />;
  }

  if (refreshing) {
    return (
      <HeroVerdictRefreshing
        phases={HERO_REFRESH_PHASES}
        phaseIdx={phaseIdx}
      />
    );
  }

  if (llmMissing) {
    return (
      <HeroVerdictNeedsLlm setRoute={setRoute} />
    );
  }

  if (notFound) {
    return (
      <HeroVerdictEmpty onGenerate={triggerRefresh} setRoute={setRoute} />
    );
  }

  if (networkFallback || !briefing) {
    return <HeroVerdictMock setRoute={setRoute} demoBadge />;
  }

  // Briefing real → texto del coach.
  const readings = Array.isArray(briefing.readings) ? briefing.readings : [];
  const coach = readings.find(r => r.agent === "coach") || readings[0] || null;
  const dateLabel = formatBriefingDateShort(briefing.generated_at);
  const headline = extractCoachHeadline(coach, "Tu día listo.");
  const subtitle = truncateText(coach && coach.body ? coach.body : "", 280);
  const tone = toneFromRecovery(recoveryScore);
  const generatedRel = formatRelativeShort(briefing.generated_at);
  const meta = [];
  if (briefing.model) meta.push(briefing.model);
  if (briefing.cost_usd != null) meta.push(`$${Number(briefing.cost_usd).toFixed(4)}`);
  else if (briefing.cost != null) meta.push(`$${Number(briefing.cost).toFixed(4)}`);

  return (
    <div>
      <div className="eyebrow" style={{ marginBottom: 10 }}>
        VEREDICTO DEL DÍA{dateLabel ? ` · ${dateLabel}` : ""}
      </div>
      <div className="display" style={{ fontSize: 38, lineHeight: 1.05, letterSpacing: "-0.025em", marginBottom: 14 }}>
        <span style={{ color: tone }}>{headline}</span>
      </div>
      {subtitle && (
        <div style={{ fontSize: 13.5, color: "var(--text-2)", lineHeight: 1.55, maxWidth: 520, marginBottom: 18 }}>
          {subtitle}
        </div>
      )}
      <div style={{ display: "flex", gap: 8, marginBottom: 14 }}>
        <Btn variant="primary" size="md" icon={<Icon name="arrow" size={13} />} onClick={() => setRoute("plans")}>Ver plan de hoy</Btn>
        <Btn size="md" variant="default" icon={<Icon name="chat" size={13} />} onClick={() => setRoute("agents")}>Pregúntale al coach</Btn>
      </div>
      <div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 10.5, color: "var(--text-3)", fontFamily: "var(--font-mono)" }}>
        <span>Generado {generatedRel}{meta.length ? ` · ${meta.join(" · ")}` : ""}</span>
        <span style={{ color: "var(--text-4)" }}>·</span>
        <button
          onClick={triggerRefresh}
          style={{
            background: "transparent", border: "none", padding: 0,
            color: "var(--accent)", cursor: "pointer",
            fontFamily: "var(--font-mono)", fontSize: 10.5,
            textDecoration: "underline", textUnderlineOffset: 2,
          }}
        >
          Regenerar
        </button>
      </div>
    </div>
  );
};

// MOCK estático — copy original del design. `demoBadge` lo marca cuando lo
// usamos como FALLBACK por error de red (no por modo demo del usuario).
const HeroVerdictMock = ({ setRoute, demoBadge }) => (
  <div>
    <div className="eyebrow" style={{ marginBottom: 10, display: "flex", alignItems: "center", gap: 8 }}>
      <span>VEREDICTO DEL DÍA · sáb 02 may</span>
      {demoBadge && (
        <span style={{
          fontSize: 8.5, padding: "2px 6px", borderRadius: 3,
          background: "var(--bg-2)", border: "1px solid var(--line-soft)",
          color: "var(--text-3)", letterSpacing: "0.06em",
          textTransform: "uppercase", fontFamily: "var(--font-mono)",
        }}>
          datos demo (sin backend)
        </span>
      )}
    </div>
    <div className="display" style={{ fontSize: 38, lineHeight: 1.05, letterSpacing: "-0.025em", marginBottom: 14 }}>
      Día <span style={{ color: "var(--good)" }}>verde</span>. <span style={{ color: "var(--text-3)" }}>Tu cuerpo está listo para el largo de hoy.</span>
    </div>
    <div style={{ fontSize: 13.5, color: "var(--text-2)", lineHeight: 1.55, maxWidth: 520, marginBottom: 18 }}>
      Dormiste 7h48 (mejor que tu media), HRV en máximos del mes (64 ms) y la pierna se siente fresca tras el descanso del viernes. Sal antes de las 10:00 y mantén pulso bajo 150.
    </div>
    <div style={{ display: "flex", gap: 8 }}>
      <Btn variant="primary" size="md" icon={<Icon name="arrow" size={13} />} onClick={() => setRoute("plans")}>Ver plan de hoy</Btn>
      <Btn size="md" variant="default" icon={<Icon name="chat" size={13} />} onClick={() => setRoute("agents")}>Pregúntale al coach</Btn>
    </div>
  </div>
);

const HeroVerdictSkeleton = () => (
  <div>
    <div className="eyebrow" style={{ marginBottom: 10, color: "var(--text-4)" }}>VEREDICTO DEL DÍA</div>
    <div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 18 }}>
      <div style={{ height: 28, width: "70%", borderRadius: 6, background: "var(--bg-2)", animation: "skel-pulse 1.6s ease-in-out infinite" }} />
      <div style={{ height: 14, width: "90%", borderRadius: 4, background: "var(--bg-2)", animation: "skel-pulse 1.6s ease-in-out infinite" }} />
      <div style={{ height: 14, width: "80%", borderRadius: 4, background: "var(--bg-2)", animation: "skel-pulse 1.6s ease-in-out infinite" }} />
    </div>
    <div style={{ fontSize: 11, color: "var(--text-3)", fontFamily: "var(--font-mono)" }}>Cargando briefing del coach…</div>
  </div>
);

const HeroVerdictRefreshing = ({ phases, phaseIdx }) => (
  <div>
    <div className="eyebrow" style={{ marginBottom: 10, color: "var(--accent)" }}>REGENERANDO BRIEFING…</div>
    <div style={{ display: "flex", alignItems: "center", gap: 12, marginBottom: 14 }}>
      <div style={{
        width: 36, height: 36, borderRadius: "50%",
        background: "var(--accent-soft)", color: "var(--accent)",
        display: "grid", placeItems: "center", flexShrink: 0,
      }}>
        <Icon name="spinner" size={16} color="var(--accent)" />
      </div>
      <div className="display" style={{ fontSize: 22, lineHeight: 1.2, letterSpacing: "-0.02em" }}>
        Consultando al coach…
      </div>
    </div>
    <div style={{ display: "flex", flexDirection: "column", gap: 6, maxWidth: 360 }}>
      {phases.map((p, i) => {
        const done = i < phaseIdx;
        const active = i === phaseIdx;
        return (
          <div key={i} style={{
            display: "flex", alignItems: "center", gap: 8,
            fontSize: 11.5, fontFamily: "var(--font-mono)",
            color: done ? "var(--good)" : active ? "var(--text-2)" : "var(--text-4)",
            opacity: done ? 0.7 : 1,
          }}>
            <span style={{ width: 10, display: "inline-block", textAlign: "center" }}>
              {done ? "✓" : active ? "●" : "·"}
            </span>
            <span>{p}</span>
          </div>
        );
      })}
    </div>
  </div>
);

const HeroVerdictEmpty = ({ onGenerate, setRoute }) => (
  <div>
    <div className="eyebrow" style={{ marginBottom: 10 }}>VEREDICTO DEL DÍA</div>
    <div className="display" style={{ fontSize: 28, lineHeight: 1.15, letterSpacing: "-0.02em", marginBottom: 10 }}>
      No hay briefing aún.
    </div>
    <div style={{ fontSize: 13, color: "var(--text-2)", lineHeight: 1.55, marginBottom: 16, maxWidth: 480 }}>
      Generá uno en ~30 s. Los agentes leen tus últimos días y te proponen un plan para hoy.
    </div>
    <div style={{ display: "flex", gap: 8 }}>
      <Btn variant="primary" size="md" icon={<Icon name="refresh" size={13} />} onClick={onGenerate}>
        Generar briefing del día
      </Btn>
      <Btn size="md" variant="default" icon={<Icon name="chat" size={13} />} onClick={() => setRoute("agents")}>
        Pregúntale al coach
      </Btn>
    </div>
  </div>
);

const HeroVerdictNeedsLlm = ({ setRoute }) => (
  <div>
    <div className="eyebrow" style={{ marginBottom: 10, color: "var(--warn)" }}>VEREDICTO DEL DÍA · LLM NO CONFIGURADO</div>
    <div className="display" style={{ fontSize: 26, lineHeight: 1.15, letterSpacing: "-0.02em", marginBottom: 10 }}>
      Falta un proveedor de IA.
    </div>
    <div style={{ fontSize: 13, color: "var(--text-2)", lineHeight: 1.55, marginBottom: 16, maxWidth: 480 }}>
      El briefing diario lo escribe un LLM (OpenAI, Anthropic o local). Configurá uno para activarlo.
    </div>
    <Btn variant="primary" size="md" icon={<Icon name="settings" size={13} />} onClick={() => setRoute("settings")}>
      Configurá un LLM en Configuración → LLM
    </Btn>
  </div>
);

/* ────────── InsightsStrip ──────────
 * Cablea /insights/today al card "QUÉ ESTÁ FUNCIONANDO · QUÉ MEJORAR".
 *  - demo mode → MOCK (idéntico al original).
 *  - live → fetch al montar.
 *      empty/404   → CTA "Generar insights".
 *      412         → CTA "Configurá un LLM…".
 *      red caída   → MOCK con badge "datos demo (sin backend)".
 */
const InsightsStrip = ({ inDemo, scrollTarget }) => {
  const [loading, setLoading] = React.useState(!inDemo);
  const [data, setData] = React.useState(null);     // {insights, generated_at, stale, ttl_minutes}
  const [refreshing, setRefreshing] = React.useState(false);
  const [phaseIdx, setPhaseIdx] = React.useState(0);
  const [llmMissing, setLlmMissing] = React.useState(false);
  const [networkFallback, setNetworkFallback] = React.useState(false);
  const [empty, setEmpty] = React.useState(false);

  React.useEffect(() => {
    if (inDemo) { setLoading(false); return; }
    let alive = true;
    (async () => {
      try {
        const res = await API.insightsToday();
        if (!alive) return;
        const list = Array.isArray(res?.insights) ? res.insights : [];
        if (list.length === 0) {
          setEmpty(true);
        } else {
          setData(res);
        }
      } catch (e) {
        if (!alive) return;
        if (e.status === 401 || e.status === 403) return;
        if (e.status === 404) { setEmpty(true); return; }
        if (e.status === 412) { setLlmMissing(true); return; }
        if (e.network) { setNetworkFallback(true); return; }
        setNetworkFallback(true);
      } finally {
        if (alive) setLoading(false);
      }
    })();
    return () => { alive = false; };
  }, [inDemo]);

  React.useEffect(() => {
    if (!refreshing) { setPhaseIdx(0); return; }
    const t = setInterval(() => {
      setPhaseIdx(i => Math.min(i + 1, INSIGHTS_REFRESH_PHASES.length - 1));
    }, 3500);
    return () => clearInterval(t);
  }, [refreshing]);

  const triggerRefresh = async () => {
    setRefreshing(true);
    setPhaseIdx(0);
    setLlmMissing(false);
    setNetworkFallback(false);
    try {
      const res = await API.insightsRefresh();
      const list = Array.isArray(res?.insights) ? res.insights : [];
      if (list.length === 0) {
        setEmpty(true);
        setData(null);
      } else {
        setData(res);
        setEmpty(false);
      }
      setTimeout(() => {
        if (scrollTarget && scrollTarget.current) {
          scrollTarget.current.scrollIntoView({ behavior: "smooth", block: "nearest" });
        }
      }, 60);
    } catch (e) {
      if (e.llmMissing) { setLlmMissing(true); return; }
      if (e.network) { setNetworkFallback(true); return; }
      setNetworkFallback(true);
    } finally {
      setRefreshing(false);
    }
  };

  if (inDemo) return <InsightsStripMock />;
  if (loading) return <InsightsStripSkeleton />;
  if (refreshing) return <InsightsStripRefreshing phaseIdx={phaseIdx} />;
  if (llmMissing) return <InsightsStripNeedsLlm />;
  if (networkFallback) return <InsightsStripMock demoBadge />;
  if (empty || !data) return <InsightsStripEmpty onGenerate={triggerRefresh} />;

  // Stale si el backend lo marca o si hace > 1h.
  const generatedRel = formatRelativeShort(data.generated_at);
  const ageMin = data.generated_at ? minutesSince(data.generated_at) : null;
  const stale = !!data.stale || (ageMin != null && ageMin > 60);

  // Sources agregadas (chips de header) para "atribución global".
  const allSources = new Set();
  for (const i of data.insights) {
    if (Array.isArray(i.sources)) i.sources.forEach(s => allSources.add(s));
  }

  return (
    <Card>
      <div style={{ display: "flex", alignItems: "center", marginBottom: 14, gap: 8, flexWrap: "wrap" }}>
        <div className="eyebrow">QUÉ ESTÁ FUNCIONANDO · QUÉ MEJORAR</div>
        <div style={{ flex: 1 }} />
        <Badge tone="default">
          <Icon name="bot" size={9} />
          <span style={{ marginLeft: 2 }}>actualizado {generatedRel}</span>
        </Badge>
        {stale && (
          <button
            onClick={triggerRefresh}
            style={{
              background: "transparent", border: "1px solid var(--line)", borderRadius: 4,
              padding: "3px 8px", cursor: "pointer", color: "var(--accent)",
              fontFamily: "var(--font-mono)", fontSize: 10, letterSpacing: "0.05em",
              textTransform: "uppercase", display: "inline-flex", alignItems: "center", gap: 4,
            }}
          >
            <Icon name="refresh" size={10} color="var(--accent)" /> regenerar
          </button>
        )}
      </div>
      <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
        {data.insights.map((it, i) => (
          <InsightRow key={i} insight={it} />
        ))}
      </div>
      <div style={{ marginTop: 14, display: "flex", alignItems: "center", gap: 8, fontSize: 11, color: "var(--text-3)" }}>
        {allSources.size > 0 && <Source sources={Array.from(allSources)} />}
        <div style={{ flex: 1 }} />
        {!stale && (
          <button
            onClick={triggerRefresh}
            style={{
              background: "transparent", border: "none", padding: 0, cursor: "pointer",
              color: "var(--text-3)", fontFamily: "var(--font-mono)", fontSize: 11,
              textDecoration: "underline", textUnderlineOffset: 2,
            }}
          >
            regenerar
          </button>
        )}
      </div>
    </Card>
  );
};

// Mapeo del shape "kind" del backend al InsightTile primitive.
//   good     → "good"   (✓ verde)
//   watch    → "bad"    (! ámbar — el primitive ya usa color warn/bad)
//   improve  → "improve" (↗ azul/accent)
const InsightRow = ({ insight }) => {
  const kindMap = { good: "good", watch: "bad", improve: "improve", info: "info" };
  const tileKind = kindMap[insight.kind] || "info";
  const sources = Array.isArray(insight.sources) ? insight.sources : [];
  return (
    <div>
      <InsightTile kind={tileKind} title={insight.title} body={insight.body} />
      {sources.length > 0 && (
        <div style={{
          display: "flex", flexWrap: "wrap", gap: 4,
          marginTop: 4, marginLeft: 34, // align con el contenido del tile
        }}>
          {sources.map((s, i) => (
            <span key={i} style={{
              fontSize: 9, padding: "1px 6px", borderRadius: 3,
              background: "var(--bg-2)", border: "1px solid var(--line-soft)",
              color: "var(--text-3)", fontFamily: "var(--font-mono)",
            }}>{s}</span>
          ))}
        </div>
      )}
    </div>
  );
};

const InsightsStripMock = ({ demoBadge } = {}) => (
  <Card>
    <div style={{ display: "flex", alignItems: "center", marginBottom: 14, gap: 8 }}>
      <div className="eyebrow">QUÉ ESTÁ FUNCIONANDO · QUÉ MEJORAR</div>
      <div style={{ flex: 1 }} />
      {demoBadge && (
        <span style={{
          fontSize: 8.5, padding: "2px 6px", borderRadius: 3,
          background: "var(--bg-2)", border: "1px solid var(--line-soft)",
          color: "var(--text-3)", letterSpacing: "0.06em",
          textTransform: "uppercase", fontFamily: "var(--font-mono)",
        }}>
          datos demo (sin backend)
        </span>
      )}
      <Badge tone="default">
        <Icon name="bot" size={9} />
        <span style={{ marginLeft: 2 }}>actualizado hace 4h</span>
      </Badge>
    </div>
    <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
      <InsightTile
        kind="good"
        title="Tu base aeróbica está respondiendo."
        body="HRV +12% en 7d, RHR -3 bpm desde abril. El bloque Z2 está dando frutos."
      />
      <InsightTile
        kind="good"
        title="Pérdida de grasa sostenible: -1.4 kg en 30d."
        body="Sin perder músculo (+0.3 kg masa magra). Buen ritmo."
      />
      <InsightTile
        kind="bad"
        title="Sueño irregular los viernes."
        body="Promedio 5h42 los últimos 4 viernes — afecta tu HRV del sábado."
      />
      <InsightTile
        kind="improve"
        title="Sumá 10g más de proteína al desayuno."
        body="Tu media diaria (1.4 g/kg) es justa para tu volumen actual de fuerza."
      />
    </div>
    <div style={{ marginTop: 14, display: "flex", alignItems: "center", gap: 8, fontSize: 11, color: "var(--text-3)" }}>
      <Source sources={["Garmin", "Eufy", "registro de comidas"]} />
      <div style={{ flex: 1 }} />
      <span style={{ fontFamily: "var(--font-mono)" }}>cacheado · próximo: 16:00</span>
    </div>
  </Card>
);

const InsightsStripSkeleton = () => (
  <Card>
    <div style={{ display: "flex", alignItems: "center", marginBottom: 14 }}>
      <div className="eyebrow" style={{ color: "var(--text-4)" }}>QUÉ ESTÁ FUNCIONANDO · QUÉ MEJORAR</div>
    </div>
    <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
      {[0,1,2].map(i => (
        <div key={i} style={{
          height: 56, borderRadius: "var(--r-2)",
          background: "var(--bg-2)", border: "1px solid var(--line)",
          animation: "skel-pulse 1.6s ease-in-out infinite",
        }} />
      ))}
    </div>
  </Card>
);

const InsightsStripRefreshing = ({ phaseIdx }) => (
  <Card>
    <div style={{ display: "flex", alignItems: "center", marginBottom: 14 }}>
      <div className="eyebrow" style={{ color: "var(--accent)" }}>GENERANDO INSIGHTS…</div>
      <div style={{ flex: 1 }} />
      <Icon name="spinner" size={14} color="var(--accent)" />
    </div>
    <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
      {INSIGHTS_REFRESH_PHASES.map((p, i) => {
        const done = i < phaseIdx;
        const active = i === phaseIdx;
        return (
          <div key={i} style={{
            display: "flex", alignItems: "center", gap: 8,
            fontSize: 12, fontFamily: "var(--font-mono)",
            color: done ? "var(--good)" : active ? "var(--text-2)" : "var(--text-4)",
            opacity: done ? 0.7 : 1,
          }}>
            <span style={{ width: 10, display: "inline-block", textAlign: "center" }}>
              {done ? "✓" : active ? "●" : "·"}
            </span>
            <span>{p}</span>
          </div>
        );
      })}
    </div>
  </Card>
);

const InsightsStripEmpty = ({ onGenerate }) => (
  <Card>
    <div style={{ display: "flex", alignItems: "center", marginBottom: 14 }}>
      <div className="eyebrow">QUÉ ESTÁ FUNCIONANDO · QUÉ MEJORAR</div>
    </div>
    <div style={{ fontSize: 13, color: "var(--text-2)", lineHeight: 1.55, marginBottom: 14 }}>
      Aún no hay insights para hoy. Generamos micro-cards persistentes
      (qué funciona, qué vigilar, qué ajustar) a partir de tus últimas tendencias.
    </div>
    <Btn variant="primary" size="sm" icon={<Icon name="refresh" size={12} />} onClick={onGenerate}>
      Generar insights
    </Btn>
  </Card>
);

const InsightsStripNeedsLlm = () => (
  <Card>
    <div style={{ display: "flex", alignItems: "center", marginBottom: 14 }}>
      <div className="eyebrow" style={{ color: "var(--warn)" }}>QUÉ ESTÁ FUNCIONANDO · QUÉ MEJORAR · LLM NO CONFIGURADO</div>
    </div>
    <div style={{ fontSize: 13, color: "var(--text-2)", lineHeight: 1.55 }}>
      Configurá un LLM en <strong style={{ color: "var(--text)" }}>Configuración → LLM</strong> para activar los insights.
    </div>
  </Card>
);

// "minutes since iso" — usa el mismo trick UTC que formatRelativeShort.
function minutesSince(iso) {
  if (!iso) return null;
  let s = String(iso);
  if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(s) && !/[zZ]|[+-]\d{2}:?\d{2}$/.test(s)) {
    s = s.replace(" ", "T") + "Z";
  }
  const d = new Date(s);
  if (isNaN(d.getTime())) return null;
  return Math.round((Date.now() - d.getTime()) / 60000);
}

Object.assign(window, { Today, normalizeDailyRow, normalizeActivityRow });
