import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useSearch } from "wouter";
import {
  ArrowLeft,
  ArrowRight,
  StickyNote,
  Pin,
  Archive,
  ArchiveRestore,
  Trash2,
  Palette,
  Tag,
  Search,
  Plus,
  X,
  Check,
  Send,
  Inbox,
  Mail,
  MessageCircle,
  ListTodo,
  LayoutGrid,
  List as ListIcon,
  Folder,
  Pencil,
  MoreVertical,
  ChevronLeft,
  Share2,
  Users,
  Lock,
} from "lucide-react";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Checkbox } from "@/components/ui/checkbox";
import { formatDistanceToNow } from "date-fns";
import { ar as dfAr, enUS as dfEn } from "date-fns/locale";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useVisualViewport } from "@/hooks/use-visual-viewport";
import { Badge } from "@/components/ui/badge";
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { toast } from "@/hooks/use-toast";
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import {
  type ChecklistItem,
  type Note,
  type NoteKind,
  type NoteLabel,
  type NoteThread,
  type ReceivedNote,
  type SentNote,
  type SentNoteRecipient,
  type UserSummary,
  NOTE_COLORS,
  colorBg,
  colorAccent,
  useArchiveReceivedNote,
  useBulkArchiveReceivedNotes,
  useDeleteReceivedNote,
  useBulkDeleteReceivedNotes,
  useCreateLabel,
  useCreateNote,
  useDeleteLabel,
  useDeleteNote,
  useBulkDeleteSentNotes,
  useMarkNoteRead,
  useDeleteFolder,
  useMoveNoteToFolder,
  useReorderNotes,
  useNoteFolders,
  useNoteLabels,
  useUpdateFolder,
  type NoteFolder,
  useNoteThread,
  useNotes,
  useReceivedNotes,
  useReplyToNote,
  useSendNote,
  useSentNotes,
  useToggleChecklistItem,
  useUpdateLabel,
  useUpdateNote,
  useUserDirectory,
  userDisplayName,
  useFolderShares,
  useSharedFolderNotes,
  useSharedWithMeFolders,
  useUpdateFolderShares,
  type FolderShareRecipient,
} from "@/lib/notes-api";
import {
  FoldersRail,
  type FolderSelection,
} from "@/components/notes/folders-rail";
import {
  ChecklistEditor,
  ColorPicker,
  Composer,
  KindToggle,
  LabelMenu,
} from "@/components/notes/composer";
import { SendNoteDialog } from "@/components/notes/send-note-dialog";
import { AppDock } from "@/components/app-dock";
import {
  DndContext,
  MouseSensor,
  TouchSensor,
  useSensor,
  useSensors,
  useDroppable,
  closestCenter,
  type DragEndEvent,
} from "@dnd-kit/core";
import {
  SortableContext,
  arrayMove,
  rectSortingStrategy,
  useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useAuth } from "@/contexts/AuthContext";

type TabId = "active" | "received" | "sent" | "archived";
type ViewMode = "grid" | "list";
const VIEW_MODE_STORAGE_KEY = "tx-os.notes.viewMode";

function loadViewMode(): ViewMode {
  if (typeof window === "undefined") return "grid";
  try {
    const v = window.localStorage.getItem(VIEW_MODE_STORAGE_KEY);
    return v === "list" ? "list" : "grid";
  } catch {
    return "grid";
  }
}

export default function NotesPage() {
  const { t, i18n } = useTranslation();
  const [, setLocation] = useLocation();
  const isRtl = i18n.language === "ar";
  const BackIcon = isRtl ? ArrowRight : ArrowLeft;

  const [view, setView] = useState<TabId>("active");
  const [viewMode, setViewModeState] = useState<ViewMode>(() => loadViewMode());
  const setViewMode = useCallback((m: ViewMode) => {
    setViewModeState(m);
    if (typeof window !== "undefined") {
      try {
        window.localStorage.setItem(VIEW_MODE_STORAGE_KEY, m);
      } catch {
        // localStorage may be unavailable (private mode); ignore.
      }
    }
  }, []);
  const [search, setSearch] = useState("");
  const [labelFilter, setLabelFilter] = useState<number | null>(null);
  const [folderSelection, setFolderSelection] = useState<FolderSelection>({
    kind: "all",
  });
  const [editing, setEditing] = useState<Note | null>(null);
  const [labelsDialogOpen, setLabelsDialogOpen] = useState(false);
  const [sendForNoteId, setSendForNoteId] = useState<number | null>(null);
  const [openThreadId, setOpenThreadId] = useState<number | null>(null);
  const [threadReplyIntent, setThreadReplyIntent] = useState(false);
  const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
  // When the user changes tab, folder, label filter, or search, the visible
  // note set changes — clear selection so stale ids do not leak across views.
  useEffect(() => {
    setSelectedIds((prev) => (prev.size === 0 ? prev : new Set()));
  }, [view, folderSelection, labelFilter, search]);
  const toggleSelected = useCallback((id: number) => {
    setSelectedIds((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  }, []);
  const clearSelected = useCallback(() => setSelectedIds(new Set()), []);

  // bulk-select + bulk-delete for the Sent tab. Lives at the
  // page level (not inside SentList) so the toggle button in the header
  // and the AlertDialog rendered next to the other dialogs can share
  // state with the list. Selection is kept as a `Set<number>` for O(1)
  // toggle/has lookups against potentially large sent lists.
  const [sentSelectionMode, setSentSelectionMode] = useState(false);
  const [selectedSentIds, setSelectedSentIds] = useState<Set<number>>(new Set());
  const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
  const bulkDeleteSent = useBulkDeleteSentNotes();
  const toggleSentSelected = useCallback((id: number) => {
    setSelectedSentIds((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  }, []);
  const exitSentSelection = useCallback(() => {
    setSentSelectionMode(false);
    setSelectedSentIds(new Set());
    setBulkDeleteOpen(false);
  }, []);
  // Whenever the user navigates away from the Sent tab, reset selection
  // mode so stale ids don't leak across views and the toggle button
  // doesn't render in the wrong tab.
  useEffect(() => {
    if (view !== "sent" && sentSelectionMode) {
      exitSentSelection();
    }
  }, [view, sentSelectionMode, exitSentSelection]);

  // bulk-select + bulk-archive for the Inbox tab. Identical
  // shape to the Sent tab's selection state above, but the destructive
  // action is "archive" (the recipient doesn't own the underlying note,
  // so we hide it from their inbox via POST /notes/:id/archive instead
  // of DELETE).
  const [inboxSelectionMode, setInboxSelectionMode] = useState(false);
  const [selectedInboxIds, setSelectedInboxIds] = useState<Set<number>>(new Set());
  const [bulkArchiveOpen, setBulkArchiveOpen] = useState(false);
  const bulkArchiveInbox = useBulkArchiveReceivedNotes();
  // bulk-delete for the Notes Inbox. Distinct from archive
  // (which moves to the Archived tab) — this permanently removes the
  // caller's own recipient rows so the notes disappear from both
  // Inbox and Archived. The sender's copy is untouched.
  const [bulkDeleteInboxOpen, setBulkDeleteInboxOpen] = useState(false);
  const bulkDeleteInbox = useBulkDeleteReceivedNotes();
  // Per-row delete on inbox cards (separate from archive + bulk delete).
  // Lifted to the page so the confirm AlertDialog can mount above the
  // card grid without each card owning its own dialog.
  const [singleDeleteInboxId, setSingleDeleteInboxId] = useState<number | null>(
    null,
  );
  const deleteInboxRow = useDeleteReceivedNote();
  const toggleInboxSelected = useCallback((id: number) => {
    setSelectedInboxIds((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  }, []);
  const exitInboxSelection = useCallback(() => {
    setInboxSelectionMode(false);
    setSelectedInboxIds(new Set());
    setBulkArchiveOpen(false);
    setBulkDeleteInboxOpen(false);
  }, []);
  useEffect(() => {
    if (view !== "received" && inboxSelectionMode) {
      exitInboxSelection();
    }
  }, [view, inboxSelectionMode, exitInboxSelection]);
  // Allow deep-links like /notes?thread=42&reply=1 (from the incoming-note
  // popup) to jump into the inbox tab + thread dialog. Reactive on the
  // current query string so this still works when the user is already on
  // /notes and the popup just updates the URL.
  const queryString = useSearch();
  // Composer auto-open trigger: bumped whenever a `?new=1` deep-link is
  // observed so <Composer> can react and open + focus its textarea even
  // if it was already mounted.
  const [composerOpenTrigger, setComposerOpenTrigger] = useState(0);
  useEffect(() => {
    const params = new URLSearchParams(queryString);

    // /notes?new=1 — opened from the meetings quick-note tab. Force the
    // Active tab so the top composer is visible, bump the open-trigger
    // so the composer expands + focuses, and strip the param so a later
    // navigation doesn't keep re-opening it.
    if (params.get("new") === "1") {
      setView("active");
      // The top Composer is only mounted when
      // `view === "active" && folderSelection.kind !== "shared-folder"`.
      // Always land on the default "all" folder so /notes?new=1 has a
      // deterministic landing state and the composer is guaranteed
      // mounted regardless of where the user was before.
      setFolderSelection({ kind: "all" });
      setComposerOpenTrigger((n) => n + 1);
      if (typeof window !== "undefined") {
        params.delete("new");
        const qs = params.toString();
        const next = `${window.location.pathname}${qs ? `?${qs}` : ""}`;
        window.history.replaceState(null, "", next);
      }
    }

    const threadParam = params.get("thread");
    if (!threadParam) return;
    const id = Number(threadParam);
    if (!Number.isInteger(id) || id <= 0) return;
    const wantsReply = params.get("reply") === "1";
    setView("received");
    setOpenThreadId(id);
    setThreadReplyIntent(wantsReply);
    if (typeof window !== "undefined") {
      params.delete("thread");
      params.delete("reply");
      const qs = params.toString();
      const next = `${window.location.pathname}${qs ? `?${qs}` : ""}`;
      window.history.replaceState(null, "", next);
    }
  }, [queryString]);

  const { data: notes = [], isLoading: loadingNotes } = useNotes(view === "archived");
  const { data: labels = [] } = useNoteLabels();
  const { data: folders = [] } = useNoteFolders(view === "archived");
  const { data: sharedFolders = [] } = useSharedWithMeFolders();
  const moveNote = useMoveNoteToFolder();
  const reorderNotes = useReorderNotes();
  const showFolders = view === "active" || view === "archived";

  // SharedFolderView publishes its visible notes here only when the
  // viewer has edit permission, so handleDragEnd can resolve the bucket
  // for shared folders. Null means "no editable shared-folder context".
  const sharedFolderBucketRef = useRef<{
    folderId: number;
    ownerId: number;
    notes: Note[];
  } | null>(null);

  // MouseSensor for desktop (8px threshold so clicks still open the editor)
  // and TouchSensor for iPad with a 200ms long-press, kept on separate input
  // sources so touch can never activate before the long-press elapses.
  const sensors = useSensors(
    useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
    useSensor(TouchSensor, {
      activationConstraint: { delay: 200, tolerance: 8 },
    }),
  );

  // Folder the share dialog is currently open for. `null` → dialog closed.
  // Owned folder only — recipients never see this entry point.
  const [folderShareFor, setFolderShareFor] = useState<NoteFolder | null>(null);

  // If the user switches tabs (or a folder is deleted) such that the current
  // selection no longer exists in this view's folder set, fall back to "all"
  // so they don't get stuck on an empty list. Same guard for "shared-folder"
  // selections when the owner revokes the share.
  useEffect(() => {
    if (folderSelection.kind === "folder") {
      if (!folders.some((f) => f.id === folderSelection.id)) {
        setFolderSelection({ kind: "all" });
      }
    } else if (folderSelection.kind === "shared-folder") {
      if (!sharedFolders.some((f) => f.id === folderSelection.id)) {
        setFolderSelection({ kind: "all" });
      }
    }
  }, [folders, sharedFolders, folderSelection]);
  const { data: receivedNotes = [], isLoading: loadingReceived } =
    useReceivedNotes(false);
  const { data: archivedReceivedNotes = [], isLoading: loadingArchivedReceived } =
    useReceivedNotes(true);
  const { data: sentNotes = [], isLoading: loadingSent } = useSentNotes();

  const filteredNotes = useMemo(() => {
    const q = search.trim().toLowerCase();
    return notes.filter((n) => {
      if (labelFilter && !n.labelIds.includes(labelFilter)) return false;
      // File-manager behavior: in "All notes", show only unfiled notes
      // alongside the folder cards (FoldersBrowser). Notes inside folders
      // are reachable by opening their folder card — listing them flat
      // here too made users think notes had been copied when moved.
      if (folderSelection.kind === "all" && n.folderId !== null) return false;
      if (folderSelection.kind === "unfiled" && n.folderId !== null) return false;
      if (folderSelection.kind === "folder" && n.folderId !== folderSelection.id)
        return false;
      if (!q) return true;
      return n.content.toLowerCase().includes(q);
    });
  }, [notes, search, labelFilter, folderSelection]);

  const unfiledCount = notes.filter((n) => n.folderId === null).length;

  // Move a note into a target folder (or "unfiled"). Used by the
  // page-level DndContext.onDragEnd below; kept as a callback so the
  // FoldersBrowser button-style fallback can call it directly too.
  const moveNoteToTarget = useCallback(
    (target: "unfiled" | number, noteId: number) => {
      const note = notes.find((n) => n.id === noteId);
      const nextFolderId = target === "unfiled" ? null : target;
      if (note && note.folderId === nextFolderId) return;
      moveNote.mutate({ id: noteId, folderId: nextFolderId });
    },
    [notes, moveNote],
  );

  // Single drop pipeline for both desktop and iPad. The DndContext is
  // mounted at the page root so any NoteCard (owned or in a shared folder
  // the user can edit) can drop onto any rail row, folder browser tile,
  // or sibling card. Two routes:
  //  - over.type === "folder-drop"  → move to that folder.
  //  - over.type === "note"         → reorder within the same bucket
  //                                    (same folderId AND same isPinned).
  const handleDragEnd = useCallback(
    (e: DragEndEvent) => {
      const { active, over } = e;
      if (!over) return;
      const a = active.data.current as
        | {
            type?: string;
            noteId?: number;
            folderId?: number | null;
            isPinned?: boolean;
          }
        | undefined;
      const o = over.data.current as
        | {
            type?: string;
            target?: "unfiled" | number;
            ownerId?: number;
            noteId?: number;
            folderId?: number | null;
            isPinned?: boolean;
          }
        | undefined;
      if (!a || a.type !== "note" || a.noteId == null) return;

      // Only allow drag actions when the source note belongs to a context
      // we can actually mutate: the user's own notes, or a shared folder
      // they have edit permission on (published via sharedFolderBucketRef).
      const sharedBucket = sharedFolderBucketRef.current;
      const isOwn = notes.some((n) => n.id === a.noteId);
      const isEditableShared =
        !!sharedBucket && sharedBucket.notes.some((n) => n.id === a.noteId);
      if (!isOwn && !isEditableShared) return;

      if (o?.type === "folder-drop" && o.target !== undefined) {
        // Enforce same-owner rule client-side (server also validates).
        if (o.ownerId === undefined) {
          if (!isOwn) return;
        } else {
          if (!isEditableShared || sharedBucket?.ownerId !== o.ownerId) return;
        }
        moveNoteToTarget(o.target, a.noteId);
        return;
      }

      if (o?.type === "note" && o.noteId != null) {
        if (a.noteId === o.noteId) return;
        if (a.folderId !== o.folderId) return;
        if (a.isPinned !== o.isPinned) return;
        // Use the FULL bucket source (not the filtered view) so search /
        // label filters can't strand hidden siblings out of orderedIds.
        const sourceNotes =
          sharedBucket && sharedBucket.folderId === a.folderId
            ? sharedBucket.notes
            : notes;
        const bucket = sourceNotes.filter(
          (n) =>
            n.folderId === a.folderId &&
            n.isPinned === a.isPinned &&
            !n.isArchived,
        );
        const oldIdx = bucket.findIndex((n) => n.id === a.noteId);
        const newIdx = bucket.findIndex((n) => n.id === o.noteId);
        if (oldIdx < 0 || newIdx < 0 || oldIdx === newIdx) return;
        const reordered = arrayMove(bucket, oldIdx, newIdx);
        reorderNotes.mutate({
          folderId: a.folderId ?? null,
          isPinned: a.isPinned ?? false,
          orderedIds: reordered.map((n) => n.id),
        });
      }
    },
    [notes, moveNoteToTarget, reorderNotes],
  );

  const filteredReceived = useMemo(() => {
    const q = search.trim().toLowerCase();
    if (!q) return receivedNotes;
    return receivedNotes.filter((n) => n.content.toLowerCase().includes(q));
  }, [receivedNotes, search]);

  const filteredSent: SentNote[] = useMemo(() => {
    const q = search.trim().toLowerCase();
    if (!q) return sentNotes;
    return sentNotes.filter((n: SentNote) =>
      n.content.toLowerCase().includes(q),
    );
  }, [sentNotes, search]);

  // (architect fix): when the visible Sent set changes
  // (search/filter, server refresh, or successful delete), prune the
  // selection to currently visible ids so we never delete a hidden
  // note. If the prune empties the visible list while in selection
  // mode, drop out of selection mode entirely so the user isn't
  // stranded without an in-view exit path.
  const visibleSentIdsKey = filteredSent.map((n) => n.id).join(",");
  useEffect(() => {
    if (!sentSelectionMode) return;
    if (filteredSent.length === 0) {
      exitSentSelection();
      return;
    }
    const visible = new Set(filteredSent.map((n) => n.id));
    setSelectedSentIds((prev) => {
      let changed = false;
      const next = new Set<number>();
      prev.forEach((id) => {
        if (visible.has(id)) next.add(id);
        else changed = true;
      });
      return changed ? next : prev;
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [visibleSentIdsKey, sentSelectionMode, exitSentSelection]);

  // Same prune-to-visible guard for the Inbox bulk selection.
  const visibleInboxIdsKey = filteredReceived.map((n) => n.id).join(",");
  useEffect(() => {
    if (!inboxSelectionMode) return;
    if (filteredReceived.length === 0) {
      exitInboxSelection();
      return;
    }
    const visible = new Set(filteredReceived.map((n) => n.id));
    setSelectedInboxIds((prev) => {
      let changed = false;
      const next = new Set<number>();
      prev.forEach((id) => {
        if (visible.has(id)) next.add(id);
        else changed = true;
      });
      return changed ? next : prev;
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [visibleInboxIdsKey, inboxSelectionMode, exitInboxSelection]);

  const unreadInboxCount = receivedNotes.filter((n) => n.status === "unread").length;
  const pinned = filteredNotes.filter((n) => n.isPinned);
  const others = filteredNotes.filter((n) => !n.isPinned);

  // Composer is rendered inside the main content column of the
  // folders+content row so its top edge aligns with the FoldersRail
  //. On narrow widths the row stacks (`flex-col`) and the
  // composer is forced above the rail via `order-*` classes so the
  // user can still capture a quick note without scrolling past the
  // rail. It is hidden on read-only / non-owner views (Inbox, Sent,
  // Archived, Shared-with-me).
  const showTopComposer =
    view === "active" && folderSelection.kind !== "shared-folder";

  return (
    // switched from a fixed-viewport `h-screen overflow-hidden`
    // shell to a document-scrolling layout (`min-h-[100dvh]`) so iOS
    // Safari can collapse its URL bar on scroll, matching the executive
    // meetings page. The header below is `sticky top-0` so it stays
    // pinned while the document scrolls; `100dvh` (dynamic viewport
    // height) sizes against the *visible* area, so the layout reflows
    // cleanly when the URL bar appears/disappears.
    <DndContext
      sensors={sensors}
      collisionDetection={closestCenter}
      onDragEnd={handleDragEnd}
    >
    <div
      className="min-h-[100dvh] os-bg flex flex-col"
      dir={isRtl ? "rtl" : "ltr"}
      data-testid="notes-page"
    >
      {/* enlarged header — taller padding, bigger title /
          icons / search / tabs so the bar is easier to read on big
          screens. Sizing kept responsive (flex-wrap) so it still folds
          nicely on narrow widths. */}
      <div className="glass-panel border-b border-slate-200/70 px-3 py-3 sm:px-6 sm:py-5 shrink-0 sticky top-0 z-10">
        <div className="flex items-center gap-2 sm:gap-4 flex-wrap">
          <button
            onClick={() => setLocation("/")}
            className="p-2 sm:p-2.5 rounded-xl hover:bg-slate-100/60 text-muted-foreground hover:text-foreground transition-colors"
            aria-label={t("common.back", "Back")}
          >
            <BackIcon size={20} className="sm:hidden" />
            <BackIcon size={24} className="hidden sm:block" />
          </button>
          <div className="flex items-center gap-2 sm:gap-3">
            <StickyNote size={22} className="text-amber-500 sm:hidden" />
            <StickyNote size={28} className="text-amber-500 hidden sm:block" />
            <h1 className="text-lg sm:text-2xl font-semibold text-foreground">
              {t("notes.title", "Notes")}
            </h1>
          </div>

          <div className="flex-1 min-w-[160px] sm:min-w-[220px] max-w-xl ms-auto relative">
            <Search
              size={18}
              className="absolute top-1/2 -translate-y-1/2 text-muted-foreground"
              style={isRtl ? { right: 12 } : { left: 12 }}
            />
            {/* The icon sits on the start side in both directions
                (right in RTL, left in LTR), so the input's reserved
                padding must also be on the start side — using `ps-11`
                here unconditionally. The previous `pe-9` in RTL put
                the padding on the left while the icon was on the
                right, which let the placeholder overlap the icon. */}
            <Input
              value={search}
              onChange={(e) => setSearch(e.target.value)}
              placeholder={t("notes.searchPlaceholder", "Search notes")}
              className="ps-10 sm:ps-11 h-10 sm:h-12 text-sm sm:text-base"
            />
          </div>

          <Button
            variant="ghost"
            onClick={() => setLabelsDialogOpen(true)}
            data-testid="notes-manage-labels-open"
            className="h-10 sm:h-12 px-2 sm:px-4 text-sm sm:text-base"
          >
            <Tag size={18} className="sm:me-2" />
            <span className="hidden sm:inline">{t("notes.labels", "Labels")}</span>
          </Button>
        </div>

        <div className="mt-2 sm:mt-4 flex items-center gap-2 sm:gap-3 flex-wrap">
          {/* replaced the four-tab email-style bar with a unified
              feed. The page opens directly on "My Notes"; Inbox / Sent /
              Archived now live in the overflow menu (⋯) on the end of the
              row. A small back button appears when the user has navigated
              into one of those secondary views so they can return easily. */}
          {view !== "active" && (
            <button
              type="button"
              onClick={() => setView("active")}
              data-testid="notes-back-to-active"
              className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors px-2.5 py-1.5 rounded-lg hover:bg-slate-100/60"
            >
              <ChevronLeft size={16} className="rtl:rotate-180" />
              <span>{t("notes.tabs.active", "My Notes")}</span>
            </button>
          )}
          {(view === "active" || view === "archived") && labels.length > 0 && (
            <div className="flex items-center gap-1.5 flex-wrap">
              <button
                onClick={() => setLabelFilter(null)}
                className={`text-xs px-2.5 py-1 rounded-full border transition ${
                  labelFilter === null
                    ? "bg-foreground text-background border-foreground"
                    : "border-slate-300/60 text-muted-foreground hover:border-foreground"
                }`}
              >
                {t("notes.allLabels", "All")}
              </button>
              {labels.map((l) => (
                <button
                  key={l.id}
                  onClick={() => setLabelFilter(l.id)}
                  className={`text-xs px-2.5 py-1 rounded-full border transition flex items-center gap-1 ${
                    labelFilter === l.id
                      ? "bg-foreground text-background border-foreground"
                      : "border-slate-300/60 text-muted-foreground hover:border-foreground"
                  }`}
                >
                  <Tag size={11} /> {l.name}
                </button>
              ))}
            </div>
          )}
          <div className="ms-auto flex items-center gap-2">
          {/* bulk-select toggle for the Sent tab. Only shown
              when there are sent notes to act on. Entering selection
              mode swaps the cards' click behavior from "open thread"
              to "toggle checkbox" and exposes the bulk action bar
              below the header. */}
          {view === "received" && filteredReceived.length > 0 && (
            inboxSelectionMode ? (
              <button
                type="button"
                onClick={exitInboxSelection}
                data-testid="inbox-bulk-cancel"
                className="text-sm px-3 py-1.5 rounded-lg border border-slate-300/60 bg-white/60 text-muted-foreground hover:text-foreground hover:bg-white transition-colors"
              >
                {t("notes.bulkCancel", "Cancel")}
              </button>
            ) : (
              <button
                type="button"
                onClick={() => setInboxSelectionMode(true)}
                data-testid="inbox-bulk-select-toggle"
                className="text-sm px-3 py-1.5 rounded-lg border border-slate-300/60 bg-white/60 text-muted-foreground hover:text-foreground hover:bg-white transition-colors"
              >
                {t("notes.bulkSelect", "Select")}
              </button>
            )
          )}
          {view === "sent" && filteredSent.length > 0 && (
            sentSelectionMode ? (
              <button
                type="button"
                onClick={exitSentSelection}
                data-testid="sent-bulk-cancel"
                className="text-sm px-3 py-1.5 rounded-lg border border-slate-300/60 bg-white/60 text-muted-foreground hover:text-foreground hover:bg-white transition-colors"
              >
                {t("notes.bulkCancel", "Cancel")}
              </button>
            ) : (
              <button
                type="button"
                onClick={() => setSentSelectionMode(true)}
                data-testid="sent-bulk-select-toggle"
                className="text-sm px-3 py-1.5 rounded-lg border border-slate-300/60 bg-white/60 text-muted-foreground hover:text-foreground hover:bg-white transition-colors"
              >
                {t("notes.bulkSelect", "Select")}
              </button>
            )
          )}
          {view === "active" && folderSelection.kind === "all" && (
            <div
              className="inline-flex rounded-lg border border-slate-300/60 bg-white/60 p-0.5"
              data-testid="notes-view-toggle"
            >
              <button
                onClick={() => setViewMode("grid")}
                aria-pressed={viewMode === "grid"}
                aria-label={t("notes.viewGrid", "Grid view")}
                title={t("notes.viewGrid", "Grid view")}
                data-testid="notes-view-grid"
                className={`p-1.5 rounded-md transition ${
                  viewMode === "grid"
                    ? "bg-slate-900 text-white"
                    : "text-muted-foreground hover:text-foreground"
                }`}
              >
                <LayoutGrid size={14} />
              </button>
              <button
                onClick={() => setViewMode("list")}
                aria-pressed={viewMode === "list"}
                aria-label={t("notes.viewList", "List view")}
                title={t("notes.viewList", "List view")}
                data-testid="notes-view-list"
                className={`p-1.5 rounded-md transition ${
                  viewMode === "list"
                    ? "bg-slate-900 text-white"
                    : "text-muted-foreground hover:text-foreground"
                }`}
              >
                <ListIcon size={14} />
              </button>
            </div>
          )}
          <DropdownMenu>
            <DropdownMenuTrigger asChild>
              <button
                type="button"
                aria-label={t("common.more", "More")}
                title={t("common.more", "More")}
                data-testid="notes-overflow-menu-trigger"
                className="p-2 rounded-lg border border-slate-300/60 bg-white/60 text-muted-foreground hover:text-foreground hover:bg-white transition-colors"
              >
                <MoreVertical size={16} />
              </button>
            </DropdownMenuTrigger>
            <DropdownMenuContent align="end" className="min-w-[180px]">
              <DropdownMenuItem
                onSelect={() => setView("received")}
                data-testid="notes-overflow-received"
              >
                <Inbox className="me-2" />
                <span className="flex-1">
                  {t("notes.tabs.received", "Inbox")}
                </span>
                {unreadInboxCount > 0 && (
                  <span
                    className="ms-auto inline-flex items-center justify-center rounded-full bg-rose-500 text-white text-xs min-w-[20px] h-[20px] px-1.5"
                    data-testid="notes-inbox-unread-badge"
                  >
                    {unreadInboxCount}
                  </span>
                )}
              </DropdownMenuItem>
              <DropdownMenuItem
                onSelect={() => setView("sent")}
                data-testid="notes-overflow-sent"
              >
                <Send className="me-2" />
                <span>{t("notes.tabs.sent", "Sent")}</span>
              </DropdownMenuItem>
              <DropdownMenuItem
                onSelect={() => setView("archived")}
                data-testid="notes-overflow-archived"
              >
                <Archive className="me-2" />
                <span>{t("notes.tabs.archived", "Archived")}</span>
              </DropdownMenuItem>
            </DropdownMenuContent>
          </DropdownMenu>
          </div>
        </div>
      </div>

      {/* inner scroll container removed (was
          `flex-1 min-h-0 overflow-y-auto`) — content now scrolls with
          the document so iOS Safari collapses the URL bar on scroll.
          The sticky header above keeps the controls pinned. */}
      <div className="flex-1 flex flex-col">

      {/* switched the folders+content row from flex to CSS
          grid so the top composer can sit at the SAME vertical
          position as the FoldersRail on desktop, while still
          preserving the original mobile reading order
          (composer → rail → content). Pure flex can't do both: on
          desktop we need composer + content stacked in the right
          column, on mobile we need composer above the rail —
          incompatible with a single flex direction. Grid solves it
          with explicit `col-start` / `row-start` placement on md+
          and natural `order-*` flow on mobile. Hidden-composer views
          (Inbox / Sent / Archived / Shared-with-me) collapse cleanly
          to the original two-cell layout via conditional row-span
          / row-start. */}
      <div
        className={`flex-1 px-4 py-6 max-w-6xl w-full mx-auto grid gap-4 md:gap-x-6 md:gap-y-4${
          showFolders ? " md:grid-cols-[14rem_minmax(0,1fr)]" : ""
        }`}
      >
        {showFolders && (
          <FoldersRail
            folders={folders}
            selected={folderSelection}
            onSelect={setFolderSelection}
            unfiledCount={unfiledCount}
            totalCount={notes.length}
            onShareFolder={(f) => setFolderShareFor(f)}
            mode={view === "active" && folderSelection.kind === "all" ? "chips-only" : "rail"}
            className="order-2 md:order-none md:col-start-1 md:row-start-1"
          />
        )}
        {/* Right column wrapper: on desktop it's a single grid cell
            (col-start-2, row-start-1) with `flex flex-col` so the
            composer and the content stack with a fixed gap, regardless
            of how tall the FoldersRail gets (no empty grid-row gap).
            On mobile the wrapper uses `display: contents` so its
            children become direct grid items of the outer grid and the
            order utilities (composer=1, rail=2, content=3) preserve
            the original reading order. */}
        <div
          className={`contents md:flex md:flex-col md:gap-4 md:min-w-0${
            showFolders ? " md:col-start-2 md:row-start-1" : ""
          }`}
        >
          {showTopComposer && (
            <div
              className="order-1 md:order-none"
              data-testid="notes-top-composer"
            >
              <Composer
                labels={labels}
                folderSelection={folderSelection}
                onSend={(id) => setSendForNoteId(id)}
                openTrigger={composerOpenTrigger}
              />
            </div>
          )}
          <div className="min-w-0 order-3 md:order-none">
        {view === "active" && folderSelection.kind === "shared-folder" && (
          // Recipient view of a folder shared with the current user. Rendered
          // before the regular active branch so we never mount the Composer
          // (recipients are read-only) or run the owner-only filteredNotes
          // pipeline against the wrong note set.
          <SharedFolderView
            folderId={folderSelection.id}
            labels={labels}
            search={search}
            onBack={() => setFolderSelection({ kind: "all" })}
            onEdit={setEditing}
            onSend={(n) => setSendForNoteId(n.id)}
            bucketRef={sharedFolderBucketRef}
          />
        )}
        {view === "active" && folderSelection.kind !== "shared-folder" && (
          <>
            {folderSelection.kind === "all" && (
              <FoldersBrowser
                folders={folders}
                viewMode={viewMode}
                onSelect={setFolderSelection}
              />
            )}
            {folderSelection.kind !== "all" && (
              <button
                type="button"
                onClick={() => setFolderSelection({ kind: "all" })}
                className="mb-3 inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
                data-testid="notes-folders-back"
              >
                <ChevronLeft size={14} className="rtl:rotate-180" />
                <span>
                  {t("notes.folders.backToFolders", "Back to folders")}
                </span>
                <span className="text-foreground/80 font-medium ms-1">
                  {folderSelection.kind === "unfiled"
                    ? t("notes.folders.unfiled", "Unfiled")
                    : folders.find((f) => f.id === folderSelection.id)?.name ||
                      ""}
                </span>
              </button>
            )}
            {/* Composer was moved to the top of the page (above the
                folders rail/browser) so it's reachable from every active
                view without drilling into a folder first. */}
            {loadingNotes ? (
              <Loading />
            ) : filteredNotes.length === 0 ? (
              <Empty icon={<StickyNote size={48} />} text={t("notes.empty", "Your notes will appear here")} />
            ) : (
              <div className="space-y-6 mt-4">
                {pinned.length > 0 && (
                  <Section
                    title={t("notes.pinned", "Pinned")}
                    notes={pinned}
                    labels={labels}
                    onEdit={setEditing}
                    onSend={(n) => setSendForNoteId(n.id)}
                  />
                )}
                <Section
                  title={pinned.length > 0 ? t("notes.others", "Others") : ""}
                  notes={others}
                  labels={labels}
                  onEdit={setEditing}
                  onSend={(n) => setSendForNoteId(n.id)}
                />
              </div>
            )}
          </>
        )}

        {view === "archived" && (
          <ArchivedView
            loadingMine={loadingNotes}
            mine={filteredNotes}
            labels={labels}
            onEdit={setEditing}
            onSend={(n) => setSendForNoteId(n.id)}
            loadingInbox={loadingArchivedReceived}
            archivedReceived={archivedReceivedNotes.filter((n) => {
              const q = search.trim().toLowerCase();
              if (!q) return true;
              return n.content.toLowerCase().includes(q);
            })}
            onOpenThread={(id) => setOpenThreadId(id)}
          />
        )}

        {view === "received" && (
          <>
            {inboxSelectionMode && (
              <div
                className="sticky top-0 z-10 -mx-2 px-3 py-2 mb-2 flex items-center gap-2 rounded-lg border border-slate-300/60 bg-white/95 backdrop-blur shadow-sm"
                data-testid="inbox-bulk-bar"
              >
                <span
                  className="text-sm font-medium text-foreground"
                  data-testid="inbox-bulk-count"
                >
                  {t("notes.bulkCount", "{{n}} selected", {
                    n: selectedInboxIds.size,
                  })}
                </span>
                <button
                  type="button"
                  onClick={() => {
                    if (selectedInboxIds.size === filteredReceived.length) {
                      setSelectedInboxIds(new Set());
                    } else {
                      setSelectedInboxIds(
                        new Set(filteredReceived.map((n) => n.id)),
                      );
                    }
                  }}
                  data-testid="inbox-bulk-select-all"
                  className="text-sm px-2.5 py-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-slate-100"
                >
                  {selectedInboxIds.size === filteredReceived.length &&
                  filteredReceived.length > 0
                    ? t("notes.bulkClear", "Clear selection")
                    : t("notes.bulkSelectAll", "Select all")}
                </button>
                <div className="ms-auto flex items-center gap-2">
                  <button
                    type="button"
                    onClick={exitInboxSelection}
                    disabled={bulkArchiveInbox.isPending}
                    data-testid="inbox-bulk-bar-cancel"
                    className="text-sm px-3 py-1.5 rounded-lg border border-slate-300/60 bg-white text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
                  >
                    {t("notes.bulkCancel", "Cancel")}
                  </button>
                  <button
                    type="button"
                    onClick={() => setBulkArchiveOpen(true)}
                    disabled={
                      selectedInboxIds.size === 0 ||
                      bulkArchiveInbox.isPending ||
                      bulkDeleteInbox.isPending
                    }
                    data-testid="inbox-bulk-archive"
                    className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-slate-900 text-white hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
                  >
                    <Archive size={14} />
                    {t("notes.bulkArchive", "Archive")}
                  </button>
                  {/* bulk Delete sits next to Archive. It's
                      destructive and user-scoped — copy in the confirm
                      dialog clarifies the sender keeps their copy. */}
                  <button
                    type="button"
                    onClick={() => setBulkDeleteInboxOpen(true)}
                    disabled={
                      selectedInboxIds.size === 0 ||
                      bulkArchiveInbox.isPending ||
                      bulkDeleteInbox.isPending
                    }
                    data-testid="inbox-bulk-delete"
                    className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-rose-600 text-white hover:bg-rose-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
                  >
                    <Trash2 size={14} />
                    {t("notes.bulkDelete", "Delete")}
                  </button>
                </div>
              </div>
            )}
            <ReceivedList
              loading={loadingReceived}
              notes={filteredReceived}
              onOpen={(id) => setOpenThreadId(id)}
              selectionMode={inboxSelectionMode}
              selectedIds={selectedInboxIds}
              onToggle={toggleInboxSelected}
              onRequestDelete={(id) => setSingleDeleteInboxId(id)}
            />
          </>
        )}

        {view === "sent" && (
          <>
            {sentSelectionMode && (
              <div
                className="sticky top-0 z-10 -mx-2 px-3 py-2 mb-2 flex items-center gap-2 rounded-lg border border-slate-300/60 bg-white/95 backdrop-blur shadow-sm"
                data-testid="sent-bulk-bar"
              >
                <span
                  className="text-sm font-medium text-foreground"
                  data-testid="sent-bulk-count"
                >
                  {t("notes.bulkCount", "{{n}} selected", {
                    n: selectedSentIds.size,
                  })}
                </span>
                <button
                  type="button"
                  onClick={() => {
                    if (selectedSentIds.size === filteredSent.length) {
                      setSelectedSentIds(new Set());
                    } else {
                      setSelectedSentIds(
                        new Set(filteredSent.map((n) => n.id)),
                      );
                    }
                  }}
                  data-testid="sent-bulk-select-all"
                  className="text-sm px-2.5 py-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-slate-100"
                >
                  {selectedSentIds.size === filteredSent.length &&
                  filteredSent.length > 0
                    ? t("notes.bulkClear", "Clear selection")
                    : t("notes.bulkSelectAll", "Select all")}
                </button>
                <div className="ms-auto flex items-center gap-2">
                  {/* Always-visible Cancel inside the bar so the user
                      can leave selection mode even when the header
                      toggle is hidden (e.g., search yields 0 results
                      mid-selection). */}
                  <button
                    type="button"
                    onClick={exitSentSelection}
                    disabled={bulkDeleteSent.isPending}
                    data-testid="sent-bulk-bar-cancel"
                    className="text-sm px-3 py-1.5 rounded-lg border border-slate-300/60 bg-white text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
                  >
                    {t("notes.bulkCancel", "Cancel")}
                  </button>
                  <button
                    type="button"
                    onClick={() => setBulkDeleteOpen(true)}
                    disabled={
                      selectedSentIds.size === 0 || bulkDeleteSent.isPending
                    }
                    data-testid="sent-bulk-delete"
                    className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-rose-600 text-white hover:bg-rose-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
                  >
                    <Trash2 size={14} />
                    {t("notes.bulkDelete", "Delete")}
                  </button>
                </div>
              </div>
            )}
            <SentList
              loading={loadingSent}
              notes={filteredSent}
              onOpen={(id) => setOpenThreadId(id)}
              selectionMode={sentSelectionMode}
              selectedIds={selectedSentIds}
              onToggle={toggleSentSelected}
            />
          </>
        )}
        </div>
        </div>
      </div>
      </div>

      {editing && (
        <EditNoteDialog
          note={editing}
          labels={labels}
          onClose={() => setEditing(null)}
        />
      )}

      {/* confirmation for bulk-deleting selected sent notes.
          Fires N parallel DELETE /notes/:id calls via Promise.allSettled
          so partial failures still report a precise count. On success
          the React Query "notes" key is invalidated by the mutation so
          both Sent and Received tabs refresh. */}
      <AlertDialog
        open={bulkDeleteOpen}
        onOpenChange={(open) => {
          if (!bulkDeleteSent.isPending) setBulkDeleteOpen(open);
        }}
      >
        <AlertDialogContent data-testid="sent-bulk-confirm">
          <AlertDialogHeader>
            <AlertDialogTitle>
              {t(
                selectedSentIds.size === 1
                  ? "notes.bulkConfirmTitle_one"
                  : "notes.bulkConfirmTitle",
                { n: selectedSentIds.size },
              )}
            </AlertDialogTitle>
            <AlertDialogDescription>
              {t(
                "notes.bulkConfirmBody",
                "The notes and all their replies will be permanently deleted. This cannot be undone.",
              )}
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel disabled={bulkDeleteSent.isPending}>
              {t("notes.bulkCancel", "Cancel")}
            </AlertDialogCancel>
            <AlertDialogAction
              data-testid="sent-bulk-confirm-delete"
              disabled={bulkDeleteSent.isPending}
              className="bg-rose-600 hover:bg-rose-700 text-white"
              onClick={async (e) => {
                e.preventDefault();
                const ids = Array.from(selectedSentIds);
                if (ids.length === 0) return;
                try {
                  const res = await bulkDeleteSent.mutateAsync(ids);
                  if (res.failed.length === 0) {
                    toast({
                      title: t(
                        res.ok.length === 1
                          ? "notes.bulkResultAll_one"
                          : "notes.bulkResultAll",
                        { n: res.ok.length },
                      ),
                    });
                  } else if (res.ok.length === 0) {
                    toast({
                      title: t(
                        "notes.bulkResultNone",
                        "Could not delete the selected notes",
                      ),
                      variant: "destructive",
                    });
                  } else {
                    toast({
                      title: t("notes.bulkResultPartial", {
                        ok: res.ok.length,
                        total: ids.length,
                        failed: res.failed.length,
                      }),
                      variant: "destructive",
                    });
                  }
                } catch {
                  toast({
                    title: t(
                      "notes.bulkResultNone",
                      "Could not delete the selected notes",
                    ),
                    variant: "destructive",
                  });
                } finally {
                  exitSentSelection();
                }
              }}
            >
              {bulkDeleteSent.isPending
                ? t("notes.bulkDeleting", "Deleting...")
                : t("notes.bulkDelete", "Delete")}
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>

      {/* confirmation for bulk-archiving selected inbox notes.
          Mirrors the Sent bulk-delete dialog: parallel POST /notes/:id/archive
          via worker pool, partial-failure aware toasts. */}
      <AlertDialog
        open={bulkArchiveOpen}
        onOpenChange={(open) => {
          if (!bulkArchiveInbox.isPending) setBulkArchiveOpen(open);
        }}
      >
        <AlertDialogContent data-testid="inbox-bulk-confirm">
          <AlertDialogHeader>
            <AlertDialogTitle>
              {t(
                selectedInboxIds.size === 1
                  ? "notes.bulkArchiveConfirmTitle_one"
                  : "notes.bulkArchiveConfirmTitle",
                { n: selectedInboxIds.size },
              )}
            </AlertDialogTitle>
            <AlertDialogDescription>
              {t(
                "notes.bulkArchiveConfirmBody",
                "These notes will be moved to your archive. You can find them later in the Archived tab.",
              )}
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel disabled={bulkArchiveInbox.isPending}>
              {t("notes.bulkCancel", "Cancel")}
            </AlertDialogCancel>
            <AlertDialogAction
              data-testid="inbox-bulk-confirm-archive"
              disabled={bulkArchiveInbox.isPending}
              className="bg-slate-900 hover:bg-slate-800 text-white"
              onClick={async (e) => {
                e.preventDefault();
                const ids = Array.from(selectedInboxIds);
                if (ids.length === 0) return;
                try {
                  const res = await bulkArchiveInbox.mutateAsync(ids);
                  if (res.failed.length === 0) {
                    toast({
                      title: t(
                        res.ok.length === 1
                          ? "notes.bulkArchivedAll_one"
                          : "notes.bulkArchivedAll",
                        { n: res.ok.length },
                      ),
                    });
                  } else if (res.ok.length === 0) {
                    toast({
                      title: t(
                        "notes.bulkArchiveNone",
                        "Could not archive the selected notes",
                      ),
                      variant: "destructive",
                    });
                  } else {
                    toast({
                      title: t("notes.bulkArchivePartial", {
                        ok: res.ok.length,
                        total: ids.length,
                        failed: res.failed.length,
                      }),
                      variant: "destructive",
                    });
                  }
                } catch {
                  toast({
                    title: t(
                      "notes.bulkArchiveNone",
                      "Could not archive the selected notes",
                    ),
                    variant: "destructive",
                  });
                } finally {
                  exitInboxSelection();
                }
              }}
            >
              {bulkArchiveInbox.isPending
                ? t("notes.bulkArchiving", "Archiving...")
                : t("notes.bulkArchive", "Archive")}
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>

      {/* confirm dialog for bulk-deleting inbox notes. The
          copy makes clear it removes them from BOTH inbox + archive
          and that the sender keeps their copy. Single POST to the
          bulk endpoint; reports per-id ok/notFound. */}
      <AlertDialog
        open={bulkDeleteInboxOpen}
        onOpenChange={(open) => {
          if (!bulkDeleteInbox.isPending) setBulkDeleteInboxOpen(open);
        }}
      >
        <AlertDialogContent data-testid="inbox-bulk-delete-confirm">
          <AlertDialogHeader>
            <AlertDialogTitle>
              {t(
                selectedInboxIds.size === 1
                  ? "notes.bulkDeleteInboxConfirmTitle_one"
                  : "notes.bulkDeleteInboxConfirmTitle",
                { n: selectedInboxIds.size },
              )}
            </AlertDialogTitle>
            <AlertDialogDescription>
              {t(
                "notes.bulkDeleteInboxConfirmBody",
                "This removes the notes from your inbox and archive. The sender keeps their copy.",
              )}
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel disabled={bulkDeleteInbox.isPending}>
              {t("notes.bulkCancel", "Cancel")}
            </AlertDialogCancel>
            <AlertDialogAction
              data-testid="inbox-bulk-delete-confirm-button"
              disabled={bulkDeleteInbox.isPending}
              className="bg-rose-600 hover:bg-rose-700 text-white"
              onClick={async (e) => {
                e.preventDefault();
                const ids = Array.from(selectedInboxIds);
                if (ids.length === 0) return;
                try {
                  const res = await bulkDeleteInbox.mutateAsync(ids);
                  if (res.notFound.length === 0) {
                    toast({
                      title: t(
                        res.ok.length === 1
                          ? "notes.bulkDeletedInboxAll_one"
                          : "notes.bulkDeletedInboxAll",
                        { n: res.ok.length },
                      ),
                    });
                  } else if (res.ok.length === 0) {
                    toast({
                      title: t(
                        "notes.bulkDeleteInboxNone",
                        "Could not delete the selected notes",
                      ),
                      variant: "destructive",
                    });
                  } else {
                    toast({
                      title: t("notes.bulkDeleteInboxPartial", {
                        ok: res.ok.length,
                        total: ids.length,
                        failed: res.notFound.length,
                      }),
                      variant: "destructive",
                    });
                  }
                } catch {
                  toast({
                    title: t(
                      "notes.bulkDeleteInboxNone",
                      "Could not delete the selected notes",
                    ),
                    variant: "destructive",
                  });
                } finally {
                  exitInboxSelection();
                }
              }}
            >
              {bulkDeleteInbox.isPending
                ? t("notes.bulkDeleting", "Deleting...")
                : t("notes.bulkDelete", "Delete")}
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>

      {/* per-row inbox delete confirm. Driven by the page-level
          singleDeleteInboxId state set from each ReceivedList card so the
          dialog can mount once instead of per card. */}
      <AlertDialog
        open={singleDeleteInboxId !== null}
        onOpenChange={(open) => {
          if (!deleteInboxRow.isPending && !open) setSingleDeleteInboxId(null);
        }}
      >
        <AlertDialogContent data-testid="inbox-row-delete-confirm">
          <AlertDialogHeader>
            <AlertDialogTitle>
              {t("notes.deleteInboxConfirmTitle", "Delete this note from your inbox?")}
            </AlertDialogTitle>
            <AlertDialogDescription>
              {t(
                "notes.bulkDeleteInboxConfirmBody",
                "This removes the notes from your inbox and archive. The sender keeps their copy.",
              )}
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel disabled={deleteInboxRow.isPending}>
              {t("notes.bulkCancel", "Cancel")}
            </AlertDialogCancel>
            <AlertDialogAction
              data-testid="inbox-row-delete-confirm-button"
              disabled={deleteInboxRow.isPending}
              className="bg-rose-600 hover:bg-rose-700 text-white"
              onClick={async (e) => {
                e.preventDefault();
                const id = singleDeleteInboxId;
                if (id == null) return;
                try {
                  await deleteInboxRow.mutateAsync(id);
                  toast({
                    title: t(
                      "notes.deletedInboxOne",
                      "Note removed from your inbox",
                    ),
                  });
                  setSingleDeleteInboxId(null);
                } catch {
                  toast({
                    title: t(
                      "notes.bulkDeleteInboxNone",
                      "Could not delete the selected notes",
                    ),
                    variant: "destructive",
                  });
                }
              }}
            >
              {deleteInboxRow.isPending
                ? t("notes.bulkDeleting", "Deleting...")
                : t("notes.delete", "Delete")}
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>

      <LabelsDialog
        open={labelsDialogOpen}
        onClose={() => setLabelsDialogOpen(false)}
        labels={labels}
      />

      {sendForNoteId !== null && (
        <SendNoteDialog
          noteId={sendForNoteId}
          onClose={() => setSendForNoteId(null)}
        />
      )}

      {folderShareFor && (
        <FolderShareDialog
          folder={folderShareFor}
          onClose={() => setFolderShareFor(null)}
        />
      )}

      {openThreadId !== null && (
        <ThreadDialog
          noteId={openThreadId}
          autoFocusReply={threadReplyIntent}
          onClose={() => {
            setOpenThreadId(null);
            setThreadReplyIntent(false);
          }}
        />
      )}
      <AppDock currentSlug="notes" />
    </div>
    </DndContext>
  );
}

function Loading() {
  const { t } = useTranslation();
  return (
    <div className="flex items-center justify-center py-20 text-muted-foreground">
      {t("common.loading", "Loading...")}
    </div>
  );
}

function Empty({ icon, text }: { icon: React.ReactNode; text: string }) {
  return (
    <div className="flex flex-col items-center justify-center py-24 gap-3 text-muted-foreground">
      <div className="opacity-30">{icon}</div>
      <span>{text}</span>
    </div>
  );
}

function Section({
  title,
  notes,
  labels,
  onEdit,
  onSend,
}: {
  title: string;
  notes: Note[];
  labels: NoteLabel[];
  onEdit: (n: Note) => void;
  onSend: (n: Note) => void;
}) {
  if (notes.length === 0) return null;
  return (
    <div>
      {title && (
        <h2 className="text-xs uppercase tracking-wider text-muted-foreground mb-2">
          {title}
        </h2>
      )}
      {/* SortableContext registers the bucket order with @dnd-kit. The
          page-level handleDragEnd uses (folderId, isPinned) — not the
          context items list — to compute the new order, so the context's
          job here is just to make each card a sortable participant. */}
      <SortableContext
        items={notes.map((n) => `note-${n.id}`)}
        strategy={rectSortingStrategy}
      >
        <div
          className="gap-3 [column-fill:_balance]"
          style={{ columnWidth: 230 }}
        >
          {notes.map((n) => (
            <NoteCard key={n.id} note={n} labels={labels} onEdit={onEdit} onSend={onSend} />
          ))}
        </div>
      </SortableContext>
    </div>
  );
}

function NoteCard({
  note,
  labels,
  onEdit,
  onSend,
  hideSend = false,
}: {
  note: Note;
  labels: NoteLabel[];
  onEdit: (n: Note) => void;
  onSend: (n: Note) => void;
  // shared-folder editors don't own the note, and the
  // server-side /send route is owner-only, so the Send button is
  // suppressed for them to avoid a UI affordance that would 403.
  hideSend?: boolean;
}) {
  const { t } = useTranslation();
  const update = useUpdateNote();
  const del = useDeleteNote();
  const noteLabels = labels.filter((l) => note.labelIds.includes(l.id));
  const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);

  // @dnd-kit sortable. Disabled for archived notes (the server reorder
  // endpoint scopes to non-archived) and for the trivial case where the
  // card is rendered outside any DndContext (we still call the hook to
  // keep hook order stable; isDragging is just always false then).
  const sortable = useSortable({
    id: `note-${note.id}`,
    data: {
      type: "note",
      noteId: note.id,
      folderId: note.folderId,
      isPinned: note.isPinned,
    },
    disabled: note.isArchived,
  });
  const dragStyle: React.CSSProperties = {
    transform: CSS.Transform.toString(sortable.transform),
    transition: sortable.transition,
    opacity: sortable.isDragging ? 0.4 : undefined,
  };

  return (
    <>
    <div
      ref={sortable.setNodeRef}
      style={dragStyle}
      {...sortable.attributes}
      {...sortable.listeners}
      data-testid={`note-card-${note.id}`}
      data-folder-id={note.folderId ?? ""}
      data-dragging={sortable.isDragging ? "true" : "false"}
      className={`break-inside-avoid mb-3 rounded-xl border border-black/5 shadow-sm p-3 cursor-pointer transition hover:shadow-md group touch-manipulation ${colorBg(
        note.color,
      )}`}
      onClick={() => onEdit(note)}
    >
      <div className="flex items-start justify-between gap-2">
        <div className="flex-1 min-w-0">
          {note.kind === "checklist" ? (
            <ChecklistView
              items={note.items ?? []}
              testIdPrefix={`note-card-checklist-${note.id}`}
              className="mt-1"
              noteColor={note.color}
              onToggle={(itemId, done) => {
                const next = (note.items ?? []).map((it) =>
                  it.id === itemId ? { ...it, done } : it,
                );
                update.mutate({ id: note.id, kind: "checklist", items: next });
              }}
            />
          ) : (
            note.content && (
              <div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[12]">
                {note.content}
              </div>
            )
          )}
        </div>
        <button
          onClick={(e) => {
            e.stopPropagation();
            update.mutate({ id: note.id, isPinned: !note.isPinned });
          }}
          className={`shrink-0 p-1 rounded-md hover:bg-black/5 ${
            note.isPinned
              ? "text-amber-600"
              : "text-muted-foreground opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
          }`}
          aria-label={t("notes.pin", "Pin")}
        >
          <Pin size={16} className={note.isPinned ? "fill-amber-500" : ""} />
        </button>
      </div>

      {noteLabels.length > 0 && (
        <div className="flex flex-wrap gap-1 mt-2">
          {noteLabels.map((l) => (
            <Badge key={l.id} variant="secondary" className="text-[10px] py-0 px-1.5">
              {l.name}
            </Badge>
          ))}
        </div>
      )}

      <div
        // On touch devices (no hover capability) keep the action row
        // always visible so users can tap Send / archive / delete
        // without a hover gesture they don't have. Mouse-driven
        // desktops keep the existing hover-reveal behaviour.
        className="flex items-center gap-1 mt-2 opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100 transition"
        onClick={(e) => e.stopPropagation()}
      >
        <ColorPicker
          value={note.color}
          onChange={(c) => update.mutate({ id: note.id, color: c })}
        />
        <LabelMenu
          labels={labels}
          selected={note.labelIds}
          onChange={(ids) => update.mutate({ id: note.id, labelIds: ids })}
        />
        {!hideSend && (
          <button
            onClick={() => onSend(note)}
            className="p-1 rounded-md hover:bg-black/5 text-muted-foreground hover:text-primary"
            aria-label={t("notes.send", "Send")}
            data-testid={`note-card-send-${note.id}`}
          >
            <Send size={15} />
          </button>
        )}
        <button
          onClick={() =>
            update.mutate({ id: note.id, isArchived: !note.isArchived })
          }
          className="p-1 rounded-md hover:bg-black/5 text-muted-foreground"
          aria-label={
            note.isArchived
              ? t("notes.unarchive", "Unarchive")
              : t("notes.archive", "Archive")
          }
        >
          {note.isArchived ? <ArchiveRestore size={15} /> : <Archive size={15} />}
        </button>
        <button
          onClick={(e) => {
            e.stopPropagation();
            setConfirmDeleteOpen(true);
          }}
          className="p-1 rounded-md hover:bg-black/5 text-muted-foreground hover:text-destructive"
          aria-label={t("notes.delete", "Delete")}
          data-testid={`note-card-delete-${note.id}`}
        >
          <Trash2 size={15} />
        </button>
      </div>
    </div>
    {/* AlertDialog rendered as a sibling (not a child of the clickable card)
        so React's portal event bubbling can't trigger the card's onEdit
        click handler when the user interacts with the confirmation. */}
    <AlertDialog
      open={confirmDeleteOpen}
      onOpenChange={setConfirmDeleteOpen}
    >
      <AlertDialogContent data-testid={`note-delete-dialog-${note.id}`}>
        <AlertDialogHeader>
          <AlertDialogTitle>
            {t("notes.deleteConfirm", "Delete this note?")}
          </AlertDialogTitle>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel>
            {t("common.cancel", "Cancel")}
          </AlertDialogCancel>
          <AlertDialogAction
            onClick={() => {
              del.mutate(note.id);
              setConfirmDeleteOpen(false);
            }}
            data-testid={`note-delete-confirm-${note.id}`}
          >
            {t("notes.delete", "Delete")}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
    </>
  );
}

// File-manager style folder browser shown at the top of the "All notes" view.

function useRelativeTime(iso: string) {
  const { i18n } = useTranslation();
  return useMemo(() => {
    try {
      const d = new Date(iso);
      if (Number.isNaN(d.getTime())) return "";
      return formatDistanceToNow(d, {
        addSuffix: true,
        locale: i18n.language === "ar" ? dfAr : dfEn,
      });
    } catch {
      return "";
    }
  }, [iso, i18n.language]);
}

function FoldersBrowser({
  folders,
  viewMode,
  onSelect,
}: {
  folders: NoteFolder[];
  viewMode: ViewMode;
  onSelect: (s: FolderSelection) => void;
}) {
  const { t } = useTranslation();
  const del = useDeleteFolder();
  const [selectedIds, setSelectedIds] = useState<Set<number>>(() => new Set());
  const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false);

  // Drop deselected ids if folders disappear (e.g. after bulk delete).
  useEffect(() => {
    setSelectedIds((prev) => {
      const valid = new Set(folders.map((f) => f.id));
      const next = new Set<number>();
      let changed = false;
      prev.forEach((id) => {
        if (valid.has(id)) next.add(id);
        else changed = true;
      });
      return changed ? next : prev;
    });
  }, [folders]);

  const total = folders.length;
  if (total === 0) return null;

  const toggleSelect = (id: number) => {
    setSelectedIds((prev) => {
      const next = new Set(prev);
      if (next.has(id)) next.delete(id);
      else next.add(id);
      return next;
    });
  };
  const clearSelection = () => setSelectedIds(new Set());

  const itemsLabel =
    total === 1
      ? t("notes.folders.itemCountOne", "1 folder")
      : t("notes.folders.itemCount", "{{count}} folders", { count: total });

  const selectedCount = selectedIds.size;

  const runBulkDelete = async () => {
    const ids = Array.from(selectedIds);
    setBulkConfirmOpen(false);
    try {
      await Promise.all(ids.map((id) => del.mutateAsync(id)));
      clearSelection();
    } catch {
      toast({
        title: t("notes.folders.bulkDeleteFailed", "Some folders could not be deleted"),
        variant: "destructive",
      });
    }
  };

  return (
    <div className="mt-2 mb-4 space-y-3" data-testid="notes-folders-browser">
      {selectedCount > 0 && (
        <div
          className="flex items-center justify-between gap-2 px-3 py-2 rounded-lg bg-slate-900 text-white shadow"
          data-testid="folders-bulk-bar"
        >
          <span className="text-sm" data-testid="folders-bulk-count">
            {t("notes.folders.bulkSelected", "{{count}} selected", {
              count: selectedCount,
            })}
          </span>
          <div className="flex items-center gap-2">
            <button
              type="button"
              onClick={clearSelection}
              className="text-xs px-2 py-1 rounded-md hover:bg-white/10"
              data-testid="folders-bulk-clear"
            >
              {t("common.cancel", "Cancel")}
            </button>
            <button
              type="button"
              onClick={() => setBulkConfirmOpen(true)}
              className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md bg-red-600 hover:bg-red-700"
              data-testid="folders-bulk-delete"
            >
              <Trash2 size={14} />
              {t("notes.folders.deleteAction", "Delete")}
            </button>
          </div>
        </div>
      )}

      {viewMode === "grid" ? (
        <div
          className="grid gap-3"
          style={{ gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))" }}
          data-testid="notes-folders-grid"
        >
          {folders.map((f) => (
            <FolderItem
              key={f.id}
              folder={f}
              viewMode="grid"
              isSelected={selectedIds.has(f.id)}
              onToggleSelect={() => toggleSelect(f.id)}
              onSelect={() => onSelect({ kind: "folder", id: f.id })}
            />
          ))}
        </div>
      ) : (
        <div
          className="rounded-lg border border-slate-200/70 bg-white/60 divide-y divide-slate-200/60 overflow-hidden"
          data-testid="notes-folders-list"
        >
          {folders.map((f) => (
            <FolderItem
              key={f.id}
              folder={f}
              viewMode="list"
              isSelected={selectedIds.has(f.id)}
              onToggleSelect={() => toggleSelect(f.id)}
              onSelect={() => onSelect({ kind: "folder", id: f.id })}
            />
          ))}
        </div>
      )}
      <div
        className="text-xs text-muted-foreground pt-1"
        data-testid="notes-folders-count"
      >
        {itemsLabel}
      </div>

      <AlertDialog open={bulkConfirmOpen} onOpenChange={setBulkConfirmOpen}>
        <AlertDialogContent data-testid="folders-bulk-delete-dialog">
          <AlertDialogHeader>
            <AlertDialogTitle>
              {t("notes.folders.bulkDeleteTitle", "Delete selected folders?")}
            </AlertDialogTitle>
            <AlertDialogDescription>
              {t(
                "notes.folders.bulkDeleteConfirm",
                "Notes inside these folders will move to Unfiled.",
              )}
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel data-testid="folders-bulk-delete-cancel">
              {t("common.cancel", "Cancel")}
            </AlertDialogCancel>
            <AlertDialogAction
              className="bg-red-600 text-white hover:bg-red-700 focus:ring-red-600"
              data-testid="folders-bulk-delete-confirm"
              onClick={runBulkDelete}
            >
              {t("notes.folders.deleteAction", "Delete")}
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
    </div>
  );
}

function FolderItem({
  folder,
  viewMode,
  isSelected,
  onToggleSelect,
  onSelect,
}: {
  folder: NoteFolder;
  viewMode: ViewMode;
  isSelected: boolean;
  onToggleSelect: () => void;
  onSelect: () => void;
}) {
  const { t } = useTranslation();
  const update = useUpdateFolder();
  const del = useDeleteFolder();
  const [editing, setEditing] = useState(false);
  const [editingName, setEditingName] = useState(folder.name);
  const [confirmOpen, setConfirmOpen] = useState(false);
  const relTime = useRelativeTime(folder.updatedAt || folder.createdAt);
  // Each browser tile is its own droppable. The id namespace (`-browser-`)
  // is distinct from the rail (`folder-drop-rail-…`) so dnd-kit can
  // resolve overlap correctly when the rail and browser are both visible.
  const { setNodeRef: setDropRef, isOver } = useDroppable({
    id: `folder-drop-browser-${folder.id}`,
    data: { type: "folder-drop", target: folder.id },
  });

  const submitRename = () => {
    const name = editingName.trim();
    if (!name) return;
    update.mutate(
      { id: folder.id, name },
      {
        onSuccess: () => setEditing(false),
        onError: () =>
          toast({
            title: t("notes.folders.renameFailed", "Could not rename folder"),
            variant: "destructive",
          }),
      },
    );
  };

  const kebab = (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <button
          type="button"
          onClick={(e) => e.stopPropagation()}
          className="p-1 rounded-md text-muted-foreground hover:bg-black/5 hover:text-foreground"
          aria-label={t("notes.folders.actions", "Folder actions")}
          data-testid={`folder-menu-${folder.id}`}
        >
          <MoreVertical size={14} />
        </button>
      </DropdownMenuTrigger>
      <DropdownMenuContent
        align="end"
        className="w-40"
        onClick={(e) => e.stopPropagation()}
      >
        <DropdownMenuItem
          onSelect={() => {
            setEditingName(folder.name);
            setEditing(true);
          }}
          data-testid={`folder-rename-${folder.id}`}
        >
          <Pencil size={14} className="me-2" />
          {t("notes.folders.rename", "Rename folder")}
        </DropdownMenuItem>
        <DropdownMenuItem
          onSelect={() => setConfirmOpen(true)}
          className="text-red-600 focus:text-red-600"
          data-testid={`folder-delete-${folder.id}`}
        >
          <Trash2 size={14} className="me-2" />
          {t("notes.folders.delete", "Delete folder")}
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );

  const renameRow = (
    <div
      className="flex items-center gap-1 w-full"
      onClick={(e) => e.stopPropagation()}
    >
      <Input
        autoFocus
        value={editingName}
        onChange={(e) => setEditingName(e.target.value)}
        onKeyDown={(e) => {
          if (e.key === "Enter") submitRename();
          if (e.key === "Escape") setEditing(false);
        }}
        className="h-7 text-sm"
        data-testid={`folder-rename-input-${folder.id}`}
      />
      <button
        type="button"
        onClick={submitRename}
        className="p-1 text-muted-foreground hover:text-foreground"
        aria-label={t("common.save", "Save")}
        data-testid={`folder-rename-save-${folder.id}`}
      >
        <Check size={14} />
      </button>
      <button
        type="button"
        onClick={() => setEditing(false)}
        className="p-1 text-muted-foreground hover:text-foreground"
        aria-label={t("common.cancel", "Cancel")}
      >
        <X size={14} />
      </button>
    </div>
  );

  const deleteDialog = (
    <AlertDialog
      open={confirmOpen}
      onOpenChange={setConfirmOpen}
    >
      <AlertDialogContent
        data-testid={`folder-delete-dialog-${folder.id}`}
      >
        <AlertDialogHeader>
          <AlertDialogTitle>
            {t("notes.folders.deleteTitle", "Delete this folder?")}
          </AlertDialogTitle>
          <AlertDialogDescription>
            {t(
              "notes.folders.deleteConfirm",
              "Delete this folder? Notes inside will move to Unfiled.",
            )}
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel
            data-testid={`folder-delete-cancel-${folder.id}`}
          >
            {t("common.cancel", "Cancel")}
          </AlertDialogCancel>
          <AlertDialogAction
            className="bg-red-600 text-white hover:bg-red-700 focus:ring-red-600"
            data-testid={`folder-delete-confirm-${folder.id}`}
            onClick={() => {
              del.mutate(folder.id, {
                onError: () =>
                  toast({
                    title: t(
                      "notes.folders.deleteFailed",
                      "Could not delete folder",
                    ),
                    variant: "destructive",
                  }),
              });
              setConfirmOpen(false);
            }}
          >
            {t("notes.folders.deleteAction", "Delete")}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  );

  const checkbox = (
    <span
      onClick={(e) => e.stopPropagation()}
      className="inline-flex items-center"
    >
      <Checkbox
        checked={isSelected}
        onCheckedChange={() => onToggleSelect()}
        aria-label={t("notes.folders.select", "Select folder")}
        data-testid={`folder-select-checkbox-${folder.id}`}
      />
    </span>
  );

  if (viewMode === "list") {
    return (
      <>
        <div
          ref={setDropRef}
          data-testid={`folder-drop-${folder.id}`}
          data-drop-active={isOver ? "true" : "false"}
          data-selected={isSelected ? "true" : "false"}
          className="group flex items-center gap-3 px-3 py-2 cursor-pointer hover:bg-slate-50/80 transition data-[drop-active=true]:ring-2 data-[drop-active=true]:ring-amber-400 data-[drop-active=true]:ring-inset data-[selected=true]:bg-slate-100"
          onClick={editing ? undefined : onSelect}
        >
          {checkbox}
          <Folder
            size={20}
            className="shrink-0 text-foreground"
            fill="currentColor"
          />
          {editing ? (
            renameRow
          ) : (
            <>
              <button
                type="button"
                onClick={(e) => {
                  e.stopPropagation();
                  onSelect();
                }}
                data-testid={`folder-select-${folder.id}`}
                className="flex-1 flex items-center gap-2 min-w-0 text-start"
              >
                <span className="text-sm font-medium text-foreground truncate">
                  {folder.name}
                </span>
                <span
                  data-testid={`folder-drop-${folder.id}-count`}
                  className="text-[10px] px-1.5 py-0.5 rounded-full bg-slate-200/80 text-muted-foreground shrink-0"
                >
                  {folder.noteCount}
                </span>
              </button>
              <span className="text-xs text-muted-foreground shrink-0 hidden sm:inline">
                {relTime}
              </span>
              {kebab}
            </>
          )}
        </div>
        {deleteDialog}
      </>
    );
  }

  return (
    <>
      <div
        ref={setDropRef}
        data-testid={`folder-drop-${folder.id}`}
        data-drop-active={isOver ? "true" : "false"}
        data-selected={isSelected ? "true" : "false"}
        onClick={editing ? undefined : onSelect}
        className="group relative rounded-xl border border-slate-200/70 bg-white/70 hover:shadow-md hover:border-slate-300 p-3 cursor-pointer transition data-[drop-active=true]:ring-2 data-[drop-active=true]:ring-amber-400 data-[selected=true]:ring-2 data-[selected=true]:ring-slate-900 data-[selected=true]:bg-slate-50"
      >
        <div className="absolute top-1.5 start-1.5 z-10">
          {checkbox}
        </div>
        <div className="absolute top-1.5 end-1.5 z-10">
          {!editing && kebab}
        </div>
        <div className="flex flex-col items-center text-center pt-3">
          <Folder
            size={56}
            className="text-foreground"
            fill="currentColor"
            strokeWidth={1.5}
          />
          {editing ? (
            <div className="mt-2 w-full">{renameRow}</div>
          ) : (
            <>
              <button
                type="button"
                onClick={(e) => {
                  e.stopPropagation();
                  onSelect();
                }}
                data-testid={`folder-select-${folder.id}`}
                className="mt-2 text-sm font-medium text-foreground line-clamp-2 break-words w-full"
              >
                {folder.name}
              </button>
              {relTime && (
                <div className="text-[11px] text-muted-foreground mt-0.5">
                  {relTime}
                </div>
              )}
              <span
                data-testid={`folder-drop-${folder.id}-count`}
                className="mt-1 text-[10px] px-1.5 py-0.5 rounded-full bg-slate-200/80 text-muted-foreground"
              >
                {folder.noteCount}
              </span>
            </>
          )}
        </div>
      </div>
      {deleteDialog}
    </>
  );
}


function ReceivedList({
  loading,
  notes,
  onOpen,
  selectionMode = false,
  selectedIds,
  onToggle,
  onRequestDelete,
}: {
  loading: boolean;
  notes: ReceivedNote[];
  onOpen: (id: number) => void;
  selectionMode?: boolean;
  selectedIds?: Set<number>;
  onToggle?: (id: number) => void;
  // optional per-row delete affordance. Only the live
  // Inbox passes it; the Archived section omits it because deletes
  // are surfaced at the page level there.
  onRequestDelete?: (id: number) => void;
}) {
  const { t, i18n } = useTranslation();
  const lang = i18n.language;
  if (loading) return <Loading />;
  if (notes.length === 0) {
    return (
      <Empty
        icon={<Inbox size={48} />}
        text={t("notes.emptyReceived", "No notes have been sent to you yet")}
      />
    );
  }
  return (
    <div
      className="gap-3 mt-2 [column-fill:_balance]"
      style={{ columnWidth: 260 }}
      data-testid="notes-received-list"
    >
      {notes.map((n) => {
        const checked = selectedIds?.has(n.id) ?? false;
        const handleActivate = () => {
          if (selectionMode) onToggle?.(n.id);
          else onOpen(n.id);
        };
        // Use a div (not <button>) so the Checkbox inside selection mode
        // isn't a nested interactive element.
        return (
          <div
            key={n.id}
            role="button"
            tabIndex={0}
            onClick={handleActivate}
            onKeyDown={(e) => {
              if (e.key === "Enter" || e.key === " ") {
                e.preventDefault();
                handleActivate();
              }
            }}
            aria-pressed={selectionMode ? checked : undefined}
            data-testid={`received-note-card-${n.id}`}
            className={`relative text-start break-inside-avoid mb-3 w-full rounded-xl border border-black/5 shadow-sm p-3 cursor-pointer transition hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 ${colorBg(
              n.color,
            )} ${checked ? "ring-2 ring-sky-500" : ""}`}
          >
            {selectionMode && (
              <span
                className="absolute top-2 end-2 z-[1] flex items-center justify-center rounded-md bg-white/80 backdrop-blur p-0.5 shadow-sm pointer-events-none"
              >
                <Checkbox
                  checked={checked}
                  tabIndex={-1}
                  data-testid={`received-note-checkbox-${n.id}`}
                  aria-hidden="true"
                />
              </span>
            )}
            <div className="flex items-center justify-between gap-2 mb-1.5">
              <div className="text-[11px] text-muted-foreground flex items-center gap-1 min-w-0">
                <Mail size={12} className="shrink-0" />
                <span className="truncate">
                  {t("notes.from", "From:")} {userDisplayName(n.sender, lang)}
                </span>
              </div>
              {!selectionMode && (
                <div className="flex items-center gap-1">
                  <StatusBadge status={n.status} />
                  {onRequestDelete && (
                    <button
                      type="button"
                      data-testid={`received-note-delete-${n.id}`}
                      aria-label={t("notes.delete", "Delete")}
                      title={t("notes.delete", "Delete")}
                      className="p-1 rounded hover:bg-rose-100 text-rose-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-500"
                      onClick={(e) => {
                        e.stopPropagation();
                        onRequestDelete(n.id);
                      }}
                      onKeyDown={(e) => {
                        // Don't bubble Enter/Space into the card's
                        // role=button activation handler.
                        if (e.key === "Enter" || e.key === " ") {
                          e.stopPropagation();
                        }
                      }}
                    >
                      <Trash2 size={14} />
                    </button>
                  )}
                </div>
              )}
            </div>
            {n.kind === "checklist" ? (
              <ChecklistView
                items={n.items ?? []}
                testIdPrefix={`received-note-checklist-${n.id}`}
                className="mt-1"
                noteColor={n.color}
              />
            ) : (
              n.content && (
                <div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[8]">
                  {n.content}
                </div>
              )
            )}
          </div>
        );
      })}
    </div>
  );
}

function SentList({
  loading,
  notes,
  onOpen,
  selectionMode = false,
  selectedIds,
  onToggle,
}: {
  loading: boolean;
  notes: SentNote[];
  onOpen: (id: number) => void;
  // when `selectionMode` is on the cards become checkbox
  // toggles instead of thread openers. The page-level state is passed
  // in so the bulk action bar (rendered above this list) can mirror
  // the same Set without prop-drilling state setters.
  selectionMode?: boolean;
  selectedIds?: Set<number>;
  onToggle?: (id: number) => void;
}) {
  const { t, i18n } = useTranslation();
  const lang = i18n.language;
  if (loading) return <Loading />;
  if (notes.length === 0) {
    return (
      <Empty
        icon={<Send size={48} />}
        text={t("notes.emptySent", "You haven't sent any notes yet")}
      />
    );
  }
  return (
    <div
      className="gap-3 mt-2 [column-fill:_balance]"
      style={{ columnWidth: 260 }}
      data-testid="notes-sent-list"
    >
      {notes.map((n) => {
        const checked = selectedIds?.has(n.id) ?? false;
        // Use a div (not <button>) so the Checkbox inside selection mode
        // isn't a nested interactive control. Keyboard support is
        // provided via role="button" + Enter/Space handler.
        const handleActivate = () => {
          if (selectionMode) onToggle?.(n.id);
          else onOpen(n.id);
        };
        return (
        <div
          key={n.id}
          role="button"
          tabIndex={0}
          onClick={handleActivate}
          onKeyDown={(e) => {
            if (e.key === "Enter" || e.key === " ") {
              e.preventDefault();
              handleActivate();
            }
          }}
          aria-pressed={selectionMode ? checked : undefined}
          data-testid={`sent-note-card-${n.id}`}
          className={`relative text-start break-inside-avoid mb-3 w-full rounded-xl border border-black/5 shadow-sm p-3 cursor-pointer transition hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 ${colorBg(
            n.color,
          )} ${checked ? "ring-2 ring-rose-500" : ""}`}
        >
          {selectionMode && (
            <span
              className="absolute top-2 end-2 z-[1] flex items-center justify-center rounded-md bg-white/80 backdrop-blur p-0.5 shadow-sm pointer-events-none"
            >
              <Checkbox
                checked={checked}
                tabIndex={-1}
                data-testid={`sent-note-checkbox-${n.id}`}
                aria-hidden="true"
              />
            </span>
          )}
          {n.kind === "checklist" ? (
            <ChecklistView
              items={n.items ?? []}
              testIdPrefix={`sent-note-checklist-${n.id}`}
              className="mt-1"
              noteColor={n.color}
            />
          ) : (
            n.content && (
              <div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[6]">
                {n.content}
              </div>
            )
          )}
          <div className="text-[11px] text-muted-foreground mt-2">
            {t("notes.to", "To:")}{" "}
            <span className="text-foreground/80">
              {n.recipients
                .map((r) => userDisplayName(r.recipient, lang))
                .join("، ")}
            </span>
          </div>
          <div className="flex flex-wrap gap-1 mt-2">
            {n.recipients.map((r) => (
              <span
                key={r.id}
                data-testid={`sent-recipient-status-${n.id}-${r.recipientUserId}`}
                className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full bg-white/60 border border-black/5"
              >
                <span className="truncate max-w-[80px]">
                  {userDisplayName(r.recipient, lang)}
                </span>
                <StatusDot status={r.status} />
              </span>
            ))}
          </div>
        </div>
        );
      })}
    </div>
  );
}

function StatusBadge({ status }: { status: string }) {
  const { t } = useTranslation();
  const map: Record<string, string> = {
    unread: "bg-rose-500/15 text-rose-700",
    read: "bg-slate-200 text-slate-700",
    replied: "bg-emerald-500/15 text-emerald-700",
    archived: "bg-slate-200 text-slate-500",
  };
  return (
    <span
      className={`text-[10px] px-1.5 py-0.5 rounded-full ${map[status] ?? map.read}`}
      data-testid={`status-badge-${status}`}
    >
      {t(`notes.status.${status}`, status)}
    </span>
  );
}

function StatusDot({ status }: { status: string }) {
  const map: Record<string, string> = {
    unread: "bg-rose-500",
    read: "bg-slate-400",
    replied: "bg-emerald-500",
    archived: "bg-slate-300",
  };
  return (
    <span
      className={`inline-block w-1.5 h-1.5 rounded-full ${map[status] ?? map.read}`}
      aria-label={status}
    />
  );
}

function ArchivedView({
  loadingMine,
  mine,
  labels,
  onEdit,
  onSend,
  loadingInbox,
  archivedReceived,
  onOpenThread,
}: {
  loadingMine: boolean;
  mine: Note[];
  labels: NoteLabel[];
  onEdit: (n: Note) => void;
  onSend: (n: Note) => void;
  loadingInbox: boolean;
  archivedReceived: ReceivedNote[];
  onOpenThread: (id: number) => void;
}) {
  const { t } = useTranslation();
  if (loadingMine || loadingInbox) return <Loading />;
  if (mine.length === 0 && archivedReceived.length === 0) {
    return (
      <Empty
        icon={<StickyNote size={48} />}
        text={t("notes.emptyArchived", "No archived notes")}
      />
    );
  }
  return (
    <div className="space-y-8 mt-2">
      {mine.length > 0 && (
        <div>
          <h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
            {t("notes.archivedSection.myNotes", "My archived notes")}
          </h2>
          <Section title="" notes={mine} labels={labels} onEdit={onEdit} onSend={onSend} />
        </div>
      )}
      {archivedReceived.length > 0 && (
        <div data-testid="notes-archived-inbox">
          <h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
            {t("notes.archivedSection.inbox", "Archived inbox")}
          </h2>
          <ReceivedList
            loading={false}
            notes={archivedReceived}
            onOpen={onOpenThread}
          />
        </div>
      )}
    </div>
  );
}

function ThreadDialog({
  noteId,
  onClose,
  autoFocusReply = false,
}: {
  noteId: number;
  onClose: () => void;
  autoFocusReply?: boolean;
}) {
  const { t, i18n } = useTranslation();
  const lang = i18n.language;
  const { data: thread, isLoading } = useNoteThread(noteId);
  const markRead = useMarkNoteRead();
  const reply = useReplyToNote();
  const archive = useArchiveReceivedNote();
  // per-row delete from the open thread. Lives next to the
  // existing Archive button so single-note delete doesn't require
  // entering bulk-selection mode.
  const deleteReceived = useDeleteReceivedNote();
  const [singleDeleteOpen, setSingleDeleteOpen] = useState(false);
  // collaborative checklist toggle scoped to the open thread.
  const toggleChecklistItem = useToggleChecklistItem();
  const [replyText, setReplyText] = useState("");
  const [ownerReplyTarget, setOwnerReplyTarget] = useState<number | null>(null);
  const markedRef = useRef(false);
  const replyInputRef = useRef<HTMLTextAreaElement | null>(null);
  const focusedRef = useRef(false);
  const replyFocused = useRef(false);
  const { keyboardInset } = useVisualViewport();
  // When the on-screen keyboard appears while the reply box is focused,
  // pull the cursor into the centre of the visible viewport. Gated on a
  // non-trivial inset so desktop focus is unaffected.
  useEffect(() => {
    if (keyboardInset <= 80) return;
    if (!replyFocused.current) return;
    replyInputRef.current?.scrollIntoView({ block: "center" });
  }, [keyboardInset]);

  // When opened from the incoming-note popup with reply intent, jump
  // focus into the reply input as soon as the thread loads.
  useEffect(() => {
    if (!autoFocusReply || focusedRef.current) return;
    if (!thread) return;
    const el = replyInputRef.current;
    if (!el) return;
    focusedRef.current = true;
    el.focus();
  }, [autoFocusReply, thread]);

  useEffect(() => {
    if (!thread || markedRef.current) return;
    if (!thread.isOwner && !thread.isAdmin && thread.myStatus === "unread") {
      markedRef.current = true;
      markRead.mutate(noteId);
    }
  }, [thread, noteId, markRead]);

  const submitReply = () => {
    const c = replyText.trim();
    if (!c) return;
    if (!thread) return;
    const isOwner = thread.isOwner;
    const recipients = thread.recipients;
    let recipientUserId: number | undefined;
    if (isOwner) {
      if (recipients.length === 0) return;
      if (recipients.length === 1) {
        recipientUserId = recipients[0].recipientUserId;
      } else {
        if (ownerReplyTarget == null) return;
        recipientUserId = ownerReplyTarget;
      }
    }
    reply.mutate(
      { id: noteId, content: c, recipientUserId },
      { onSuccess: () => setReplyText("") },
    );
  };

  return (
    <>
    <Dialog open onOpenChange={(o) => !o && onClose()}>
      <DialogContent
        className={`max-w-lg max-h-[85vh] flex flex-col ${thread ? colorBg(thread.color) : ""}`}
        data-testid="note-thread-dialog"
      >
        <DialogHeader>
          <DialogTitle className="text-base">
            {t("notes.editTitle", "Edit note")}
          </DialogTitle>
        </DialogHeader>
        {isLoading || !thread ? (
          <Loading />
        ) : (
          <>
            {/* Three vertical zones inside the 85vh-capped DialogContent:
                1) static meta + recipient chips (always visible),
                2) scrollable replies-only region,
                3) static composer (pinned below).
                This keeps the From line, recipient pills, and the reply
                box on screen while long conversations scroll inside the
                middle zone. */}
            <div className="shrink-0 flex flex-col gap-2" data-testid="note-thread-meta">
              <div className="text-[11px] text-muted-foreground flex items-center gap-1">
                <Mail size={12} />
                {t("notes.from", "From:")} {userDisplayName(thread.sender, lang)}
              </div>
              {thread.kind === "checklist" ? (
                <ChecklistView
                  items={thread.items ?? []}
                  testIdPrefix={`thread-checklist-${thread.id}`}
                  noteColor={thread.color}
                  // gate toggles to match server auth — only
                  // the owner or an active (non-archived) recipient can
                  // collaboratively check items. Admins viewing a note
                  // they neither own nor were sent get a read-only
                  // checklist (server returns 403 for them, so showing
                  // a live checkbox would just produce a failed toggle).
                  onToggle={
                    thread.isOwner ||
                    (thread.myStatus !== null &&
                      thread.myStatus !== "archived")
                      ? (itemId, done) =>
                          toggleChecklistItem.mutate({
                            id: thread.id,
                            itemId,
                            done,
                          })
                      : undefined
                  }
                />
              ) : (
                thread.content && (
                  <div className="text-sm text-foreground/85 whitespace-pre-wrap">
                    {thread.content}
                  </div>
                )
              )}
              {(thread.isOwner || thread.isAdmin) && thread.recipients.length > 0 && (
                <div className="border-t pt-3">
                  <div className="text-xs text-muted-foreground mb-1">
                    {t("notes.to", "To:")}
                  </div>
                  <div className="flex flex-wrap gap-1.5">
                    {thread.recipients.map((r) => (
                      <span
                        key={r.id}
                        className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-full bg-white/70 border border-black/5"
                        data-testid={`thread-recipient-${r.recipientUserId}`}
                      >
                        <span>{userDisplayName(r.recipient, lang)}</span>
                        <StatusDot status={r.status} />
                        <span className="text-muted-foreground">
                          {t(`notes.status.${r.status}`, r.status)}
                        </span>
                      </span>
                    ))}
                  </div>
                </div>
              )}
            </div>

            <div className="border-t pt-3 flex flex-col flex-1 min-h-0">
              <div className="text-xs text-muted-foreground mb-1.5 flex items-center gap-1 shrink-0">
                <MessageCircle size={12} />
                {t("notes.replies", "Replies")}
                {thread.replies.length > 0 && (
                  <span className="text-foreground/70">
                    ({thread.replies.length})
                  </span>
                )}
              </div>
              <div
                className="flex-1 min-h-0 overflow-y-auto -mx-1 px-1"
                data-testid="note-thread-scroll"
              >
                {thread.replies.length === 0 ? (
                  <div className="text-xs text-muted-foreground italic">
                    {t("notes.noRepliesYet", "No replies yet")}
                  </div>
                ) : thread.isOwner || thread.isAdmin ? (
                  <GroupedReplies thread={thread} lang={lang} />
                ) : (
                  <div className="space-y-2">
                    {thread.replies.map((r) => (
                      <ReplyBubble key={r.id} reply={r} lang={lang} />
                    ))}
                  </div>
                )}
              </div>
            </div>

            {(thread.isOwner ||
              (thread.myStatus !== null && thread.myStatus !== "archived")) && (
              <div className="border-t pt-3 flex flex-col gap-2 shrink-0">
                {thread.isOwner && thread.recipients.length > 1 && (
                  <select
                    value={ownerReplyTarget ?? ""}
                    onChange={(e) =>
                      setOwnerReplyTarget(
                        e.target.value === "" ? null : Number(e.target.value),
                      )
                    }
                    data-testid="thread-owner-reply-target"
                    className="text-sm rounded-md border border-black/10 bg-white/80 px-2 py-1.5"
                  >
                    <option value="">
                      {t("notes.to", "To:")}
                    </option>
                    {thread.recipients.map((r) => (
                      <option key={r.recipientUserId} value={r.recipientUserId}>
                        {userDisplayName(r.recipient, lang)}
                      </option>
                    ))}
                  </select>
                )}
                <Textarea
                  ref={replyInputRef}
                  value={replyText}
                  onChange={(e) => setReplyText(e.target.value)}
                  onFocus={(e) => {
                    replyFocused.current = true;
                    if (keyboardInset > 80) {
                      e.currentTarget.scrollIntoView({ block: "center" });
                    }
                  }}
                  onBlur={() => {
                    replyFocused.current = false;
                  }}
                  placeholder={t("notes.replyPlaceholder", "Write a reply…")}
                  className="min-h-[70px] resize-none"
                  data-testid="thread-reply-input"
                />
                <div className="flex items-center justify-between">
                  {!thread.isOwner ? (
                    <div className="flex items-center gap-1">
                      <Button
                        size="sm"
                        variant="ghost"
                        data-testid="thread-archive"
                        onClick={() => {
                          archive.mutate(
                            { id: noteId, archived: true },
                            { onSuccess: onClose },
                          );
                        }}
                      >
                        <Archive size={14} className="me-1" />
                        {t("notes.archive", "Archive")}
                      </Button>
                      {/* per-row Delete sits next to Archive
                          in the thread footer. Confirms inline before
                          calling DELETE /notes/received/:id so users
                          don't lose a note by accident. */}
                      <Button
                        size="sm"
                        variant="ghost"
                        data-testid="thread-delete"
                        className="text-rose-600 hover:text-rose-700 hover:bg-rose-50"
                        onClick={() => setSingleDeleteOpen(true)}
                      >
                        <Trash2 size={14} className="me-1" />
                        {t("notes.delete", "Delete")}
                      </Button>
                    </div>
                  ) : (
                    <span />
                  )}
                  <Button
                    size="sm"
                    onClick={submitReply}
                    disabled={
                      !replyText.trim() ||
                      reply.isPending ||
                      (thread.isOwner &&
                        thread.recipients.length > 1 &&
                        ownerReplyTarget == null)
                    }
                    data-testid="thread-reply-submit"
                  >
                    <Send size={14} className="me-1" />
                    {t("notes.sendReply", "Send reply")}
                  </Button>
                </div>
              </div>
            )}
          </>
        )}
      </DialogContent>
    </Dialog>
    {/* single-note delete confirm rendered as a sibling so
        it overlays cleanly above the thread dialog. */}
    <AlertDialog
      open={singleDeleteOpen}
      onOpenChange={(open) => {
        if (!deleteReceived.isPending) setSingleDeleteOpen(open);
      }}
    >
      <AlertDialogContent data-testid="thread-delete-confirm">
        <AlertDialogHeader>
          <AlertDialogTitle>
            {t("notes.deleteInboxConfirmTitle", "Delete this note?")}
          </AlertDialogTitle>
          <AlertDialogDescription>
            {t(
              "notes.bulkDeleteInboxConfirmBody",
              "This removes the notes from your inbox and archive. The sender keeps their copy.",
            )}
          </AlertDialogDescription>
        </AlertDialogHeader>
        <AlertDialogFooter>
          <AlertDialogCancel disabled={deleteReceived.isPending}>
            {t("notes.bulkCancel", "Cancel")}
          </AlertDialogCancel>
          <AlertDialogAction
            data-testid="thread-delete-confirm-button"
            disabled={deleteReceived.isPending}
            className="bg-rose-600 hover:bg-rose-700 text-white"
            onClick={async (e) => {
              e.preventDefault();
              try {
                await deleteReceived.mutateAsync(noteId);
                toast({
                  title: t(
                    "notes.deletedInboxOne",
                    "Note removed from your inbox",
                  ),
                });
                setSingleDeleteOpen(false);
                onClose();
              } catch {
                toast({
                  title: t(
                    "notes.bulkDeleteInboxNone",
                    "Could not delete the selected notes",
                  ),
                  variant: "destructive",
                });
              }
            }}
          >
            {deleteReceived.isPending
              ? t("notes.bulkDeleting", "Deleting...")
              : t("notes.delete", "Delete")}
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
    </>
  );
}

function ReplyBubble({
  reply,
  lang,
}: {
  reply: NoteThread["replies"][number];
  lang: string;
}) {
  return (
    <div
      data-testid={`thread-reply-${reply.id}`}
      className="text-sm bg-white/70 rounded-lg p-2 border border-black/5"
    >
      <div className="text-[11px] text-muted-foreground mb-0.5 flex items-center justify-between gap-2">
        <span className="truncate">{userDisplayName(reply.sender, lang)}</span>
        <span className="shrink-0">{formatReplyTime(reply.createdAt, lang)}</span>
      </div>
      <div className="whitespace-pre-wrap break-words">{reply.content}</div>
    </div>
  );
}

function formatReplyTime(iso: string, lang: string) {
  try {
    return new Date(iso).toLocaleString(lang === "ar" ? "ar" : "en", {
      month: "short",
      day: "numeric",
      hour: "2-digit",
      minute: "2-digit",
    });
  } catch {
    return iso;
  }
}

function GroupedReplies({
  thread,
  lang,
}: {
  thread: NoteThread;
  lang: string;
}) {
  const { t } = useTranslation();
  const recipientNames = new Map<number, string>();
  for (const r of thread.recipients) {
    recipientNames.set(r.recipientUserId, userDisplayName(r.recipient, lang));
  }
  // Group replies by the recipient that the reply is "with" — i.e. the
  // non-owner participant. For replies the owner sent, that is the
  // `recipientUserId` field on the reply; for replies sent by a recipient
  // it is the reply's `senderUserId`.
  const groups = new Map<number, NoteThread["replies"]>();
  const ownerId = thread.senderUserId;
  for (const r of thread.replies) {
    const otherId = r.senderUserId === ownerId ? r.recipientUserId : r.senderUserId;
    if (!groups.has(otherId)) groups.set(otherId, []);
    groups.get(otherId)!.push(r);
  }
  if (groups.size === 0) {
    return (
      <div className="text-xs text-muted-foreground italic">
        {t("notes.noConversationsYet", "No conversations yet")}
      </div>
    );
  }
  return (
    <div className="space-y-3">
      {Array.from(groups.entries()).map(([otherId, replies]) => (
        <div
          key={otherId}
          data-testid={`thread-conversation-${otherId}`}
          className="rounded-lg bg-white/40 border border-black/5 p-2"
        >
          <div className="text-[11px] font-semibold text-foreground/80 mb-1.5">
            {t("notes.conversationWith", {
              name: recipientNames.get(otherId) ?? `#${otherId}`,
            })}
          </div>
          <div className="space-y-2">
            {replies.map((r) => (
              <ReplyBubble key={r.id} reply={r} lang={lang} />
            ))}
          </div>
        </div>
      ))}
    </div>
  );
}

function ChecklistView({
  items,
  onToggle,
  testIdPrefix,
  className = "",
  noteColor,
}: {
  items: ChecklistItem[];
  onToggle?: (id: string, done: boolean) => void;
  testIdPrefix: string;
  className?: string;
  // Optional: when provided, the checked-checkbox accent matches the
  // parent note's color (pink note → pink check, etc.). Falls back to
  // amber when omitted or unknown — see colorAccent().
  noteColor?: string | null;
}) {
  if (items.length === 0) return null;
  const accent = colorAccent(noteColor);
  return (
    <div
      className={`space-y-1 ${className}`}
      data-testid={`${testIdPrefix}-view`}
      onClick={(e) => {
        // Prevent the parent card's click handler (which opens edit) when
        // toggling a checkbox is the intended action.
        if (onToggle) e.stopPropagation();
      }}
    >
      {items.map((it) => (
        <label
          key={it.id}
          className="flex items-start gap-2 text-sm cursor-pointer"
          data-testid={`${testIdPrefix}-row-${it.id}`}
        >
          <input
            type="checkbox"
            checked={it.done}
            disabled={!onToggle}
            onChange={(e) => onToggle?.(it.id, e.target.checked)}
            className={`mt-0.5 h-4 w-4 rounded border-black/30 ${accent} disabled:cursor-default`}
            data-testid={`${testIdPrefix}-check-${it.id}`}
          />
          <span
            className={`flex-1 break-words ${
              it.done ? "line-through decoration-foreground/60 text-foreground/55" : "text-foreground"
            }`}
          >
            {it.text || "\u00A0"}
          </span>
        </label>
      ))}
    </div>
  );
}

function EditNoteDialog({
  note,
  labels,
  onClose,
}: {
  note: Note;
  labels: NoteLabel[];
  onClose: () => void;
}) {
  const { t } = useTranslation();
  const [content, setContent] = useState(note.content);
  const [color, setColor] = useState(note.color);
  const [labelIds, setLabelIds] = useState<number[]>(note.labelIds);
  const [kind, setKind] = useState<NoteKind>(note.kind ?? "text");
  const [items, setItems] = useState<ChecklistItem[]>(note.items ?? []);
  const update = useUpdateNote();

  const save = () => {
    const cleanItems =
      kind === "checklist"
        ? items
            .map((it) => ({ ...it, text: it.text.trim() }))
            .filter((it) => it.text.length > 0)
        : [];
    update.mutate(
      {
        id: note.id,
        content: kind === "checklist" ? "" : content.trim(),
        color,
        labelIds,
        kind,
        items: kind === "checklist" ? cleanItems : null,
      },
      { onSuccess: onClose },
    );
  };

  return (
    <Dialog open onOpenChange={(o) => !o && save()}>
      <DialogContent
        className={`max-w-lg ${colorBg(color)}`}
        onInteractOutside={(e) => {
          e.preventDefault();
          save();
        }}
      >
        <DialogHeader>
          <DialogTitle className="sr-only">
            {t("notes.editTitle", "Edit note")}
          </DialogTitle>
        </DialogHeader>
        {kind === "checklist" ? (
          <div className="px-1 min-h-[160px]" data-testid="notes-edit-checklist-wrap">
            <ChecklistEditor
              items={items}
              onChange={setItems}
              testIdPrefix="notes-edit-checklist"
            />
          </div>
        ) : (
          <Textarea
            value={content}
            onChange={(e) => setContent(e.target.value)}
            placeholder={t("notes.contentPlaceholder", "Take a note...")}
            className="border-0 bg-transparent shadow-none focus-visible:ring-0 px-1 min-h-[160px]"
          />
        )}
        <div className="flex items-center gap-1 mt-2">
          <ColorPicker value={color} onChange={setColor} />
          <KindToggle
            kind={kind}
            onChange={setKind}
            testId="notes-edit-kind-toggle"
          />
          <LabelMenu labels={labels} selected={labelIds} onChange={setLabelIds} />
          <div className="ms-auto">
            <Button size="sm" onClick={save}>
              {t("notes.save", "Save")}
            </Button>
          </div>
        </div>
      </DialogContent>
    </Dialog>
  );
}

function LabelsDialog({
  open,
  onClose,
  labels,
}: {
  open: boolean;
  onClose: () => void;
  labels: NoteLabel[];
}) {
  const { t } = useTranslation();
  const [name, setName] = useState("");
  const [editingId, setEditingId] = useState<number | null>(null);
  const [editingName, setEditingName] = useState("");
  const [pendingDeleteLabel, setPendingDeleteLabel] = useState<NoteLabel | null>(
    null,
  );
  const create = useCreateLabel();
  const update = useUpdateLabel();
  const del = useDeleteLabel();

  return (
    <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>{t("notes.manageLabels", "Manage labels")}</DialogTitle>
        </DialogHeader>
        <div className="flex gap-2">
          <Input
            value={name}
            onChange={(e) => setName(e.target.value)}
            placeholder={t("notes.newLabel", "New label")}
            onKeyDown={(e) => {
              if (e.key === "Enter" && name.trim()) {
                create.mutate(name.trim(), { onSuccess: () => setName("") });
              }
            }}
          />
          <Button
            disabled={!name.trim()}
            onClick={() =>
              create.mutate(name.trim(), { onSuccess: () => setName("") })
            }
          >
            <Plus size={16} />
          </Button>
        </div>
        <div className="space-y-1 max-h-72 overflow-y-auto">
          {labels.length === 0 && (
            <div className="text-sm text-muted-foreground py-4 text-center">
              {t("notes.noLabelsYet", "No labels yet")}
            </div>
          )}
          {labels.map((l) => (
            <div
              key={l.id}
              className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-slate-100 group"
            >
              <Tag size={14} className="text-muted-foreground" />
              {editingId === l.id ? (
                <>
                  <Input
                    value={editingName}
                    onChange={(e) => setEditingName(e.target.value)}
                    autoFocus
                    className="h-7"
                    onKeyDown={(e) => {
                      if (e.key === "Enter" && editingName.trim()) {
                        update.mutate(
                          { id: l.id, name: editingName.trim() },
                          { onSuccess: () => setEditingId(null) },
                        );
                      }
                      if (e.key === "Escape") setEditingId(null);
                    }}
                  />
                  <button
                    onClick={() => {
                      if (editingName.trim())
                        update.mutate(
                          { id: l.id, name: editingName.trim() },
                          { onSuccess: () => setEditingId(null) },
                        );
                    }}
                    className="p-1 text-muted-foreground hover:text-foreground"
                  >
                    <Check size={14} />
                  </button>
                  <button
                    onClick={() => setEditingId(null)}
                    className="p-1 text-muted-foreground hover:text-foreground"
                  >
                    <X size={14} />
                  </button>
                </>
              ) : (
                <>
                  <span
                    className="flex-1 text-sm cursor-pointer"
                    onClick={() => {
                      setEditingId(l.id);
                      setEditingName(l.name);
                    }}
                  >
                    {l.name}
                  </span>
                  <button
                    onClick={() => setPendingDeleteLabel(l)}
                    className="p-1 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100"
                    data-testid={`label-delete-${l.id}`}
                  >
                    <Trash2 size={14} />
                  </button>
                </>
              )}
            </div>
          ))}
        </div>
      </DialogContent>
      <AlertDialog
        open={pendingDeleteLabel !== null}
        onOpenChange={(o) => {
          if (!o) setPendingDeleteLabel(null);
        }}
      >
        <AlertDialogContent
          data-testid={
            pendingDeleteLabel
              ? `label-delete-dialog-${pendingDeleteLabel.id}`
              : "label-delete-dialog"
          }
        >
          <AlertDialogHeader>
            <AlertDialogTitle>
              {t("notes.deleteLabelConfirm", "Delete this label?")}
            </AlertDialogTitle>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel>
              {t("common.cancel", "Cancel")}
            </AlertDialogCancel>
            <AlertDialogAction
              onClick={() => {
                if (pendingDeleteLabel) {
                  del.mutate(pendingDeleteLabel.id);
                  setPendingDeleteLabel(null);
                }
              }}
              data-testid={
                pendingDeleteLabel
                  ? `label-delete-confirm-${pendingDeleteLabel.id}`
                  : "label-delete-confirm"
              }
            >
              {t("notes.delete", "Delete")}
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
    </Dialog>
  );
}

// Recipient view of a folder shared with the current user.
//
// Strict read-only: no Composer, no edit/send/archive/delete affordances on
// cards. Notes are rendered with the same styling as the inbox/sent grids
// (which are themselves read-only) so the visual language stays familiar
// without us having to thread a `readOnly` flag through the full NoteCard.
// Drives the "Shared with me" entry in the folders rail and is unmounted
// the moment the owner revokes access (parent effect falls back to "all").
function SharedFolderView({
  folderId,
  labels,
  search,
  onBack,
  onEdit,
  onSend,
  bucketRef,
}: {
  folderId: number;
  labels: NoteLabel[];
  search: string;
  onBack: () => void;
  onEdit: (n: Note) => void;
  onSend: (n: Note) => void;
  bucketRef: React.MutableRefObject<{ folderId: number; ownerId: number; notes: Note[] } | null>;
}) {
  const { t, i18n } = useTranslation();
  const lang = i18n.language;
  const { data, isLoading, error } = useSharedFolderNotes(folderId);

  // Recipients use the same top-bar search input as their personal notes;
  // filter the read-only list client-side over title + content + checklist
  // item text so the experience matches the owner's view.
  // Publish the full bucket only for editors so handleDragEnd has a
  // correct source. View-only viewers leave the ref null (drag disabled).
  useEffect(() => {
    if (!data || data.myPermission !== "edit") {
      bucketRef.current = null;
      return;
    }
    bucketRef.current = {
      folderId,
      ownerId: data.folder.ownerId,
      notes: data.notes,
    };
    return () => {
      if (bucketRef.current?.folderId === folderId) {
        bucketRef.current = null;
      }
    };
  }, [data, folderId, bucketRef]);

  const visibleNotes = useMemo(() => {
    if (!data) return [];
    const q = search.trim().toLowerCase();
    if (!q) return data.notes;
    return data.notes.filter((n) => {
      if (n.content?.toLowerCase().includes(q)) return true;
      if (n.kind === "checklist" && Array.isArray(n.items)) {
        return n.items.some((it) => it.text?.toLowerCase().includes(q));
      }
      return false;
    });
  }, [data, search]);

  if (isLoading) return <Loading />;

  // 403/404 — the owner unshared the folder while we were viewing it. Show a
  // brief "no longer shared" empty state; the parent effect will switch the
  // selection back to "all" on the next sharedFolders tick.
  if (error || !data) {
    return (
      <>
        <button
          type="button"
          onClick={onBack}
          className="mb-3 inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
          data-testid="notes-folders-back"
        >
          <ChevronLeft size={14} className="rtl:rotate-180" />
          <span>{t("notes.folders.backToFolders", "Back to folders")}</span>
        </button>
        <Empty
          icon={<Lock size={48} />}
          text={t(
            "notes.folders.shareRevoked",
            "This folder is no longer shared with you",
          )}
        />
      </>
    );
  }

  return (
    <>
      <button
        type="button"
        onClick={onBack}
        className="mb-3 inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
        data-testid="notes-folders-back"
      >
        <ChevronLeft size={14} className="rtl:rotate-180" />
        <span>{t("notes.folders.backToFolders", "Back to folders")}</span>
        <span className="text-foreground/80 font-medium ms-1">
          {data.folder.name}
        </span>
      </button>
      <div
        className="mb-4 flex items-center gap-2 text-xs text-muted-foreground rounded-lg bg-slate-50 dark:bg-slate-900/40 border border-slate-200/70 dark:border-slate-700/60 px-3 py-2"
        data-testid="shared-folder-banner"
      >
        <Lock size={12} />
        <span>
          {t("notes.folders.sharedBy", "Shared by")}{" "}
          <span className="text-foreground/80 font-medium">
            {userDisplayName(data.owner, lang)}
          </span>
          {" · "}
          {data.myPermission === "edit"
            ? t("notes.folders.canEdit", "You can edit")
            : t("notes.folders.readOnly", "Read-only")}
        </span>
      </div>
      {/* Editors get the same composer as the owner so they can add notes
          directly into this shared folder. The composer's targetFolderId
          derives from folderSelection.kind === "folder", so we pass a
          synthetic folder selection scoped to this folder id. */}
      {data.myPermission === "edit" && (
        <div className="mb-3" data-testid="shared-folder-composer">
          <Composer
            labels={labels}
            folderSelection={{ kind: "folder", id: folderId }}
            onSend={(id) => onSend({ id } as Note)}
            hideSend
          />
        </div>
      )}
      {visibleNotes.length === 0 ? (
        <Empty
          icon={<StickyNote size={48} />}
          text={
            search.trim()
              ? t("notes.noMatches", "No notes match your search")
              : t("notes.folders.sharedEmpty", "This folder has no notes yet")
          }
        />
      ) : data.myPermission === "edit" ? (
        // Editors see full NoteCard affordances (edit, delete, send,
        // checklist toggle, color/labels). Each NoteCard's mutations
        // call PATCH /notes/:id which the server now allows for editors;
        // the server emits `note-folder-shared` on success and the socket
        // hook invalidates the shared-folder query so all viewers refresh.
        <SortableContext
          items={visibleNotes.map((n) => `note-${n.id}`)}
          strategy={rectSortingStrategy}
        >
          <div
            className="gap-3 mt-2 [column-fill:_balance]"
            style={{ columnWidth: 230 }}
            data-testid="shared-folder-notes"
          >
            {visibleNotes.map((n) => (
              <NoteCard
                key={n.id}
                note={n}
                labels={labels}
                onEdit={onEdit}
                onSend={onSend}
              />
            ))}
          </div>
        </SortableContext>
      ) : (
        <div
          className="gap-3 mt-2 [column-fill:_balance]"
          style={{ columnWidth: 260 }}
          data-testid="shared-folder-notes"
        >
          {visibleNotes.map((n) => (
            <div
              key={n.id}
              data-testid={`shared-folder-note-${n.id}`}
              className={`relative text-start break-inside-avoid mb-3 w-full rounded-xl border border-black/5 shadow-sm p-3 ${colorBg(
                n.color,
              )}`}
            >
              {n.kind === "checklist" ? (
                <ChecklistView
                  items={n.items ?? []}
                  testIdPrefix={`shared-folder-note-checklist-${n.id}`}
                  className="mt-1"
                  noteColor={n.color}
                />
              ) : (
                n.content && (
                  <div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[8]">
                    {n.content}
                  </div>
                )
              )}
              <button
                type="button"
                onClick={(e) => {
                  e.stopPropagation();
                  onSend(n);
                }}
                className="absolute top-2 end-2 p-1.5 rounded-md bg-white/70 hover:bg-white text-muted-foreground hover:text-primary transition-colors"
                aria-label={t("notes.send", "Send")}
                data-testid={`shared-folder-note-send-${n.id}`}
              >
                <Send size={15} />
              </button>
            </div>
          ))}
        </div>
      )}
    </>
  );
}

// Owner-side dialog: pick which users this folder is shared with.
//
// Renders the directory minus self; pre-checks anyone already in the share
// list. "Save" calls the idempotent PUT endpoint with the full desired set
// — server diffs adds/removes and broadcasts share/unshare events so
// recipients' rails update live. Closing without changes is a no-op.
function FolderShareDialog({
  folder,
  onClose,
}: {
  folder: NoteFolder;
  onClose: () => void;
}) {
  const { t, i18n } = useTranslation();
  const lang = i18n.language;
  const { user } = useAuth();
  const { data: directory = [], isLoading: loadingDir } = useUserDirectory();
  const { data: existing = [], isLoading: loadingShares } = useFolderShares(
    folder.id,
  );
  const update = useUpdateFolderShares();
  const [search, setSearch] = useState("");
  // Map<userId, "view" | "edit">: presence = picked, value = permission.
  // null until the existing-shares list has loaded so we can seed it
  // from the current server state without clobbering user toggles.
  const [picked, setPicked] = useState<Map<number, "view" | "edit"> | null>(
    null,
  );

  // Seed the picked map from the existing shares (incl. their saved
  // permission) once both sources have landed.
  useEffect(() => {
    if (picked !== null) return;
    if (loadingShares) return;
    setPicked(
      new Map(existing.map((r) => [r.userId, r.permission])),
    );
  }, [existing, loadingShares, picked]);

  const candidates = useMemo(() => {
    const me = user?.id;
    return directory.filter((u) => u.id !== me);
  }, [directory, user]);

  const filtered = useMemo(() => {
    const q = search.trim().toLowerCase();
    if (!q) return candidates;
    return candidates.filter((u) =>
      [u.username, u.displayNameAr, u.displayNameEn]
        .filter(Boolean)
        .some((s) => (s as string).toLowerCase().includes(q)),
    );
  }, [candidates, search]);

  const togglePicked = (id: number) => {
    setPicked((prev) => {
      const next = new Map(prev ?? []);
      if (next.has(id)) next.delete(id);
      else next.set(id, "view");
      return next;
    });
  };

  const setPermission = (id: number, perm: "view" | "edit") => {
    setPicked((prev) => {
      const next = new Map(prev ?? []);
      // Picking a permission also implies adding the recipient — saves
      // one click for "share with edit" in a single gesture.
      next.set(id, perm);
      return next;
    });
  };

  const submit = () => {
    if (picked === null) return;
    const recipients = Array.from(picked.entries()).map(
      ([userId, permission]) => ({ userId, permission }),
    );
    update.mutate(
      { folderId: folder.id, recipients },
      {
        onSuccess: () => {
          toast({ title: t("notes.folders.shareSaved", "Sharing updated") });
          onClose();
        },
        onError: () => {
          toast({
            title: t("notes.folders.shareFailed", "Could not update sharing"),
            variant: "destructive",
          });
        },
      },
    );
  };

  const isLoading = loadingDir || loadingShares || picked === null;
  const count = picked?.size ?? 0;

  return (
    <Dialog open onOpenChange={(o) => !o && onClose()}>
      <DialogContent className="max-w-md" data-testid="folder-share-dialog">
        <DialogHeader className="pr-8">
          <DialogTitle>
            {t("notes.folders.shareTitle", "Share folder")}
          </DialogTitle>
        </DialogHeader>
        <div className="text-xs text-muted-foreground -mt-1">
          <span className="text-foreground/80 font-medium">{folder.name}</span>
          {" · "}
          {t(
            "notes.folders.shareDescriptionPerm",
            "Pick view or edit for each recipient.",
          )}
        </div>
        <div className="relative">
          <Search
            size={14}
            className="absolute top-1/2 -translate-y-1/2 text-muted-foreground start-2.5"
          />
          <Input
            value={search}
            onChange={(e) => setSearch(e.target.value)}
            placeholder={t("notes.recipientsPlaceholder", "Search people…")}
            className="ps-9"
            data-testid="folder-share-search"
          />
        </div>
        <div className="max-h-72 overflow-y-auto border rounded-lg divide-y">
          {isLoading && (
            <div className="p-3 text-sm text-muted-foreground">
              {t("common.loading", "Loading...")}
            </div>
          )}
          {!isLoading && filtered.length === 0 && (
            <div className="p-3 text-sm text-muted-foreground text-center">—</div>
          )}
          {!isLoading &&
            filtered.map((u) => {
              const perm = picked?.get(u.id);
              const checked = perm !== undefined;
              return (
                <div
                  key={u.id}
                  data-testid={`folder-share-option-${u.id}`}
                  className={`w-full flex items-center justify-between gap-2 px-3 py-2 text-sm ${
                    checked ? "bg-primary/5" : ""
                  }`}
                >
                  <button
                    type="button"
                    onClick={() => togglePicked(u.id)}
                    className="flex items-center gap-2 min-w-0 flex-1 text-start hover:opacity-80"
                  >
                    <span
                      className={`inline-flex items-center justify-center h-4 w-4 rounded border ${
                        checked
                          ? "bg-primary border-primary text-primary-foreground"
                          : "border-slate-300"
                      }`}
                    >
                      {checked && <Check size={12} />}
                    </span>
                    <span className="truncate">{userDisplayName(u, lang)}</span>
                  </button>
                  <div
                    className="inline-flex rounded-md border overflow-hidden text-xs"
                    aria-disabled={!checked}
                  >
                    <button
                      type="button"
                      disabled={!checked}
                      onClick={() => setPermission(u.id, "view")}
                      data-testid={`folder-share-perm-view-${u.id}`}
                      className={`px-2 py-1 ${
                        perm === "view"
                          ? "bg-primary text-primary-foreground"
                          : "bg-background hover:bg-slate-100 disabled:opacity-50"
                      }`}
                    >
                      {t("notes.folders.permissionView", "View")}
                    </button>
                    <button
                      type="button"
                      disabled={!checked}
                      onClick={() => setPermission(u.id, "edit")}
                      data-testid={`folder-share-perm-edit-${u.id}`}
                      className={`px-2 py-1 border-s ${
                        perm === "edit"
                          ? "bg-primary text-primary-foreground"
                          : "bg-background hover:bg-slate-100 disabled:opacity-50"
                      }`}
                    >
                      {t("notes.folders.permissionEdit", "Edit")}
                    </button>
                  </div>
                </div>
              );
            })}
        </div>
        {count > 0 && (
          <div className="text-xs text-muted-foreground">
            {t("notes.folders.sharedWithCount", { count })}
          </div>
        )}
        <div className="flex items-center justify-end gap-2">
          <Button variant="ghost" onClick={onClose}>
            {t("common.cancel", "Cancel")}
          </Button>
          <Button
            onClick={submit}
            disabled={isLoading || update.isPending}
            data-testid="folder-share-submit"
          >
            <Share2 size={14} className="me-1" />
            {t("common.save", "Save")}
          </Button>
        </div>
      </DialogContent>
    </Dialog>
  );
}
