// ─── Micos CRM · Thread (lector + panel contextual) ─
// Carril B-fr2 (obra 07/07): mail real + conexión ERP.
//  · Responder a todos / Reenviar DE VERDAD (destinatarios calculados,
//    reenvío = hilo nuevo con cita + adjuntos arrastrados) — auditoría #17/#18.
//  · Respuesta inline con adjuntos locales/del ERP, plantillas con variables
//    reemplazadas y borrador con autosave real — #19/#20/#22/#33.
//  · Panel contextual real: vincular con cuenta (#24), acciones rápidas reales
//    (#25), cards + chip EXP navegan al ERP (#15/#26), timeline REAL del
//    pipeline del expediente vinculado (#27, sin vínculo = bloque oculto).
// Consume crm/envio.jsx (destinatarios/cita/borrador/adjuntos/plantillas/
// picker/detalle) + ExpedienteCombobox + PartySelectorModal + MicosStore.

const { useState: _useStateCT, useEffect: _useEffectCT, useRef: _useRefCT } = React;

// Navegación cruzada al ERP (CONEXIONES §1): ERP y CRM comparten origen+sesión;
// el ERP resuelve /erp/expedientes/<publicId> por deep-link (applyPath). Es una
// navegación de página completa (bundles separados) — window.location alcanza.
function crmAbrirExpediente(publicId) {
  if (!publicId) return;
  window.location.href = '/erp/expedientes/' + encodeURIComponent(publicId);
}

// Format date for messages
function _fmtMsgDate(iso) {
  const d = new Date(iso);
  // 24hs SIEMPRE (regla de la casa — formato fecha/hora).
  return d.toLocaleDateString('es-AR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit', hour12: false });
}

// Full date parts for the open message header
function _fmtFull(iso) {
  const d = new Date(iso);
  const fecha = d.toLocaleDateString('es-AR', { day: '2-digit', month: 'long', year: 'numeric' });
  // Corta DD/MM — para el encabezado del mensaje abierto, que antes mostraba
  // SOLO la hora (regla de la casa: DD/MM + 24hs siempre).
  const fechaCorta = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`;
  // 24hs SIEMPRE (regla de la casa — formato fecha/hora).
  const hora = d.toLocaleTimeString('es-AR', { hour: '2-digit', minute: '2-digit', hour12: false });
  return { fecha, fechaCorta, hora, rel: relTime(iso) };
}

// Fecha corta para la banda de auditoría (leidoPor/respondidoPor) — regla de
// la casa: DD/MM + hora 24hs, nada de "hace X".
function _fmtAudit(iso) {
  const d = new Date(iso);
  const dd = String(d.getDate()).padStart(2, '0');
  const mm = String(d.getMonth() + 1).padStart(2, '0');
  const hh = String(d.getHours()).padStart(2, '0');
  const mi = String(d.getMinutes()).padStart(2, '0');
  return `${dd}/${mm} ${hh}:${mi}`;
}

// Tamaño legible de un adjunto (B/KB/MB) — defensivo: si no viene sizeBytes
// (adjuntos mock del seed, sin ingesta real todavía) devuelve null y el chip
// cae al texto genérico de siempre.
function _fmtAttSize(bytes) {
  if (bytes == null || Number.isNaN(bytes)) return null;
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

// Candado para mensajes privados (visibilidad='privada') — mismo trazo que
// LockGlyph (screens-nuevo.jsx); no hay ícono para esto en crm-icons.jsx
// (CLAUDE.md §2: SVGs propios solo si son simples, candado incluido).
const IconLock = (p) => (
  <Icon {...p}>
    <rect x="4" y="11" width="16" height="9" rx="2" />
    <path d="M8 11V8a4 4 0 0 1 8 0v3" />
  </Icon>
);

// ─── Empty thread (when no email selected) ─────────
function EmptyThread() {
  return (
    <div className="flex-1 flex flex-col items-center justify-center px-8 py-20 text-center"
         style={{ background: 'var(--background)' }}>
      <div style={{
        width: 80, height: 80, borderRadius: 20,
        background: 'color-mix(in oklab, var(--accent) 8%, var(--card))',
        color: 'var(--accent)',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        marginBottom: 18,
      }}>
        <IconMail size={36} />
      </div>
      <p className="text-[16px] font-extrabold tracking-tight" style={{ color: 'var(--foreground)' }}>
        Seleccioná una conversación
      </p>
      <p className="text-[13px] mt-2 max-w-md" style={{ color: 'var(--muted-foreground)' }}>
        Cada hilo te muestra el contexto del cliente — expedientes vinculados, saldo a cobrar y cirugías agendadas — directo desde el ERP.
      </p>
    </div>
  );
}

// ─── Toolbar icon button (ghost, subtle hover) ─────
function TBtn({ icon: Icon, label, onClick, active, danger, color, size = 17 }) {
  const resting = color || (active ? 'var(--accent)' : 'var(--muted-foreground)');
  return (
    <button
      type="button"
      onClick={(e) => { onClick && onClick(e); e.currentTarget.blur(); }}
      title={label}
      className="inline-flex items-center justify-center rounded-full transition-colors cursor-pointer"
      style={{
        width: 36, height: 36,
        background: active ? 'color-mix(in oklab, var(--accent) 14%, transparent)' : 'transparent',
        color: resting,
      }}
      onMouseEnter={(e) => { if (!active) { e.currentTarget.style.background = 'color-mix(in oklab, var(--muted-foreground) 12%, transparent)'; e.currentTarget.style.color = color || (danger ? '#ef4444' : 'var(--foreground)'); } }}
      onMouseLeave={(e) => { if (!active) { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = resting; } }}
    >
      <Icon size={size} />
    </button>
  );
}

// ─── Thread header: top action toolbar + subject row ─
function ThreadHeader({ email, onClose, onUpdate, onShowContext, contextOpen, nav, user }) {
  // Banda de auditoría (discreta, muted): solo para ADMIN/JEFE y solo si el
  // hilo trae esos campos (el server ya los omite para el resto de roles —
  // esto es un segundo blindaje del lado front, no la única barrera).
  const esSupervisor = !!user && (user.rol === 'ADMIN' || user.rol === 'JEFE');
  const leidoPor = esSupervisor ? (email.leidoPor || []) : [];
  const respondidoPor = esSupervisor ? (email.respondidoPor || []) : [];
  const auditParts = [];
  if (leidoPor.length) {
    auditParts.push(`Leído por ${leidoPor.map(p => `${p.nombre} (${_fmtAudit(p.fecha)})`).join(', ')}`);
  }
  if (respondidoPor.length) {
    auditParts.push(`Respondido por ${respondidoPor.map(p => `${p.nombre} con ${p.alias} (${_fmtAudit(p.fecha)})`).join(', ')}`);
  }
  const auditLine = auditParts.join(' · ');

  return (
    <div className="flex-shrink-0" style={{ borderBottom: '1px solid var(--border)' }}>
      {/* Top action toolbar */}
      <div className="flex items-center gap-1 px-3 py-2">
        <TBtn icon={IconArrowLeft} label="Volver a la bandeja" onClick={onClose} size={18} />
        <div className="w-px h-5 mx-1" style={{ background: 'var(--border)' }} />
        <TBtn icon={IconMail} label="Marcar como no leído" onClick={() => onUpdate({ unread: true })} />
        {email.folder === 'trash' ? (
          <TBtn icon={IconInbox} label="Restaurar" onClick={() => onUpdate({ folder: 'inbox' })} />
        ) : email.folder === 'archived' ? (
          <TBtn icon={IconInbox} label="Desarchivar" onClick={() => onUpdate({ folder: 'inbox' })} />
        ) : (
          <TBtn icon={IconArchive} label="Archivar" onClick={() => onUpdate({ folder: 'archived' })} />
        )}
        {email.folder !== 'trash' &&
          <TBtn icon={IconTrash} label="Eliminar" onClick={() => onUpdate({ folder: 'trash' })} danger />}
        <div className="w-px h-5 mx-1" style={{ background: 'var(--border)' }} />
        <TBtn icon={IconUser} label="Panel de contexto" onClick={onShowContext} active={contextOpen} size={16} />
        <TBtn icon={IconTag} label="Etiquetar como" onClick={() => {}} size={16} />
        <TBtn icon={IconFilter} label="Filtrar mensajes como estos" onClick={() => {}} size={16} />

        {/* Right: counter + prev/next */}
        <div className="ml-auto flex items-center gap-1">
          {nav && (
            <span className="text-[12px] tabular-nums mr-1 hidden sm:inline" style={{ color: 'var(--muted-foreground)' }}>
              {nav.index} de {nav.total.toLocaleString('es-AR')}
            </span>
          )}
          <TBtn icon={IconChevronLeft} label="Anterior" onClick={() => nav && nav.onPrev()} size={18} />
          <TBtn icon={IconChevronRight} label="Siguiente" onClick={() => nav && nav.onNext()} size={18} />
        </div>
      </div>

      {/* Subject row */}
      <div className="flex items-start gap-3 px-6 pb-4 pt-1">
        <div className="min-w-0 flex-1">
          <h2 className="text-[22px] font-extrabold tracking-tight leading-tight" style={{ color: 'var(--foreground)' }}>
            {email.asunto}
          </h2>
          {auditLine && (
            <p className="text-[11px] mt-1" style={{ color: 'var(--muted-foreground)' }}>
              {auditLine}
            </p>
          )}
          <div className="flex items-center gap-2.5 flex-wrap mt-2.5">
            {email.tags.map(t => <LabelPill key={t} id={t} />)}
            {email.linkedExpedienteId && (
              <a
                href={`/erp/expedientes/${encodeURIComponent(email.linkedExpedienteId)}`}
                onClick={(e) => { e.preventDefault(); crmAbrirExpediente(email.linkedExpedienteId); }}
                title={`Abrir ${email.linkedExpedienteId} en el ERP`}
                className="font-mono inline-flex items-center gap-1 cursor-pointer"
                style={{
                  fontSize: 10.5, fontWeight: 700,
                  color: 'var(--accent)',
                  background: 'color-mix(in oklab, var(--accent) 10%, transparent)',
                  padding: '3px 9px', borderRadius: 999, textDecoration: 'none',
                }}
              >
                {email.linkedExpedienteId}
                <IconArrowUpRight size={11} />
              </a>
            )}
            {email.slaHours >= 24 && (
              <span
                className="text-[10px] font-bold uppercase tracking-[0.06em] inline-flex items-center gap-1"
                style={{
                  color: email.slaHours >= 48 ? 'var(--danger)' : 'var(--warning)',
                  background: email.slaHours >= 48 ? 'rgba(220, 38, 38, 0.10)' : 'rgba(217, 119, 6, 0.10)',
                  padding: '3px 9px', borderRadius: 999,
                }}
              >
                <IconClock size={10} />
                Sin responder {email.slaHours}h
              </span>
            )}
          </div>
        </div>
      </div>
    </div>
  );
}

// ─── Collapsed message row (older messages in a thread) ─
// Estado de entrega del mensaje SALIENTE (tracking SendGrid — la info que Gmail
// no te da). Solo se pinta si el mensaje trae estadoEnvio (mensajes de entrada
// no lo tienen). El motivo (motivoError) va inline en rebotes/bloqueos.
const _ENVIO_META = {
  pendiente:  { label: 'Pendiente de envío', color: 'var(--muted-foreground)' },
  enviado:    { label: 'Enviado',            color: 'var(--muted-foreground)' },
  entregado:  { label: 'Entregado',          color: 'var(--success)' },
  diferido:   { label: 'Diferido — reintentando', color: 'var(--warning)' },
  rebotado:   { label: 'Rebotó',             color: 'var(--destructive)' },
  bloqueado:  { label: 'Bloqueado',          color: 'var(--destructive)' },
};

function EnvioChip({ msg }) {
  if (msg.direccion !== 'salida' || !msg.estadoEnvio) return null;
  const meta = _ENVIO_META[msg.estadoEnvio];
  if (!meta) return null;
  const detalle = msg.motivoError ? ` — ${msg.motivoError}` : '';
  return (
    <span
      className="inline-flex items-center gap-1.5 text-[10.5px] font-bold flex-shrink-0 px-1.5 py-0.5 rounded-md"
      style={{
        color: meta.color,
        background: 'color-mix(in oklab, currentColor 9%, transparent)',
        maxWidth: 340,
      }}
      title={`${meta.label}${detalle}`}
    >
      <span aria-hidden="true" className="w-1.5 h-1.5 rounded-full flex-shrink-0" style={{ background: 'currentColor' }} />
      <span className="truncate">{meta.label}{detalle}</span>
    </span>
  );
}

function CollapsedMessage({ msg, onToggle }) {
  return (
    <button
      type="button"
      onClick={onToggle}
      className="w-full flex items-center gap-3 px-1 py-2.5 text-left cursor-pointer rounded-lg transition-colors"
      onMouseEnter={(e) => e.currentTarget.style.background = 'color-mix(in oklab, var(--muted-foreground) 5%, transparent)'}
      onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
    >
      <CrmAvatar name={msg.from.nombre} email={msg.from.email} size={30} />
      <span className="text-[13px] font-bold flex-shrink-0" style={{ color: 'var(--foreground)' }}>{msg.from.nombre}</span>
      {msg.visibilidad === 'privada' && (
        <span
          className="inline-flex items-center gap-1 text-[10px] font-bold flex-shrink-0"
          style={{ color: 'var(--muted-foreground)' }}
          title="Privado — solo lo ven emisor y destinatarios"
        >
          <IconLock size={10} />
          Privado
        </span>
      )}
      <span className="text-[12.5px] truncate flex-1" style={{ color: 'var(--muted-foreground)' }}>
        {msg.cuerpo.slice(0, 120).replace(/\n/g, ' ')}
      </span>
      <span className="text-[11.5px] tabular-nums flex-shrink-0" style={{ color: 'var(--muted-foreground)' }}>
        {_fmtMsgDate(msg.fecha)}
      </span>
    </button>
  );
}

// Tokens de la casa RESUELTOS a hex (light/dark) — el srcDoc del iframe es un
// DOCUMENTO APARTE: var(--card)/var(--foreground) no resuelven adentro (no
// hereda el :root del documento padre). Solo hay 2 temas, alcanza con los 2
// pares hardcodeados acá — mismos valores que styles.css (`:root` / `[data-theme="dark"]`).
const _MAIL_THEME = {
  light: { bg: '#ffffff', fg: '#0a1628' },
  dark: { bg: '#111d30', fg: '#e8ecf2' },
};

// Mismo stack que styles.css:116 (fuente de la casa). El srcDoc tampoco hereda
// @font-face del documento padre — se inyecta uno propio apuntando al woff2
// LOCAL ya servido por el dev server (nada de Google Fonts: DNS intermitente
// en la VM). Es variable font → font-weight con rango.
const _MAIL_FONT_STACK = "'Plus Jakarta Sans', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
const _MAIL_FONT_FACE = '@font-face{font-family:"Plus Jakarta Sans";src:url("/erp/assets/fonts/PlusJakartaSans-var.woff2") format("woff2");font-weight:100 900;font-style:normal;font-display:swap;}';

// Inyecta <base target="_blank"> + reset mínimo en el HTML real del mail
// (SPEC-crm-ingesta-real §Carril C', punto 1) sin asumir estructura completa
// <html>/<head> — los mails reenviados por n8n vienen en formas variadas.
// `dark` decide los 2 colores resueltos del reset (ver _MAIL_THEME). Los
// mails con colores propios inline ganan igual — el reset solo cubre lo que
// el mail no definió (mismo trade-off que cualquier cliente de mail).
function _prepMailHtml(rawHtml, dark) {
  const t = dark ? _MAIL_THEME.dark : _MAIL_THEME.light;
  const inject = '<base target="_blank">'
    + '<style>'
    + _MAIL_FONT_FACE
    + `:root{color-scheme:${dark ? 'dark' : 'light'};}`
    + `body{margin:0;font-family:${_MAIL_FONT_STACK};font-size:14px;color:${t.fg};background:${t.bg};}`
    + '</style>';
  if (/<head[^>]*>/i.test(rawHtml)) {
    return rawHtml.replace(/<head[^>]*>/i, (m) => m + inject);
  }
  if (/<html[^>]*>/i.test(rawHtml)) {
    return rawHtml.replace(/<html[^>]*>/i, (m) => m + '<head>' + inject + '</head>');
  }
  return '<head>' + inject + '</head>' + rawHtml;
}

// Reescribe <img src="cid:XXX"> contra los adjuntos REALES del MISMO mensaje
// (SPEC-crm-ingesta-real §Carril C', imágenes embebidas) — Content-ID (RFC
// 2392) matcheado a `contentId` -> `/api/crm-adjuntos/<id>`. Sin match, se deja
// como está (queda roto — correcto, no hay nada mejor para mostrar).
function _resolveCidImages(html, attachments) {
  if (!html || !Array.isArray(attachments) || attachments.length === 0) return html;
  const byCid = new Map();
  attachments.forEach((a) => { if (a.contentId && a.id != null) byCid.set(a.contentId, a.id); });
  if (byCid.size === 0) return html;
  return html.replace(/(\bsrc\s*=\s*)(["'])cid:([^"']+)\2/gi, (match, prefix, quote, cid) => {
    const id = byCid.get(cid);
    return id != null ? `${prefix}${quote}/api/crm-adjuntos/${id}${quote}` : match;
  });
}

// Neutraliza <img src="http(s)://…"> (imágenes remotas) para que el mail NO
// dispare requests solo con abrirlo — patrón click-to-load de Gmail/Outlook
// (evita beacons/tracking pixels además de la carga en sí). Mueve `src` a
// `data-src`; el botón de afuera del iframe decide si se restauran. Las `data:`
// y las `cid:` ya resueltas (rutas locales, no http/https) nunca se tocan.
function _neutralizeRemoteImages(html) {
  if (!html) return { html, hadRemote: false };
  let hadRemote = false;
  const out = html.replace(/<img\b([^>]*?)\ssrc\s*=\s*(["'])(https?:\/\/[^"']+)\2([^>]*)>/gi, (m, pre, q, url, post) => {
    hadRemote = true;
    return `<img${pre} data-src=${q}${url}${q}${post}>`;
  });
  return { html: out, hadRemote };
}

// Colapsa el historial citado que agrega el cliente de correo del REMITENTE
// al responder (Gmail: <div class="gmail_quote"> con la línea de atribución
// "On ... wrote:" / "El ... escribió:" + <blockquote>; otros clientes: un
// <blockquote> suelto precedido de esa misma línea). No podemos traducirlo —
// lo generó SU cliente en SU idioma — así que, como cualquier cliente de
// correo, lo colapsamos por default. Devuelve `{ main, quoted }`; `quoted`
// es `null` si no se detectó nada citado (mail sin cita = cero cambios).
function _splitQuotedHtml(html) {
  if (!html) return { main: html, quoted: null };
  try {
    const doc = new DOMParser().parseFromString(html, 'text/html');
    let quoteEl = doc.querySelector('div.gmail_quote, div.gmail_quote_container');
    if (!quoteEl) {
      const bq = doc.querySelector('blockquote');
      if (bq) {
        quoteEl = bq;
        const prev = bq.previousElementSibling;
        const prevText = prev ? (prev.textContent || '').trim() : '';
        // Atribución típica en inglés ("On Fri, Jul 3 ... wrote:") o español
        // ("El vie, 3 jul ... escribió:") justo antes del blockquote.
        if (prev && /(wrote:|escribi[oó]:)\s*$/i.test(prevText)) {
          const wrap = doc.createElement('div');
          prev.parentNode.insertBefore(wrap, prev);
          wrap.appendChild(prev);
          wrap.appendChild(bq);
          quoteEl = wrap;
        }
      }
    }
    if (!quoteEl || !doc.body) return { main: html, quoted: null };
    const quoted = quoteEl.outerHTML;
    quoteEl.remove();
    // Preservar el <head> original (si el mail traía <style>) — `_prepMailHtml`
    // matchea el tag `<head>` literal e inyecta ahí adentro.
    const headHtml = doc.head ? doc.head.innerHTML : '';
    const main = headHtml ? `<head>${headHtml}</head><body>${doc.body.innerHTML}</body>` : doc.body.innerHTML;
    return { main, quoted };
  } catch (e) {
    // Defensivo: si el parseo falla por lo que sea, mostramos el mail
    // completo sin colapsar nada — nunca romper el hilo por esto.
    return { main: html, quoted: null };
  }
}

// Cuerpo HTML real de un mail entrante — iframe sandboxeado sin scripts
// (sandbox solo con allow-same-origin), medido en altura al cargar (cap
// 1200px + scroll interno). El fondo/tipografía siguen el tema activo de la
// casa (antes: hoja blanca + Arial hardcodeados siempre) — ver _MAIL_THEME.
// `attachments` son los del MISMO mensaje (para resolver cid:); `theme` es
// 'light' | 'dark' del bundle.
function HtmlMailBody({ html, attachments, theme }) {
  const iframeRef = _useRefCT(null);
  const [height, setHeight] = _useStateCT(160);
  const [imagesShown, setImagesShown] = _useStateCT(false);
  const [historyShown, setHistoryShown] = _useStateCT(false);
  const dark = theme === 'dark';

  const onLoad = () => {
    try {
      const doc = iframeRef.current && iframeRef.current.contentDocument;
      const h = doc && doc.body ? doc.body.scrollHeight : 160;
      setHeight(Math.min(1200, Math.max(60, h + 24)));
    } catch (e) {
      // Defensivo: no debería tirar (mismo origen por srcDoc + allow-same-origin),
      // pero si algo falla no queremos romper el hilo entero por esto.
    }
  };

  const cidResolved = _resolveCidImages(html, attachments);
  const { html: neutralHtml, hadRemote } = _neutralizeRemoteImages(cidResolved);
  const imagesBase = imagesShown ? cidResolved : neutralHtml;
  // Historial citado (respuesta con "On ... wrote:"/"El ... escribió:" + el
  // hilo repetido en el idioma del remitente) — colapsado por default.
  const { main: quoteCollapsed, quoted } = _splitQuotedHtml(imagesBase);
  const hasQuoted = !!quoted;
  const finalHtml = hasQuoted && !historyShown ? quoteCollapsed : imagesBase;

  return (
    <div className="mt-6">
      {hadRemote && !imagesShown && (
        <button
          type="button"
          onClick={() => setImagesShown(true)}
          className="mb-2 inline-flex items-center gap-2 cursor-pointer transition-colors"
          style={{
            fontSize: 12, fontWeight: 700, padding: '7px 13px', borderRadius: 999,
            background: 'var(--surface)', border: '1px solid var(--border)', color: 'var(--muted-foreground)',
          }}
          onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--accent)'; e.currentTarget.style.borderColor = 'color-mix(in oklab, var(--accent) 45%, var(--border))'; }}
          onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--muted-foreground)'; e.currentTarget.style.borderColor = 'var(--border)'; }}
        >
          <IconEye size={13} />
          Este mensaje tiene imágenes bloqueadas — Mostrar imágenes
        </button>
      )}
      {hasQuoted && (
        <button
          type="button"
          onClick={() => setHistoryShown(s => !s)}
          className="mb-2 inline-flex items-center gap-2 cursor-pointer transition-colors"
          style={{
            fontSize: 12, fontWeight: 700, padding: '7px 13px', borderRadius: 999,
            background: 'var(--surface)', border: '1px solid var(--border)', color: 'var(--muted-foreground)',
          }}
          onMouseEnter={(e) => { e.currentTarget.style.color = 'var(--accent)'; e.currentTarget.style.borderColor = 'color-mix(in oklab, var(--accent) 45%, var(--border))'; }}
          onMouseLeave={(e) => { e.currentTarget.style.color = 'var(--muted-foreground)'; e.currentTarget.style.borderColor = 'var(--border)'; }}
        >
          <IconChevronDown size={13} style={{ transform: historyShown ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }} />
          {historyShown ? 'Ocultar historial' : 'Mostrar historial'}
        </button>
      )}
      {/* Sin caja/borde/recorte propio: el mail fluye directo sobre el fondo
          de la card (var(--card)), igual que el cuerpo en texto plano. Un
          border-radius acá le recortaba el contenido en las esquinas. */}
      <iframe
        ref={iframeRef}
        title="Cuerpo del mensaje"
        srcDoc={_prepMailHtml(finalHtml, dark)}
        sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
        onLoad={onLoad}
        style={{ display: 'block', width: '100%', height, border: 'none', background: 'var(--card)' }}
      />
    </div>
  );
}

// ─── Open message (Gmail-style reading layout) ─────
function ThreadMessage({ msg, expanded, onToggle, starred, onStar, onReply, onForward, attachments, email, user, theme }) {
  const [showDetails, setShowDetails] = _useStateCT(false);

  if (!expanded) {
    return <CollapsedMessage msg={msg} onToggle={onToggle} />;
  }

  const u = user || CRM_USER;
  const toMe = (msg.to || []).some(t => t === u.email);
  const paraLabel = toMe ? 'para mí' : `para ${(msg.to || []).join(', ')}`;
  const { fecha, fechaCorta, hora, rel } = _fmtFull(msg.fecha);

  return (
    <div className="py-1">
      {/* Sender row */}
      <div className="flex items-start gap-3.5">
        <CrmAvatar name={msg.from.nombre} email={msg.from.email} size={44} />
        <div className="min-w-0 flex-1">
          <div className="flex items-baseline gap-2 min-w-0">
            <span className="text-[14.5px] font-extrabold flex-shrink-0 whitespace-nowrap" style={{ color: 'var(--foreground)' }}>{msg.from.nombre}</span>
            <span className="text-[12px] font-mono truncate" style={{ color: 'var(--muted-foreground)' }}>&lt;{msg.from.email}&gt;</span>
            {msg.visibilidad === 'privada' && (
              <span
                className="inline-flex items-center gap-1 text-[10.5px] font-bold flex-shrink-0"
                style={{ color: 'var(--muted-foreground)' }}
                title="Privado — solo lo ven emisor y destinatarios"
              >
                <IconLock size={11} />
                Privado
              </span>
            )}
          </div>
          {/* El chip de entrega va en ESTA línea (la del "para …"), no en la del
              nombre: la de arriba comparte ancho con fecha+acciones a la derecha
              y el chip largo (rebote con motivo) las pisaba. */}
          <div className="flex items-center gap-2 mt-0.5 min-w-0">
            <button
              type="button"
              onClick={() => setShowDetails(s => !s)}
              className="inline-flex items-center gap-1 cursor-pointer flex-shrink-0"
              style={{ color: 'var(--muted-foreground)' }}
            >
              <span className="text-[12.5px]">{paraLabel}</span>
              <IconChevronDown size={13} style={{ transform: showDetails ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }} />
            </button>
            <EnvioChip msg={msg} />
          </div>

          {showDetails && (
            <div
              className="mt-2 px-3.5 py-2.5 rounded-lg text-[11.5px] leading-[1.7]"
              style={{ background: 'var(--surface)', border: '1px solid var(--border)', color: 'var(--muted-foreground)' }}
            >
              <DetailRow k="De" v={`${msg.from.nombre} <${msg.from.email}>`} />
              <DetailRow k="Para" v={(msg.to || []).join(', ')} />
              {msg.cc && msg.cc.length > 0 && <DetailRow k="CC" v={msg.cc.join(', ')} />}
              <DetailRow k="Fecha" v={`${fecha}, ${hora}`} />
            </div>
          )}
        </div>

        {/* Right cluster: date + star + reply */}
        <div className="flex items-center gap-1 flex-shrink-0">
          <span className="text-[12px] tabular-nums mr-1 hidden sm:inline" style={{ color: 'var(--muted-foreground)' }}>
            {fechaCorta} · {hora} ({`hace ${rel}`})
          </span>
          <TBtn icon={starred ? IconStarFilled : IconStar} label={starred ? 'Quitar destacado' : 'Destacar'} onClick={onStar} color={starred ? '#fbbc04' : undefined} size={16} />
          <TBtn icon={IconReply} label="Responder" onClick={onReply} size={16} />
          <TBtn icon={IconMoreV} label="Más" onClick={() => {}} size={18} />
        </div>
      </div>

      {/* Body — HTML real (entrantes con msg.html) en iframe sandboxeado; los
          mensajes de salida (los nuestros) y los que no traen html siguen en
          texto plano como siempre. */}
      {msg.direccion !== 'salida' && msg.html
        ? <HtmlMailBody html={msg.html} attachments={attachments} theme={theme} />
        : (
          <pre
            className="whitespace-pre-wrap text-[14px] leading-[1.75]"
            style={{ color: 'var(--foreground)', fontFamily: 'inherit', margin: 0, marginTop: 24, paddingLeft: 2 }}
          >
            {msg.cuerpo}
          </pre>
        )}

      {/* Attachments */}
      {attachments && attachments.length > 0 && (
        <AttachmentList attachments={attachments} email={email} />
      )}
    </div>
  );
}

// ─── Attachment chips (Gmail-style cards under a message) ─
const _ATT_META = {
  pdf: { ext: 'PDF',  color: '#ea4335' },
  xls: { ext: 'XLSX', color: '#1e8e3e' },
  doc: { ext: 'DOC',  color: '#4285f4' },
  img: { ext: 'IMG',  color: '#a142f4' },
};
function _attMeta(tipo) { return _ATT_META[tipo] || { ext: (tipo || 'FILE').toUpperCase(), color: 'var(--muted-foreground)' }; }

function AttachmentList({ attachments, email }) {
  return (
    <div className="mt-5 pt-4" style={{ borderTop: '1px solid color-mix(in oklab, var(--border) 50%, transparent)' }}>
      <div className="flex items-center gap-1.5 mb-2.5" style={{ color: 'var(--muted-foreground)' }}>
        <IconPaperclip size={13} />
        <span className="text-[11.5px] font-bold uppercase tracking-[0.08em]">
          {attachments.length} {attachments.length === 1 ? 'adjunto' : 'adjuntos'}
        </span>
      </div>
      <div className="flex flex-wrap gap-2.5">
        {attachments.map((a, i) => {
          const meta = _attMeta(a.tipo);
          const sizeLabel = _fmtAttSize(a.sizeBytes);
          return (
            <button
              key={a.id ?? i}
              type="button"
              onClick={() => openDoc(a, email)}
              title={`Abrir ${a.nombre}`}
              className="group flex items-center gap-3 text-left cursor-pointer transition-all"
              style={{
                width: 232,
                padding: '10px 12px',
                borderRadius: 12,
                background: 'var(--surface)',
                border: '1px solid var(--border)',
              }}
              onMouseEnter={(e) => { e.currentTarget.style.borderColor = `color-mix(in oklab, ${meta.color} 55%, var(--border))`; e.currentTarget.style.background = 'var(--card)'; }}
              onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'var(--border)'; e.currentTarget.style.background = 'var(--surface)'; }}
            >
              <span
                className="flex items-center justify-center flex-shrink-0"
                style={{ width: 38, height: 38, borderRadius: 9, background: `color-mix(in oklab, ${meta.color} 16%, transparent)`, color: meta.color, fontSize: 9.5, fontWeight: 800, letterSpacing: '0.04em' }}
              >
                {meta.ext}
              </span>
              <span className="min-w-0 flex-1">
                <span className="block text-[12.5px] font-semibold truncate" style={{ color: 'var(--foreground)' }}>{a.nombre}</span>
                <span className="block text-[11px] mt-0.5" style={{ color: 'var(--muted-foreground)' }}>
                  {sizeLabel ? sizeLabel : 'Toca para previsualizar'}
                </span>
              </span>
              <span
                className="flex items-center justify-center flex-shrink-0 transition-opacity opacity-0 group-hover:opacity-100"
                style={{ color: 'var(--muted-foreground)' }}
              >
                <IconDownload size={15} />
              </span>
            </button>
          );
        })}
      </div>
    </div>
  );
}

function DetailRow({ k, v }) {
  return (
    <div className="flex gap-2">
      <span className="font-bold flex-shrink-0" style={{ minWidth: 42, color: 'color-mix(in oklab, var(--muted-foreground) 80%, var(--foreground))' }}>{k}:</span>
      <span className="font-mono break-all" style={{ color: 'var(--foreground)' }}>{v}</span>
    </div>
  );
}

// ─── Reply composer (inline) — responder / responder a todos / reenviar ──────
// Un solo composer inline con 3 modos (auditoría #17/#18):
//   · reply/replyAll → agregan un mensaje al MISMO hilo (POST /api/crm-mensajes)
//     con los destinatarios REALES (crmDestinatariosRespuesta: el/los del último
//     entrante menos las casillas propias). replyAll suma el resto en CC.
//   · forward → mail NUEVO (POST /api/crm-hilos), destinatario vacío, cuerpo con
//     la cita "---------- Mensaje reenviado ----------" y los adjuntos del
//     original arrastrados (adjuntosDeIds).
// Adjuntos locales/del ERP (#19/#20), plantillas con variables reemplazadas
// (#33) y borrador con autosave real (#22) — maquinaria de crm/envio.jsx.
const _REPLY_TITULO = { reply: 'Responder', replyAll: 'Responder a todos', forward: 'Reenviar' };
function _crmAsuntoReenvio(asunto) {
  const a = String(asunto || '').trim();
  return /^rv:/i.test(a) ? a : `Rv: ${a}`;
}
// Pill de acción del bloque colapsado (Responder / Responder a todos / Reenviar).
function _ReplyPill({ icon: Icon, label, onClick }) {
  return (
    <button
      type="button"
      onClick={onClick}
      className="inline-flex items-center gap-2 px-5 py-2.5 text-[13.5px] font-bold cursor-pointer transition-colors"
      style={{ borderRadius: 999, border: '1px solid var(--border)', background: 'var(--card)', color: 'var(--foreground)' }}
      onMouseEnter={(e) => { e.currentTarget.style.borderColor = 'color-mix(in oklab, var(--accent) 45%, var(--border))'; e.currentTarget.style.color = 'var(--accent)'; }}
      onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'var(--border)'; e.currentTarget.style.color = 'var(--foreground)'; }}
    >
      <Icon size={15} />
      {label}
    </button>
  );
}

function InlineReply({ email, user, onReplace, openReq }) {
  const [expanded, setExpanded] = _useStateCT(false);
  const [mode, setMode] = _useStateCT('reply');
  const [to, setTo] = _useStateCT('');
  const [cc, setCc] = _useStateCT('');
  const [showCc, setShowCc] = _useStateCT(false);
  const [subject, setSubject] = _useStateCT('');
  const [text, setText] = _useStateCT('');
  // Alias de envío: null = personal (privado), o uno de los 3 alias del equipo.
  const [alias, setAlias] = _useStateCT(null);
  const [enviando, setEnviando] = _useStateCT(false);
  const [error, setError] = _useStateCT(null);
  const [showTpls, setShowTpls] = _useStateCT(false);
  const [pickerErp, setPickerErp] = _useStateCT(false);
  // Comprobante a preferir en el picker del ERP (lo setea "Adjuntar presupuesto
  // del ERP" del panel vía evento — auto-adjunta ese sin clicks de más).
  const [pickerPreferido, setPickerPreferido] = _useStateCT(null);
  const fileRef = _useRefCT(null);
  const textareaRef = _useRefCT(null);

  const linkedExp = email.linkedExpedienteId;
  const adj = useCrmAdjuntosSalientes();
  // Autosave real (#22) solo para reply/replyAll (mismo hilo). El reenvío es un
  // mail nuevo y arranca prellenado con la cita — no tiene sentido restaurarlo.
  const borrador = useCrmBorrador({
    activo: expanded && mode !== 'forward',
    hiloId: email.id,
    datos: { para: to, cc, asunto: '', cuerpo: text, alias },
  });

  const abrir = (m) => {
    setMode(m); setExpanded(true); setError(null); setEnviando(false);
    setShowTpls(false); setPickerErp(false);
    adj.limpiar(); borrador.reiniciar();
    const lastMsg = crmLastMsg(email);
    if (m === 'forward') {
      setTo(''); setCc(''); setShowCc(false);
      setSubject(_crmAsuntoReenvio(email.asunto));
      setText(crmCitaReenvio(lastMsg, email.asunto));
      setAlias(null);
      // "Adjuntos arrastrados" (#18): los del mensaje que estás reenviando
      // (reales, con id — el backend copia la fila compartiendo el storage).
      const fuente = (Array.isArray(lastMsg.adjuntos) && lastMsg.adjuntos.length) ? lastMsg.adjuntos : (email.adjuntos || []);
      fuente.forEach((a) => {
        if (a && a.id != null) adj.agregarItem({ nombre: a.nombre, tipo: a.tipo, sizeBytes: a.sizeBytes, origen: 'reenvio', adjuntoId: a.id });
      });
    } else {
      const dest = crmDestinatariosRespuesta(email, user, m === 'replyAll');
      setTo((dest.para || []).join(', '));
      setCc((dest.cc || []).join(', '));
      setShowCc((dest.cc || []).length > 0);
      setSubject(''); setText(''); setAlias(null);
      // Retomar el borrador de respuesta guardado para este hilo (#22).
      borrador.restaurar().then((b) => { if (b) { setText(b.cuerpo || ''); if (b.alias) setAlias(b.alias); } });
    }
    setTimeout(() => { if (textareaRef.current) textareaRef.current.focus(); }, 40);
  };

  // Botón "Responder" por-mensaje (ThreadMessage) → abre en modo reply.
  _useEffectCT(() => { if (openReq) abrir(openReq.mode); }, [openReq]);
  // Cambio de hilo: cerrar y limpiar.
  _useEffectCT(() => { setExpanded(false); adj.limpiar(); borrador.reiniciar(); }, [email.id]);
  // "Adjuntar presupuesto del ERP" del panel (#25): abre una respuesta y el
  // picker del ERP prefiriendo ese comprobante. Panel y hilo son hermanos —
  // se hablan por evento.
  _useEffectCT(() => {
    const onAdj = (e) => {
      const d = (e && e.detail) || {};
      if (d.hiloId && d.hiloId !== email.id) return;
      abrir('reply');
      setTimeout(() => { setPickerPreferido(d.preferido || 'Presupuesto'); setPickerErp(true); }, 60);
    };
    window.addEventListener('crm:reply-adjuntar-erp', onAdj);
    return () => window.removeEventListener('crm:reply-adjuntar-erp', onAdj);
  }, [email.id]);

  const cerrar = () => { borrador.flush(); setExpanded(false); adj.limpiar(); };

  const applyTpl = (tpl) => {
    setShowTpls(false);
    crmPrepararPlantilla(tpl, { email, user }).then(({ cuerpo }) => {
      setText((prev) => prev ? prev + '\n\n' + cuerpo : cuerpo);
      if (textareaRef.current) textareaRef.current.focus();
    });
  };

  const enviar = () => {
    if (enviando) return;
    const toList = crmParseEmails(to);
    const ccList = crmParseEmails(cc);
    if (!text.trim()) { setError('Escribí un mensaje.'); return; }
    if (!toList.length) { setError(mode === 'forward' ? 'Cargá a quién se lo reenviás.' : 'Cargá al menos un destinatario.'); return; }
    setError(null); setEnviando(true);
    // Los adjuntos (archivo nuevo, comprobante del ERP o reenvío de uno existente)
    // viajan en el MISMO body del POST: el server los sube/registra ANTES de mandar
    // el mail y devuelve el hilo ya con los adjuntos. Sin adjuntos es `{}` (no-op).
    const payloadAdj = crmPayloadAdjuntos(adj.adjuntos);

    if (mode === 'forward') {
      fetch('/api/crm-hilos', {
        method: 'POST', credentials: 'same-origin',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ asunto: subject || email.asunto, cuerpo: text, to: toList, cc: ccList, alias, ...payloadAdj }),
      })
        .then(async (r) => {
          if (r.ok) return r.json();
          const e = await r.json().catch(() => ({}));
          throw new Error(e.error || 'POST /api/crm-hilos → ' + r.status);
        })
        .then((hilo) => { if (onReplace) onReplace(hilo); setEnviando(false); cerrar(); })
        .catch((e) => { setEnviando(false); setError(e.message || 'No se pudo reenviar.'); });
      return;
    }

    fetch('/api/crm-mensajes', {
      method: 'POST', credentials: 'same-origin',
      headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
      body: JSON.stringify({ hiloId: email.id, cuerpo: text, alias, to: toList, cc: ccList, ...payloadAdj }),
    })
      .then(async (r) => {
        if (r.ok) return r.json();
        const e = await r.json().catch(() => ({}));
        throw new Error(e.error || 'POST /api/crm-mensajes → ' + r.status);
      })
      .then((hilo) => { borrador.descartar(); if (onReplace) onReplace(hilo); setEnviando(false); cerrar(); })
      .catch((e) => { setEnviando(false); setError(e.message || 'No se pudo enviar la respuesta.'); });
  };

  if (!expanded) {
    return (
      <div className="flex items-center gap-2.5 flex-wrap mt-5">
        <_ReplyPill icon={IconReply} label="Responder" onClick={() => abrir('reply')} />
        <_ReplyPill icon={IconReplyAll} label="Responder a todos" onClick={() => abrir('replyAll')} />
        <_ReplyPill icon={IconForward} label="Reenviar" onClick={() => abrir('forward')} />
      </div>
    );
  }

  const esReenvio = mode === 'forward';
  const campoBorde = { borderBottom: '1px solid color-mix(in oklab, var(--border) 55%, transparent)' };
  return (
    <div
      className="mt-4 rounded-2xl overflow-hidden"
      style={{
        background: 'var(--card)',
        border: '1px solid color-mix(in oklab, var(--accent) 30%, var(--border))',
        boxShadow: '0 0 0 3px color-mix(in oklab, var(--accent) 10%, transparent)',
      }}
    >
      <div className="px-4 py-2.5 flex items-center justify-between" style={{ borderBottom: '1px solid var(--border)' }}>
        <p className="text-[11.5px] font-bold" style={{ color: 'var(--foreground)' }}>{_REPLY_TITULO[mode]}</p>
        <button type="button" onClick={cerrar} className="text-[11.5px] cursor-pointer" style={{ color: 'var(--muted-foreground)' }}>
          Cerrar
        </button>
      </div>

      {/* Para */}
      <div className="flex items-center gap-2.5 px-4 py-2" style={campoBorde}>
        <span className="text-[11px] font-bold uppercase tracking-[0.08em] w-10 flex-shrink-0" style={{ color: 'var(--muted-foreground)' }}>Para</span>
        <input
          type="text"
          value={to}
          onChange={(e) => { setTo(e.target.value); if (error) setError(null); }}
          placeholder="ejemplo@dominio.com"
          className="flex-1 bg-transparent outline-none text-[13px]"
          style={{ color: 'var(--foreground)' }}
        />
        {!showCc && (
          <button type="button" onClick={() => setShowCc(true)} className="text-[11px] font-bold cursor-pointer flex-shrink-0" style={{ color: 'var(--muted-foreground)' }}>
            CC
          </button>
        )}
      </div>

      {/* CC */}
      {showCc && (
        <div className="flex items-center gap-2.5 px-4 py-2" style={campoBorde}>
          <span className="text-[11px] font-bold uppercase tracking-[0.08em] w-10 flex-shrink-0" style={{ color: 'var(--muted-foreground)' }}>CC</span>
          <input
            type="text"
            value={cc}
            onChange={(e) => setCc(e.target.value)}
            placeholder="ejemplo@dominio.com"
            className="flex-1 bg-transparent outline-none text-[13px]"
            style={{ color: 'var(--foreground)' }}
          />
        </div>
      )}

      {/* Asunto — solo al reenviar (mail nuevo) */}
      {esReenvio && (
        <div className="flex items-center gap-2.5 px-4 py-2" style={campoBorde}>
          <span className="text-[11px] font-bold uppercase tracking-[0.08em] w-10 flex-shrink-0" style={{ color: 'var(--muted-foreground)' }}>Asunto</span>
          <input
            type="text"
            value={subject}
            onChange={(e) => setSubject(e.target.value)}
            placeholder="Asunto"
            className="flex-1 bg-transparent outline-none text-[13px] font-bold"
            style={{ color: 'var(--foreground)' }}
          />
        </div>
      )}

      <textarea
        ref={textareaRef}
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder={esReenvio ? 'Agregá una nota arriba de lo reenviado…' : 'Escribí tu respuesta…'}
        className="edit-textarea"
        style={{ border: 'none', borderRadius: 0, minHeight: 140, fontSize: 13.5, lineHeight: 1.6 }}
      />

      {/* Adjuntos que van a salir */}
      <CrmChipsAdjuntos adjuntos={adj.adjuntos} onQuitar={adj.quitar} error={adj.error} />
      {error && (
        <p className="px-4 pb-1 text-[11.5px] font-semibold" style={{ color: 'var(--destructive)' }}>{error}</p>
      )}

      <div className="px-3 py-2.5 flex items-center gap-2 flex-wrap" style={{ borderTop: '1px solid var(--border)' }}>
        <button type="button" onClick={enviar} disabled={enviando} className="btn btn-primary">
          <IconSend size={14} />
          {enviando ? 'Enviando…' : (esReenvio ? 'Reenviar' : 'Enviar')}
        </button>
        <input
          ref={fileRef}
          type="file"
          multiple
          className="hidden"
          onChange={(e) => { adj.agregarArchivos(e.target.files); e.target.value = ''; }}
        />
        <button type="button" onClick={() => fileRef.current && fileRef.current.click()} className="btn btn-ghost h-8 w-8 p-0" title="Adjuntar archivo">
          <IconPaperclip size={15} />
        </button>
        <button
          type="button"
          onClick={() => { if (linkedExp) { setPickerPreferido(null); setPickerErp(true); } }}
          disabled={!linkedExp}
          className="btn btn-ghost h-8 w-8 p-0"
          style={{ color: linkedExp ? 'var(--accent)' : 'var(--muted-foreground)', opacity: linkedExp ? 1 : 0.5 }}
          title={linkedExp ? 'Adjuntar comprobante del ERP' : 'Vinculá un expediente para adjuntar sus comprobantes'}
        >
          <IconLink size={15} />
        </button>
        {/* Plantillas — se insertan YA con las variables del hilo reemplazadas (#33) */}
        <div className="relative">
          <button
            type="button"
            onClick={() => setShowTpls((o) => !o)}
            className="btn btn-ghost h-8 w-8 p-0"
            style={{ color: showTpls ? 'var(--accent)' : 'var(--muted-foreground)' }}
            title="Plantilla"
          >
            <IconSparkles size={15} />
          </button>
          <CrmPlantillasDropdown open={showTpls} onClose={() => setShowTpls(false)} onPick={applyTpl} />
        </div>
        {/* Alias de envío — mismas 3 opciones + personal que el composer */}
        <label className="inline-flex items-center gap-1.5 text-[11px] font-semibold cursor-pointer" style={{ color: 'var(--muted-foreground)' }}>
          De
          <select
            value={alias || ''}
            onChange={(e) => setAlias(e.target.value || null)}
            className="bg-transparent outline-none text-[11.5px] font-bold cursor-pointer"
            style={{ color: 'var(--foreground)' }}
          >
            <option value="">Personal (sin alias)</option>
            {CRM_ALIASES.map((a) => <option key={a.key} value={a.key}>{a.label}</option>)}
          </select>
        </label>
        {!esReenvio && (
          <span className="ml-auto">
            <CrmEstadoBorrador estado={borrador.estado} />
          </span>
        )}
      </div>

      {/* Picker de comprobantes del ERP (expediente vinculado) — mismo que el composer */}
      <CrmPickerComprobantes
        open={pickerErp}
        expRef={linkedExp}
        preferido={pickerPreferido}
        onClose={() => { setPickerErp(false); setPickerPreferido(null); }}
        onPick={(item) => adj.agregarItem(item)}
      />
    </div>
  );
}

// ─── Thread reader (main) ──────────────────────────
function CrmThread({ email, onClose, onUpdate, onReplace, contextOpen, onToggleContext, nav, user, theme }) {
  // Expanded message state — by default only last expanded
  const lastIdx = email.messages.length - 1;
  const [expanded, setExpanded] = _useStateCT(() => new Set([lastIdx]));
  // Pedido de apertura de la respuesta desde el botón por-mensaje: objeto nuevo
  // en cada click ({mode, tick}) para que el efecto de InlineReply lo detecte.
  const [replyReq, setReplyReq] = _useStateCT(null);
  // Direction of the arrow that triggered the next email swap. Held in a ref
  // (not state) so setting it never re-renders and never re-applies an animation
  // class to an already-settled message — which is what caused the post-slide blink.
  const navDirRef = _useRefCT(null);
  // The entrance animation class for the current email. Computed ONCE per email.id
  // (during the render where the id changes) and then kept stable, so the class on
  // the keyed body never swaps mid-life and the animation plays exactly once.
  const seenIdRef = _useRefCT(email.id);
  const entranceRef = _useRefCT('animate-fade-in');
  if (email.id !== seenIdRef.current) {
    seenIdRef.current = email.id;
    entranceRef.current = navDirRef.current === 'next' ? 'thread-nav-next'
                        : navDirRef.current === 'prev' ? 'thread-nav-prev'
                        : 'animate-fade-in';
    navDirRef.current = null;
  }
  const toggleMsg = (i) => {
    setExpanded(s => {
      const n = new Set(s);
      n.has(i) ? n.delete(i) : n.add(i);
      return n;
    });
  };
  _useEffectCT(() => {
    setExpanded(new Set([email.messages.length - 1]));
    setReplyReq(null);
  }, [email.id]);

  // Wrap nav so the arrows record which way we're heading before the email swaps.
  // Recorded in a ref → consumed during the next render's id-change check above.
  const navWrapped = nav && {
    ...nav,
    onPrev: () => { navDirRef.current = 'prev'; nav.onPrev(); },
    onNext: () => { navDirRef.current = 'next'; nav.onNext(); },
  };
  const bodyAnim = entranceRef.current;

  return (
    <div className="flex-1 flex flex-col min-w-0 animate-fade-in-up" style={{ background: 'var(--background)' }}>
      <ThreadHeader
        email={email}
        onClose={onClose}
        onUpdate={onUpdate}
        contextOpen={contextOpen}
        onShowContext={onToggleContext}
        nav={navWrapped}
        user={user}
      />
      <div key={email.id} className={`flex-1 overflow-y-auto px-6 py-6 ${bodyAnim}`}>
        <div className="w-full">
          {/* Message card */}
          <div
            style={{
              borderRadius: 16,
              background: 'var(--card)',
              border: '1px solid color-mix(in oklab, var(--border) 70%, transparent)',
              boxShadow: 'var(--shadow-xs)',
              padding: '20px 24px',
            }}
          >
            {email.messages.map((m, i) => {
              const isExp = expanded.has(i);
              return (
                <div key={i} style={i > 0 && isExp ? { borderTop: '1px solid color-mix(in oklab, var(--border) 50%, transparent)', marginTop: 14, paddingTop: 14 } : undefined}>
                  <ThreadMessage
                    msg={m}
                    expanded={isExp}
                    onToggle={() => toggleMsg(i)}
                    /* `starred` es un bit por HILO (persiste en crm_estados_usuario,
                       no por mensaje — no hay columna de mensaje para esto). Antes
                       solo el ÚLTIMO mensaje (i === lastIdx) leía email.starred, así
                       que tocar la estrella de CUALQUIER otro mensaje SÍ actualizaba
                       el hilo (onStar ya usaba email.starred correctamente) pero el
                       cambio se veía reflejado en el último mensaje, no en el
                       tocado. Fix: todos los mensajes leen el mismo bit real, así
                       el que tocás es el que se ve marcado. */
                    starred={email.starred}
                    onStar={() => onUpdate({ starred: !email.starred })}
                    onReply={() => setReplyReq({ mode: 'reply', tick: Date.now() })}
                    /* Adjuntos reales del propio mensaje (ingesta n8n) si vienen;
                       si no (hilos/mails viejos sin esa columna todavía), cae al
                       agregado del hilo solo en el último mensaje — igual que antes. */
                    attachments={Array.isArray(m.adjuntos) ? m.adjuntos : (i === lastIdx ? email.adjuntos : null)}
                    email={email}
                    user={user}
                    theme={theme}
                  />
                </div>
              );
            })}
          </div>

          {/* Responder / Responder a todos / Reenviar — destinatarios reales,
              adjuntos, plantillas y borrador viven adentro de InlineReply. */}
          <InlineReply
            email={email}
            user={user}
            onReplace={onReplace}
            openReq={replyReq}
          />
        </div>
      </div>
    </div>
  );
}

// ─── Context panel (right side: contact + ERP data) ─
// Mapea el `type` de PartySelectorModal (paciente/medico/entidad) al
// `linkedEntityType` del hilo (particular/medico/entidad).
const _PARTY_A_LINK = { paciente: 'particular', medico: 'medico', entidad: 'entidad' };
function CrmContextPanel({ email, onClose }) {
  // Vincular con cuenta (#24): tipo elegido para el PartySelectorModal +
  // `linkTick` para recomputar los memos tras un PATCH (MicosStore.update muta
  // el hilo EN EL LUGAR — misma referencia, el memo no lo ve solo).
  const [linkTick, setLinkTick] = _useStateCT(0);
  const [partyType, setPartyType] = _useStateCT(null);   // null | 'paciente'|'medico'|'entidad'
  const [chooser, setChooser] = _useStateCT(false);      // menú "vincular con cuenta"
  const [assocOpen, setAssocOpen] = _useStateCT(false);  // combobox "asociar expediente"
  const [assocText, setAssocText] = _useStateCT('');
  const [creando, setCreando] = _useStateCT(false);      // creando expediente desde el hilo
  const [accErr, setAccErr] = _useStateCT(null);

  // Resolve linked entity from ERP data (recompute tras vincular — linkTick).
  const ctx = _useMemoCT(() => {
    if (!email) return null;
    if (email.linkedEntityType === 'entidad') {
      return { type: 'entidad', data: (window.ENTIDADES || []).find(e => e.id === email.linkedEntityId) };
    }
    if (email.linkedEntityType === 'medico') {
      return { type: 'medico', data: (window.MEDICOS || []).find(m => m.id === email.linkedEntityId) };
    }
    if (email.linkedEntityType === 'particular') {
      return { type: 'particular', data: (window.PARTICULARES || []).find(p => p.id === email.linkedEntityId) };
    }
    return { type: email.linkedEntityType || 'externo', data: null };
  }, [email, linkTick]);

  // Expedientes relacionados por entidad/médico (datos reales de window.EXPEDIENTES).
  const relatedExp = _useMemoCT(() => {
    if (!email) return [];
    const all = window.EXPEDIENTES || [];
    if (email.linkedExpedienteId) {
      const main = all.find(e => e.publicId === email.linkedExpedienteId);
      const others = all.filter(e =>
        e.publicId !== email.linkedExpedienteId &&
        (
          (email.linkedEntityType === 'entidad' && e.entidad?.id === email.linkedEntityId) ||
          (email.linkedEntityType === 'medico'  && e.medico?.id  === email.linkedEntityId)
        )
      ).slice(0, 3);
      return [main, ...others].filter(Boolean);
    }
    return all.filter(e =>
      (email.linkedEntityType === 'entidad' && e.entidad?.id === email.linkedEntityId) ||
      (email.linkedEntityType === 'medico'  && e.medico?.id  === email.linkedEntityId)
    ).slice(0, 4);
  }, [email, linkTick]);

  if (!email) return null;

  const lastMsg = crmLastMsg(email);
  const formatMoney = window.formatMoney || ((n) => `$${n.toLocaleString('es-AR')}`);

  // ── Acciones reales del panel (persisten YA por el store; el hilo se muta en
  //    el lugar y linkTick fuerza el recompute local) ──────────────────────────
  const patchHilo = (patch) => {
    if (window.MicosStore) window.MicosStore.update('crmHilos', email.id, patch);
    else Object.assign(email, patch);
    setLinkTick((t) => t + 1);
  };

  // #24 — vincular el hilo con una cuenta del directorio (entidad/médico/particular).
  const vincularCuenta = (picked) => {
    const tipo = _PARTY_A_LINK[partyType] || 'entidad';
    setPartyType(null);
    if (picked && picked.id) patchHilo({ linkedEntityId: picked.id, linkedEntityType: tipo });
  };

  // #25 — asociar el hilo a un expediente que ya existe (por publicId).
  const asociarExpediente = (exp) => {
    setAssocOpen(false); setAssocText('');
    if (exp && exp.publicId) patchHilo({ linkedExpedienteId: exp.publicId });
  };

  // #25 — crear un expediente REAL desde el hilo (prefill de la parte vinculada
  // si la hay), enlazarlo y abrirlo en el ERP. El publicId real llega en el
  // callback de reconciliación del store (el optimista todavía no lo tiene).
  const crearExpediente = () => {
    if (creando || !window.MicosStore) return;
    setCreando(true); setAccErr(null);
    const hoy = (window.MicosTodayISO && window.MicosTodayISO()) || new Date().toISOString().slice(0, 10);
    const payload = { inicio: hoy, actualizado: hoy, status: 'INICIADO', __nuevo: true };
    if (ctx && ctx.data) {
      if (ctx.type === 'entidad') { payload.entidadId = ctx.data.id; payload.entidad = ctx.data; }
      else if (ctx.type === 'medico') { payload.medicoId = ctx.data.id; payload.medico = ctx.data; }
      else if (ctx.type === 'particular') { payload.particularId = ctx.data.id; payload.particular = ctx.data; }
    }
    let tempId;
    const created = window.MicosStore.create('expedientes', payload, (reconciliado) => {
      // Enlazar el hilo al expediente ya reconciliado y navegar al detalle real.
      if (reconciliado && reconciliado.publicId) {
        patchHilo({ linkedExpedienteId: reconciliado.publicId });
        crmAbrirExpediente(reconciliado.publicId);
      } else {
        setCreando(false);
        setAccErr('El expediente se creó pero no se pudo abrir. Buscalo en el ERP.');
      }
    });
    tempId = created && created.id;
    // Fallback: si el POST tarda/no reconcilia, no dejamos el botón colgado para siempre.
    setTimeout(() => { if (tempId) setCreando(false); }, 8000);
  };

  // #25 — "Adjuntar presupuesto del ERP": abre una respuesta con el picker del
  // ERP prefiriendo el Presupuesto. El panel es hermano del hilo (los monta
  // app.jsx por separado) → se comunican por evento; InlineReply lo escucha.
  const adjuntarPresupuesto = () => {
    window.dispatchEvent(new CustomEvent('crm:reply-adjuntar-erp', {
      detail: { hiloId: email.id, preferido: 'Presupuesto' },
    }));
  };

  return (
    <aside
      className="hidden xl:flex flex-col flex-shrink-0 animate-fade-in"
      style={{
        width: 340,
        background: 'var(--card)',
        borderLeft: '1px solid var(--border)',
        overflowY: 'auto',
      }}
    >
      <div className="px-5 pt-5 pb-4" style={{ borderBottom: '1px solid var(--border)' }}>
        <p className="text-[10.5px] font-bold uppercase tracking-[0.14em]" style={{ color: 'var(--muted-foreground)' }}>
          Contacto
        </p>
        <div className="flex items-center gap-3 mt-3">
          <CrmAvatar name={lastMsg.from.nombre} email={lastMsg.from.email} size={48} />
          <div className="min-w-0">
            <p className="text-[15px] font-extrabold truncate" style={{ color: 'var(--foreground)' }}>{lastMsg.from.nombre}</p>
            <p className="text-[11.5px] font-mono truncate" style={{ color: 'var(--muted-foreground)' }}>{lastMsg.from.email}</p>
          </div>
        </div>

        {/* Entity badge */}
        {ctx && ctx.data && (
          <div
            className="mt-3 px-3 py-2.5 rounded-xl flex items-center gap-2.5"
            style={{
              background: 'color-mix(in oklab, var(--accent) 6%, transparent)',
              border: '1px solid color-mix(in oklab, var(--accent) 25%, var(--border))',
            }}
          >
            {ctx.type === 'entidad' && <IconBuilding size={15} style={{ color: 'var(--accent)', flexShrink: 0 }} />}
            {ctx.type === 'medico' && <IconStethoscope size={15} style={{ color: 'var(--accent)', flexShrink: 0 }} />}
            {ctx.type === 'particular' && <IconUser size={15} style={{ color: 'var(--accent)', flexShrink: 0 }} />}
            <div className="min-w-0">
              <p className="text-[10.5px] font-bold uppercase tracking-[0.1em]" style={{ color: 'var(--muted-foreground)' }}>
                {ctx.type === 'entidad' ? 'Entidad' : ctx.type === 'medico' ? 'Médico' : 'Particular'}
              </p>
              <p className="text-[12.5px] font-extrabold truncate" style={{ color: 'var(--foreground)' }}>
                {ctx.type === 'entidad' ? ctx.data.razonSocial :
                 ctx.type === 'medico' ? `${ctx.data.nombre} ${ctx.data.apellido}` :
                 `${ctx.data.nombre} ${ctx.data.apellido}`}
              </p>
            </div>
          </div>
        )}

        {!ctx?.data && (
          <div className="mt-3 px-3 py-2.5 rounded-xl"
               style={{ background: 'var(--surface)', border: '1px dashed var(--border)' }}>
            <p className="text-[11.5px]" style={{ color: 'var(--muted-foreground)' }}>
              Sin vínculo con el ERP.
            </p>
            <button
              type="button"
              onClick={() => setChooser((o) => !o)}
              className="text-[11.5px] font-bold mt-1 flex items-center gap-1 cursor-pointer"
              style={{ color: 'var(--accent)' }}
            >
              <IconLink size={12} />
              Vincular con cuenta
            </button>
          </div>
        )}
        {ctx?.data && (
          <button
            type="button"
            onClick={() => setChooser((o) => !o)}
            className="text-[11px] font-bold mt-2 flex items-center gap-1 cursor-pointer"
            style={{ color: 'var(--muted-foreground)' }}
          >
            <IconLink size={11} />
            Cambiar cuenta vinculada
          </button>
        )}
        {/* Chooser: qué tipo de cuenta (abre el PartySelectorModal reusado) */}
        {chooser && (
          <div className="mt-2 grid grid-cols-3 gap-1.5">
            {[
              { t: 'entidad', label: 'Entidad', Icon: IconBuilding },
              { t: 'medico', label: 'Médico', Icon: IconStethoscope },
              { t: 'paciente', label: 'Particular', Icon: IconUser },
            ].map(({ t, label, Icon }) => (
              <button
                key={t}
                type="button"
                onClick={() => { setChooser(false); setPartyType(t); }}
                className="flex flex-col items-center gap-1 px-1 py-2 rounded-lg cursor-pointer transition-colors"
                style={{ background: 'var(--surface)', border: '1px solid var(--border)', color: 'var(--foreground)' }}
                onMouseEnter={(e) => { e.currentTarget.style.borderColor = 'color-mix(in oklab, var(--accent) 45%, var(--border))'; e.currentTarget.style.color = 'var(--accent)'; }}
                onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'var(--border)'; e.currentTarget.style.color = 'var(--foreground)'; }}
              >
                <Icon size={15} />
                <span className="text-[10.5px] font-bold">{label}</span>
              </button>
            ))}
          </div>
        )}
      </div>

      {/* Stats for entidad */}
      {ctx?.type === 'entidad' && ctx.data && (
        <div className="px-5 py-4 grid grid-cols-2 gap-2" style={{ borderBottom: '1px solid var(--border)' }}>
          <ContextStat label="Saldo a cobrar" value={formatMoney(ctx.data.saldoCobrar)} tone={ctx.data.saldoCobrar > 200000 ? 'neg' : 'neutral'} />
          <ContextStat label="Exp. activos" value={String(ctx.data.expedientesActivos)} />
          <ContextStat label="Condición" value={ctx.data.condicionVenta} small />
          <ContextStat label="CUIT" value={ctx.data.cuit} small mono />
        </div>
      )}
      {ctx?.type === 'medico' && ctx.data && (
        <div className="px-5 py-4 grid grid-cols-2 gap-2" style={{ borderBottom: '1px solid var(--border)' }}>
          <ContextStat label="Expedientes" value={String(ctx.data.expedientes)} />
          <ContextStat label="Derivaciones" value={String(ctx.data.derivaciones)} />
          <ContextStat label="Especialidad" value={ctx.data.especialidad} small />
          <ContextStat label="Matrícula" value={ctx.data.matricula} small mono />
        </div>
      )}

      {/* Related expedientes */}
      {relatedExp.length > 0 && (
        <div className="px-5 py-4" style={{ borderBottom: '1px solid var(--border)' }}>
          <div className="flex items-center justify-between mb-2.5">
            <p className="text-[10.5px] font-bold uppercase tracking-[0.14em]" style={{ color: 'var(--muted-foreground)' }}>
              Expedientes relacionados
            </p>
            <span className="text-[10px] tabular-nums" style={{ color: 'var(--muted-foreground)' }}>{relatedExp.length}</span>
          </div>
          <div className="space-y-2">
            {relatedExp.map((exp) => (
              <a
                key={exp.id}
                href={`/erp/expedientes/${encodeURIComponent(exp.publicId)}`}
                onClick={(e) => { e.preventDefault(); crmAbrirExpediente(exp.publicId); }}
                title={`Abrir ${exp.publicId} en el ERP`}
                className="block px-3 py-2.5 rounded-lg cursor-pointer no-underline"
                style={{
                  background: exp.publicId === email.linkedExpedienteId
                    ? 'color-mix(in oklab, var(--accent) 7%, transparent)'
                    : 'var(--surface)',
                  border: exp.publicId === email.linkedExpedienteId
                    ? '1px solid color-mix(in oklab, var(--accent) 35%, var(--border))'
                    : '1px solid transparent',
                }}
              >
                <div className="flex items-center justify-between gap-2">
                  <span className="font-mono text-[11.5px] font-bold" style={{ color: 'var(--accent)' }}>{exp.publicId}</span>
                  <span className="text-[10px] font-bold tabular-nums" style={{ color: 'var(--muted-foreground)' }}>
                    {exp.total ? formatMoney(exp.total) : '—'}
                  </span>
                </div>
                <p className="text-[11.5px] font-semibold truncate mt-1" style={{ color: 'var(--foreground)' }}>
                  {exp.particular ? `${exp.particular.nombre} ${exp.particular.apellido}` : 'Sin paciente'}
                </p>
              </a>
            ))}
          </div>
        </div>
      )}

      {/* Acciones rápidas — reales (auditoría #25). La 4ta ("Crear tarea de
          seguimiento") no está: no existe sistema de tareas en el ERP, sería un
          botón de mentira (CONEXIONES §"Qué NO haría"). */}
      <div className="px-5 py-4 space-y-2">
        <p className="text-[10.5px] font-bold uppercase tracking-[0.14em] mb-1" style={{ color: 'var(--muted-foreground)' }}>
          Acciones rápidas
        </p>
        <ActionButton
          icon={IconPlus}
          label={creando ? 'Creando expediente…' : 'Crear expediente desde este hilo'}
          onClick={crearExpediente}
        />
        {assocOpen ? (
          <div className="px-1 py-1">
            <ExpedienteCombobox value={assocText} onChange={setAssocText} onPick={asociarExpediente} />
            <button type="button" onClick={() => { setAssocOpen(false); setAssocText(''); }} className="text-[11px] mt-1 cursor-pointer" style={{ color: 'var(--muted-foreground)' }}>
              Cancelar
            </button>
          </div>
        ) : (
          <ActionButton
            icon={IconLink}
            label={email.linkedExpedienteId ? 'Cambiar expediente asociado' : 'Asociar a expediente existente'}
            onClick={() => setAssocOpen(true)}
          />
        )}
        <ActionButton
          icon={IconPaperclip}
          label="Adjuntar presupuesto del ERP"
          onClick={adjuntarPresupuesto}
          disabled={!email.linkedExpedienteId}
        />
        {!email.linkedExpedienteId && (
          <p className="text-[10.5px] pl-1" style={{ color: 'var(--muted-foreground)' }}>
            Asociá un expediente para adjuntar sus comprobantes.
          </p>
        )}
        {accErr && (
          <p className="text-[11px] font-semibold pl-1" style={{ color: 'var(--destructive)' }}>{accErr}</p>
        )}
      </div>

      {/* Actividad reciente — timeline REAL del pipeline del expediente vinculado
          (#27). Sin expediente vinculado, el bloque no se muestra: nunca datos
          inventados. */}
      {email.linkedExpedienteId && <ContextTimeline expId={email.linkedExpedienteId} />}

      {/* #24 — selector de cuenta reusado del ERP (buscar/elegir, sin alta embebida) */}
      <PartySelectorModal
        type={partyType}
        open={!!partyType}
        onClose={() => setPartyType(null)}
        onPick={vincularCuenta}
        allowCrear={false}
      />
    </aside>
  );
}

// ─── ContextPanel helpers ──────────────────────────
function ContextStat({ label, value, small, mono, tone }) {
  const valueColor = tone === 'neg' ? 'var(--danger)' : tone === 'pos' ? 'var(--success)' : 'var(--foreground)';
  return (
    <div className="px-2.5 py-2 rounded-lg" style={{ background: 'var(--surface)' }}>
      <p className="text-[9.5px] font-bold uppercase tracking-[0.08em]" style={{ color: 'var(--muted-foreground)' }}>
        {label}
      </p>
      <p
        className={`${mono ? 'font-mono' : ''} tabular-nums truncate`}
        style={{
          color: valueColor,
          fontSize: small ? 11.5 : 14,
          fontWeight: small ? 600 : 800,
          marginTop: 2,
        }}
      >
        {value}
      </p>
    </div>
  );
}

function ActionButton({ icon: Icon, label, onClick, disabled }) {
  return (
    <button
      type="button"
      onClick={disabled ? undefined : onClick}
      disabled={disabled}
      className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-left transition-colors"
      style={{
        background: 'var(--surface)',
        color: disabled ? 'var(--muted-foreground)' : 'var(--foreground)',
        cursor: disabled ? 'not-allowed' : 'pointer',
        opacity: disabled ? 0.6 : 1,
      }}
      onMouseEnter={(e) => {
        if (disabled) return;
        e.currentTarget.style.background = 'color-mix(in oklab, var(--accent) 6%, var(--surface))';
        e.currentTarget.style.color = 'var(--accent)';
      }}
      onMouseLeave={(e) => {
        if (disabled) return;
        e.currentTarget.style.background = 'var(--surface)';
        e.currentTarget.style.color = 'var(--foreground)';
      }}
    >
      <Icon size={14} style={{ color: 'currentColor', flexShrink: 0 }} />
      <span className="text-[12px] font-semibold flex-1 truncate">{label}</span>
      <IconChevronRight size={12} style={{ opacity: 0.5 }} />
    </button>
  );
}

// ─── Timeline REAL del pipeline del expediente vinculado (#27) ──────────────
// Las fechas salen de /api/expedientes/detail (crmDetalleExpediente, envio.jsx):
// presupuesto/OC/remito/factura(s)/recibo — NADA inventado. Con expediente pero
// sin movimientos, un aviso honesto en vez de eventos falsos.
function ContextTimeline({ expId }) {
  const [exp, setExp] = _useStateCT(undefined); // undefined=cargando · null=no cargó
  _useEffectCT(() => {
    let vivo = true;
    setExp(undefined);
    const p = window.crmDetalleExpediente ? window.crmDetalleExpediente(expId) : Promise.resolve(null);
    p.then((e) => { if (vivo) setExp(e || null); });
    return () => { vivo = false; };
  }, [expId]);

  const fecha = window.formatDate || ((d) => d);
  const codFactura = (f) => [f.tipoFactura, String(f.puntoVenta == null ? '' : f.puntoVenta).padStart(4, '0'), f.numero].filter(Boolean).join('-');
  const eventos = [];
  if (exp) {
    const ini = exp.inicio || exp.fechaInicio;
    if (ini) eventos.push({ dot: 'var(--pipeline-iniciado)', label: 'Expediente iniciado', detail: exp.publicId || expId, iso: ini });
    if (exp.presupuesto && exp.presupuesto.fechaEmision) eventos.push({ dot: 'var(--pipeline-presupuesto)', label: 'Presupuesto emitido', detail: exp.presupuesto.publicId || (exp.presupuesto.numero ? `N° ${exp.presupuesto.numero}` : ''), iso: exp.presupuesto.fechaEmision });
    if (exp.ordenCompra && exp.ordenCompra.fechaEmision) eventos.push({ dot: 'var(--accent)', label: 'Orden de compra', detail: exp.ordenCompra.publicId || (exp.ordenCompra.numero ? `N° ${exp.ordenCompra.numero}` : ''), iso: exp.ordenCompra.fechaEmision });
    if (exp.remito && (exp.remito.fechaCreacion || exp.remito.fechaEmision)) eventos.push({ dot: 'var(--pipeline-remito)', label: 'Remito', detail: exp.remito.publicId || (exp.remito.numero ? `N° ${exp.remito.numero}` : ''), iso: exp.remito.fechaCreacion || exp.remito.fechaEmision });
    (Array.isArray(exp.facturas) ? exp.facturas : []).forEach((f) => {
      if (f && f.fechaEmision) eventos.push({ dot: 'var(--pipeline-factura)', label: `Factura ${f.tipoFactura || ''}`.trim(), detail: codFactura(f), iso: f.fechaEmision });
    });
    if (exp.recibo && exp.recibo.fechaCobro) eventos.push({ dot: 'var(--pipeline-recibo)', label: 'Cobro registrado', detail: exp.recibo.numeroComprobante || exp.recibo.publicId || '', iso: exp.recibo.fechaCobro });
  }
  eventos.sort((a, b) => new Date(b.iso) - new Date(a.iso));

  return (
    <div className="px-5 py-4" style={{ borderTop: '1px solid var(--border)' }}>
      <p className="text-[10.5px] font-bold uppercase tracking-[0.14em] mb-3" style={{ color: 'var(--muted-foreground)' }}>
        Actividad del expediente
      </p>
      {exp === undefined ? (
        <p className="text-[11.5px]" style={{ color: 'var(--muted-foreground)' }}>Cargando…</p>
      ) : eventos.length === 0 ? (
        <p className="text-[11.5px]" style={{ color: 'var(--muted-foreground)' }}>Todavía no hay movimientos en el expediente.</p>
      ) : (
        <div className="space-y-3">
          {eventos.map((ev, i) => (
            <TimelineItem key={i} dot={ev.dot} label={ev.label} detail={ev.detail || '—'} time={fecha(ev.iso)} />
          ))}
        </div>
      )}
    </div>
  );
}

function TimelineItem({ dot, label, detail, time }) {
  return (
    <div className="flex gap-3 items-start">
      <div style={{
        width: 8, height: 8, borderRadius: 999,
        background: dot, flexShrink: 0, marginTop: 6,
      }} />
      <div className="min-w-0 flex-1">
        <p className="text-[12px] font-bold" style={{ color: 'var(--foreground)' }}>{label}</p>
        <p className="text-[11px] truncate" style={{ color: 'var(--muted-foreground)' }}>{detail}</p>
      </div>
      <span className="text-[10.5px] tabular-nums flex-shrink-0" style={{ color: 'var(--muted-foreground)' }}>{time}</span>
    </div>
  );
}

const { useMemo: _useMemoCT } = React;

Object.assign(window, { CrmThread, CrmContextPanel, EmptyThread, crmAbrirExpediente });
