// ─── Micos CRM · Main app ──────────────────────────
// Estado raíz del CRM: hilos (vía MicosStore), etiquetas del equipo
// (/api/crm-etiquetas), borradores propios (/api/crm-borradores), búsqueda
// unificada con chips (persona/etiqueta/fecha), vista "Para responder"
// (?vista=para-responder), Configuración (filtros+firma) y el gate de permiso
// `crm` (deny-by-default, re-aplicado del P12 tras el port del demo).
// Expone: CrmApp (mount only). Consume: MicosPuedeVer/ROUTES (dominio/sesion.js,
// core/bundle-config.js), crmFiltrarEmails/crmOrdenarEmails (crm/data.js).

const { useState: _useStateA, useMemo: _useMemoA, useEffect: _useEffectA, useRef: _useRefA } = React;

function CrmApp() {
  // State
  const [emails, setEmails] = _useStateA(CRM_EMAILS);
  // Identidad del usuario logueado. `CRM_USER` (crm-data.js) es solo la foto
  // congelada al parsear el script (una sola llamada a MicosCurrentUser());
  // acá se re-hidrata como estado real, igual que `authReady`/`user` del ERP
  // (app.jsx), para que la sesión REAL (GET /api/me) gane sobre el fallback
  // ADMIN/Franco cuando no hay `micos-user` en storage.
  const [crmUser, setCrmUser] = _useStateA(() => CRM_USER);
  // Gate de permisos (re-aplicado del P12 — la versión del demo lo había
  // pisado): igual que `authReady` del ERP, no se decide nada mientras GET
  // /api/me no resolvió — evita un flash de "denegado" con el fallback.
  const [permReady, setPermReady] = _useStateA(false);
  _useEffectA(() => {
    let cancelled = false;
    (async () => {
      try {
        const res = await fetch('/api/me', { credentials: 'same-origin' });
        if (res.ok) {
          const me = await res.json();
          const nombre = me.nombre || '';
          const apellido = me.apellido || '';
          const initials = ((nombre[0] || '') + (apellido[0] || '')).toUpperCase();
          const sess = {
            name: (nombre + ' ' + apellido).trim() || me.email,
            email: me.email, initials, rol: me.rol, permisos: me.permisos || {},
          };
          try { localStorage.setItem('micos-user', JSON.stringify(sess)); } catch (_) {}
          if (!cancelled) { setCrmUser({ nombre, apellido, email: me.email, iniciales: initials, rol: me.rol, permisos: me.permisos || {} }); setPermReady(true); }
          return;
        }
      } catch (_) { /* sin backend / sin sesión → cae al storage vía MicosCurrentUser() */ }
      if (!cancelled) {
        if (window.MicosCurrentUser) setCrmUser(window.MicosCurrentUser());
        setPermReady(true);
      }
    })();
    return () => { cancelled = true; };
  }, []);
  // Deny-by-default (P13, fail-closed): si dominio/sesion.js no cargó,
  // MicosPuedeVer undefined ⇒ denegado (no abierto).
  const crmDenied = permReady && (!window.MicosPuedeVer || !window.MicosPuedeVer('crm', crmUser));
  // Polling liviano (45s): la ingesta real corre por atrás (bridge legacy→CRM) —
  // re-hidratamos la colección para que los mails nuevos "lleguen solos" a la
  // bandeja sin F5, y las respuestas entrantes aparezcan en el hilo abierto.
  _useEffectA(() => {
    const t = setInterval(() => {
      if (!window.MicosStore) return;
      window.MicosStore.refresh('crmHilos')
        .then(() => setEmails([...(window.CRM_EMAILS || [])]))
        .catch(() => {});
    }, 45000);
    return () => clearInterval(t);
  }, []);

  const [folder, setFolder] = _useStateA('inbox');
  const [smartView, setSmartView] = _useStateA(null);
  const [labelFilter, setLabelFilter] = _useStateA(null);
  const [search, setSearch] = _useStateA('');
  // Chips clickeables del buscador (sin sintaxis tipeada): persona = email
  // exacto del remitente, etiqueta = id seed o uuid custom, desde/hasta =
  // YYYY-MM-DD. Se combinan con el texto libre (crmFiltrarEmails).
  const [searchChips, setSearchChips] = _useStateA({ persona: null, personaNombre: null, etiqueta: null, desde: null, hasta: null });
  const [sortBy, setSortBy] = _useStateA('fecha');
  const [openEmailId, setOpenEmailId] = _useStateA(null);
  const [selectedIds, setSelectedIds] = _useStateA([]);
  const [composeOpen, setComposeOpen] = _useStateA(false);
  const [composeInit, setComposeInit] = _useStateA({ to: '', asunto: '' });
  const [contextOpen, setContextOpen] = _useStateA(false);
  const [configOpen, setConfigOpen] = _useStateA(false);
  const [refreshing, setRefreshing] = _useStateA(false);

  // ─── Aviso de error humano (reusa el toast de store-error) ────────────
  // Las llamadas directas a /api/crm-etiquetas y /api/crm-borradores no pasan
  // por MicosStore, así que disparan el MISMO evento global que escucha
  // CrmStoreErrorToast — un solo mecanismo de feedback, no dos.
  const avisoError = (action, humano, message) => {
    try {
      window.dispatchEvent(new CustomEvent('micos:store-error', {
        detail: { action, key: 'crm', message: message || humano, humano },
      }));
    } catch (_) {}
  };

  // ─── Etiquetas del equipo (crm_etiquetas — persistentes, contrato B0) ──
  const [etiquetas, setEtiquetas] = _useStateA([]);
  const cargarEtiquetas = () =>
    fetch('/api/crm-etiquetas', { credentials: 'same-origin' })
      .then(r => (r.ok ? r.json() : Promise.reject(new Error('GET /api/crm-etiquetas → ' + r.status))))
      .then(rows => { if (Array.isArray(rows)) setEtiquetas(rows); })
      .catch(e => console.warn('[crm] etiquetas:', e.message));
  _useEffectA(() => { cargarEtiquetas(); }, []);

  const crearEtiqueta = (nombre, color) => {
    fetch('/api/crm-etiquetas', {
      method: 'POST', credentials: 'same-origin',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ nombre, color: color || null }),
    })
      .then(r => {
        if (r.status === 409) throw Object.assign(new Error('409'), { humano: 'Ya existe una etiqueta con ese nombre.' });
        if (!r.ok) throw new Error('POST /api/crm-etiquetas → ' + r.status);
        return r.json();
      })
      .then(row => setEtiquetas(ls => [...ls, row].sort((a, b) => (a.nombre || '').localeCompare(b.nombre || ''))))
      .catch(e => avisoError('create', e.humano || 'No se pudo crear la etiqueta. Reintentá.', e.message));
  };

  const editarEtiqueta = (id, patch) => {
    const prev = etiquetas;
    setEtiquetas(ls => ls.map(l => (l.id === id ? { ...l, ...patch } : l))); // optimista
    fetch('/api/crm-etiquetas/' + encodeURIComponent(id), {
      method: 'PATCH', credentials: 'same-origin',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(patch),
    })
      .then(r => {
        if (r.status === 409) throw Object.assign(new Error('409'), { humano: 'Ya existe una etiqueta con ese nombre.' });
        if (!r.ok) throw new Error('PATCH /api/crm-etiquetas → ' + r.status);
        return r.json();
      })
      .then(row => setEtiquetas(ls => ls.map(l => (l.id === id ? row : l))))
      .catch(e => { setEtiquetas(prev); avisoError('update', e.humano || 'No se pudo guardar la etiqueta. Reintentá.', e.message); });
  };

  const borrarEtiqueta = (id) => {
    const prev = etiquetas;
    setEtiquetas(ls => ls.filter(l => l.id !== id)); // optimista
    if (labelFilter === id) setLabelFilter(null);
    fetch('/api/crm-etiquetas/' + encodeURIComponent(id), { method: 'DELETE', credentials: 'same-origin' })
      .then(r => { if (!r.ok) throw new Error('DELETE /api/crm-etiquetas → ' + r.status); })
      .catch(e => { setEtiquetas(prev); avisoError('remove', 'No se pudo eliminar la etiqueta. Reintentá.', e.message); });
  };

  // ─── Borradores propios (crm_borradores — bandeja real, contrato B0) ───
  const [borradores, setBorradores] = _useStateA([]);
  const [borradoresError, setBorradoresError] = _useStateA(false);
  const cargarBorradores = () =>
    fetch('/api/crm-borradores', { credentials: 'same-origin' })
      .then(r => (r.ok ? r.json() : Promise.reject(new Error('GET /api/crm-borradores → ' + r.status))))
      .then(rows => { setBorradores(Array.isArray(rows) ? rows : []); setBorradoresError(false); })
      .catch(e => { setBorradoresError(true); console.warn('[crm] borradores:', e.message); });
  _useEffectA(() => { cargarBorradores(); }, []);

  // Abrir un borrador retoma el composer con todo lo guardado. El composer
  // hoy consume initialTo/initialAsunto; el resto (cc/cuerpo/alias/draftId)
  // viaja como props nuevas — las consume el carril del composer (B-fr2).
  const abrirBorrador = (b) => {
    setComposeInit({
      to: (b.para || []).join(', '), cc: (b.cc || []).join(', '),
      asunto: b.asunto || '', cuerpo: b.cuerpo || '',
      alias: b.alias || null, draftId: b.id, hiloId: b.hiloId || null,
    });
    setComposeOpen(true);
  };

  const eliminarBorrador = (id) => {
    const prev = borradores;
    setBorradores(bs => bs.filter(b => b.id !== id)); // optimista
    fetch('/api/crm-borradores/' + encodeURIComponent(id), { method: 'DELETE', credentials: 'same-origin' })
      .then(r => { if (!r.ok) throw new Error('DELETE /api/crm-borradores → ' + r.status); })
      .catch(e => { setBorradores(prev); avisoError('remove', 'No se pudo descartar el borrador. Reintentá.', e.message); });
  };

  // ─── Refrescar real (auditoría 05/07 #1: era un no-op literal) ─────────
  const onRefresh = () => {
    if (refreshing) return;
    setRefreshing(true);
    const jobs = [
      window.MicosStore
        ? window.MicosStore.refresh('crmHilos').then(() => setEmails([...(window.CRM_EMAILS || [])]))
        : Promise.resolve(),
      cargarEtiquetas(),
      cargarBorradores(),
    ];
    Promise.allSettled(jobs).then(() => setRefreshing(false));
  };

  // ─── Deep-link: la URL refleja el hilo abierto (/crm?hilo=<id>) ───────
  // Mínimo viable con la History API (sin router propio): abrir un hilo
  // empuja `?hilo=<id>`; cerrar vuelve a `/crm`; atrás/adelante (popstate)
  // navega; cargar la página con `?hilo=<id>` ya en la URL abre ese hilo
  // directo. No toca el rewrite de `/crm` (next.config.ts matchea por
  // pathname, el querystring viaja aparte) ni el gate de login (middleware,
  // también por pathname) — solo lee/escribe el querystring de la página en
  // la que este bundle ya está montado.
  const suppressUrlPushRef = _useRefA(false);
  // Cambio de folder que viene de un deep-link (mount/popstate) — el efecto
  // "cerrar hilo al cambiar de bandeja" lo tiene que dejar pasar, si no un
  // F5 sobre /crm?vista=para-responder&hilo=<id> cerraba el hilo recién abierto.
  const deepLinkFolderRef = _useRefA(false);

  // Al montar: si la URL ya trae `?hilo=<id>` y/o `?vista=…` (deep-link / F5),
  // restaurarlos. Solo se manejan las vistas propias (para-responder /
  // configuracion) — `vista=metricas` es de crm-metrics.jsx (B-hist), no se toca.
  _useEffectA(() => {
    const params = new URLSearchParams(window.location.search);
    const hilo = params.get('hilo');
    const vista = params.get('vista');
    if (hilo) { suppressUrlPushRef.current = true; setOpenEmailId(hilo); }
    if (vista === 'para-responder') { suppressUrlPushRef.current = true; deepLinkFolderRef.current = true; setFolder('para-responder'); }
    else if (vista === 'configuracion') { suppressUrlPushRef.current = true; setConfigOpen(true); }
  }, []);

  // Atrás/adelante del navegador.
  _useEffectA(() => {
    const onPop = () => {
      const params = new URLSearchParams(window.location.search);
      suppressUrlPushRef.current = true;
      setOpenEmailId(params.get('hilo') || null);
      const vista = params.get('vista');
      setConfigOpen(vista === 'configuracion');
      if (vista === 'para-responder') { deepLinkFolderRef.current = true; setFolder('para-responder'); }
    };
    window.addEventListener('popstate', onPop);
    return () => window.removeEventListener('popstate', onPop);
  }, []);

  // Reflejar hilo abierto + vista en la URL — salvo que el cambio haya venido
  // de la carga inicial o de popstate (esos casos ya reflejan la URL real).
  _useEffectA(() => {
    if (suppressUrlPushRef.current) { suppressUrlPushRef.current = false; return; }
    const url = new URL(window.location.href);
    if (openEmailId) url.searchParams.set('hilo', openEmailId);
    else url.searchParams.delete('hilo');
    const vistaActual = url.searchParams.get('vista');
    const vistaNueva = configOpen
      ? 'configuracion'
      : (folder === 'para-responder' && !smartView && !labelFilter) ? 'para-responder' : null;
    if (vistaNueva) url.searchParams.set('vista', vistaNueva);
    else if (vistaActual === 'para-responder' || vistaActual === 'configuracion') url.searchParams.delete('vista');
    const next = url.pathname + url.search + url.hash;
    const current = window.location.pathname + window.location.search + window.location.hash;
    if (next !== current) window.history.pushState({ hilo: openEmailId || null }, '', next);
  }, [openEmailId, folder, smartView, labelFilter, configOpen]);

  // Theme (shared with ERP — same localStorage key so toggling in one
  // app carries over to the other on next load).
  const [theme, setTheme] = _useStateA(() => {
    try { return localStorage.getItem('micos-theme') || 'light'; } catch (_) { return 'light'; }
  });
  _useEffectA(() => {
    document.documentElement.setAttribute('data-theme', theme);
    try { localStorage.setItem('micos-theme', theme); } catch (_) {}
  }, [theme]);
  const onToggleTheme = () => setTheme(t => t === 'light' ? 'dark' : 'light');

  // Global cursor-spotlight delegate — same handler as the ERP shell so
  // .glow-card / .nav-item / .exp-row surfaces light up under the pointer.
  _useEffectA(() => {
    const SELECTOR = '.glow-card, .kpi-card, .kpi-card-tile, .stat-tile, .sidebar-block, .exp-row, .nav-item';
    let raf = 0, lastEv = null;
    const apply = () => {
      raf = 0;
      if (!lastEv) return;
      const el = lastEv.target.closest ? lastEv.target.closest(SELECTOR) : null;
      if (!el) return;
      const r = el.getBoundingClientRect();
      el.style.setProperty('--mx', `${lastEv.clientX - r.left}px`);
      el.style.setProperty('--my', `${lastEv.clientY - r.top}px`);
    };
    const onMove = (e) => { lastEv = e; if (!raf) raf = requestAnimationFrame(apply); };
    document.addEventListener('mousemove', onMove, { passive: true });
    return () => { document.removeEventListener('mousemove', onMove); if (raf) cancelAnimationFrame(raf); };
  }, []);

  // Counts for sidebar (per folder + para-responder + per tag/etiqueta)
  const counts = _useMemoA(() => {
    const c = {};
    CRM_FOLDERS.forEach(f => {
      if (f.key === 'all') {
        c[f.key] = emails.filter(e => e.folder !== 'trash' && e.folder !== 'spam' && e.unread).length;
      } else if (f.key === 'inbox') {
        c[f.key] = emails.filter(e => e.folder === 'inbox' && !crmSnoozeActivo(e) && e.unread).length;
      } else if (f.key === 'starred') {
        c[f.key] = emails.filter(e => e.starred && e.folder !== 'trash').length;
      } else if (f.key === 'drafts') {
        c[f.key] = borradores.length;
      } else if (f.key === 'scheduled') {
        c[f.key] = emails.filter(e => (crmSnoozeActivo(e) || e.folder === 'scheduled') && e.folder !== 'trash').length;
      } else {
        c[f.key] = emails.filter(e => e.folder === f.key).length;
      }
    });
    c['para-responder'] = emails.filter(crmEsperaRespuesta).length;
    CRM_SMART_VIEWS.forEach(v => {
      c[`sv:${v.key}`] = emails.filter(v.test).length;
    });
    Object.keys(CRM_LABELS).forEach(id => {
      c[`tag:${id}`] = emails.filter(e => e.tags.includes(id)).length;
    });
    etiquetas.forEach(et => {
      c[`tag:${et.id}`] = emails.filter(e => (e.etiquetasIds || []).includes(et.id)).length;
    });
    return c;
  }, [emails, etiquetas, borradores]);

  // Update helpers — persisten YA vía el store (write-through: optimista +
  // rollback si el PATCH falla, ver store.js) y después resincronizan el
  // estado local desde el array global (el store lo muta en el lugar, no
  // dispara un re-render por sí solo).
  const updateEmail = (id, patch) => {
    if (id === '__sort') { setSortBy(patch); return; }
    if (id === '__search') { setSearch(patch); return; }
    if (window.MicosStore) window.MicosStore.update('crmHilos', id, patch);
    setEmails([...(window.CRM_EMAILS || [])]);
  };

  // Reemplaza UN hilo completo en el array global (después de una respuesta
  // que YA persistió del lado del server — POST /api/crm-hilos ó
  // /api/crm-mensajes, que devuelven el hilo entero en el shape del GET) y
  // resincroniza. A diferencia de updateEmail, no vuelve a pegarle a
  // MicosStore.update (no hace falta: el POST ya escribió en la DB).
  const replaceEmail = (hilo) => {
    if (!hilo || !hilo.id) return;
    const arr = window.CRM_EMAILS || [];
    const idx = arr.findIndex(e => e.id === hilo.id);
    if (idx >= 0) arr[idx] = hilo; else arr.unshift(hilo);
    setEmails([...arr]);
  };

  const openEmail = (emails || []).find(e => e.id === openEmailId);

  // Lista ordenada visible para navegar anterior/siguiente y mostrar "N de M"
  // en el lector — EL MISMO filtro y orden que usa la bandeja (crmFiltrarEmails/
  // crmOrdenarEmails de crm/data.js), así el contador siempre coincide con la
  // lista (auditoría 05/07 #5: antes acá se filtraba solo asunto/extracto).
  const navList = _useMemoA(() => {
    const filtrados = crmFiltrarEmails(emails, {
      folder, smartView, labelFilter, search,
      persona: searchChips.persona, etiqueta: searchChips.etiqueta,
      desde: searchChips.desde, hasta: searchChips.hasta, etiquetas,
    });
    return crmOrdenarEmails(filtrados, sortBy, (labelFilter || smartView) ? null : folder);
  }, [emails, folder, smartView, labelFilter, search, searchChips, etiquetas, sortBy]);

  const navInfo = _useMemoA(() => {
    if (!openEmailId) return null;
    const idx = navList.findIndex(e => e.id === openEmailId);
    if (idx === -1) return { index: 1, total: navList.length, onPrev: () => {}, onNext: () => {} };
    return {
      index: idx + 1,
      total: navList.length,
      onPrev: () => { if (idx > 0) setOpenEmailId(navList[idx - 1].id); },
      onNext: () => { if (idx < navList.length - 1) setOpenEmailId(navList[idx + 1].id); },
    };
  }, [openEmailId, navList]);

  // Label config lookup (seed + etiquetas del equipo) para banner/título.
  // Las custom se normalizan al shape del banner: {label, colorHex}.
  const activeLabelCfg = labelFilter
    ? (CRM_LABELS[labelFilter] ||
       (() => { const et = etiquetas.find(l => l.id === labelFilter); return et ? { label: et.nombre, colorHex: et.color } : null; })())
    : null;

  const contextTitle = configOpen
    ? 'Configuración'
    : labelFilter
      ? (activeLabelCfg?.label || 'Etiqueta')
      : smartView
        ? CRM_SMART_VIEWS.find(v => v.key === smartView)?.label
        : folder === 'para-responder'
          ? 'Para responder'
          : CRM_FOLDERS.find(f => f.key === folder)?.label;
  const contextSubtitle = configOpen ? 'CRM' : labelFilter ? 'Etiqueta' : smartView ? 'Vista inteligente' : folder === 'para-responder' ? 'Vista' : 'Bandeja';

  // Close email on folder change — se saltea la PRIMERA corrida (mount):
  // este efecto también dispara en el commit inicial (dependencias siempre
  // corren al menos una vez), y si la URL trae `?hilo=<id>` (deep-link) el
  // setOpenEmailId(null) de acá pisaba, en el mismo batch, el setOpenEmailId(hilo)
  // del efecto de deep-link de más arriba — cerraba el hilo que se acababa
  // de abrir. En cambios REALES de folder/vista sigue cerrando como siempre.
  const skipFirstFolderCloseRef = _useRefA(true);
  _useEffectA(() => {
    if (skipFirstFolderCloseRef.current) { skipFirstFolderCloseRef.current = false; return; }
    // Cambio de folder que vino de un deep-link (mount/popstate): la URL puede
    // traer vista + hilo juntos — no cerrar el hilo que se acaba de abrir.
    if (deepLinkFolderRef.current) { deepLinkFolderRef.current = false; setSelectedIds([]); return; }
    setOpenEmailId(null); setSelectedIds([]);
  }, [folder, smartView, labelFilter]);

  // Gate de permiso `crm` (P12 re-aplicado): nada se pinta hasta conocer la
  // sesión; sin permiso → redirección completa a /auth/no-autorizado.
  if (!permReady) return null;
  if (crmDenied) return <CrmAccessDenied />;

  const cerrarVistas = () => { setConfigOpen(false); setOpenEmailId(null); setSelectedIds([]); };

  return (
    <div className="flex min-h-screen" style={{ background: 'var(--background)', color: 'var(--foreground)' }}>
      {/* Ambient lights (same as ERP) */}
      <div className="ambient-lights">
        <div className="ambient-light al-1" />
        <div className="ambient-light al-2" />
        <div className="ambient-light al-3" />
      </div>

      {/* Sidebar */}
      <CrmSidebar
        folder={configOpen ? null : folder}
        smartView={smartView}
        labelFilter={labelFilter}
        etiquetas={etiquetas}
        onChangeFolder={(k) => { cerrarVistas(); setFolder(k); setSmartView(null); setLabelFilter(null); }}
        onChangeSmartView={(k) => { cerrarVistas(); setSmartView(k === smartView ? null : k); setLabelFilter(null); }}
        onChangeLabel={(id) => { cerrarVistas(); setLabelFilter(id === labelFilter ? null : id); setSmartView(null); }}
        onAddLabel={crearEtiqueta}
        onEditLabel={editarEtiqueta}
        onDeleteLabel={borrarEtiqueta}
        onCompose={() => { setComposeInit({ to: '', asunto: '' }); setComposeOpen(true); }}
        counts={counts}
        rol={crmUser && crmUser.rol}
      />

      {/* Main column */}
      <div className="flex-1 flex flex-col min-w-0 relative" style={{ zIndex: 1 }}>
        <CrmTopbar
          search={search}
          onSearch={setSearch}
          onRefresh={onRefresh}
          refreshing={refreshing}
          onOpenConfig={() => { setConfigOpen(true); setOpenEmailId(null); setSelectedIds([]); }}
          configActive={configOpen}
          contextTitle={contextTitle}
          contextSubtitle={contextSubtitle}
          theme={theme}
          onToggleTheme={onToggleTheme}
          user={crmUser}
        />

        <div className="flex-1 flex min-h-0">
          {/* Configuración (engranaje): filtros + firma. Reemplaza la bandeja. */}
          {configOpen && (
            <CrmConfigScreen etiquetas={etiquetas} onClose={() => setConfigOpen(false)} />
          )}

          {/* Email list — hidden while a message is open (full reading view) */}
          {!configOpen && !openEmail && (
            <CrmInbox
              folder={folder}
              smartView={smartView}
              labelFilter={labelFilter}
              activeLabel={activeLabelCfg}
              search={search}
              searchChips={searchChips}
              onSetChips={setSearchChips}
              sortBy={sortBy}
              emails={emails}
              etiquetas={etiquetas}
              borradores={borradores}
              borradoresError={borradoresError}
              onOpenDraft={abrirBorrador}
              onDeleteDraft={eliminarBorrador}
              onUpdateEmail={updateEmail}
              selectedIds={selectedIds}
              setSelectedIds={setSelectedIds}
              openEmailId={openEmailId}
              onOpenEmail={setOpenEmailId}
              compact={false}
              contextTitle={contextTitle}
              onCompose={() => { setComposeInit({ to: '', asunto: '' }); setComposeOpen(true); }}
            />
          )}

          {/* Thread + context (when open) */}
          {!configOpen && openEmail && (
            <>
              {/* No key here on purpose: keeping the same CrmThread instance across
                  email changes preserves the navDir state so the body can slide in
                  horizontally (see bodyAnim in CrmThread). A key would remount the
                  whole component and replay the from-below fade instead. */}
              <CrmThread
                email={openEmail}
                onClose={() => setOpenEmailId(null)}
                onUpdate={(patch) => updateEmail(openEmail.id, patch)}
                onReplace={replaceEmail}
                contextOpen={contextOpen}
                onToggleContext={() => setContextOpen(o => !o)}
                nav={navInfo}
                user={crmUser}
                theme={theme}
              />
              {contextOpen && <CrmContextPanel key={openEmail.id} email={openEmail} onClose={() => setContextOpen(false)} />}
            </>
          )}
        </div>
      </div>

      {/* Composer — la creación real (MicosStore.create) vive adentro de
          CrmComposer; acá solo resincronizamos el estado local con el array
          global tras el alta optimista (mismo patrón que updateEmail). Si el
          envío salió de un borrador retomado (draftId), el borrador se
          descarta — ya viajó como mail. initialCc/initialCuerpo/initialAlias/
          draftId son props NUEVAS del contrato de borradores (las consume el
          carril del composer, B-fr2). */}
      <CrmComposer
        open={composeOpen}
        onClose={() => setComposeOpen(false)}
        onSend={() => {
          setEmails([...(window.CRM_EMAILS || [])]);
          if (composeInit.draftId) eliminarBorrador(composeInit.draftId);
        }}
        initialTo={composeInit.to}
        initialAsunto={composeInit.asunto}
        initialCc={composeInit.cc}
        initialCuerpo={composeInit.cuerpo}
        initialAlias={composeInit.alias}
        draftId={composeInit.draftId}
        user={crmUser}
      />

      {/* Visor de adjuntos (estilo documento ERP) */}
      <DocViewer />

      {/* Escritura rechazada por el server → aviso SIEMPRE visible (mismo
          contrato micos:store-error que el ERP; sin esto un guardado fallido
          se revierte en silencio y la UI miente — CLAUDE.md §10). */}
      <CrmStoreErrorToast />
    </div>
  );
}

// Pantalla de acceso denegado (llave `crm`, deny-by-default — P12 re-aplicado).
// El CRM no carga core/router.jsx, así que no reusa RouteDenied — mismo
// criterio, mismo destino: redirección completa (sale del SPA) a
// /auth/no-autorizado, ejecutada como efecto, nunca en el render.
function CrmAccessDenied() {
  _useEffectA(() => {
    window.location.href = (window.ROUTES && window.ROUTES.NO_AUTORIZADO) || '/auth/no-autorizado';
  }, []);
  return null;
}

// Gemelo mínimo del StoreErrorToast del ERP (app.jsx) — el CRM no carga app.jsx,
// así que define el suyo. Escucha el evento global que emite store.js al hacer
// rollback de un create/update/remove rechazado.
function CrmStoreErrorToast() {
  const [err, setErr] = _useStateA(null);
  _useEffectA(() => {
    let hideTimer = null;
    const onErr = (e) => {
      setErr((e && e.detail) || null);
      clearTimeout(hideTimer);
      hideTimer = setTimeout(() => setErr(null), 5200);
    };
    window.addEventListener('micos:store-error', onErr);
    return () => { window.removeEventListener('micos:store-error', onErr); clearTimeout(hideTimer); };
  }, []);
  if (!err) return null;
  const ACTION_LABEL = { create: 'crear', update: 'guardar', remove: 'eliminar', setSingleton: 'guardar' };
  const label = ACTION_LABEL[err.action] || 'guardar';
  // `humano`: texto puntual en cristiano (ej. "Ya existe una etiqueta con ese
  // nombre.") que mandan las llamadas directas del CRM — gana sobre el genérico.
  const texto = err.humano || `No se pudo ${label} — el cambio NO se guardó (se revirtió). Reintentá.`;
  return (
    <div className="fixed bottom-6 right-6 z-[70] flex items-center gap-3 rounded-xl px-4 py-3 animate-fade-in-up"
      style={{ background: 'var(--card)', border: '1px solid color-mix(in oklab, var(--destructive) 45%, transparent)', boxShadow: '0 18px 50px -14px rgba(8, 20, 24, 0.5)', maxWidth: 'min(92vw, 420px)' }}>
      <span className="inline-flex items-center justify-center rounded-lg flex-shrink-0"
        style={{ width: 30, height: 30, background: 'color-mix(in oklab, var(--destructive) 16%, transparent)', color: 'var(--destructive)' }}>
        <span className="text-[15px] font-black leading-none" aria-hidden="true">!</span>
      </span>
      <p className="text-[12.5px] font-bold leading-snug" style={{ color: 'var(--foreground)' }}>
        {texto}
      </p>
    </div>
  );
}

// ─── Mount ─────────────────────────────────────────
// Gate de hidratación: igual que app.jsx (ERP) — poblamos CRM_EMAILS con el
// GET real (MicosStore.hydrate) ANTES del primer render, así CrmApp arranca
// con `emails` ya leyendo datos de la DB en vez de la semilla ([]). Si la
// hidratación falla, montamos igual (bandeja vacía) para no dejar la
// pantalla en blanco.
function _mountCrmApp() {
  const _crmRoot = ReactDOM.createRoot(document.getElementById('root'));
  _crmRoot.render(<CrmApp />);
}
if (window.MicosStore && typeof window.MicosStore.hydrate === 'function') {
  window.MicosStore.hydrate()
    .then(_mountCrmApp)
    .catch(function (e) { console.error('[crm] hydrate falló, monto con la semilla:', e); _mountCrmApp(); });
} else {
  _mountCrmApp();
}
