import {
  CollisionDetection,
  DndContext,
  DragEndEvent,
  DragOverEvent,
  DragOverlay,
  DragStartEvent,
  KeyboardSensor,
  PointerSensor,
  closestCenter,
  closestCorners,
  pointerWithin,
  rectIntersection,
  useSensor,
  useSensors,
} from "@dnd-kit/core";
import {
  SortableContext,
  arrayMove,
  horizontalListSortingStrategy,
  sortableKeyboardCoordinates,
  verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import {
  ActionIcon,
  AppShell,
  Avatar,
  Badge,
  Box,
  Button,
  Checkbox,
  Group,
  Loader,
  Menu,
  MultiSelect,
  Paper,
  Select,
  Stack,
  Tabs,
  Text,
  TextInput,
  Title,
  Tooltip,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import "@mantine/dates/styles.css";
import { modals } from "@mantine/modals";
import { notifications } from "@mantine/notifications";
import {
  IconArrowBackUp,
  IconCalendar,
  IconChartBar,
  IconCheck,
  IconChevronDown,
  IconChevronRight,
  IconLayoutKanban,
  IconLogout,
  IconPlug,
  IconKey,
  IconBrandJira,
  IconMenu2,
  IconMessageChatbot,
  IconMoodSmile,
  IconPlus,
  IconRefresh,
  IconSearch,
  IconTrash,
  IconTrashX,
  IconX,
} from "@tabler/icons-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as api from "./api";
import { useAuth } from "./auth";
import { CardForm } from "./components/CardForm";
import { CardEditPanel } from "./components/CardEditPanel";
import { ChatPanel } from "./components/ChatPanel";
import { CalendarView } from "./components/CalendarView";
import { DailyReportView } from "./components/DailyReport";
import { Dashboard } from "./components/Dashboard";
import { HistoryModal } from "./components/HistoryModal";
import { KanbanCard } from "./components/KanbanCard";
import { KanbanColumn } from "./components/KanbanColumn";
import { StickerPicker } from "./components/StickerPicker";
import { ColorPickerGrid, CustomColorModal } from "./components/ColorPickerGrid";
import { AVATAR_COLORS } from "./components/colors";
import { colorBg, colorBorder } from "./components/colors";
import { NotificationsBell } from "./components/NotificationsBell";
import { ModulesModal } from "./components/ModulesModal";
import { MCPTokensModal } from "./components/MCPTokensModal";
import { ImportJiraModal } from "./components/ImportJiraModal";
import { useEventStream } from "./hooks/useEventStream";
import type { Board, Card, CardColor, Column, ColumnLocation, Notification, User } from "./types";

const COL_PREFIX = "column-";

// Custom collision detection: prefiere otras columnas como destino al arrastrar
// columnas; al arrastrar cards prefiere cards/columnas via closestCorners.
function makeCollisionDetection(activeType: string | undefined): CollisionDetection {
  if (activeType === "column") {
    return (args) => {
      // Solo considerar drops sobre otras columnas (ids con COL_PREFIX).
      const filtered = args.droppableContainers.filter((c) =>
        String(c.id).startsWith(COL_PREFIX)
      );
      const inter = rectIntersection({ ...args, droppableContainers: filtered });
      if (inter.length > 0) return inter;
      return closestCenter({ ...args, droppableContainers: filtered });
    };
  }
  return (args) => {
    const pw = pointerWithin(args);
    if (pw.length > 0) return pw;
    return closestCorners(args);
  };
}

export function App() {
  const auth = useAuth();
  const [board, setBoard] = useState<Board | null>(null);
  const [users, setUsers] = useState<User[]>([]);
  const [activeCard, setActiveCard] = useState<Card | null>(null);
  const [activeColumnId, setActiveColumnId] = useState<string | null>(null);
  const [activeType, setActiveType] = useState<string | undefined>(undefined);
  const [addingCol, setAddingCol] = useState(false);
  const [colName, setColName] = useState("");
  const [now, setNow] = useState(Date.now());
  const [chatOpen, setChatOpen] = useState(false);
  const [activeTab, setActiveTab] = useState<string>("board");
  const [trash, setTrash] = useState<Card[]>([]);
  const [trashOpen, setTrashOpen] = useState(false);
  const [archive, setArchive] = useState<Card[]>([]);
  const [archiveOpen, setArchiveOpen] = useState(false);
  const [tagOptions, setTagOptions] = useState<string[]>([]);
  const [requesterOptions, setRequesterOptions] = useState<string[]>([]);
  const [searchTerm, setSearchTerm] = useState("");
  const [filterAssigneeId, setFilterAssigneeId] = useState<string | null>(null);
  const [filterRequester, setFilterRequester] = useState<string | null>(null);
  const [filterTags, setFilterTags] = useState<string[]>([]);
  const [filterUnassigned, setFilterUnassigned] = useState(false);
  const [filterDateFrom, setFilterDateFrom] = useState<Date | null>(null);
  const [filterDateTo, setFilterDateTo] = useState<Date | null>(null);
  const [filterDeadlineOnly, setFilterDeadlineOnly] = useState(false);
  const [highlightCardId, setHighlightCardId] = useState<string | null>(null);
  const [stickerPickerOpen, setStickerPickerOpen] = useState(false);
  const [activeSticker, setActiveSticker] = useState<string | null>(null);
  const [avatarColorModalOpen, setAvatarColorModalOpen] = useState(false);
  const [avatarCustomColor, setAvatarCustomColor] = useState("#888888");
  const [cardColorModal, setCardColorModal] = useState<{ cardId: string; color: string } | null>(null);
  const [navOpen, setNavOpen] = useState(false);
  const [navWidth, setNavWidth] = useState<number>(() => {
    const stored = localStorage.getItem("kanban_nav_width");
    const n = stored ? parseInt(stored, 10) : NaN;
    return Number.isFinite(n) && n >= 180 && n <= 600 ? n : 240;
  });
  const navWidthRef = useRef(navWidth);
  useEffect(() => {
    navWidthRef.current = navWidth;
    localStorage.setItem("kanban_nav_width", String(navWidth));
  }, [navWidth]);

  const onNavResizeMouseDown = (e: React.MouseEvent) => {
    e.preventDefault();
    const startX = e.clientX;
    const startWidth = navWidthRef.current;
    document.body.style.cursor = "col-resize";
    document.body.style.userSelect = "none";
    const onMove = (ev: MouseEvent) => {
      const dx = ev.clientX - startX;
      const next = Math.min(600, Math.max(180, startWidth + dx));
      setNavWidth(next);
    };
    const onUp = () => {
      document.body.style.cursor = "";
      document.body.style.userSelect = "";
      window.removeEventListener("mousemove", onMove);
      window.removeEventListener("mouseup", onUp);
    };
    window.addEventListener("mousemove", onMove);
    window.addEventListener("mouseup", onUp);
  };

  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
  );

  // -------- Issue 0091 — drag-aware sidebar dropzone --------
  // While a card or column is being dragged, watch the global pointer.
  // If it dwells inside the 32px left strip for >=400ms, auto-open the sidebar.
  // We listen to mousemove globally because dnd-kit owns the pointer during
  // drag, and the strip itself has pointer-events:none so dnd-kit keeps
  // detecting drop targets underneath.
  const DRAG_EDGE_WIDTH = 32;
  const DRAG_EDGE_HOVER_MS = 400;
  const isDragging = activeCard !== null || activeColumnId !== null;
  const [edgeArmed, setEdgeArmed] = useState(false);
  const navOpenRef = useRef(navOpen);
  useEffect(() => {
    navOpenRef.current = navOpen;
  }, [navOpen]);
  useEffect(() => {
    if (!isDragging) {
      setEdgeArmed(false);
      return;
    }
    let timer: number | null = null;
    let inside = false;
    // Para evitar que un drag iniciado dentro del sidebar abierto dispare un
    // cierre inmediato, exigimos que el puntero haya salido de la franja al
    // menos una vez tras empezar el drag. Asi: abrir = entrar a la franja
    // tras empezar fuera (que ya pasaba); cerrar = salir de la franja y
    // volver a entrar.
    let hasLeftStrip = false;
    const clear = () => {
      if (timer !== null) {
        window.clearTimeout(timer);
        timer = null;
      }
    };
    const onMove = (ev: MouseEvent) => {
      const nowInside = ev.clientX <= DRAG_EDGE_WIDTH;
      if (nowInside === inside) return;
      inside = nowInside;
      // Brillo visible siempre que el puntero este en la franja y haya drag.
      setEdgeArmed(nowInside);
      if (!nowInside) {
        hasLeftStrip = true;
        clear();
        return;
      }
      // nowInside = true. Para cerrar (navOpen=true) exigimos que el puntero
      // haya salido al menos una vez de la franja desde que empezo el drag;
      // asi un drag que arranca dentro del sidebar abierto no auto-cierra.
      const armable = !navOpenRef.current || hasLeftStrip;
      if (!armable) return;
      clear();
      const willOpen = !navOpenRef.current;
      timer = window.setTimeout(() => {
        setNavOpen(willOpen);
        // Tras toggle, resetea el flag para no encadenar otra accion sin
        // que el usuario salga + vuelva.
        hasLeftStrip = false;
      }, DRAG_EDGE_HOVER_MS);
    };
    document.addEventListener("mousemove", onMove);
    return () => {
      document.removeEventListener("mousemove", onMove);
      clear();
      setEdgeArmed(false);
    };
  }, [isDragging]);

  const reload = useCallback(async () => {
    try {
      const b = await api.getBoard();
      setBoard(b);
    } catch (e) {
      notifications.show({ color: "red", message: (e as Error).message });
    }
  }, []);

  // Coalesce ráfagas de board.invalidated (trailing debounce 300ms) — sin esto
  // cada mutación remota dispara un refetch /api/board completo y la memoria
  // del navegador crece sin techo.
  const reloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const debouncedReload = useCallback(() => {
    if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
    reloadTimerRef.current = setTimeout(() => {
      reloadTimerRef.current = null;
      reload();
    }, 300);
  }, [reload]);
  useEffect(() => {
    return () => {
      if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
    };
  }, []);

  useEffect(() => {
    reload();
  }, [reload]);

  const reloadUsers = useCallback(async () => {
    try {
      const us = await api.listUsers();
      setUsers(us);
    } catch (e) {
      console.warn("listUsers failed", e);
    }
  }, []);

  const reloadTrash = useCallback(async () => {
    try {
      const t = await api.listTrash();
      setTrash(t);
    } catch (e) {
      console.warn("listTrash failed", e);
    }
  }, []);

  const reloadArchive = useCallback(async () => {
    try {
      const a = await api.listArchive();
      setArchive(a);
    } catch (e) {
      console.warn("listArchive failed", e);
    }
  }, []);

  const reloadTags = useCallback(async () => {
    try {
      const t = await api.listTags();
      setTagOptions(t);
    } catch (e) {
      console.warn("listTags failed", e);
    }
  }, []);

  const reloadRequesters = useCallback(async () => {
    try {
      const r = await api.listRequesters();
      setRequesterOptions(r);
    } catch (e) {
      console.warn("listRequesters failed", e);
    }
  }, []);

  useEffect(() => {
    reloadUsers();
  }, [reloadUsers]);

  useEffect(() => {
    reloadTrash();
  }, [reloadTrash]);

  useEffect(() => {
    reloadArchive();
  }, [reloadArchive]);

  useEffect(() => {
    reloadTags();
    reloadRequesters();
  }, [reloadTags, reloadRequesters]);

  // Tick de reloj para "tiempo en columna" en cards. Pausamos durante drag
  // porque dispara re-render de TODAS las cards cada segundo y el drag de
  // dnd-kit sufre tirones serios con muchos elementos.
  useEffect(() => {
    if (activeCard || activeColumnId) return;
    const t = setInterval(() => setNow(Date.now()), 1000);
    return () => clearInterval(t);
  }, [activeCard, activeColumnId]);

  // Notifications state (populated by SSE + initial fetch).
  const [notifs, setNotifs] = useState<Notification[]>([]);
  const [notifUnread, setNotifUnread] = useState(0);

  // Build version (injected at compile time via -ldflags). Fetched once.
  const [appVersion, setAppVersion] = useState<string>("");
  useEffect(() => {
    api
      .getVersion()
      .then((v) => setAppVersion(v.version))
      .catch(() => setAppVersion(""));
  }, []);

  const [modulesOpen, setModulesOpen] = useState(false);
  const [mcpTokensOpen, setMcpTokensOpen] = useState(false);
  const [jiraImportOpen, setJiraImportOpen] = useState(false);

  const reloadNotifs = useCallback(async () => {
    try {
      const [list, c] = await Promise.all([api.listNotifications(false), api.unreadNotificationCount()]);
      setNotifs(list);
      setNotifUnread(c.count);
    } catch {
      // best-effort; SSE will reconcile
    }
  }, []);

  useEffect(() => {
    if (auth.user) reloadNotifs();
  }, [auth.user, reloadNotifs]);

  // Replace 30s polling with SSE. Server pushes board.invalidated on every
  // mutation, message.created on chat traffic and notification.created on
  // per-user notifications. We refetch /api/board on invalidate (cheap +
  // keeps merge logic simple) and patch notification state in-place.
  useEventStream(
    useMemo(
      () => ({
        "board.invalidated": () => {
          debouncedReload();
        },
        "notification.created": (payload: unknown) => {
          const n = payload as Notification;
          if (!n || !n.id) return;
          setNotifs((prev) => (prev.some((x) => x.id === n.id) ? prev : [n, ...prev].slice(0, 100)));
          setNotifUnread((c) => c + 1);
          const who = n.actor_name || "Alguien";
          const card = n.card_seq_num ? `#${n.card_seq_num}` : n.card_title;
          notifications.show({
            autoClose: 4000,
            color: n.kind === "mention" ? "grape" : "blue",
            title: `${who} en ${card}`,
            message: n.snippet,
          });
        },
        "notification.read": (payload: unknown) => {
          const p = payload as { id?: string } | null;
          if (!p?.id) return;
          setNotifs((prev) => prev.map((x) => (x.id === p.id ? { ...x, read_at: new Date().toISOString() } : x)));
          setNotifUnread((c) => Math.max(0, c - 1));
        },
        "notification.read_all": () => {
          setNotifs((prev) => prev.map((x) => (x.read_at ? x : { ...x, read_at: new Date().toISOString() })));
          setNotifUnread(0);
        },
      }),
      [debouncedReload],
    ),
    !!auth.user,
  );

  useEffect(() => {
    if (!activeSticker) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") setActiveSticker(null);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [activeSticker]);

  const usersById = useMemo(() => {
    const m = new Map<string, User>();
    for (const u of users) m.set(u.id, u);
    return m;
  }, [users]);

  const sortedColumns = useMemo(() => {
    if (!board) return [];
    return [...board.columns].sort((a, b) => a.position - b.position);
  }, [board]);

  const boardColumns = useMemo(() => sortedColumns.filter((c) => c.location !== "sidebar"), [sortedColumns]);
  const sidebarColumns = useMemo(() => sortedColumns.filter((c) => c.location === "sidebar"), [sortedColumns]);

  const boardSortableIds = useMemo(() => boardColumns.map((c) => `${COL_PREFIX}${c.id}`), [boardColumns]);
  const sidebarSortableIds = useMemo(() => sidebarColumns.map((c) => `${COL_PREFIX}${c.id}`), [sidebarColumns]);

  const cardMatches = useCallback(
    (c: Card): boolean => {
      const term = searchTerm.trim().toLowerCase();
      if (term) {
        const seqStr = c.seq_num > 0 ? String(c.seq_num) : "";
        const seqPadded = c.seq_num > 0 ? String(c.seq_num).padStart(5, "0") : "";
        const hay = [
          c.title,
          c.description,
          c.requester,
          seqStr,
          seqPadded,
          ...(c.tags || []),
        ]
          .filter(Boolean)
          .join(" ")
          .toLowerCase();
        const normalizedTerm = term.replace(/^#/, "").replace(/^0+(?=\d)/, "");
        if (!hay.includes(term) && !(normalizedTerm && hay.includes(normalizedTerm))) return false;
      }
      if (filterAssigneeId && c.assignee_id !== filterAssigneeId) return false;
      if (filterUnassigned && c.assignee_id) return false;
      if (filterRequester && c.requester !== filterRequester) return false;
      if (filterTags.length > 0) {
        const cardTags = new Set(c.tags || []);
        for (const t of filterTags) if (!cardTags.has(t)) return false;
      }
      if (filterDeadlineOnly && !c.deadline) return false;
      if (filterDateFrom || filterDateTo) {
        const fromMs = filterDateFrom ? new Date(filterDateFrom).setHours(0, 0, 0, 0) : -Infinity;
        const toMs = filterDateTo ? new Date(filterDateTo).setHours(23, 59, 59, 999) : Infinity;
        const created = c.created_at ? new Date(c.created_at).getTime() : NaN;
        const moved = c.entered_at ? new Date(c.entered_at).getTime() : NaN;
        const inRange = (t: number) => !isNaN(t) && t >= fromMs && t <= toMs;
        if (!inRange(created) && !inRange(moved)) return false;
      }
      return true;
    },
    [searchTerm, filterAssigneeId, filterUnassigned, filterRequester, filterTags, filterDateFrom, filterDateTo, filterDeadlineOnly]
  );

  const cardsByColumn = useMemo(() => {
    const map = new Map<string, Card[]>();
    if (!board) return map;
    for (const col of board.columns) map.set(col.id, []);
    for (const c of [...board.cards].sort((a, b) => a.position - b.position)) {
      if (!cardMatches(c)) continue;
      const arr = map.get(c.column_id);
      if (arr) arr.push(c);
    }
    return map;
  }, [board, cardMatches]);

  const filtersActive =
    !!searchTerm.trim() ||
    !!filterAssigneeId ||
    filterUnassigned ||
    !!filterRequester ||
    filterTags.length > 0 ||
    !!filterDateFrom ||
    !!filterDateTo ||
    filterDeadlineOnly;

  const findCard = (id: string): Card | undefined => board?.cards.find((c) => c.id === id);
  const findColumn = (id: string): Column | undefined => board?.columns.find((c) => c.id === id);
  const findColumnIdOfCard = (id: string): string | undefined => findCard(id)?.column_id;

  const isColumnId = (id: string) => id.startsWith(COL_PREFIX);
  const stripColumnPrefix = (id: string) => id.slice(COL_PREFIX.length);

  const resolveColumnId = (overId: string): string | undefined => {
    if (!board) return undefined;
    if (isColumnId(overId)) return stripColumnPrefix(overId);
    return findColumnIdOfCard(overId);
  };

  // --- DnD handlers ---

  const onDragStart = (e: DragStartEvent) => {
    const id = e.active.id as string;
    const type = e.active.data.current?.type as string | undefined;
    setActiveType(type);
    if (type === "column") {
      setActiveColumnId(stripColumnPrefix(id));
      return;
    }
    const c = findCard(id);
    if (c) setActiveCard(c);
  };

  const onDragOver = (e: DragOverEvent) => {
    if (!board) return;
    if (e.active.data.current?.type !== "card") return;

    const activeId = e.active.id as string;
    const overId = e.over?.id as string | undefined;
    if (!overId) return;

    const fromCol = findColumnIdOfCard(activeId);
    const toCol = resolveColumnId(overId);
    if (!fromCol || !toCol || fromCol === toCol) return;

    setBoard((prev) => {
      if (!prev) return prev;
      const cards = prev.cards.map((c) => (c.id === activeId ? { ...c, column_id: toCol } : c));
      return { ...prev, cards };
    });
  };

  const onDragEnd = async (e: DragEndEvent) => {
    const type = e.active.data.current?.type as string | undefined;
    const activeId = e.active.id as string;
    const overId = e.over?.id as string | undefined;
    setActiveCard(null);
    setActiveColumnId(null);
    setActiveType(undefined);

    if (!board || !overId) return;

    if (type === "column") {
      if (!isColumnId(overId)) return;
      const activeColId = stripColumnPrefix(activeId);
      const overColId = stripColumnPrefix(overId);
      if (activeColId === overColId) return;

      const activeCol = findColumn(activeColId);
      const overCol = findColumn(overColId);
      if (!activeCol || !overCol) return;

      // Determine destination location: same as the column it was dropped on.
      const destLocation: ColumnLocation = overCol.location;
      const destSiblings = sortedColumns.filter((c) => c.location === destLocation);
      const destIds = destSiblings.map((c) => c.id);
      const oldIdx = destIds.indexOf(activeColId);
      const newIdx = destIds.indexOf(overColId);

      let reordered: string[];
      if (oldIdx === -1) {
        // Coming from another location: append at overCol position.
        const insertAt = newIdx === -1 ? destIds.length : newIdx;
        reordered = [...destIds.slice(0, insertAt), activeColId, ...destIds.slice(insertAt)];
      } else {
        if (oldIdx === newIdx) return;
        reordered = arrayMove(destIds, oldIdx, newIdx);
      }

      // Optimistic update.
      setBoard((prev) => {
        if (!prev) return prev;
        const posMap = new Map(reordered.map((id, i) => [id, i]));
        const columns = prev.columns.map((c) => {
          if (c.id === activeColId) return { ...c, location: destLocation, position: posMap.get(c.id) ?? c.position };
          if (posMap.has(c.id)) return { ...c, position: posMap.get(c.id)! };
          return c;
        });
        return { ...prev, columns };
      });

      try {
        if (activeCol.location !== destLocation) {
          await api.updateColumn(activeColId, { location: destLocation });
        }
        await api.reorderColumns(reordered);
      } catch (err) {
        notifications.show({ color: "red", message: (err as Error).message });
      }
      reload();
      return;
    }

    // Card drag
    const destCol = resolveColumnId(overId);
    if (!destCol) return;
    const activeCard = board.cards.find((c) => c.id === activeId);
    if (activeCard?.locked && activeCard.column_id !== destCol) {
      notifications.show({ color: "yellow", message: "Card bloqueada: no se puede mover entre columnas" });
      reload();
      return;
    }
    const destCards = board.cards
      .filter((c) => c.column_id === destCol)
      .sort((a, b) => a.position - b.position);
    const oldIdx = destCards.findIndex((c) => c.id === activeId);

    let orderedIds: string[];
    if (isColumnId(overId) || oldIdx === -1) {
      orderedIds = [...destCards.filter((c) => c.id !== activeId).map((c) => c.id), activeId];
    } else {
      const newIdx = destCards.findIndex((c) => c.id === overId);
      orderedIds = arrayMove(destCards.map((c) => c.id), oldIdx, newIdx);
    }

    setBoard((prev) => {
      if (!prev) return prev;
      const orderMap = new Map(orderedIds.map((id, i) => [id, i]));
      const cards = prev.cards.map((c) => {
        if (c.column_id === destCol && orderMap.has(c.id)) return { ...c, position: orderMap.get(c.id)! };
        return c;
      });
      return { ...prev, cards };
    });

    try {
      await api.moveCard(activeId, destCol, orderedIds);
    } catch (err) {
      notifications.show({ color: "red", message: (err as Error).message });
    }
    reload();
  };

  // --- mutations ---

  const handleAddColumn = async () => {
    const n = colName.trim();
    if (!n) return;
    try {
      await api.createColumn(n);
      setColName("");
      setAddingCol(false);
      reload();
    } catch (e) {
      notifications.show({ color: "red", message: (e as Error).message });
    }
  };

  const handleRenameColumn = useCallback(async (id: string, name: string) => {
    try {
      await api.updateColumn(id, { name });
      reload();
    } catch (e) {
      notifications.show({ color: "red", message: (e as Error).message });
    }
  }, [reload]);

  const handleResizeColumn = useCallback(async (id: string, width: number) => {
    try {
      await api.updateColumn(id, { width });
      reload();
    } catch (e) {
      notifications.show({ color: "red", message: (e as Error).message });
    }
  }, [reload]);

  const handleMoveColumnLocation = useCallback(async (id: string, location: ColumnLocation) => {
    try {
      await api.updateColumn(id, { location });
      reload();
    } catch (e) {
      notifications.show({ color: "red", message: (e as Error).message });
    }
  }, [reload]);

  const handleDeleteColumn = useCallback((id: string) => {
    modals.openConfirmModal({
      title: "Eliminar columna",
      children: <Text size="sm">Se borraran todas sus tarjetas. Continuar?</Text>,
      labels: { confirm: "Eliminar", cancel: "Cancelar" },
      confirmProps: { color: "red" },
      onConfirm: async () => {
        try {
          await api.deleteColumn(id);
          reload();
        } catch (e) {
          notifications.show({ color: "red", message: (e as Error).message });
        }
      },
    });
  }, [reload]);

  const openCreateCard = useCallback((columnId: string) => {
    const id = modals.open({
      title: "Nueva tarjeta",
      size: "md",
      children: (
        <CardForm
          users={users}
          requesterOptions={requesterOptions}
          tagOptions={tagOptions}
          initial={{ requester: "" }}
          submitLabel="Crear"
          onCancel={() => modals.close(id)}
          onSubmit={async (v) => {
            try {
              await api.createCard({
                column_id: columnId,
                requester: v.requester,
                title: v.title,
                description: v.description,
                assignee_id: v.assignee_id,
                tags: v.tags,
              });
              modals.close(id);
              reload();
              reloadTags();
              reloadRequesters();
            } catch (e) {
              notifications.show({ color: "red", message: (e as Error).message });
            }
          }}
        />
      ),
    });
  }, [reload, users, auth.user, requesterOptions, tagOptions]);

  const openEditCard = useCallback((card: Card, options?: { highlightMessageId?: string }) => {
    const id = modals.open({
      title: "Editar tarjeta",
      size: "85%",
      children: (
        <CardEditPanel
          card={card}
          users={users}
          currentUserId={auth.user?.id}
          requesterOptions={requesterOptions}
          tagOptions={tagOptions}
          highlightMessageId={options?.highlightMessageId}
          onCancel={() => modals.close(id)}
          onSubmit={async (v) => {
            try {
              await api.updateCard(card.id, {
                requester: v.requester,
                title: v.title,
                description: v.description,
                assignee_id: v.assignee_id,
                tags: v.tags,
              });
              modals.close(id);
              reload();
              reloadTags();
              reloadRequesters();
            } catch (e) {
              notifications.show({ color: "red", message: (e as Error).message });
            }
          }}
        />
      ),
    });
  }, [reload, users, auth.user, requesterOptions, tagOptions]);

  const handleDuplicateCard = useCallback(async (cardId: string) => {
    try {
      const dup = await api.duplicateCard(cardId);
      await reload();
      notifications.show({ color: "teal", message: `Duplicada: ${dup.title}` });
    } catch (e) {
      notifications.show({ color: "red", message: (e as Error).message });
    }
  }, [reload]);

  const handleSetRequester = useCallback(async (id: string, requester: string) => {
    setBoard((prev) => {
      if (!prev) return prev;
      return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, requester } : c)) };
    });
    try {
      await api.updateCard(id, { requester });
    } catch (e) {
      notifications.show({ color: "red", message: (e as Error).message });
      reload();
    }
  }, [reload]);

  const handleJumpToCard = useCallback((cardId: string) => {
    setActiveTab("board");
    setHighlightCardId(cardId);
    window.setTimeout(() => setHighlightCardId(null), 3000);
  }, []);

  const handleOpenDailyReport = useCallback((date: string) => {
    const id = modals.open({
      title: "Reporte diario",
      size: "90%",
      children: (
        <DailyReportView
          date={date}
          onJumpToCard={(cardId) => {
            modals.close(id);
            handleJumpToCard(cardId);
          }}
        />
      ),
    });
  }, [handleJumpToCard]);

  const handleSetCardDeadline = useCallback(async (id: string, deadline: string | null) => {
    setBoard((prev) => {
      if (!prev) return prev;
      return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, deadline } : c)) };
    });
    try {
      await api.updateCard(id, { deadline });
    } catch (e) {
      notifications.show({ color: "red", message: (e as Error).message });
      reload();
    }
  }, [reload]);

  const handleAssignCard = useCallback(async (id: string, assignee_id: string | null) => {
    setBoard((prev) => {
      if (!prev) return prev;
      return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, assignee_id } : c)) };
    });
    try {
      await api.updateCard(id, { assignee_id });
    } catch (e) {
      notifications.show({ color: "red", message: (e as Error).message });
      reload();
    }
  }, [reload]);

  const handleDeleteCard = useCallback(async (id: string) => {
    try {
      await api.deleteCard(id);
      reload();
      reloadTrash();
    } catch (e) {
      notifications.show({ color: "red", message: (e as Error).message });
    }
  }, [reload, reloadTrash]);

  const handleRestoreCard = useCallback(async (id: string) => {
    try {
      await api.restoreCard(id);
      reload();
      reloadTrash();
    } catch (e) {
      notifications.show({ color: "red", message: (e as Error).message });
    }
  }, [reload, reloadTrash]);

  const handleUnarchiveCard = useCallback(async (id: string) => {
    try {
      await api.unarchiveCard(id);
      reload();
      reloadArchive();
    } catch (e) {
      notifications.show({ color: "red", message: (e as Error).message });
    }
  }, [reload, reloadArchive]);

  const handleArchiveCard = useCallback(async (id: string) => {
    try {
      await api.archiveCard(id);
      reload();
      reloadArchive();
    } catch (e) {
      notifications.show({ color: "red", message: (e as Error).message });
    }
  }, [reload, reloadArchive]);

  const handlePurgeCard = useCallback(async (id: string) => {
    modals.openConfirmModal({
      title: "Borrar permanentemente",
      children: <Text size="sm">Esta accion no se puede deshacer.</Text>,
      labels: { confirm: "Borrar", cancel: "Cancelar" },
      confirmProps: { color: "red" },
      onConfirm: async () => {
        try {
          await api.purgeCard(id);
          reloadTrash();
        } catch (e) {
          notifications.show({ color: "red", message: (e as Error).message });
        }
      },
    });
  }, [reloadTrash]);

  const handleChangeCardColor = useCallback(async (id: string, color: CardColor) => {
    setBoard((prev) => {
      if (!prev) return prev;
      return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, color } : c)) };
    });
    try {
      await api.updateCard(id, { color });
    } catch (e) {
      notifications.show({ color: "red", message: (e as Error).message });
      reload();
    }
  }, [reload]);

  const persistStickers = useCallback(async (id: string, stickers: Card["stickers"]) => {
    setBoard((prev) => {
      if (!prev) return prev;
      return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, stickers } : c)) };
    });
    try {
      await api.updateCardStickers(id, stickers);
    } catch (e) {
      notifications.show({ color: "red", message: (e as Error).message });
      reload();
    }
  }, [reload]);

  const handleAddSticker = useCallback((cardId: string, x: number, y: number) => {
    if (!activeSticker) return;
    setBoard((prev) => {
      if (!prev) return prev;
      const cards = prev.cards.map((c) => {
        if (c.id !== cardId) return c;
        const stickers = [...(c.stickers || []), { emoji: activeSticker, x, y }];
        api.updateCardStickers(cardId, stickers).catch((e) => {
          notifications.show({ color: "red", message: (e as Error).message });
          reload();
        });
        return { ...c, stickers };
      });
      return { ...prev, cards };
    });
  }, [activeSticker, reload]);

  const handleRemoveSticker = useCallback((cardId: string, index: number) => {
    setBoard((prev) => {
      if (!prev) return prev;
      const cards = prev.cards.map((c) => {
        if (c.id !== cardId) return c;
        const stickers = (c.stickers || []).filter((_, i) => i !== index);
        api.updateCardStickers(cardId, stickers).catch((e) => {
          notifications.show({ color: "red", message: (e as Error).message });
          reload();
        });
        return { ...c, stickers };
      });
      return { ...prev, cards };
    });
  }, [reload]);

  const handleMoveSticker = useCallback((cardId: string, index: number, x: number, y: number) => {
    setBoard((prev) => {
      if (!prev) return prev;
      const cards = prev.cards.map((c) => {
        if (c.id !== cardId) return c;
        const stickers = (c.stickers || []).map((s, i) => (i === index ? { ...s, x, y } : s));
        return { ...c, stickers };
      });
      return { ...prev, cards };
    });
  }, []);

  const handleCommitSticker = useCallback((cardId: string) => {
    setBoard((prev) => {
      if (!prev) return prev;
      const card = prev.cards.find((c) => c.id === cardId);
      if (card) persistStickers(cardId, card.stickers || []);
      return prev;
    });
  }, [persistStickers]);

  const handleShowHistory = useCallback((card: Card) => {
    modals.open({
      title: card.title,
      size: "md",
      children: <HistoryModal card={card} columns={board?.columns ?? []} />,
    });
  }, [board?.columns]);

  const handleToggleCardLock = useCallback(async (id: string, locked: boolean) => {
    setBoard((prev) => {
      if (!prev) return prev;
      return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, locked } : c)) };
    });
    try {
      await api.updateCard(id, { locked });
    } catch (e) {
      notifications.show({ color: "red", message: (e as Error).message });
      reload();
    }
  }, [reload]);

  const handleSetWIPLimit = useCallback(async (id: string, wip_limit: number) => {
    setBoard((prev) => {
      if (!prev) return prev;
      return { ...prev, columns: prev.columns.map((c) => (c.id === id ? { ...c, wip_limit } : c)) };
    });
    try {
      await api.updateColumn(id, { wip_limit });
    } catch (e) {
      notifications.show({ color: "red", message: (e as Error).message });
      reload();
    }
  }, [reload]);

  // Issue 0090: ruleta de seleccion aleatoria por columna.
  // Recorre las cards visibles (post-filtro) no bloqueadas con highlight
  // acelerado-decelerado y termina con flash verde sobre la ganadora.
  const handlePickRandom = useCallback((columnId: string) => {
    const cards = (cardsByColumn.get(columnId) || []).filter((c) => !c.locked);
    if (cards.length === 0) {
      notifications.show({ color: "yellow", message: "No hay cards disponibles (filtro y bloqueadas excluidas)" });
      return;
    }
    if (cards.length === 1) {
      const el = document.querySelector<HTMLElement>(`[data-card-id="${cards[0].id}"]`);
      if (el) {
        el.scrollIntoView({ behavior: "smooth", block: "center" });
        el.classList.add("kanban-roulette-winner");
        setTimeout(() => el.classList.remove("kanban-roulette-winner"), 1700);
      }
      return;
    }

    // Decide ganadora con seguridad criptografica.
    const winnerIdx = (() => {
      const buf = new Uint32Array(1);
      crypto.getRandomValues(buf);
      return buf[0] % cards.length;
    })();

    // Total steps: minimo 2 vueltas completas + offset hasta la ganadora.
    const baseLaps = 2;
    const totalSteps = baseLaps * cards.length + ((winnerIdx - 0 + cards.length) % cards.length);

    // Decay temporal: empieza rapido (50ms), termina lento (220ms).
    const startMs = 50;
    const endMs = 220;
    const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);

    let step = 0;
    const tick = () => {
      const idx = step % cards.length;
      const prevIdx = (idx - 1 + cards.length) % cards.length;
      const prevEl = document.querySelector<HTMLElement>(`[data-card-id="${cards[prevIdx].id}"]`);
      const currEl = document.querySelector<HTMLElement>(`[data-card-id="${cards[idx].id}"]`);
      if (prevEl) prevEl.classList.remove("kanban-roulette-active");
      if (currEl) {
        currEl.classList.add("kanban-roulette-active");
        currEl.scrollIntoView({ behavior: "smooth", block: "center" });
      }
      step++;
      if (step > totalSteps) {
        if (currEl) {
          currEl.classList.remove("kanban-roulette-active");
          currEl.classList.add("kanban-roulette-winner");
          setTimeout(() => currEl.classList.remove("kanban-roulette-winner"), 1700);
        }
        return;
      }
      const t = totalSteps > 0 ? step / totalSteps : 1;
      const delay = startMs + (endMs - startMs) * easeOut(t);
      setTimeout(tick, delay);
    };
    tick();
  }, [cardsByColumn]);

  const handleSetMaxTimeMinutes = useCallback(async (id: string, max_time_minutes: number) => {
    setBoard((prev) => {
      if (!prev) return prev;
      return { ...prev, columns: prev.columns.map((c) => (c.id === id ? { ...c, max_time_minutes } : c)) };
    });
    try {
      await api.updateColumn(id, { max_time_minutes });
    } catch (e) {
      notifications.show({ color: "red", message: (e as Error).message });
      reload();
    }
  }, [reload]);

  const handleToggleDone = useCallback(async (id: string, is_done: boolean) => {
    setBoard((prev) => {
      if (!prev) return prev;
      return { ...prev, columns: prev.columns.map((c) => (c.id === id ? { ...c, is_done } : c)) };
    });
    try {
      await api.updateColumn(id, { is_done });
      reload();
    } catch (e) {
      notifications.show({ color: "red", message: (e as Error).message });
      reload();
    }
  }, [reload]);

  const headerConfig = useMemo(() => ({ height: 50 }), []);
  const navbarConfig = useMemo(
    () => ({
      width: navWidth,
      breakpoint: "md" as const,
      collapsed: { mobile: !navOpen, desktop: !navOpen },
    }),
    [navWidth, navOpen]
  );
  const asideConfig = useMemo(
    () => ({
      width: 380,
      breakpoint: "md" as const,
      collapsed: { mobile: !chatOpen, desktop: !chatOpen },
    }),
    [chatOpen]
  );
  const appShellStyles = useMemo(
    () => ({ main: { paddingInlineStart: 0, paddingInlineEnd: 0 } }),
    []
  );

  if (!board) {
    return (
      <Group justify="center" p="xl">
        <Loader />
      </Group>
    );
  }

  const dragOverlayCard = activeCard;
  const dragOverlayColumn = activeColumnId ? findColumn(activeColumnId) : null;

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={makeCollisionDetection(activeType)}
      onDragStart={onDragStart}
      onDragOver={onDragOver}
      onDragEnd={onDragEnd}
    >
      {/* Issue 0091 — drag-aware left edge strip; opens sidebar on hover>=400ms */}
      <div
        className={
          "kanban-drag-edge" +
          (isDragging ? " is-active" : "") +
          (edgeArmed ? " is-armed" : "")
        }
        data-test="kanban-drag-edge"
        data-active={isDragging ? "1" : "0"}
        data-armed={edgeArmed ? "1" : "0"}
        aria-hidden="true"
      />
      <AppShell
        header={headerConfig}
        navbar={navbarConfig}
        aside={asideConfig}
        padding={0}
        styles={appShellStyles}
      >
        <AppShell.Header>
          <Group h="100%" px="md" justify="space-between">
            <Group gap={6}>
              <ActionIcon
                variant={navOpen ? "filled" : "subtle"}
                onClick={() => setNavOpen((v) => !v)}
                aria-label="Toggle sidebar"
              >
                <IconMenu2 size={16} />
              </ActionIcon>
              <IconLayoutKanban size={22} />
              <Title order={4}>Kanban</Title>
              <Tabs value={activeTab} onChange={(v) => v && setActiveTab(v)} variant="pills" ml="md">
                <Tabs.List>
                  <Tabs.Tab value="board" leftSection={<IconLayoutKanban size={14} />}>
                    Tablero
                  </Tabs.Tab>
                  <Tabs.Tab value="dashboard" leftSection={<IconChartBar size={14} />}>
                    Dashboard
                  </Tabs.Tab>
                  <Tabs.Tab value="calendar" leftSection={<IconCalendar size={14} />}>
                    Calendario
                  </Tabs.Tab>
                </Tabs.List>
              </Tabs>
            </Group>
            <Group gap={4}>
              <ActionIcon variant="subtle" onClick={reload} aria-label="Refresh">
                <IconRefresh size={16} />
              </ActionIcon>
              {auth.user && (
                <NotificationsBell
                  unreadCount={notifUnread}
                  notifications={notifs}
                  onOpenCard={async (cardId, messageId) => {
                    // Resolve the card across all possible buckets: live
                    // board, refreshed board, archive, trash. Notifications
                    // can point at any of them.
                    const find = (cs?: Card[]) => cs?.find((c) => c.id === cardId);
                    let card = find(board?.cards);
                    if (!card) {
                      await reload();
                      const fresh = await api.getBoard();
                      card = find(fresh.cards);
                    }
                    if (!card) {
                      const archived = await api.listArchive();
                      card = find(archived);
                    }
                    if (!card) {
                      const trashed = await api.listTrash();
                      card = find(trashed);
                    }
                    if (!card) {
                      notifications.show({ color: "red", message: "Card no encontrada" });
                      return;
                    }
                    openEditCard(card, { highlightMessageId: messageId });
                  }}
                  onChanged={reloadNotifs}
                />
              )}
              <ActionIcon
                variant={chatOpen ? "filled" : "subtle"}
                onClick={() => setChatOpen((v) => !v)}
                aria-label="Toggle chat"
              >
                <IconMessageChatbot size={16} />
              </ActionIcon>
              {auth.user && (
                <Menu position="bottom-end" shadow="md" withArrow closeOnItemClick={false}>
                  <Menu.Target>
                    <ActionIcon variant="subtle" aria-label="Usuario">
                      <Avatar size={26} radius="xl" color={auth.user.color || "blue"}>
                        {(auth.user.display_name || auth.user.username).slice(0, 2).toUpperCase()}
                      </Avatar>
                    </ActionIcon>
                  </Menu.Target>
                  <Menu.Dropdown>
                    <Menu.Label>
                      <Group justify="space-between" gap={6} wrap="nowrap">
                        <Text size="xs" fw={600} truncate>
                          {auth.user.display_name || auth.user.username}
                        </Text>
                        {appVersion && (
                          <Text size="xs" c="dimmed" ff="monospace">v{appVersion}</Text>
                        )}
                      </Group>
                    </Menu.Label>
                    <Box p="xs">
                      <Text size="xs" c="dimmed" mb={4}>Color del avatar</Text>
                      <ColorPickerGrid
                        value={auth.user.color || ""}
                        onChange={async (c) => {
                          try {
                            const u = await api.updateMe({ color: c });
                            auth.setUser(u);
                          } catch (e) {
                            notifications.show({ color: "red", message: (e as Error).message });
                          }
                        }}
                        options={AVATAR_COLORS}
                        onOpenCustom={() => {
                          setAvatarCustomColor(auth.user?.color?.startsWith("#") ? auth.user.color : "#888888");
                          setAvatarColorModalOpen(true);
                        }}
                      />
                    </Box>
                    <Menu.Divider />
                    {auth.user.is_admin && (
                      <Menu.Item
                        leftSection={<IconPlug size={14} />}
                        onClick={() => setModulesOpen(true)}
                      >
                        Modulos
                      </Menu.Item>
                    )}
                    {auth.user.is_admin && (
                      <Menu.Item
                        leftSection={<IconBrandJira size={14} />}
                        onClick={() => setJiraImportOpen(true)}
                      >
                        Importar de Jira
                      </Menu.Item>
                    )}
                    <Menu.Item
                      leftSection={<IconKey size={14} />}
                      onClick={() => setMcpTokensOpen(true)}
                    >
                      MCP tokens
                    </Menu.Item>
                    <Menu.Item
                      leftSection={<IconLogout size={14} />}
                      color="red"
                      onClick={() => auth.logout()}
                    >
                      Cerrar sesion
                    </Menu.Item>
                  </Menu.Dropdown>
                </Menu>
              )}
              {auth.user?.is_admin && (
                <ModulesModal opened={modulesOpen} onClose={() => setModulesOpen(false)} />
              )}
              {auth.user?.is_admin && board && (
                <ImportJiraModal
                  opened={jiraImportOpen}
                  onClose={() => setJiraImportOpen(false)}
                  columns={board.columns}
                  onImported={() => reload()}
                />
              )}
              <MCPTokensModal opened={mcpTokensOpen} onClose={() => setMcpTokensOpen(false)} />
            </Group>
          </Group>
        </AppShell.Header>

        <AppShell.Navbar p="xs">
          {/* Drag handle to resize navbar — absolute relative to navbar (which is position:fixed in v9) */}
          <Box
            onMouseDown={onNavResizeMouseDown}
            style={{
              position: "absolute",
              top: 0,
              right: -3,
              width: 6,
              height: "100%",
              cursor: "col-resize",
              zIndex: 10,
            }}
            aria-label="Resize sidebar"
          />
          <Stack gap="xs" h="100%">
            <Text size="xs" c="dimmed" fw={600} tt="uppercase">
              Columnas parqueadas
            </Text>
            <Box style={{ flex: 1, overflowY: "auto" }}>
              <SortableContext items={sidebarSortableIds} strategy={verticalListSortingStrategy}>
                <Stack gap="xs">
                  {sidebarColumns.length === 0 && (
                    <Text size="xs" c="dimmed">
                      Vacio. Mueve columnas aqui con el icono "archivar" en su cabecera.
                    </Text>
                  )}
                  {sidebarColumns.map((col) => (
                    <KanbanColumn
                      key={col.id}
                      column={col}
                      cards={cardsByColumn.get(col.id) ?? []}
                      now={now}
                      collapsed
                      onAddCard={openCreateCard}
                      onRenameColumn={handleRenameColumn}
                      onResizeColumn={handleResizeColumn}
                      onMoveColumnLocation={handleMoveColumnLocation}
                      onDeleteColumn={handleDeleteColumn}
                      onSetWIPLimit={handleSetWIPLimit}
                      onSetMaxTimeMinutes={handleSetMaxTimeMinutes}
                      onPickRandom={handlePickRandom}
                      onToggleDone={handleToggleDone}
                      onEditCard={openEditCard}
                      onDeleteCard={handleDeleteCard}
                      onDuplicateCard={handleDuplicateCard}
                      onChangeCardColor={handleChangeCardColor}
                      onShowHistory={handleShowHistory}
                      onToggleCardLock={handleToggleCardLock}
                      onAssignCard={handleAssignCard}
                      onSetCardDeadline={handleSetCardDeadline}
                      highlightCardId={highlightCardId}
                      onSetRequester={handleSetRequester}
                    onArchiveCard={handleArchiveCard}
                      requesterOptions={requesterOptions}
                      onOpenCustomCardColor={(cardId, current) => setCardColorModal({ cardId, color: current })}
                      activeSticker={activeSticker}
                      onAddSticker={handleAddSticker}
                      onRemoveSticker={handleRemoveSticker}
                      onMoveSticker={handleMoveSticker}
                      onCommitSticker={handleCommitSticker}
                      users={users}
                      usersById={usersById}
                    />
                  ))}
                </Stack>
              </SortableContext>
            </Box>
            <Box style={{ borderTop: "1px solid var(--mantine-color-dark-5)", paddingTop: 8 }}>
              <Button
                variant="subtle"
                color="gray"
                size="xs"
                fullWidth
                justify="space-between"
                leftSection={<IconTrash size={14} />}
                rightSection={
                  <Group gap={4}>
                    <Badge size="xs" variant="light" color={trash.length > 0 ? "red" : "gray"}>
                      {trash.length}
                    </Badge>
                    {trashOpen ? <IconChevronDown size={12} /> : <IconChevronRight size={12} />}
                  </Group>
                }
                onClick={() => setTrashOpen((v) => !v)}
              >
                Papelera
              </Button>
              {trashOpen && (
                <Stack gap={4} mt={4} style={{ maxHeight: 220, overflowY: "auto" }}>
                  {trash.length === 0 && (
                    <Text size="xs" c="dimmed" px="xs">
                      Vacia.
                    </Text>
                  )}
                  {trash.map((c) => (
                    <Paper key={c.id} p={6} withBorder radius="sm" bg="dark.7">
                      <Group justify="space-between" gap={4} wrap="nowrap">
                        <Text size="xs" truncate style={{ flex: 1 }} title={c.title}>
                          {c.title}
                        </Text>
                        <Tooltip label="Restaurar" withArrow>
                          <ActionIcon size="xs" variant="subtle" color="green" onClick={() => handleRestoreCard(c.id)}>
                            <IconArrowBackUp size={12} />
                          </ActionIcon>
                        </Tooltip>
                        <Tooltip label="Borrar permanentemente" withArrow>
                          <ActionIcon size="xs" variant="subtle" color="red" onClick={() => handlePurgeCard(c.id)}>
                            <IconTrashX size={12} />
                          </ActionIcon>
                        </Tooltip>
                      </Group>
                    </Paper>
                  ))}
                </Stack>
              )}
            </Box>
            <Box style={{ borderTop: "1px solid var(--mantine-color-dark-5)", paddingTop: 8 }}>
              <Button
                variant="subtle"
                color="gray"
                size="xs"
                fullWidth
                justify="space-between"
                leftSection={<IconCheck size={14} />}
                rightSection={
                  <Group gap={4}>
                    <Badge size="xs" variant="light" color={archive.length > 0 ? "teal" : "gray"}>
                      {archive.length}
                    </Badge>
                    {archiveOpen ? <IconChevronDown size={12} /> : <IconChevronRight size={12} />}
                  </Group>
                }
                onClick={() => setArchiveOpen((v) => !v)}
                data-test="archive-toggle"
              >
                Hecho (archivo)
              </Button>
              {archiveOpen && (
                <Stack gap={4} mt={4} style={{ maxHeight: 220, overflowY: "auto" }}>
                  {archive.length === 0 && (
                    <Text size="xs" c="dimmed" px="xs">
                      Sin cards archivadas.
                    </Text>
                  )}
                  {archive.map((c) => (
                    <Paper key={c.id} p={6} withBorder radius="sm" bg="dark.7" data-archived-card-id={c.id}>
                      <Group justify="space-between" gap={4} wrap="nowrap">
                        <Text size="xs" truncate style={{ flex: 1 }} title={c.title}>
                          {c.title}
                        </Text>
                        <Tooltip label="Sacar del archivo (volver a Hecho)" withArrow>
                          <ActionIcon size="xs" variant="subtle" color="teal" onClick={() => handleUnarchiveCard(c.id)}>
                            <IconArrowBackUp size={12} />
                          </ActionIcon>
                        </Tooltip>
                      </Group>
                    </Paper>
                  ))}
                </Stack>
              )}
            </Box>
          </Stack>
        </AppShell.Navbar>

        <AppShell.Aside>
          <ChatPanel onBoardChange={reload} />
        </AppShell.Aside>

        <AppShell.Main>
          {activeTab === "dashboard" ? (
            <Box style={{ height: "calc(100vh - 50px)", overflow: "auto" }}>
              <Dashboard users={users} />
            </Box>
          ) : activeTab === "calendar" ? (
            <Box style={{ height: "calc(100vh - 50px)", overflow: "auto" }}>
              <CalendarView users={users} cards={board.cards} onJumpToCard={handleJumpToCard} onOpenDailyReport={handleOpenDailyReport} />
            </Box>
          ) : (
          <Box style={{ height: "calc(100vh - 50px)", overflow: "hidden", display: "flex", flexDirection: "column" }}>
            <Group gap="xs" p="xs" wrap="wrap" align="end" style={{ borderBottom: "1px solid var(--mantine-color-dark-4)" }}>
              <TextInput
                leftSection={<IconSearch size={14} />}
                placeholder="Buscar (titulo, descripcion, solicitante, tag)"
                value={searchTerm}
                onChange={(e) => setSearchTerm(e.currentTarget.value)}
                rightSection={
                  searchTerm ? (
                    <ActionIcon size="sm" variant="subtle" color="gray" onClick={() => setSearchTerm("")} aria-label="Limpiar">
                      <IconX size={14} />
                    </ActionIcon>
                  ) : null
                }
                style={{ minWidth: 280, flex: 1 }}
                size="xs"
              />
              <Select
                placeholder="Asignado"
                value={filterAssigneeId}
                onChange={setFilterAssigneeId}
                data={users.map((u) => ({ value: u.id, label: u.display_name || u.username }))}
                clearable
                searchable
                size="xs"
                style={{ minWidth: 160 }}
                disabled={filterUnassigned}
              />
              <Checkbox
                size="xs"
                label="Sin asignar"
                checked={filterUnassigned}
                onChange={(e) => {
                  const v = e.currentTarget.checked;
                  setFilterUnassigned(v);
                  if (v) setFilterAssigneeId(null);
                }}
              />
              <Checkbox
                size="xs"
                label="Con deadline"
                checked={filterDeadlineOnly}
                onChange={(e) => setFilterDeadlineOnly(e.currentTarget.checked)}
              />
              <Select
                placeholder="Solicitante"
                value={filterRequester}
                onChange={setFilterRequester}
                data={requesterOptions}
                clearable
                searchable
                size="xs"
                style={{ minWidth: 160 }}
              />
              <MultiSelect
                placeholder="Tags"
                value={filterTags}
                onChange={setFilterTags}
                data={tagOptions}
                clearable
                searchable
                size="xs"
                style={{ minWidth: 200 }}
              />
              <DatePickerInput
                placeholder="Desde"
                value={filterDateFrom}
                onChange={(v) => setFilterDateFrom(v ? new Date(v as unknown as string) : null)}
                clearable
                size="xs"
                style={{ minWidth: 130 }}
                valueFormat="DD/MM/YY"
              />
              <DatePickerInput
                placeholder="Hasta"
                value={filterDateTo}
                onChange={(v) => setFilterDateTo(v ? new Date(v as unknown as string) : null)}
                clearable
                size="xs"
                style={{ minWidth: 130 }}
                valueFormat="DD/MM/YY"
              />
              <Group gap={4}>
                <Button size="xs" variant="default" onClick={() => {
                  const t = new Date();
                  setFilterDateFrom(t);
                  setFilterDateTo(t);
                }}>Hoy</Button>
                <Button size="xs" variant="default" onClick={() => {
                  const t = new Date();
                  const f = new Date();
                  f.setDate(f.getDate() - 7);
                  setFilterDateFrom(f);
                  setFilterDateTo(t);
                }}>7d</Button>
                <Button size="xs" variant="default" onClick={() => {
                  const t = new Date();
                  const f = new Date();
                  f.setDate(f.getDate() - 30);
                  setFilterDateFrom(f);
                  setFilterDateTo(t);
                }}>30d</Button>
              </Group>
              <StickerPicker
                opened={stickerPickerOpen}
                onClose={() => setStickerPickerOpen(false)}
                onSelect={(emoji) => setActiveSticker(emoji)}
                target={
                  <Button
                    size="xs"
                    variant={activeSticker ? "filled" : "default"}
                    color={activeSticker ? "yellow" : undefined}
                    leftSection={<IconMoodSmile size={14} />}
                    onClick={() => {
                      if (!activeSticker) {
                        setActiveSticker("😀");
                      } else {
                        setStickerPickerOpen((v) => !v);
                      }
                    }}
                  >
                    {activeSticker ? `Modo sticker: ${activeSticker}` : "Stickers"}
                  </Button>
                }
              />
              {activeSticker && (
                <Button
                  size="xs"
                  variant="subtle"
                  color="gray"
                  leftSection={<IconX size={12} />}
                  onClick={() => setActiveSticker(null)}
                >
                  ESC
                </Button>
              )}
              {filtersActive && (
                <Button
                  size="xs"
                  variant="subtle"
                  color="gray"
                  leftSection={<IconX size={12} />}
                  onClick={() => {
                    setSearchTerm("");
                    setFilterAssigneeId(null);
                    setFilterUnassigned(false);
                    setFilterRequester(null);
                    setFilterTags([]);
                    setFilterDateFrom(null);
                    setFilterDateTo(null);
                    setFilterDeadlineOnly(false);
                  }}
                >
                  Limpiar
                </Button>
              )}
            </Group>
            <SortableContext items={boardSortableIds} strategy={horizontalListSortingStrategy}>
              <Group
                align="stretch"
                wrap="nowrap"
                gap="md"
                p="md"
                style={{ flex: 1, overflowX: "auto", overflowY: "hidden" }}
              >
                {boardColumns.map((col) => (
                  <KanbanColumn
                    key={col.id}
                    column={col}
                    cards={cardsByColumn.get(col.id) ?? []}
                    now={now}
                    onAddCard={openCreateCard}
                    onRenameColumn={handleRenameColumn}
                    onResizeColumn={handleResizeColumn}
                    onMoveColumnLocation={handleMoveColumnLocation}
                    onDeleteColumn={handleDeleteColumn}
                    onSetWIPLimit={handleSetWIPLimit}
                      onSetMaxTimeMinutes={handleSetMaxTimeMinutes}
                      onPickRandom={handlePickRandom}
                    onToggleDone={handleToggleDone}
                    onEditCard={openEditCard}
                    onDeleteCard={handleDeleteCard}
                      onDuplicateCard={handleDuplicateCard}
                    onChangeCardColor={handleChangeCardColor}
                    onShowHistory={handleShowHistory}
                    onToggleCardLock={handleToggleCardLock}
                    onAssignCard={handleAssignCard}
                      onSetCardDeadline={handleSetCardDeadline}
                      highlightCardId={highlightCardId}
                    onSetRequester={handleSetRequester}
                    onArchiveCard={handleArchiveCard}
                    requesterOptions={requesterOptions}
                    activeSticker={activeSticker}
                    onAddSticker={handleAddSticker}
                    onRemoveSticker={handleRemoveSticker}
                    onMoveSticker={handleMoveSticker}
                    onCommitSticker={handleCommitSticker}
                    users={users}
                    usersById={usersById}
                  />
                ))}

                <Box style={{ minWidth: 280, maxWidth: 320 }}>
                  {addingCol ? (
                    <Stack gap={4}>
                      <TextInput
                        size="xs"
                        placeholder="Nombre de columna..."
                        value={colName}
                        onChange={(e) => setColName(e.currentTarget.value)}
                        autoFocus
                        onKeyDown={(e) => {
                          if (e.key === "Enter") handleAddColumn();
                          if (e.key === "Escape") {
                            setAddingCol(false);
                            setColName("");
                          }
                        }}
                      />
                      <Group gap={4}>
                        <Button size="xs" onClick={handleAddColumn}>
                          Anadir
                        </Button>
                        <ActionIcon variant="subtle" color="gray" onClick={() => setAddingCol(false)}>
                          <IconX size={14} />
                        </ActionIcon>
                      </Group>
                    </Stack>
                  ) : (
                    <Button
                      variant="light"
                      color="gray"
                      leftSection={<IconPlus size={14} />}
                      onClick={() => setAddingCol(true)}
                    >
                      Anadir columna
                    </Button>
                  )}
                </Box>
              </Group>
            </SortableContext>
          </Box>
          )}
        </AppShell.Main>

      </AppShell>

      <DragOverlay>
        {dragOverlayCard ? (
          <KanbanCard
            card={dragOverlayCard}
            now={now}
            onDelete={() => {}}
            onEdit={() => {}}
            onChangeColor={() => {}}
            onShowHistory={() => {}}
            onToggleLock={() => {}}
            onAssign={() => {}}
            users={users}
            assignee={dragOverlayCard.assignee_id ? usersById.get(dragOverlayCard.assignee_id) : undefined}
            isOverlay
          />
        ) : dragOverlayColumn ? (
          <Box
            style={{
              width: dragOverlayColumn.location === "sidebar" ? 220 : dragOverlayColumn.width,
              padding: 8,
              background: colorBg(""),
              border: `1px solid ${colorBorder("")}`,
              borderRadius: 8,
              opacity: 0.9,
            }}
          >
            <Text fw={600} size="sm">
              {dragOverlayColumn.name}
            </Text>
          </Box>
        ) : null}
      </DragOverlay>


      <CustomColorModal
        opened={avatarColorModalOpen}
        onClose={() => setAvatarColorModalOpen(false)}
        value={avatarCustomColor}
        onAccept={async (c) => {
          setAvatarCustomColor(c);
          try {
            const u = await api.updateMe({ color: c });
            auth.setUser(u);
          } catch (e) {
            notifications.show({ color: "red", message: (e as Error).message });
          }
        }}
      />

      <CustomColorModal
        opened={!!cardColorModal}
        onClose={() => setCardColorModal(null)}
        value={cardColorModal?.color || "#888888"}
        onAccept={(c) => {
          if (!cardColorModal) return;
          handleChangeCardColor(cardColorModal.cardId, c);
        }}
      />
    </DndContext>
  );
}
