import {
  ArrowLeft,
  ArrowUpRight,
  ChevronLeft,
  ChevronRight,
  Home,
  ImageIcon,
  Link2,
  LockKeyhole,
  LogOut,
  Plus,
  Save,
  Search,
  SlidersHorizontal,
  Sparkles,
  Star,
  Trash2,
  Upload,
  X,
} from "lucide-react";
import {
  type ChangeEvent,
  type DragEvent,
  type FormEvent,
  useCallback,
  useDeferredValue,
  useEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import { CATEGORY_ORDER } from "./data";
import { getSafeUrl, loadHubData, normalizeSearch } from "./storage";
import {
  deleteHubApp,
  fetchHubApps,
  fetchHubData,
  migrateLocalAppsIfNeeded,
  recordRecent,
  saveHubApp,
  setFavorite,
  subscribeToHubApps,
} from "./repository";
import { supabase } from "./supabase";
import type {
  AppCategory,
  AppVisibility,
  HubUser,
  HubApp,
  HubData,
  Session,
  ToastMessage,
} from "./types";

type AppView = "hub" | "admin";

interface AppDraft {
  name: string;
  description: string;
  category: AppCategory;
  url: string;
  iconDataUrl: string;
  visibility: AppVisibility;
}

const COLLATOR = new Intl.Collator("es-DO", { sensitivity: "base" });
const AUTHORIZED_DOMAIN = "@gomezleemarketing.com";
const ICON_GENERATOR_WEBHOOK_URL = import.meta.env.VITE_ICON_GENERATOR_WEBHOOK_URL?.trim() ?? "";
const ACCESS_REQUEST_WEBHOOK_URL = import.meta.env.VITE_ACCESS_REQUEST_WEBHOOK_URL?.trim() ?? "";

interface SupabaseAuthSession {
  access_token: string;
  expires_at?: number;
  user: {
    id: string;
    email?: string;
    user_metadata?: Record<string, unknown>;
  };
}

interface AuthorizedUserRow {
  email: string;
  full_name: string | null;
  role: "admin" | "member";
  is_active: boolean;
}

interface AuthorizationResult {
  session: Session | null;
  error: string;
}

function getAppUrl(hash = ""): string {
  return `${import.meta.env.BASE_URL}${hash}`;
}

function getOAuthRedirectUrl(): string {
  return new URL(import.meta.env.BASE_URL, window.location.origin).toString();
}

function getAuthenticatedName(
  authSession: SupabaseAuthSession,
  access: AuthorizedUserRow | null,
): string {
  const metadata = authSession.user.user_metadata ?? {};
  const metadataName = metadata.full_name ?? metadata.name;
  const email = authSession.user.email?.trim().toLocaleLowerCase("en-US") ?? "";
  const emailName = email.split("@")[0] || "Usuario GLM";

  return (
    access?.full_name?.trim() ||
    (typeof metadataName === "string" ? metadataName.trim() : "") ||
    emailName
  );
}

function getAuthenticatedAvatarUrl(authSession: SupabaseAuthSession): string {
  const metadata = authSession.user.user_metadata ?? {};
  const candidate = metadata.avatar_url ?? metadata.picture;
  if (typeof candidate !== "string" || !candidate.trim()) return "";

  try {
    const url = new URL(candidate.trim());
    return url.protocol === "https:" ? url.toString() : "";
  } catch {
    return "";
  }
}

async function authorizeSupabaseSession(authSession: SupabaseAuthSession): Promise<AuthorizationResult> {
  const email = authSession.user.email?.trim().toLocaleLowerCase("en-US") ?? "";

  if (!email.endsWith(AUTHORIZED_DOMAIN)) {
    return {
      session: null,
      error: "Debes continuar con una cuenta corporativa @gomezleemarketing.com.",
    };
  }

  // Todos los correos corporativos entran como Usuario por defecto.
  // La tabla solo conserva administradores y bloqueos excepcionales.
  // Se consulta como una lista limitada a una fila en lugar de usar
  // `maybeSingle()`. En algunas instalaciones self-hosted de PostgREST, una
  // consulta sin coincidencias puede responder como error al solicitar un
  // objeto único. Una lista vacía, en cambio, representa correctamente a un
  // usuario corporativo normal que no necesita registro en la tabla.
  const { data, error } = await supabase
    .from("glm_hub_authorized_users")
    .select("email, full_name, role, is_active")
    .eq("email", email)
    .limit(1);
  const accessRows = (data ?? []) as AuthorizedUserRow[];
  const access = accessRows[0] ?? null;

  if (error) {
    console.error("No se pudo validar el perfil de acceso:", error);
    return {
      session: null,
      error: "No pudimos completar el inicio de sesión. Intenta nuevamente.",
    };
  }

  if (access && !access.is_active) {
    return {
      session: null,
      error: "Tu acceso al GLM Hub está deshabilitado. Contacta a IT Support.",
    };
  }

  return {
    session: {
      user: {
        id: authSession.user.id,
        name: getAuthenticatedName(authSession, access),
        email,
        role: access?.role === "admin" ? "admin" : "member",
        avatarUrl: getAuthenticatedAvatarUrl(authSession),
      },
      expiresAt: (authSession.expires_at ?? Math.floor(Date.now() / 1000) + 3600) * 1000,
    },
    error: "",
  };
}

function getInitials(name: string): string {
  const words = name.trim().split(/\s+/).filter(Boolean);
  if (words.length === 0) return "GL";
  if (words.length === 1) return words[0].slice(0, 2).toLocaleUpperCase("es-DO");
  const surnameIndex = words.length >= 4 ? words.length - 2 : words.length - 1;
  return `${words[0][0]}${words[surnameIndex][0]}`.toLocaleUpperCase("es-DO");
}

function getGreeting(): string {
  const hour = new Date().getHours();
  if (hour < 12) return "Buenos días";
  if (hour < 19) return "Buenas tardes";
  return "Buenas noches";
}

function capitalize(value: string): string {
  return value ? value[0].toLocaleUpperCase("es-DO") + value.slice(1) : value;
}

function formatDateLabel(date: Date): string {
  const parts = new Intl.DateTimeFormat("es-DO", {
    weekday: "long",
    day: "numeric",
    month: "long",
    year: "numeric",
  }).formatToParts(date);

  const getPart = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? "";
  return `${capitalize(getPart("weekday"))} ${getPart("day")} de ${capitalize(getPart("month"))} de ${getPart("year")}`;
}

function useCurrentDateLabel(): string {
  const [label, setLabel] = useState(() => formatDateLabel(new Date()));

  useEffect(() => {
    const refresh = () => setLabel(formatDateLabel(new Date()));
    refresh();
    const intervalId = window.setInterval(refresh, 60_000);
    return () => window.clearInterval(intervalId);
  }, []);

  return label;
}

function makeEmptyDraft(): AppDraft {
  return {
    name: "",
    description: "",
    category: "Administración",
    url: "",
    iconDataUrl: "",
    visibility: "published",
  };
}

function appToDraft(app: HubApp): AppDraft {
  return {
    name: app.name,
    description: app.description,
    category: app.category,
    url: app.url,
    iconDataUrl: app.iconDataUrl,
    visibility: app.visibility,
  };
}

async function processIcon(file: File, enforceFileSize = true): Promise<string> {
  const allowedTypes = new Set(["image/png", "image/jpeg", "image/webp"]);
  if (!allowedTypes.has(file.type)) {
    throw new Error("Usa un ícono en formato PNG, JPG o WebP.");
  }
  if (enforceFileSize && file.size > 1024 * 1024) {
    throw new Error("El ícono debe pesar menos de 1 MB.");
  }

  const bitmap = await createImageBitmap(file);
  if (bitmap.width > 4096 || bitmap.height > 4096) {
    bitmap.close();
    throw new Error("El ícono no puede superar 4096 × 4096 px.");
  }

  const canvas = document.createElement("canvas");
  canvas.width = 192;
  canvas.height = 192;
  const context = canvas.getContext("2d");
  if (!context) {
    bitmap.close();
    throw new Error("Este navegador no pudo procesar el ícono.");
  }

  const scale = Math.min(192 / bitmap.width, 192 / bitmap.height);
  const targetWidth = bitmap.width * scale;
  const targetHeight = bitmap.height * scale;
  const targetX = (192 - targetWidth) / 2;
  const targetY = (192 - targetHeight) / 2;
  context.clearRect(0, 0, 192, 192);
  context.drawImage(bitmap, targetX, targetY, targetWidth, targetHeight);
  bitmap.close();
  return canvas.toDataURL("image/webp", 0.9);
}

async function processGeneratedIcon(dataUrl: string): Promise<string> {
  if (!dataUrl.startsWith("data:image/")) throw new Error("La IA no devolvió una imagen válida.");
  const response = await fetch(dataUrl);
  const blob = await response.blob();
  return processGeneratedIconBlob(blob);
}

async function processGeneratedIconBlob(blob: Blob): Promise<string> {
  const mimeType = blob.type || "image/png";
  if (!mimeType.startsWith("image/")) throw new Error("El flujo de IA no devolvió un archivo de imagen.");
  const extension = mimeType === "image/jpeg" ? "jpg" : mimeType.split("/")[1] || "png";
  const file = new File([blob], `icono-generado.${extension}`, { type: mimeType });
  return processIcon(file, false);
}

function BrandLogo({ compact = false }: { compact?: boolean }) {
  return (
    <img
      className={compact ? "brand-logo brand-logo--compact" : "brand-logo"}
      src={`${import.meta.env.BASE_URL}glm-logo.png`}
      alt="GomezLee Marketing"
      width="350"
      height="109"
    />
  );
}

function AppMark({
  app,
  size = "regular",
  showPlaceholder = false,
}: {
  app: HubApp;
  size?: "small" | "regular" | "large";
  showPlaceholder?: boolean;
}) {
  const [imageFailed, setImageFailed] = useState(false);
  const showImage = Boolean(app.iconDataUrl) && !imageFailed;

  return (
    <span className={`app-mark app-mark--${size}`} aria-hidden="true">
      {showImage ? (
        <img src={app.iconDataUrl} alt="" onError={() => setImageFailed(true)} />
      ) : showPlaceholder ? (
        <ImageIcon className="app-mark__placeholder" size={36} strokeWidth={1.5} />
      ) : null}
    </span>
  );
}

function UserAvatar({ user, label }: { user: HubUser; label?: string }) {
  const [imageFailed, setImageFailed] = useState(false);
  const showImage = Boolean(user.avatarUrl) && !imageFailed;

  return (
    <span className="avatar" aria-label={label} aria-hidden={label ? undefined : true}>
      {showImage ? (
        <img
          src={user.avatarUrl}
          alt=""
          referrerPolicy="no-referrer"
          onError={() => setImageFailed(true)}
        />
      ) : (
        getInitials(user.name)
      )}
    </span>
  );
}

interface ToastStackProps {
  messages: ToastMessage[];
  onDismiss: (id: string) => void;
}

function ToastStack({ messages, onDismiss }: ToastStackProps) {
  return (
    <div className="toast-stack" aria-live="polite" aria-relevant="additions">
      {messages.map((message) => (
        <div className={`toast toast--${message.kind}`} key={message.id}>
          <span className="toast__signal" aria-hidden="true" />
          <div>
            <strong>{message.title}</strong>
            {message.detail ? <p>{message.detail}</p> : null}
          </div>
          <button
            type="button"
            className="icon-button icon-button--quiet"
            aria-label="Cerrar mensaje"
            onClick={() => onDismiss(message.id)}
          >
            <X size={18} aria-hidden="true" />
          </button>
        </div>
      ))}
    </div>
  );
}

interface LoginViewProps {
  onLogin: () => Promise<void>;
  error: string;
  disabled?: boolean;
}

function LoginView({ onLogin, error, disabled = false }: LoginViewProps) {
  const [submitting, setSubmitting] = useState(false);

  const handleLogin = async () => {
    setSubmitting(true);
    await onLogin();
    setSubmitting(false);
  };

  return (
    <main className="login-shell">
      <section className="login-story" aria-labelledby="login-headline">
        <div className="login-story__topline">
          <BrandLogo />
        </div>

        <div className="login-story__content">
          <p className="eyebrow">ACCESO INTERNO</p>
          <h1 id="login-headline">
            Todo el trabajo
            <span>de GLM, a un clic.</span>
          </h1>
          <p className="login-story__lede">
            Tu punto de partida para abrir herramientas, encontrar recursos y mantener el día en movimiento.
          </p>
        </div>

        <div className="login-story__type" aria-hidden="true">GLM</div>
      </section>

      <section className="login-panel" aria-label="Inicio de sesión">
        <div className="login-panel__inner">
          <div className="login-panel__heading">
            <p className="section-kicker">GLM HUB</p>
            <h2>Entra a tu espacio.</h2>
          </div>

          <div className="login-form">
            {error ? (
              <p className="form-error" id="login-error" role="alert">
                {error}
              </p>
            ) : null}

            <button
              className="primary-button primary-button--full"
              type="button"
              disabled={disabled || submitting}
              onClick={() => void handleLogin()}
            >
              <LockKeyhole size={18} aria-hidden="true" />
              {submitting ? "Conectando…" : "Continuar con Google"}
              {!submitting ? <ArrowUpRight size={18} aria-hidden="true" /> : null}
            </button>
          </div>
        </div>
      </section>
    </main>
  );
}

interface SidebarProps {
  user: HubUser;
  view: AppView;
  onNavigate: (view: AppView) => void;
  onLogout: () => void;
}

function Sidebar({ user, view, onNavigate, onLogout }: SidebarProps) {
  return (
    <aside className="sidebar">
      <div className="sidebar__brand">
        <BrandLogo compact />
      </div>

      <nav className="sidebar__nav" aria-label="Navegación principal">
        <button
          type="button"
          className={view === "hub" ? "is-active" : ""}
          onClick={() => onNavigate("hub")}
          aria-current={view === "hub" ? "page" : undefined}
        >
          <Home size={20} aria-hidden="true" />
          <span>Aplicaciones</span>
        </button>
        {user.role === "admin" ? (
          <button
            type="button"
            className={view === "admin" ? "is-active" : ""}
            onClick={() => onNavigate("admin")}
            aria-current={view === "admin" ? "page" : undefined}
          >
            <SlidersHorizontal size={20} aria-hidden="true" />
            <span>Administrar</span>
          </button>
        ) : null}
      </nav>

      <div className="sidebar__account">
        <UserAvatar user={user} />
        <span className="sidebar__account-copy">
          <strong>{user.name}</strong>
          <small>{user.role === "admin" ? "Administrador" : "Usuario"}</small>
        </span>
        <button type="button" className="icon-button" aria-label="Cerrar sesión" onClick={onLogout}>
          <LogOut size={18} aria-hidden="true" />
        </button>
      </div>
    </aside>
  );
}

interface MobileHeaderProps {
  user: HubUser;
}

function MobileHeader({ user }: MobileHeaderProps) {
  return (
    <header className="mobile-header">
      <BrandLogo compact />
      <UserAvatar user={user} label={`Sesión de ${user.name}`} />
    </header>
  );
}

interface MobileNavProps extends SidebarProps {}

function MobileNav({ user, view, onNavigate, onLogout }: MobileNavProps) {
  return (
    <nav className="mobile-nav" aria-label="Navegación móvil">
      <button type="button" className={view === "hub" ? "is-active" : ""} onClick={() => onNavigate("hub")}>
        <Home size={20} aria-hidden="true" />
        <span>Inicio</span>
      </button>
      {user.role === "admin" ? (
        <button type="button" className={view === "admin" ? "is-active" : ""} onClick={() => onNavigate("admin")}>
          <SlidersHorizontal size={20} aria-hidden="true" />
          <span>Administrar</span>
        </button>
      ) : null}
      <button type="button" onClick={onLogout}>
        <LogOut size={20} aria-hidden="true" />
        <span>Salir</span>
      </button>
    </nav>
  );
}

interface QuickLaunchProps {
  app: HubApp;
  isFavorite: boolean;
  onActivate: (app: HubApp) => void;
  onFavorite: (appId: string) => void;
}

function QuickLaunch({ app, isFavorite, onActivate, onFavorite }: QuickLaunchProps) {
  const available = Boolean(getSafeUrl(app.url));
  return (
    <article className="quick-launch">
      <div className="quick-launch__top">
        <AppMark app={app} size="regular" />
        <button
          type="button"
          className={`favorite-button ${isFavorite ? "is-favorite" : ""}`}
          onClick={() => onFavorite(app.id)}
          aria-label={isFavorite ? `Quitar ${app.name} de favoritos` : `Agregar ${app.name} a favoritos`}
          aria-pressed={isFavorite}
        >
          <Star size={18} fill={isFavorite ? "currentColor" : "none"} aria-hidden="true" />
        </button>
      </div>
      <div className="quick-launch__copy">
        <span className="quick-launch__category">{app.category}</span>
        <h3>{app.name}</h3>
        <p>{app.description}</p>
      </div>
      <button type="button" className="quick-launch__action" onClick={() => onActivate(app)}>
        <span>{available ? "Abrir aplicación" : "Enlace pendiente"}</span>
        {available ? <ArrowUpRight size={18} aria-hidden="true" /> : <Link2 size={17} aria-hidden="true" />}
      </button>
    </article>
  );
}

interface DirectoryRowProps {
  app: HubApp;
  index: number;
  isFavorite: boolean;
  onActivate: (app: HubApp) => void;
  onFavorite: (appId: string) => void;
}

function DirectoryRow({ app, index, isFavorite, onActivate, onFavorite }: DirectoryRowProps) {
  const available = Boolean(getSafeUrl(app.url));
  return (
    <article className="directory-row">
      <span className="directory-row__number" aria-hidden="true">{String(index + 1).padStart(2, "0")}</span>
      <AppMark app={app} size="small" />
      <div className="directory-row__copy">
        <h3>{app.name}</h3>
        <p>{app.description}</p>
      </div>
      <span className="directory-row__category">{app.category}</span>
      <span className={`availability ${available ? "availability--ready" : "availability--pending"}`}>
        <span aria-hidden="true" />
        {available ? "Disponible" : "Enlace pendiente"}
      </span>
      <button
        type="button"
        className={`favorite-button ${isFavorite ? "is-favorite" : ""}`}
        onClick={() => onFavorite(app.id)}
        aria-label={isFavorite ? `Quitar ${app.name} de favoritos` : `Agregar ${app.name} a favoritos`}
        aria-pressed={isFavorite}
      >
        <Star size={18} fill={isFavorite ? "currentColor" : "none"} aria-hidden="true" />
      </button>
      <button type="button" className="directory-row__open" onClick={() => onActivate(app)}>
        <span>{available ? "Abrir" : "Ver estado"}</span>
        {available ? <ArrowUpRight size={17} aria-hidden="true" /> : <ChevronRight size={17} aria-hidden="true" />}
      </button>
    </article>
  );
}

interface EmptyResultsProps {
  search: string;
  favoritesOnly: boolean;
  onClear: () => void;
}

function EmptyResults({ search, favoritesOnly, onClear }: EmptyResultsProps) {
  return (
    <div className="empty-state">
      <span className="empty-state__mark" aria-hidden="true">0</span>
      <div>
        <p className="section-kicker">SIN RESULTADOS</p>
        <h3>{favoritesOnly ? "Aún no tienes favoritos aquí." : `No encontramos “${search || "esa aplicación"}”.`}</h3>
        <p>Prueba otro término, cambia la categoría o vuelve a ver todo el directorio.</p>
        <button type="button" className="text-button" onClick={onClear}>Limpiar filtros</button>
      </div>
    </div>
  );
}

type AccessRequestArea = "Todas" | "Administración" | "Recursos Humanos" | "CDC";

interface AccessRequestSectionProps {
  apps: HubApp[];
  onSubmit: (app: HubApp) => Promise<boolean>;
}

function AccessRequestSection({ apps, onSubmit }: AccessRequestSectionProps) {
  const dialogRef = useRef<HTMLDialogElement>(null);
  const [area, setArea] = useState<AccessRequestArea>("Todas");
  const [appId, setAppId] = useState("");
  const [submitting, setSubmitting] = useState(false);

  const publishedApps = useMemo(
    () => [...apps]
      .filter((app) => app.visibility === "published")
      .sort((a, b) => COLLATOR.compare(a.name, b.name)),
    [apps],
  );

  const filteredApps = useMemo(
    () => area === "Todas" ? publishedApps : publishedApps.filter((app) => app.category === area),
    [area, publishedApps],
  );

  useEffect(() => {
    if (appId && !filteredApps.some((app) => app.id === appId)) setAppId("");
  }, [appId, filteredApps]);

  useEffect(() => {
    if (!submitting) return undefined;
    const warnBeforeLeave = (event: BeforeUnloadEvent) => {
      event.preventDefault();
      event.returnValue = "";
    };
    window.addEventListener("beforeunload", warnBeforeLeave);
    return () => window.removeEventListener("beforeunload", warnBeforeLeave);
  }, [submitting]);

  const openDialog = () => {
    setArea("Todas");
    setAppId("");
    dialogRef.current?.showModal();
  };

  const closeDialog = () => {
    if (submitting) return;
    dialogRef.current?.close();
  };

  const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const selectedApp = publishedApps.find((app) => app.id === appId);
    if (!selectedApp || submitting) return;

    setSubmitting(true);
    try {
      const sent = await onSubmit(selectedApp);
      if (sent) {
        dialogRef.current?.close();
        setArea("Todas");
        setAppId("");
      }
    } finally {
      setSubmitting(false);
    }
  };

  return (
    <section className="access-request-bridge" aria-label="Solicitud de acceso a aplicaciones">
      <div className="access-request-bridge__number" aria-hidden="true">?</div>
      <div>
        <p className="section-kicker">SOLICITUD DE ACCESO</p>
        <h2>¿No tienes acceso a una aplicación?</h2>
        <p>Selecciona el portal que necesitas y envía la solicitud al equipo responsable.</p>
      </div>
      <button type="button" className="secondary-button" onClick={openDialog}>
        Solicitar acceso aquí
        <ArrowUpRight size={18} aria-hidden="true" />
      </button>

      <dialog
        className="access-request-dialog"
        ref={dialogRef}
        onCancel={(event) => {
          if (submitting) event.preventDefault();
        }}
      >
        <button type="button" className="access-request-dialog__close" onClick={closeDialog} aria-label="Cerrar">
          <X size={19} aria-hidden="true" />
        </button>
        <p className="section-kicker">GLM HUB</p>
        <h2>Solicitar acceso</h2>
        <p className="access-request-dialog__intro">
          Elige el área y la aplicación. El catálogo se actualiza automáticamente con los portales publicados.
        </p>

        <form onSubmit={(event) => void handleSubmit(event)}>
          <div className="form-field">
            <label htmlFor="access-request-area">Área<RequiredMark /></label>
            <select
              id="access-request-area"
              value={area}
              onChange={(event) => setArea(event.target.value as AccessRequestArea)}
              disabled={submitting}
              required
            >
              <option value="Todas">Todas</option>
              <option value="Administración">Administración</option>
              <option value="Recursos Humanos">Recursos Humanos</option>
              <option value="CDC">CDC</option>
            </select>
          </div>

          <div className="form-field">
            <label htmlFor="access-request-app">Aplicación<RequiredMark /></label>
            <select
              id="access-request-app"
              value={appId}
              onChange={(event) => setAppId(event.target.value)}
              disabled={submitting || filteredApps.length === 0}
              required
            >
              <option value="">{filteredApps.length === 0 ? "No hay aplicaciones publicadas en esta área" : "Selecciona una aplicación"}</option>
              {filteredApps.map((app) => <option key={app.id} value={app.id}>{app.name}</option>)}
            </select>
          </div>

          <div className="access-request-dialog__actions">
            <button type="button" className="secondary-button secondary-button--quiet" onClick={closeDialog} disabled={submitting}>Cancelar</button>
            <button type="submit" className="primary-button" disabled={!appId || submitting}>
              {submitting ? "Enviando…" : "Enviar solicitud"}
            </button>
          </div>
        </form>
      </dialog>

      {submitting ? (
        <div className="generation-overlay" role="dialog" aria-modal="true" aria-labelledby="access-request-loading-title">
          <div className="generation-overlay__card">
            <span className="generation-spinner" aria-hidden="true" />
            <p className="section-kicker">GLM HUB</p>
            <h2 id="access-request-loading-title">Enviando la solicitud…</h2>
            <p>Estamos registrando la solicitud y enviando el correo a las personas responsables.</p>
          </div>
        </div>
      ) : null}
    </section>
  );
}

type PaginationItem = number | "ellipsis-start" | "ellipsis-end";

function getPaginationItems(currentPage: number, totalPages: number, compact = false): PaginationItem[] {
  const visibleLimit = compact ? 5 : 7;
  if (totalPages <= visibleLimit) {
    return Array.from({ length: totalPages }, (_, index) => index + 1);
  }

  if (compact) {
    if (currentPage <= 3) return [1, 2, 3, "ellipsis-end", totalPages];
    if (currentPage >= totalPages - 2) {
      return [1, "ellipsis-start", totalPages - 2, totalPages - 1, totalPages];
    }
    return [1, "ellipsis-start", currentPage, "ellipsis-end", totalPages];
  }

  if (currentPage <= 4) return [1, 2, 3, 4, 5, "ellipsis-end", totalPages];
  if (currentPage >= totalPages - 3) {
    return [
      1,
      "ellipsis-start",
      totalPages - 4,
      totalPages - 3,
      totalPages - 2,
      totalPages - 1,
      totalPages,
    ];
  }
  return [
    1,
    "ellipsis-start",
    currentPage - 1,
    currentPage,
    currentPage + 1,
    "ellipsis-end",
    totalPages,
  ];
}

interface PaginationProps {
  currentPage: number;
  totalPages: number;
  onPageChange: (page: number) => void;
  compact?: boolean;
  label: string;
}

function Pagination({ currentPage, totalPages, onPageChange, compact = false, label }: PaginationProps) {
  if (totalPages <= 1) return null;
  const items = getPaginationItems(currentPage, totalPages, compact);

  return (
    <nav className={`pagination ${compact ? "pagination--compact" : ""}`} aria-label={label}>
      <button
        type="button"
        className="pagination__arrow"
        disabled={currentPage <= 1}
        onClick={() => onPageChange(currentPage - 1)}
        aria-label="Página anterior"
      >
        <ChevronLeft size={16} aria-hidden="true" />
      </button>

      <div className="pagination__pages">
        {items.map((item) =>
          typeof item === "number" ? (
            <button
              type="button"
              className={`pagination__page ${item === currentPage ? "is-active" : ""}`}
              key={item}
              onClick={() => onPageChange(item)}
              aria-label={`Ir a la página ${item}`}
              aria-current={item === currentPage ? "page" : undefined}
            >
              {item}
            </button>
          ) : (
            <span className="pagination__ellipsis" key={item} aria-hidden="true">…</span>
          ),
        )}
      </div>

      <button
        type="button"
        className="pagination__arrow"
        disabled={currentPage >= totalPages}
        onClick={() => onPageChange(currentPage + 1)}
        aria-label="Página siguiente"
      >
        <ChevronRight size={16} aria-hidden="true" />
      </button>
    </nav>
  );
}

const HUB_ITEMS_PER_PAGE = 10;
const QUICK_ITEMS_PER_PAGE = 6;
const ADMIN_ITEMS_PER_PAGE = 10;

interface HubViewProps {
  user: HubUser;
  data: HubData;
  storageWarning: string;
  onActivate: (app: HubApp) => void;
  onFavorite: (appId: string) => void;
  onAdmin: () => void;
  onRequestAccess: (app: HubApp) => Promise<boolean>;
}

function HubView({ user, data, storageWarning, onActivate, onFavorite, onAdmin, onRequestAccess }: HubViewProps) {
  const [categorySearches, setCategorySearches] = useState<Record<string, string>>({
    Todas: "",
    Administración: "",
    "Recursos Humanos": "",
    CDC: "",
  });
  const [category, setCategory] = useState<"Todas" | AppCategory>("Todas");
  const [favoritesOnly, setFavoritesOnly] = useState(false);
  const [currentPage, setCurrentPage] = useState(1);
  const [quickPage, setQuickPage] = useState(1);

  const search = categorySearches[category] ?? "";
  const setSearch = (value: string) => {
    setCategorySearches((prev) => ({ ...prev, [category]: value }));
  };

  const deferredSearch = useDeferredValue(search);
  const dateLabel = useCurrentDateLabel();
  const canManageCatalog = user.role === "admin";
  const favoriteIds = data.favoritesByUser[user.id] ?? [];
  const recentIds = data.recentByUser[user.id] ?? [];
  const favorites = useMemo(() => new Set(favoriteIds), [favoriteIds]);

  const publishedApps = useMemo(
    () => [...data.apps.filter((app) => app.visibility === "published" && Boolean(app.iconDataUrl.trim()))]
      .sort((a, b) => COLLATOR.compare(a.name, b.name)),
    [data.apps],
  );

  const results = useMemo(() => {
    const query = normalizeSearch(deferredSearch);
    return publishedApps
      .filter((app) => {
        if (category !== "Todas" && app.category !== category) return false;
        if (favoritesOnly && !favorites.has(app.id)) return false;
        if (!query) return true;
        return normalizeSearch(app.name).includes(query);
      })
      .sort((a, b) => {
        const favoriteDifference = Number(favorites.has(b.id)) - Number(favorites.has(a.id));
        return favoriteDifference || COLLATOR.compare(a.name, b.name);
      });
  }, [category, deferredSearch, favorites, favoritesOnly, publishedApps]);

  const totalPages = Math.max(1, Math.ceil(results.length / HUB_ITEMS_PER_PAGE));

  useEffect(() => {
    setCurrentPage(1);
  }, [search, category, favoritesOnly]);

  useEffect(() => {
    if (currentPage > totalPages) setCurrentPage(totalPages);
  }, [currentPage, totalPages]);

  const paginatedResults = useMemo(() => {
    const startIndex = (currentPage - 1) * HUB_ITEMS_PER_PAGE;
    return results.slice(startIndex, startIndex + HUB_ITEMS_PER_PAGE);
  }, [currentPage, results]);

  const changeDirectoryPage = (page: number) => {
    setCurrentPage(Math.min(Math.max(page, 1), totalPages));
    window.requestAnimationFrame(() => {
      document.getElementById("directory-title")?.scrollIntoView({ behavior: "smooth", block: "start" });
    });
  };

  const quickApps = useMemo(() => {
    const byId = new Map(publishedApps.map((app) => [app.id, app]));
    const chosen: HubApp[] = [];
    const chosenIds = new Set<string>();
    const add = (id: string) => {
      const app = byId.get(id);
      if (!app || chosenIds.has(id)) return;
      chosen.push(app);
      chosenIds.add(id);
    };

    favoriteIds.forEach(add);
    if (chosen.length < 3) {
      for (const id of recentIds) {
        add(id);
        if (chosen.length >= 3) break;
      }
    }
    if (chosen.length < 3) {
      for (const app of publishedApps) {
        add(app.id);
        if (chosen.length >= 3) break;
      }
    }
    return chosen;
  }, [favoriteIds, publishedApps, recentIds]);

  const quickTotalPages = Math.max(1, Math.ceil(quickApps.length / QUICK_ITEMS_PER_PAGE));
  const paginatedQuickApps = useMemo(() => {
    const startIndex = (quickPage - 1) * QUICK_ITEMS_PER_PAGE;
    return quickApps.slice(startIndex, startIndex + QUICK_ITEMS_PER_PAGE);
  }, [quickApps, quickPage]);

  useEffect(() => {
    if (quickPage > quickTotalPages) setQuickPage(quickTotalPages);
  }, [quickPage, quickTotalPages]);

  useEffect(() => {
    setQuickPage(1);
  }, [data.favoritesByUser, data.recentByUser, user.id]);

  const changeQuickPage = (page: number) => {
    setQuickPage(Math.min(Math.max(page, 1), quickTotalPages));
    window.requestAnimationFrame(() => {
      document.getElementById("quick-title")?.scrollIntoView({ behavior: "smooth", block: "start" });
    });
  };

  const clearFilters = () => {
    setCategorySearches({
      Todas: "",
      Administración: "",
      "Recursos Humanos": "",
      CDC: "",
    });
    setCategory("Todas");
    setFavoritesOnly(false);
  };

  return (
    <main className="page-content hub-page">
      <header className="page-header">
        <div>
          <p className="page-header__meta">{getGreeting()}, {user.name}</p>
          <h1>Tu mesa de trabajo.</h1>
        </div>
        <div className="page-header__aside">
          <span>{dateLabel}</span>
        </div>
      </header>

      {storageWarning ? <div className="inline-alert" role="status">{storageWarning}</div> : null}

      <section className="search-stage" aria-labelledby="search-title">
        <div className="search-stage__copy">
          <h2 id="search-title">¿Qué necesitas abrir?</h2>
        </div>
        <div className="hub-search">
          <Search size={21} aria-hidden="true" />
          <label className="sr-only" htmlFor="hub-search-input">Buscar por nombre de proyecto</label>
          <input
            id="hub-search-input"
            type="search"
            value={search}
            onChange={(event) => setSearch(event.target.value)}
            placeholder="Buscar por nombre de proyecto…"
          />
          {search ? (
            <button type="button" onClick={() => setSearch("")} aria-label="Limpiar búsqueda">
              <X size={18} aria-hidden="true" />
            </button>
          ) : null}
        </div>
      </section>

      <section className="quick-section" aria-labelledby="quick-title">
        <div className="section-heading">
          <div>
            <p className="section-kicker">FIJADAS PARA TI</p>
            <h2 id="quick-title">Acceso rápido</h2>
          </div>
          <p>Abre lo que más usas sin detener el ritmo.</p>
        </div>
        <div className="quick-grid">
          {paginatedQuickApps.map((app) => (
            <QuickLaunch
              key={app.id}
              app={app}
              isFavorite={favorites.has(app.id)}
              onActivate={onActivate}
              onFavorite={onFavorite}
            />
          ))}
        </div>
        <Pagination
          currentPage={quickPage}
          totalPages={quickTotalPages}
          onPageChange={changeQuickPage}
          compact={quickTotalPages > 5}
          label="Paginación de acceso rápido"
        />
      </section>

      <section className="directory-section" aria-labelledby="directory-title">
        <div className="section-heading section-heading--directory">
          <div>
            <p className="section-kicker">DIRECTORIO GLM</p>
            <h2 id="directory-title">Todas las aplicaciones</h2>
          </div>
          <div className="directory-count" aria-live="polite">
            <strong>{results.length}</strong>
            <span>{results.length === 1 ? "resultado" : "resultados"}</span>
          </div>
        </div>

        <div className="filter-bar" aria-label="Filtros del directorio">
          <div className="category-filters">
            {(["Todas", ...CATEGORY_ORDER] as const).map((item) => (
              <button
                type="button"
                key={item}
                className={category === item ? "is-active" : ""}
                onClick={() => setCategory(item)}
                aria-pressed={category === item}
              >
                {item}
              </button>
            ))}
          </div>

          <div className="filter-bar__search">
            <Search size={16} aria-hidden="true" />
            <label className="sr-only" htmlFor="directory-search-input">
              {`Buscar por nombre de proyecto en ${category}`}
            </label>
            <input
              id="directory-search-input"
              type="search"
              value={search}
              onChange={(event) => setSearch(event.target.value)}
              placeholder={
                category === "Todas"
                  ? "Buscar por nombre de proyecto…"
                  : `Buscar en ${category}…`
              }
            />
            {search ? (
              <button type="button" onClick={() => setSearch("")} aria-label="Limpiar búsqueda">
                <X size={15} aria-hidden="true" />
              </button>
            ) : null}
          </div>

          <button
            type="button"
            className={`favorites-filter ${favoritesOnly ? "is-active" : ""}`}
            onClick={() => setFavoritesOnly((current) => !current)}
            aria-pressed={favoritesOnly}
          >
            <Star size={17} fill={favoritesOnly ? "currentColor" : "none"} aria-hidden="true" />
            Solo favoritos
          </button>
        </div>

        {results.length > 0 ? (
          <>
            <div className="directory-list">
              {paginatedResults.map((app, index) => (
                <DirectoryRow
                  key={app.id}
                  app={app}
                  index={(currentPage - 1) * HUB_ITEMS_PER_PAGE + index}
                  isFavorite={favorites.has(app.id)}
                  onActivate={onActivate}
                  onFavorite={onFavorite}
                />
              ))}
            </div>
            <Pagination
              currentPage={currentPage}
              totalPages={totalPages}
              onPageChange={changeDirectoryPage}
              compact={totalPages > 5}
              label="Paginación de aplicaciones"
            />
          </>
        ) : (
          <EmptyResults search={search} favoritesOnly={favoritesOnly} onClear={clearFilters} />
        )}
      </section>

      {canManageCatalog ? (
        <section className="admin-bridge" aria-label="Acceso al panel de administración">
          <div className="admin-bridge__number" aria-hidden="true">+</div>
          <div>
            <p className="section-kicker">CONTROL DEL CATÁLOGO</p>
            <h2>¿Falta una herramienta?</h2>
            <p>Adjunta su ícono, pega el enlace y publícala para todo el equipo.</p>
          </div>
          <button type="button" className="secondary-button" onClick={onAdmin}>
            Administrar aplicaciones
            <ArrowUpRight size={18} aria-hidden="true" />
          </button>
        </section>
      ) : (
        <AccessRequestSection apps={publishedApps} onSubmit={onRequestAccess} />
      )}
    </main>
  );
}

interface AdminAppListProps {
  apps: HubApp[];
  selectedId: string | null;
  onSelect: (id: string | null) => void;
  onDelete: (id: string) => Promise<boolean>;
}


function AdminAppList({ apps, selectedId, onSelect, onDelete }: AdminAppListProps) {
  const [query, setQuery] = useState("");
  const [currentPage, setCurrentPage] = useState(1);
  const [appToDelete, setAppToDelete] = useState<HubApp | null>(null);
  const deleteModalRef = useRef<HTMLDialogElement>(null);

  const filtered = useMemo(() => {
    const normalized = normalizeSearch(query);
    if (!normalized) return apps;
    return apps.filter((app) => normalizeSearch(`${app.name} ${app.category}`).includes(normalized));
  }, [apps, query]);

  const totalPages = Math.max(1, Math.ceil(filtered.length / ADMIN_ITEMS_PER_PAGE));

  useEffect(() => {
    setCurrentPage(1);
  }, [query]);

  useEffect(() => {
    if (currentPage > totalPages) {
      setCurrentPage(totalPages);
    }
  }, [currentPage, totalPages]);

  const paginatedApps = useMemo(() => {
    const start = (currentPage - 1) * ADMIN_ITEMS_PER_PAGE;
    return filtered.slice(start, start + ADMIN_ITEMS_PER_PAGE);
  }, [filtered, currentPage]);

  const handleOpenDelete = (e: React.MouseEvent, app: HubApp) => {
    e.stopPropagation();
    setAppToDelete(app);
    deleteModalRef.current?.showModal();
  };

  const handleConfirmDelete = async () => {
    if (!appToDelete) return;
    if (await onDelete(appToDelete.id)) {
      deleteModalRef.current?.close();
      setAppToDelete(null);
    }
  };

  return (
    <aside className="admin-list" aria-label="Aplicaciones configuradas">
      <div className="admin-list__top">
        <div>
          <p className="section-kicker">CATÁLOGO</p>
          <h2>{apps.length} {apps.length === 1 ? "aplicación" : "aplicaciones"}</h2>
        </div>
        <button type="button" className="icon-button icon-button--accent" onClick={() => onSelect(null)} aria-label="Crear aplicación">
          <Plus size={20} aria-hidden="true" />
        </button>
      </div>
      <div className="admin-list__search">
        <Search size={17} aria-hidden="true" />
        <label htmlFor="admin-search" className="sr-only">Buscar en el catálogo</label>
        <input id="admin-search" type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Buscar…" />
        {query ? (
          <button type="button" onClick={() => setQuery("")} aria-label="Limpiar búsqueda">
            <X size={15} aria-hidden="true" />
          </button>
        ) : null}
      </div>
      <div className="admin-list__items">
        {currentPage === 1 ? (
          <button type="button" className={`admin-list__new ${selectedId === null ? "is-active" : ""}`} onClick={() => onSelect(null)}>
            <span className="admin-list__new-icon"><Plus size={18} aria-hidden="true" /></span>
            <span className="admin-list__item-info">
              <strong>Nueva aplicación</strong>
              <small>Crear y publicar</small>
            </span>
          </button>
        ) : null}
        {paginatedApps.map((app) => (
          <div
            key={app.id}
            className={`admin-list__item-row ${selectedId === app.id ? "is-active" : ""}`}
            onClick={() => onSelect(app.id)}
            role="button"
            tabIndex={0}
            onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onSelect(app.id); }}
          >
            <AppMark app={app} size="small" />
            <span className="admin-list__item-info">
              <strong>{app.name}</strong>
              <small>{app.category}</small>
            </span>
            <div className="admin-list__item-actions">
              <span className={`mini-status ${app.visibility === "hidden" ? "is-hidden" : ""}`}>
                {app.visibility === "hidden" ? "Oculta" : getSafeUrl(app.url) ? "Lista" : "Pendiente"}
              </span>
              <button
                type="button"
                className="admin-list__delete-icon"
                onClick={(e) => handleOpenDelete(e, app)}
                title={`Eliminar ${app.name}`}
                aria-label={`Eliminar ${app.name}`}
              >
                <Trash2 size={15} aria-hidden="true" />
              </button>
            </div>
          </div>
        ))}
      </div>

      <Pagination
        currentPage={currentPage}
        totalPages={totalPages}
        onPageChange={setCurrentPage}
        compact
        label="Paginación del catálogo de administración"
      />

      <dialog className="confirm-dialog" ref={deleteModalRef}>
        <button type="button" className="confirm-dialog__close" onClick={() => deleteModalRef.current?.close()} aria-label="Cerrar">
          <X size={19} aria-hidden="true" />
        </button>
        <span className="confirm-dialog__icon"><Trash2 size={22} aria-hidden="true" /></span>
        <p className="section-kicker">ACCIÓN PERMANENTE</p>
        <h2>Eliminar {appToDelete?.name}</h2>
        <p>El portal y sus accesos se eliminarán permanentemente. Esta acción no se puede deshacer.</p>
        <div className="confirm-dialog__actions">
          <button type="button" className="secondary-button" onClick={() => deleteModalRef.current?.close()}>Conservar</button>
          <button type="button" className="danger-button danger-button--solid" onClick={handleConfirmDelete}>Sí, eliminar</button>
        </div>
      </dialog>
    </aside>
  );
}

interface AppEditorProps {
  app: HubApp | null;
  allApps: HubApp[];
  onSave: (draft: AppDraft, id: string | null) => Promise<HubApp | null>;
  onDelete: (id: string) => Promise<boolean>;
  onCancel: () => void;
}

function RequiredMark() {
  return (
    <>
      <span className="required-mark" aria-hidden="true">*</span>
      <span className="sr-only"> obligatorio</span>
    </>
  );
}

function AppEditor({ app, allApps, onSave, onDelete, onCancel }: AppEditorProps) {
  const [draft, setDraft] = useState<AppDraft>(() => (app ? appToDraft(app) : makeEmptyDraft()));
  const [baseline, setBaseline] = useState(() => JSON.stringify(app ? appToDraft(app) : makeEmptyDraft()));
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [iconError, setIconError] = useState("");
  const [processingIcon, setProcessingIcon] = useState(false);
  const [generatingIcon, setGeneratingIcon] = useState(false);
  const [saving, setSaving] = useState(false);
  const [dragActive, setDragActive] = useState(false);
  const deleteDialogRef = useRef<HTMLDialogElement>(null);
  const firstFieldRef = useRef<HTMLInputElement>(null);
  const isDirty = JSON.stringify(draft) !== baseline;
  const isBusy = processingIcon || generatingIcon || saving;
  const canGenerateIcon = draft.name.trim().length >= 2 && Boolean(draft.description.trim());

  useEffect(() => {
    const next = app ? appToDraft(app) : makeEmptyDraft();
    setDraft(next);
    setBaseline(JSON.stringify(next));
    setErrors({});
    setIconError("");
    window.requestAnimationFrame(() => firstFieldRef.current?.focus());
  }, [app]);

  useEffect(() => {
    if (!isDirty && !generatingIcon) return undefined;
    const warn = (event: BeforeUnloadEvent) => event.preventDefault();
    window.addEventListener("beforeunload", warn);
    return () => window.removeEventListener("beforeunload", warn);
  }, [generatingIcon, isDirty]);

  useEffect(() => {
    if (!generatingIcon) return undefined;
    const previousOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      document.body.style.overflow = previousOverflow;
    };
  }, [generatingIcon]);

  const previewApp: HubApp = {
    id: app?.id ?? "preview",
    name: draft.name || "Nombre de la aplicación",
    description: draft.description || "Una descripción breve ayuda al equipo a reconocerla.",
    category: draft.category,
    keywords: [],
    url: draft.url,
    mark: getInitials(draft.name || "GLM"),
    accent: app?.accent ?? "#4F758B",
    iconDataUrl: draft.iconDataUrl,
    visibility: draft.visibility,
    createdAt: app?.createdAt ?? new Date().toISOString(),
    updatedAt: new Date().toISOString(),
  };

  const setField = <K extends keyof AppDraft>(field: K, value: AppDraft[K]) => {
    setDraft((current) => ({ ...current, [field]: value }));
    setErrors((current) => ({ ...current, [field]: "" }));
  };

  const acceptIcon = async (file: File | undefined) => {
    if (!file || generatingIcon) return;
    setIconError("");
    setProcessingIcon(true);
    try {
      const dataUrl = await processIcon(file);
      setField("iconDataUrl", dataUrl);
    } catch (error) {
      setIconError(error instanceof Error ? error.message : "No pudimos procesar el ícono.");
    } finally {
      setProcessingIcon(false);
    }
  };

  const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
    void acceptIcon(event.target.files?.[0]);
    event.target.value = "";
  };

  const handleDrop = (event: DragEvent<HTMLLabelElement>) => {
    event.preventDefault();
    setDragActive(false);
    void acceptIcon(event.dataTransfer.files?.[0]);
  };

  const handleGenerateIcon = async () => {
    const name = draft.name.trim();
    const description = draft.description.trim();
    const nextErrors: Record<string, string> = {};
    if (name.length < 2) nextErrors.name = "Completa el nombre antes de generar el ícono.";
    if (!description) nextErrors.description = "Completa la descripción antes de generar el ícono.";
    if (Object.keys(nextErrors).length > 0) {
      setErrors((current) => ({ ...current, ...nextErrors }));
      return;
    }
    if (!ICON_GENERATOR_WEBHOOK_URL) {
      setIconError("Falta configurar VITE_ICON_GENERATOR_WEBHOOK_URL en el archivo .env.");
      return;
    }

    setIconError("");
    setGeneratingIcon(true);
    const controller = new AbortController();
    const timeoutId = window.setTimeout(() => controller.abort(), 180_000);
    try {
      const { data: { session: authSession } } = await supabase.auth.getSession();
      if (!authSession?.access_token) throw new Error("La sesión venció. Inicia sesión nuevamente.");

      // El JWT viaja dentro de un cuerpo text/plain para evitar el preflight CORS
      // que impedía que la solicitud llegara al webhook de n8n desde el navegador.
      const response = await fetch(ICON_GENERATOR_WEBHOOK_URL, {
        method: "POST",
        mode: "cors",
        cache: "no-store",
        credentials: "omit",
        signal: controller.signal,
        headers: {
          Accept: "image/*, application/json;q=0.9, text/plain;q=0.8",
          "Content-Type": "text/plain;charset=UTF-8",
        },
        body: JSON.stringify({
          accessToken: authSession.access_token,
          name,
          description,
        }),
      });

      const contentType = response.headers.get("content-type")?.toLocaleLowerCase("en-US") ?? "";
      if (response.ok && contentType.startsWith("image/")) {
        const normalizedIcon = await processGeneratedIconBlob(await response.blob());
        setField("iconDataUrl", normalizedIcon);
        return;
      }

      const responseText = await response.text();
      let payload: { ok?: boolean; imageDataUrl?: string; error?: string } | null = null;
      if (responseText) {
        try {
          payload = JSON.parse(responseText) as { ok?: boolean; imageDataUrl?: string; error?: string };
        } catch {
          payload = null;
        }
      }

      // Compatibilidad con una respuesta JSON de una versión anterior del workflow.
      if (response.ok && payload?.ok && payload.imageDataUrl) {
        const normalizedIcon = await processGeneratedIcon(payload.imageDataUrl);
        setField("iconDataUrl", normalizedIcon);
        return;
      }

      throw new Error(
        payload?.error ||
        (responseText && responseText.length < 500 ? responseText : "") ||
        `El flujo no pudo generar el ícono (HTTP ${response.status}).`,
      );
    } catch (error) {
      if (error instanceof DOMException && error.name === "AbortError") {
        setIconError("La generación tardó más de 3 minutos y fue cancelada. Intenta nuevamente.");
      } else if (error instanceof TypeError && /failed to fetch/i.test(error.message)) {
        setIconError("No se pudo conectar con el webhook de n8n. Verifica que el workflow esté activo y publicado.");
      } else {
        setIconError(error instanceof Error ? error.message : "No pudimos generar el ícono con IA.");
      }
    } finally {
      window.clearTimeout(timeoutId);
      setGeneratingIcon(false);
    }
  };

  const validate = (): boolean => {
    const nextErrors: Record<string, string> = {};
    const name = draft.name.trim();
    const description = draft.description.trim();
    if (name.length < 2 || name.length > 60) nextErrors.name = "Escribe un nombre de 2 a 60 caracteres.";
    if (!description) nextErrors.description = "Añade una descripción breve.";
    if (description.length > 180) nextErrors.description = "La descripción no puede superar 180 caracteres.";
    if (!draft.iconDataUrl) nextErrors.iconDataUrl = "Adjunta o genera un ícono antes de publicar la aplicación.";
    if (draft.url.trim() && !getSafeUrl(draft.url)) {
      nextErrors.url = "Usa una URL https:// válida, sin credenciales embebidas.";
    }
    const normalizedName = normalizeSearch(name);
    const duplicate = allApps.some((candidate) => candidate.id !== app?.id && normalizeSearch(candidate.name) === normalizedName);
    if (duplicate) nextErrors.name = "Ya existe una aplicación con ese nombre.";
    setErrors(nextErrors);
    return Object.keys(nextErrors).length === 0;
  };

  const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    if (isBusy || !validate()) {
      window.requestAnimationFrame(() => document.querySelector<HTMLElement>(".field-error")?.focus?.());
      return;
    }

    setSaving(true);
    try {
      const saved = await onSave(
        {
          ...draft,
          name: draft.name.trim(),
          description: draft.description.trim(),
          url: draft.url.trim(),
        },
        app?.id ?? null,
      );
      if (saved) {
        const next = appToDraft(saved);
        setDraft(next);
        setBaseline(JSON.stringify(next));
      }
    } finally {
      setSaving(false);
    }
  };

  const confirmCancel = () => {
    if (generatingIcon) return;
    if (!isDirty || window.confirm("Hay cambios sin guardar. ¿Quieres descartarlos?")) {
      const next = app ? appToDraft(app) : makeEmptyDraft();
      setDraft(next);
      setBaseline(JSON.stringify(next));
      setErrors({});
      setIconError("");
      onCancel();
    }
  };

  const confirmDelete = async () => {
    if (!app || isBusy) return;
    if (await onDelete(app.id)) {
      deleteDialogRef.current?.close();
      onCancel();
    }
  };

  return (
    <section className="app-editor" aria-labelledby="editor-title" aria-busy={isBusy}>
      <div className="app-editor__heading">
        <div>
          <p className="section-kicker">{app ? "EDITAR APLICACIÓN" : "NUEVA APLICACIÓN"}</p>
          <h2 id="editor-title">{app ? app.name : "Configura el nuevo acceso"}</h2>
        </div>
        {isDirty ? (
          <span className="dirty-state is-dirty">
            <span aria-hidden="true" />
            Cambios sin guardar
          </span>
        ) : null}
      </div>

      <div className="editor-layout">
        <form className="editor-form" onSubmit={handleSubmit} noValidate>
          <section className="editor-form__section" aria-labelledby="identity-section-title">
            <h3 className="editor-form__section-title" id="identity-section-title">Identidad</h3>
            <div className="form-field">
              <label htmlFor="app-name">Nombre de la aplicación{!app ? <RequiredMark /> : null}</label>
              <input
                ref={firstFieldRef}
                id="app-name"
                value={draft.name}
                onChange={(event) => setField("name", event.target.value)}
                placeholder="Ej. Media Planner"
                maxLength={60}
                aria-invalid={Boolean(errors.name)}
                aria-describedby={errors.name ? "app-name-error" : undefined}
                disabled={isBusy}
                required
              />
              <div className="field-meta">
                {errors.name ? <span id="app-name-error" className="field-error" role="alert" tabIndex={-1}>{errors.name}</span> : <span>Como el equipo la reconoce.</span>}
                <span>{draft.name.length}/60</span>
              </div>
            </div>

            <div className="form-field">
              <label htmlFor="app-description">Descripción breve{!app ? <RequiredMark /> : null}</label>
              <textarea
                id="app-description"
                value={draft.description}
                onChange={(event) => setField("description", event.target.value)}
                placeholder="Explica qué resuelve en una sola frase."
                maxLength={180}
                rows={3}
                aria-invalid={Boolean(errors.description)}
                aria-describedby={errors.description ? "app-description-error" : undefined}
                disabled={isBusy}
                required
              />
              <div className={`field-meta ${errors.description ? "" : "field-meta--counter-only"}`}>
                {errors.description ? <span id="app-description-error" className="field-error" role="alert" tabIndex={-1}>{errors.description}</span> : null}
                <span>{draft.description.length}/180</span>
              </div>
            </div>

            <div className="form-split">
              <div className="form-field">
                <label htmlFor="app-category">Categoría{!app ? <RequiredMark /> : null}</label>
                <select id="app-category" value={draft.category} onChange={(event) => setField("category", event.target.value as AppCategory)} disabled={isBusy} required>
                  {CATEGORY_ORDER.map((item) => <option key={item}>{item}</option>)}
                </select>
              </div>
              <div className="form-field">
                <label htmlFor="app-visibility">Visibilidad{!app ? <RequiredMark /> : null}</label>
                <select id="app-visibility" value={draft.visibility} onChange={(event) => setField("visibility", event.target.value as AppVisibility)} disabled={isBusy} required>
                  <option value="published">Publicada</option>
                  <option value="hidden">Oculta</option>
                </select>
              </div>
            </div>
          </section>

          <section className="editor-form__section" aria-labelledby="icon-section-title">
            <h3 className="editor-form__section-title" id="icon-section-title">Ícono{!app ? <RequiredMark /> : null}</h3>
            <div className="icon-config">
              <label
                className={`icon-dropzone ${dragActive ? "is-dragging" : ""}`}
                onDragEnter={(event) => { event.preventDefault(); if (!isBusy) setDragActive(true); }}
                onDragOver={(event) => event.preventDefault()}
                onDragLeave={() => setDragActive(false)}
                onDrop={handleDrop}
              >
                <input type="file" accept="image/png,image/jpeg,image/webp" onChange={handleFileChange} disabled={isBusy} />
                <span className="icon-dropzone__symbol"><Upload size={20} aria-hidden="true" /></span>
                <span><strong>{processingIcon ? "Procesando…" : "Adjuntar ícono"}</strong><small>PNG, JPG o WebP · máximo 1 MB</small></span>
              </label>
              {draft.iconDataUrl ? (
                <button type="button" className="text-button text-button--danger" disabled={isBusy} onClick={() => setField("iconDataUrl", "")}>Quitar ícono</button>
              ) : null}
            </div>
            <div className="ai-icon-actions">
              <button
                type="button"
                className="secondary-button ai-icon-button"
                onClick={() => void handleGenerateIcon()}
                disabled={!canGenerateIcon || isBusy}
                title={!canGenerateIcon ? "Completa el nombre y la descripción breve." : undefined}
              >
                <Sparkles size={18} aria-hidden="true" />
                Generar ícono con IA
              </button>
            </div>
            {iconError ? <p className="field-error field-error--standalone" role="alert">{iconError}</p> : null}
            {errors.iconDataUrl ? (
              <p className="field-error field-error--standalone" role="alert" tabIndex={-1}>{errors.iconDataUrl}</p>
            ) : null}
          </section>

          <section className="editor-form__section" aria-labelledby="destination-section-title">
            <h3 className="editor-form__section-title" id="destination-section-title">Destino</h3>
            <div className="form-field">
              <label htmlFor="app-url">Enlace de la aplicación <span>Opcional</span></label>
              <div className="url-field">
                <Link2 size={18} aria-hidden="true" />
                <input
                  id="app-url"
                  type="url"
                  value={draft.url}
                  onChange={(event) => setField("url", event.target.value)}
                  placeholder="https://aplicacion.com"
                  aria-invalid={Boolean(errors.url)}
                  aria-describedby={errors.url ? "app-url-error" : "app-url-help"}
                  disabled={isBusy}
                />
              </div>
              {errors.url ? (
                <span id="app-url-error" className="field-error" role="alert" tabIndex={-1}>{errors.url}</span>
              ) : (
                <span id="app-url-help" className="field-help">Si lo dejas vacío, aparecerá como “Enlace pendiente”.</span>
              )}
            </div>
          </section>

          <div className="editor-actions">
            <button type="submit" className="primary-button" disabled={isBusy}>
              <Save size={18} aria-hidden="true" />
              {saving ? "Guardando…" : app ? "Guardar cambios" : "Publicar aplicación"}
            </button>
            <button type="button" className="secondary-button secondary-button--quiet" disabled={isBusy} onClick={confirmCancel}>Cancelar</button>
            {app ? (
              <button type="button" className="danger-button" disabled={isBusy} onClick={() => deleteDialogRef.current?.showModal()}>
                <Trash2 size={17} aria-hidden="true" />
                Eliminar
              </button>
            ) : null}
          </div>
        </form>

        <aside className="app-preview" aria-label="Vista previa de la aplicación">
          <div className="app-preview__label">
            <span>VISTA PREVIA</span>
            <span className="app-preview__rule" />
          </div>
          <div className="preview-card">
            <AppMark app={previewApp} size="large" showPlaceholder={!app && !draft.iconDataUrl} />
            <span className="quick-launch__category">{previewApp.category}</span>
            <h3>{previewApp.name}</h3>
            <p>{previewApp.description}</p>
            <span className={`availability ${getSafeUrl(previewApp.url) ? "availability--ready" : "availability--pending"}`}>
              <span aria-hidden="true" />
              {getSafeUrl(previewApp.url) ? "Lista para abrir" : "Enlace pendiente"}
            </span>
          </div>
          <p className="app-preview__note">Así aparecerá en el Hub para el equipo.</p>
        </aside>
      </div>

      <dialog className="confirm-dialog" ref={deleteDialogRef}>
        <button type="button" className="confirm-dialog__close" onClick={() => deleteDialogRef.current?.close()} aria-label="Cerrar">
          <X size={19} aria-hidden="true" />
        </button>
        <span className="confirm-dialog__icon"><Trash2 size={22} aria-hidden="true" /></span>
        <p className="section-kicker">ACCIÓN PERMANENTE</p>
        <h2>Eliminar {app?.name}</h2>
        <p>La aplicación se quitará del catálogo compartido para todo el equipo. Esta acción no se puede deshacer.</p>
        <div className="confirm-dialog__actions">
          <button type="button" className="secondary-button" onClick={() => deleteDialogRef.current?.close()}>Conservar</button>
          <button type="button" className="danger-button danger-button--solid" onClick={() => void confirmDelete()}>Sí, eliminar</button>
        </div>
      </dialog>

      {generatingIcon ? (
        <div className="generation-overlay" role="dialog" aria-modal="true" aria-labelledby="generation-title">
          <div className="generation-overlay__card">
            <span className="generation-spinner" aria-hidden="true" />
            <p className="section-kicker">GEMINI</p>
            <h2 id="generation-title">Generando el ícono…</h2>
            <p>Estamos creando una propuesta a partir del nombre y la descripción de la aplicación.</p>
          </div>
        </div>
      ) : null}
    </section>
  );
}

interface AdminViewProps {
  data: HubData;
  onSave: (draft: AppDraft, id: string | null) => Promise<HubApp | null>;
  onDelete: (id: string) => Promise<boolean>;
  onBack: () => void;
}

function AdminView({ data, onSave, onDelete, onBack }: AdminViewProps) {
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const selected = selectedId ? data.apps.find((app) => app.id === selectedId) ?? null : null;

  const saveAndSelect = async (draft: AppDraft, id: string | null): Promise<HubApp | null> => {
    const saved = await onSave(draft, id);
    if (saved) setSelectedId(saved.id);
    return saved;
  };

  const deleteAndClear = async (id: string): Promise<boolean> => {
    const deleted = await onDelete(id);
    if (deleted) setSelectedId(null);
    return deleted;
  };

  return (
    <main className="page-content admin-page">
      <header className="page-header admin-header">
        <div>
          <button type="button" className="back-button" onClick={onBack}>
            <ArrowLeft size={17} aria-hidden="true" />
            Volver al Hub
          </button>
          <p className="page-header__meta">PANEL DE ADMINISTRACIÓN</p>
          <h1>Controla el catálogo.</h1>
        </div>
      </header>

      <div className="admin-workspace">
        <AdminAppList
          apps={data.apps}
          selectedId={selectedId}
          onSelect={setSelectedId}
          onDelete={deleteAndClear}
        />
        <AppEditor
          app={selected}
          allApps={data.apps}
          onSave={saveAndSelect}
          onDelete={deleteAndClear}
          onCancel={() => setSelectedId(null)}
        />
      </div>
    </main>
  );
}

function CatalogLoading() {
  return (
    <div className="catalog-loading" role="status" aria-live="polite">
      <BrandLogo compact />
      <span className="generation-spinner" aria-hidden="true" />
      <p>Cargando aplicaciones…</p>
    </div>
  );
}

function App() {
  const [initialLocalData] = useState(() => loadHubData());
  const [data, setData] = useState<HubData>({
    schemaVersion: 1,
    apps: [],
    favoritesByUser: {},
    recentByUser: {},
    updatedAt: new Date().toISOString(),
  });
  const [session, setSession] = useState<Session | null>(null);
  const [view, setView] = useState<AppView>(() => "hub");
  const [toasts, setToasts] = useState<ToastMessage[]>([]);
  const [authReady, setAuthReady] = useState(false);
  const [catalogReady, setCatalogReady] = useState(false);
  const [catalogWarning, setCatalogWarning] = useState("");
  const [loginError, setLoginError] = useState("");
  const authorizationSequence = useRef(0);
  const authorizedUserIdRef = useRef<string | null>(null);

  const dismissToast = useCallback((id: string) => {
    setToasts((current) => current.filter((toast) => toast.id !== id));
  }, []);

  const notify = useCallback((kind: ToastMessage["kind"], title: string, detail?: string) => {
    const id = crypto.randomUUID();
    setToasts((current) => [...current.slice(-2), { id, kind, title, detail }]);
    window.setTimeout(() => {
      setToasts((current) => current.filter((toast) => toast.id !== id));
    }, 5200);
  }, []);

  useEffect(() => {
    let active = true;

    const updateTokenExpiry = (authSession: SupabaseAuthSession) => {
      const expiresAt = (authSession.expires_at ?? Math.floor(Date.now() / 1000) + 3600) * 1000;
      setSession((current) => {
        if (!current || current.user.id !== authSession.user.id || current.expiresAt === expiresAt) return current;
        return { ...current, expiresAt };
      });
    };

    const applyAuthSession = async (authSession: SupabaseAuthSession | null) => {
      const sequence = ++authorizationSequence.current;

      if (!authSession) {
        if (!active || sequence !== authorizationSequence.current) return;
        authorizedUserIdRef.current = null;
        setSession(null);
        setCatalogReady(false);
        setAuthReady(true);
        return;
      }

      // Supabase puede emitir SIGNED_IN o TOKEN_REFRESHED cuando una pestaña vuelve
      // al primer plano. Si sigue siendo el mismo usuario, solo actualizamos la
      // expiración del token y conservamos exactamente la pantalla y el formulario.
      if (authorizedUserIdRef.current === authSession.user.id) {
        if (!active || sequence !== authorizationSequence.current) return;
        updateTokenExpiry(authSession);
        setAuthReady(true);
        return;
      }

      const authorization = await authorizeSupabaseSession(authSession);
      if (!active || sequence !== authorizationSequence.current) return;

      if (!authorization.session) {
        authorizedUserIdRef.current = null;
        setSession(null);
        setLoginError(authorization.error);
        setCatalogReady(false);
        setAuthReady(true);
        await supabase.auth.signOut({ scope: "local" });
        return;
      }

      const nextSession = authorization.session;
      authorizedUserIdRef.current = nextSession.user.id;
      setLoginError("");
      setSession(nextSession);
      setAuthReady(true);

      if (window.location.hash !== "#hub" && window.location.hash !== "#admin") {
        window.history.replaceState(null, "", getAppUrl("#hub"));
      }
    };

    void supabase.auth.getSession().then(({ data: { session: currentSession } }: { data: { session: SupabaseAuthSession | null } }) => {
      void applyAuthSession(currentSession);
    });

    const { data: { subscription } } = supabase.auth.onAuthStateChange((event: string, nextSession: SupabaseAuthSession | null) => {
      if (event === "INITIAL_SESSION") return;

      if (event === "TOKEN_REFRESHED" && nextSession && authorizedUserIdRef.current === nextSession.user.id) {
        updateTokenExpiry(nextSession);
        return;
      }

      window.setTimeout(() => {
        void applyAuthSession(nextSession);
      }, 0);
    });

    return () => {
      active = false;
      subscription.unsubscribe();
    };
  }, []);

  const activeUser = session?.user ?? null;

  useEffect(() => {
    if (!activeUser) return undefined;
    let active = true;
    let loadingApps = false;

    const refreshApps = async () => {
      if (loadingApps) return;
      loadingApps = true;
      try {
        const apps = await fetchHubApps();
        if (!active) return;
        setData((current) => ({ ...current, apps, updatedAt: new Date().toISOString() }));
      } catch (error) {
        console.error("No se pudo refrescar el catálogo compartido:", error);
      } finally {
        loadingApps = false;
      }
    };

    const initializeCatalog = async () => {
      setCatalogReady(false);
      setCatalogWarning("");
      try {
        const migratedCount = await migrateLocalAppsIfNeeded(activeUser, initialLocalData.data.apps);
        const remoteData = await fetchHubData(activeUser.id);
        if (!active) return;
        setData(remoteData);
        setCatalogReady(true);
        if (migratedCount > 0) {
          notify(
            "success",
            "Catálogo compartido activado",
            `${migratedCount} ${migratedCount === 1 ? "aplicación fue migrada" : "aplicaciones fueron migradas"} a Supabase.`,
          );
        }
      } catch (error) {
        if (!active) return;
        const message = error instanceof Error ? error.message : "No se pudo cargar el catálogo compartido.";
        setCatalogWarning(message);
        setCatalogReady(true);
        notify("error", "No se pudo cargar el catálogo", message);
      }
    };

    void initializeCatalog();
    const unsubscribe = subscribeToHubApps(() => void refreshApps());
    return () => {
      active = false;
      unsubscribe();
    };
  }, [activeUser?.id, activeUser?.role, initialLocalData.data.apps, notify]);

  useEffect(() => {
    const syncViewWithHash = () => {
      const requestedView: AppView = window.location.hash === "#admin" ? "admin" : "hub";
      if (requestedView === "admin" && session?.user.role !== "admin") {
        setView("hub");
        window.history.replaceState(null, "", "#hub");
        return;
      }
      setView(requestedView);
    };

    syncViewWithHash();
    window.addEventListener("hashchange", syncViewWithHash);
    return () => window.removeEventListener("hashchange", syncViewWithHash);
  }, [session?.user.role]);

  const navigate = (next: AppView) => {
    if (next === "admin" && session?.user.role !== "admin") {
      notify("error", "Acceso restringido", "Necesitas permisos de administrador para abrir este panel.");
      return;
    }
    setView(next);
    window.history.replaceState(null, "", getAppUrl(next === "admin" ? "#admin" : "#hub"));
    window.scrollTo({ top: 0, behavior: "smooth" });
  };

  const handleFavorite = async (appId: string) => {
    if (!session) return;
    const userId = session.user.id;
    const current = data.favoritesByUser[userId] ?? [];
    const wasFavorite = current.includes(appId);
    const nextFavorites = wasFavorite ? current.filter((id) => id !== appId) : [appId, ...current];

    setData((currentData) => ({
      ...currentData,
      favoritesByUser: { ...currentData.favoritesByUser, [userId]: nextFavorites },
      updatedAt: new Date().toISOString(),
    }));

    try {
      await setFavorite(userId, appId, !wasFavorite);
      notify("success", wasFavorite ? "Quitada de favoritos" : "Agregada a favoritos");
    } catch (error) {
      setData((currentData) => ({
        ...currentData,
        favoritesByUser: { ...currentData.favoritesByUser, [userId]: current },
      }));
      notify("error", "No se actualizó el favorito", error instanceof Error ? error.message : undefined);
    }
  };

  const handleActivate = (app: HubApp) => {
    if (!session) return;
    const safeUrl = getSafeUrl(app.url);
    if (!safeUrl) {
      notify("info", "Enlace pendiente", `${app.name} todavía no tiene un enlace configurado.`);
      return;
    }

    const opened = window.open(safeUrl, "_blank", "noopener,noreferrer");
    if (opened) opened.opener = null;

    const userId = session.user.id;
    const currentRecents = data.recentByUser[userId] ?? [];
    const nextRecents = [app.id, ...currentRecents.filter((id) => id !== app.id)].slice(0, 30);
    setData((currentData) => ({
      ...currentData,
      recentByUser: { ...currentData.recentByUser, [userId]: nextRecents },
      updatedAt: new Date().toISOString(),
    }));
    void recordRecent(userId, app.id).catch((error) => console.warn("No se guardó el acceso reciente:", error));
  };

  const handleSaveApp = async (draft: AppDraft, id: string | null): Promise<HubApp | null> => {
    if (!session || session.user.role !== "admin") return null;
    const existing = id ? data.apps.find((app) => app.id === id) ?? null : null;
    try {
      const saved = await saveHubApp(draft, existing, session.user.id);
      setData((current) => ({
        ...current,
        apps: existing
          ? current.apps.map((candidate) => (candidate.id === saved.id ? saved : candidate))
          : [saved, ...current.apps],
        updatedAt: new Date().toISOString(),
      }));
      notify("success", existing ? "Cambios guardados" : "Aplicación publicada", `${saved.name} ya está disponible en el catálogo compartido.`);
      return saved;
    } catch (error) {
      notify("error", "No se guardó la aplicación", error instanceof Error ? error.message : undefined);
      return null;
    }
  };

  const handleDeleteApp = async (id: string): Promise<boolean> => {
    if (!session || session.user.role !== "admin") return false;
    const app = data.apps.find((candidate) => candidate.id === id);
    if (!app) return false;

    try {
      await deleteHubApp(app);
      const stripId = (groups: Record<string, string[]>) =>
        Object.fromEntries(Object.entries(groups).map(([userId, ids]) => [userId, ids.filter((candidate) => candidate !== id)]));
      setData((current) => ({
        ...current,
        apps: current.apps.filter((candidate) => candidate.id !== id),
        favoritesByUser: stripId(current.favoritesByUser),
        recentByUser: stripId(current.recentByUser),
        updatedAt: new Date().toISOString(),
      }));
      notify("success", "Aplicación eliminada", `${app.name} salió del catálogo compartido.`);
      return true;
    } catch (error) {
      notify("error", "No se eliminó la aplicación", error instanceof Error ? error.message : undefined);
      return false;
    }
  };

  const handleAccessRequest = async (app: HubApp): Promise<boolean> => {
    if (!session || session.user.role === "admin") return false;
    if (!ACCESS_REQUEST_WEBHOOK_URL) {
      notify("error", "No se pudo enviar la solicitud", "La función de solicitudes no está disponible en este momento.");
      return false;
    }

    const controller = new AbortController();
    const timeoutId = window.setTimeout(() => controller.abort(), 120_000);
    try {
      const { data: { session: authSession } } = await supabase.auth.getSession();
      if (!authSession?.access_token) throw new Error("La sesión venció. Cierra sesión e inicia nuevamente.");

      const response = await fetch(ACCESS_REQUEST_WEBHOOK_URL, {
        method: "POST",
        mode: "cors",
        cache: "no-store",
        credentials: "omit",
        signal: controller.signal,
        headers: {
          Accept: "application/json, text/plain;q=0.9",
          "Content-Type": "text/plain;charset=UTF-8",
        },
        body: JSON.stringify({
          accessToken: authSession.access_token,
          appId: app.id,
        }),
      });

      const responseText = await response.text();
      let payload: { ok?: boolean; message?: string; error?: string } | null = null;
      if (responseText) {
        try {
          payload = JSON.parse(responseText) as { ok?: boolean; message?: string; error?: string };
        } catch {
          payload = null;
        }
      }

      if (!response.ok || !payload?.ok) {
        const rawMessage = String(payload?.error || "").toLowerCase();
        let friendlyMessage = "No pudimos enviar la solicitud en este momento. Intenta nuevamente.";

        if (rawMessage.includes("pendiente")) {
          friendlyMessage = "Ya tienes una solicitud pendiente para esta aplicación.";
        } else if (rawMessage.includes("sesión") || response.status === 401) {
          friendlyMessage = "Tu sesión venció. Cierra sesión e inicia nuevamente.";
        } else if (rawMessage.includes("ya no está publicada") || rawMessage.includes("no está disponible")) {
          friendlyMessage = "La aplicación seleccionada ya no está disponible.";
        } else if (rawMessage.includes("no está autorizado") || response.status === 403) {
          friendlyMessage = "Tu cuenta no está habilitada para enviar esta solicitud.";
        }

        console.error("Error interno al enviar la solicitud de acceso", {
          status: response.status,
          responseText,
        });
        throw new Error(friendlyMessage);
      }

      notify(
        "success",
        "Solicitud enviada",
        payload.message || `La solicitud para ${app.name} fue enviada correctamente.`,
      );
      return true;
    } catch (error) {
      if (error instanceof DOMException && error.name === "AbortError") {
        notify("error", "La solicitud tardó demasiado", "Intenta nuevamente en unos minutos.");
      } else if (error instanceof TypeError && /failed to fetch/i.test(error.message)) {
        console.error("No se pudo contactar el servicio de solicitudes", error);
        notify("error", "No se pudo enviar la solicitud", "Intenta nuevamente en unos minutos.");
      } else {
        notify(
          "error",
          "No se pudo enviar la solicitud",
          error instanceof Error ? error.message : "Intenta nuevamente en unos minutos.",
        );
      }
      return false;
    } finally {
      window.clearTimeout(timeoutId);
    }
  };

  const handleGoogleLogin = async () => {
    setLoginError("");
    const { error } = await supabase.auth.signInWithOAuth({
      provider: "google",
      options: {
        redirectTo: getOAuthRedirectUrl(),
        queryParams: {
          hd: "gomezleemarketing.com",
          prompt: "select_account",
        },
      },
    });

    if (error) {
      console.error("No se pudo iniciar el acceso con Google:", error);
      setLoginError("No pudimos abrir el acceso con Google. Intenta nuevamente.");
    }
  };

  const handleLogout = async () => {
    const { error } = await supabase.auth.signOut();
    if (error) {
      notify("error", "No se pudo cerrar la sesión", error.message);
      return;
    }
    setSession(null);
    setCatalogReady(false);
    setView("hub");
    window.history.replaceState(null, "", getAppUrl());
  };

  if (!authReady || !session) {
    return (
      <>
        <LoginView
          onLogin={handleGoogleLogin}
          error={loginError}
          disabled={!authReady}
        />
        <ToastStack messages={toasts} onDismiss={dismissToast} />
      </>
    );
  }

  if (!catalogReady) {
    return (
      <>
        <CatalogLoading />
        <ToastStack messages={toasts} onDismiss={dismissToast} />
      </>
    );
  }

  return (
    <div className="app-shell">
      <Sidebar user={session.user} view={view} onNavigate={navigate} onLogout={handleLogout} />
      <MobileHeader user={session.user} />
      {view === "admin" && session.user.role === "admin" ? (
        <AdminView
          data={data}
          onSave={handleSaveApp}
          onDelete={handleDeleteApp}
          onBack={() => navigate("hub")}
        />
      ) : (
        <HubView
          user={session.user}
          data={data}
          storageWarning={catalogWarning}
          onActivate={handleActivate}
          onFavorite={(appId) => void handleFavorite(appId)}
          onAdmin={() => navigate("admin")}
          onRequestAccess={handleAccessRequest}
        />
      )}
      <MobileNav user={session.user} view={view} onNavigate={navigate} onLogout={handleLogout} />
      <ToastStack messages={toasts} onDismiss={dismissToast} />
    </div>
  );
}

export default App;
