import {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "wouter";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
  ArrowLeft,
  ArrowRight,
  CalendarClock,
  FileDown,
  Type,
  Languages,
  ListChecks,
  ClipboardList,
  CheckSquare,
  ListTodo,
  Bell,
  ScrollText,
  FileText,
  Settings as SettingsIcon,
  Plus,
  Pencil,
  Trash2,
  Check,
  X,
  ChevronUp,
  ChevronDown,
  Copy,
  Eye,
  EyeOff,
  RotateCcw,
  Palette,
  Sliders,
  Combine,
  MoreVertical,
  ChevronLeft,
  ChevronRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogFooter,
} from "@/components/ui/dialog";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover";
import { Checkbox } from "@/components/ui/checkbox";
import { useToast } from "@/hooks/use-toast";
import {
  DndContext,
  PointerSensor,
  KeyboardSensor,
  TouchSensor,
  useSensor,
  useSensors,
  closestCenter,
  type DragEndEvent,
} from "@dnd-kit/core";
import {
  SortableContext,
  horizontalListSortingStrategy,
  verticalListSortingStrategy,
  useSortable,
  sortableKeyboardCoordinates,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { GripVertical } from "lucide-react";
import { EditableCell } from "@/components/editable-cell";
import { safeHtml } from "@/lib/safe-html";
import { formatTime as i18nFormatTime } from "@/lib/i18n-format";

type Attendee = {
  id?: number;
  meetingId?: number;
  // For persons: sanitized rich-text HTML name (Tiptap output).
  // For subheadings: the user's free-text section label.
  name: string;
  title: string | null;
  attendanceType: "internal" | "virtual" | "external";
  sortOrder: number;
  // "person"     — a normal attendee row (rendered in AttendeeFlow with a
  //                running "1-, 2-…" index that skips subheadings).
  // "subheading" — a user-defined label that lives between persons inside
  //                the same attendance-type group. Excluded from numbering,
  //                attendee count, and virtual-platform-name extraction.
  // Optional in the type so older snapshots and any in-flight code paths
  // that don't set it explicitly default to "person" — same as the DB and
  // Zod defaults.
  kind?: "person" | "subheading";
  // Client-only stable id used by the manage-dialog drag-and-drop. Never
  // sent to the server (the save projection at the PATCH/POST call site
  // explicitly enumerates wire fields and omits this). Persisted attendees
  // can fall back to `id` for sortable identity but new rows have no `id`
  // until the first save, so we always assign `_sid` when the dialog opens
  // or a new row is added.
  _sid?: string;
};

type Meeting = {
  id: number;
  dailyNumber: number;
  titleAr: string;
  titleEn: string;
  meetingDate: string;
  startTime: string | null;
  endTime: string | null;
  location: string | null;
  meetingUrl: string | null;
  platform: "none" | "webex" | "teams" | "zoom" | "other";
  status: string;
  isHighlighted: number;
  notes: string | null;
  attendees: Attendee[];
  // Optional cell-merge overlay. When all three are non-null the
  // schedule renders one merged cell spanning
  // [mergeStartColumn..mergeEndColumn] showing mergeText (sanitized
  // HTML) instead of the original column values. Originals are
  // preserved on the row so clearing the merge restores them.
  mergeStartColumn?: ColumnId | null;
  mergeEndColumn?: ColumnId | null;
  mergeText?: string | null;
};

type DayResponse = { date: string; meetings: Meeting[] };

type MeRoles = {
  userId: number;
  roles: string[];
  canRead: boolean;
  canMutate: boolean;
  canApprove: boolean;
  canSubmitRequest: boolean;
  canViewAudit: boolean;
  canViewTasks: boolean;
  canViewAllTasks: boolean;
};

type RequestRow = {
  id: number;
  meetingId: number | null;
  requestedBy: number | null;
  requestType: string;
  requestDetails: unknown;
  status: "new" | "approved" | "rejected" | "withdrawn";
  reviewedBy: number | null;
  reviewDecision: string | null;
  reviewNotes: string | null;
  createdAt: string;
  updatedAt: string;
  requester: {
    username: string;
    displayNameAr: string | null;
    displayNameEn: string | null;
  } | null;
};

type TaskRow = {
  id: number;
  requestId: number | null;
  meetingId: number | null;
  assignedTo: number | null;
  taskType: string;
  status: "pending" | "in_progress" | "completed" | "cancelled";
  dueAt: string | null;
  completedAt: string | null;
  notes: string | null;
  createdAt: string;
  updatedAt: string;
  assignee: {
    username: string;
    displayNameAr: string | null;
    displayNameEn: string | null;
  } | null;
};

type AuditEntry = {
  id: number;
  action: string;
  entityType: string;
  entityId: number | null;
  oldValue: unknown;
  newValue: unknown;
  performedBy: number | null;
  performedAt: string;
  actor: {
    username: string;
    displayNameAr: string | null;
    displayNameEn: string | null;
  } | null;
};

type NotificationRow = {
  id: number;
  meetingId: number | null;
  userId: number | null;
  notificationType: string;
  scheduledAt: string | null;
  sentAt: string | null;
  status: string;
  createdAt: string;
  user: {
    username: string;
    displayNameAr: string | null;
    displayNameEn: string | null;
  } | null;
};

type FontPrefs = {
  fontFamily: string;
  fontSize: number;
  fontWeight: "regular" | "bold";
  alignment: "start" | "center";
};

type FontSettingsResponse = {
  global: ({ scope: "global"; userId: null } & FontPrefs) | null;
  user: ({ scope: "user"; userId: number } & FontPrefs) | null;
};

type PdfArchive = {
  id: number;
  archiveDate: string;
  filePath: string;
  version: number;
  // Bytes recorded by the server-side generator. Null on legacy rows
  // that were created before we wrote the PDF to object storage.
  byteSize: number | null;
  generatedBy: number | null;
  generatedAt: string;
  generator: {
    username: string;
    displayNameAr: string | null;
    displayNameEn: string | null;
  } | null;
};

function formatBytesForDisplay(bytes: number): string {
  if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
  const units = ["B", "KB", "MB"];
  let value = bytes;
  let unitIdx = 0;
  while (value >= 1024 && unitIdx < units.length - 1) {
    value /= 1024;
    unitIdx += 1;
  }
  return `${value < 10 && unitIdx > 0 ? value.toFixed(1) : Math.round(value)} ${units[unitIdx]}`;
}

const SECTIONS = [
  { key: "schedule", icon: CalendarClock },
  { key: "manage", icon: ListChecks },
  { key: "requests", icon: ClipboardList },
  { key: "approvals", icon: CheckSquare },
  { key: "tasks", icon: ListTodo },
  { key: "notifications", icon: Bell },
  { key: "audit", icon: ScrollText },
  { key: "pdf", icon: FileText },
  { key: "fontSettings", icon: SettingsIcon },
] as const;

type SectionKey = (typeof SECTIONS)[number]["key"];

type MeCapabilities = {
  userId: number;
  roles: string[];
  canRead: boolean;
  canMutate: boolean;
  canApprove: boolean;
  canSubmitRequest: boolean;
  canViewAudit: boolean;
  canViewTasks: boolean;
  canViewAllTasks: boolean;
};

function isSectionVisible(
  key: SectionKey,
  me: MeCapabilities | null | undefined,
): boolean {
  if (!me) return key === "schedule"; // Pre-load: only show the read-only schedule.
  switch (key) {
    case "schedule":
    case "pdf":
    case "fontSettings":
      return me.canRead;
    case "manage":
      return me.canMutate;
    case "requests":
      return me.canSubmitRequest || me.canApprove;
    case "approvals":
      return me.canApprove;
    case "tasks":
      // CEO and viewer roles never see it.
      return me.canViewTasks;
    case "notifications":
      return me.canRead;
    case "audit":
      return me.canViewAudit;
    default:
      return false;
  }
}

const DEFAULT_FONT: FontPrefs = {
  fontFamily: "system",
  fontSize: 14,
  fontWeight: "regular",
  alignment: "start",
};

type ColumnId = "number" | "meeting" | "attendees" | "time";

type ColumnSetting = {
  id: ColumnId;
  visible: boolean;
  width: number;
};

const DEFAULT_COLUMNS: ColumnSetting[] = [
  { id: "number", visible: true, width: 56 },
  { id: "meeting", visible: true, width: 320 },
  { id: "attendees", visible: true, width: 600 },
  // Wide enough to fit "HH:MM – HH:MM" on one line, plus the two
  // <input type="time"> controls when the cell enters edit mode.
  { id: "time", visible: true, width: 200 },
];

const MIN_COL_WIDTH = 56;
const MAX_COL_WIDTH = 1200;

const ROW_COLOR_OPTIONS: { key: string; bg: string; label: string }[] = [
  { key: "default", bg: "", label: "default" },
  { key: "red", bg: "#fee2e2", label: "red" },
  { key: "amber", bg: "#fef3c7", label: "amber" },
  { key: "green", bg: "#dcfce7", label: "green" },
  { key: "blue", bg: "#dbeafe", label: "blue" },
  { key: "violet", bg: "#ede9fe", label: "violet" },
  { key: "gray", bg: "#f3f4f6", label: "gray" },
];

const COLS_STORAGE_KEY = "em-schedule-cols-v1";
const ROW_COLORS_STORAGE_KEY = "em-schedule-row-colors-v1";
const HIGHLIGHT_STORAGE_KEY = "em-current-meeting-highlight-v1";
const EDIT_MODE_STORAGE_KEY = "em-schedule-edit-mode-v1";

type HighlightPrefs = { enabled: boolean; color: string };
const DEFAULT_HIGHLIGHT_PREFS: HighlightPrefs = {
  enabled: true,
  color: "#16a34a",
};

const HIGHLIGHT_COLOR_SWATCHES = [
  "#16a34a",
  "#2563eb",
  "#dc2626",
  "#ea580c",
  "#7c3aed",
  "#0B1E3F",
];

function parseTimeToMinutes(t: string | null): number | null {
  if (!t) return null;
  const m = /^(\d{2}):(\d{2})/.exec(t);
  if (!m) return null;
  const h = Number(m[1]);
  const mm = Number(m[2]);
  if (!Number.isFinite(h) || !Number.isFinite(mm)) return null;
  return h * 60 + mm;
}

/**
 * Returns the id of the meeting that is currently in progress on `dateIso`,
 * or null. A meeting is "current" when the wall clock falls within
 * [startTime, endTime) and the displayed date matches today.
 */
function computeCurrentMeetingId(
  meetings: Meeting[],
  dateIso: string,
  nowMs: number,
): number | null {
  const now = new Date(nowMs);
  const todayIso = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
  if (todayIso !== dateIso) return null;
  const minutesNow = now.getHours() * 60 + now.getMinutes();
  for (const m of meetings) {
    const s = parseTimeToMinutes(m.startTime);
    const e = parseTimeToMinutes(m.endTime);
    if (s === null || e === null) continue;
    if (s <= minutesNow && minutesNow < e) return m.id;
  }
  return null;
}

function formatTime(t: string | null, lang: "ar" | "en"): string {
  if (!t) return "";
  // DB stores HH:mm:ss (24h). Build a Date anchored to a fixed day so
  // Intl can format the time-of-day in the user's language. We use 24h
  // (no AM/PM) so the schedule shows a clean "10:00 – 11:00" without
  // the ص/م suffix in Arabic. Latin digits are enforced via the shared
  // formatTime helper. Falls back to the raw HH:mm slice if parsing
  // somehow fails (defensive — shouldn't happen for our DB shape).
  const hhmm = t.slice(0, 5);
  const parsed = new Date(`1970-01-01T${hhmm}:00`);
  if (Number.isNaN(parsed.getTime())) return hhmm;
  return i18nFormatTime(parsed, lang, {
    hour: "2-digit",
    minute: "2-digit",
    hour12: false,
  });
}

function todayIso(): string {
  return new Date().toISOString().slice(0, 10);
}

function fontWeightToCss(w: FontPrefs["fontWeight"]): number {
  return { regular: 400, bold: 700 }[w];
}

function fontFamilyToCss(name: string): string {
  if (name === "system") return "";
  if (/[\s-]/.test(name)) return `"${name}", system-ui, sans-serif`;
  return `${name}, system-ui, sans-serif`;
}

function buildFontStyle(prefs: FontPrefs): CSSProperties {
  return {
    fontFamily: fontFamilyToCss(prefs.fontFamily) || undefined,
    fontSize: prefs.fontSize,
    fontWeight: fontWeightToCss(prefs.fontWeight),
    textAlign: prefs.alignment,
  };
}

function tWithFallback(
  t: (key: string) => string,
  key: string,
  fallback: string,
): string {
  const value = t(key);
  return value === key ? fallback : value;
}

async function apiJson<T>(
  url: string,
  init?: Omit<RequestInit, "body"> & { body?: unknown },
): Promise<T> {
  const { body: rawBody, headers: rawHeaders, ...rest } = init ?? {};
  const opts: RequestInit = {
    credentials: "include",
    ...rest,
    headers: { "Content-Type": "application/json", ...(rawHeaders ?? {}) },
  };
  if (rawBody !== undefined) {
    opts.body =
      typeof rawBody === "string" ? rawBody : JSON.stringify(rawBody);
  }
  const res = await fetch(url, opts);
  if (res.status === 204) return undefined as T;
  const data = (await res.json().catch(() => ({}))) as T & { error?: string };
  if (!res.ok) {
    throw new Error((data as { error?: string }).error || `HTTP ${res.status}`);
  }
  return data;
}

function readJsonFromStorage<T>(key: string, fallback: T): T {
  if (typeof window === "undefined") return fallback;
  try {
    const raw = window.localStorage.getItem(key);
    if (!raw) return fallback;
    return JSON.parse(raw) as T;
  } catch {
    return fallback;
  }
}

function writeJsonToStorage(key: string, value: unknown): void {
  if (typeof window === "undefined") return;
  try {
    window.localStorage.setItem(key, JSON.stringify(value));
  } catch {
    /* ignore */
  }
}

function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = useState(() => {
    if (typeof window === "undefined") return false;
    return window.matchMedia(query).matches;
  });
  useEffect(() => {
    if (typeof window === "undefined") return;
    const mql = window.matchMedia(query);
    const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
    setMatches(mql.matches);
    mql.addEventListener("change", handler);
    return () => mql.removeEventListener("change", handler);
  }, [query]);
  return matches;
}

function normalizeColumns(value: unknown): ColumnSetting[] {
  if (!Array.isArray(value)) return DEFAULT_COLUMNS;
  const seen = new Set<ColumnId>();
  const out: ColumnSetting[] = [];
  for (const entry of value) {
    if (!entry || typeof entry !== "object") continue;
    const e = entry as Partial<ColumnSetting>;
    if (typeof e.id !== "string") continue;
    if (!DEFAULT_COLUMNS.some((d) => d.id === e.id)) continue;
    if (seen.has(e.id as ColumnId)) continue;
    seen.add(e.id as ColumnId);
    const fallback = DEFAULT_COLUMNS.find((d) => d.id === e.id)!;
    const width =
      typeof e.width === "number" && Number.isFinite(e.width)
        ? Math.min(MAX_COL_WIDTH, Math.max(MIN_COL_WIDTH, e.width))
        : fallback.width;
    out.push({
      id: e.id as ColumnId,
      visible: typeof e.visible === "boolean" ? e.visible : true,
      width,
    });
  }
  // Append any defaults that were missing (e.g. new columns added later).
  for (const def of DEFAULT_COLUMNS) {
    if (!seen.has(def.id)) out.push({ ...def });
  }
  return out;
}

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

  const [section, setSection] = useState<SectionKey>("schedule");
  const [date, setDate] = useState<string>(todayIso());

  const { data, isLoading } = useQuery<DayResponse>({
    queryKey: ["/api/executive-meetings", date],
    queryFn: async () => {
      const res = await fetch(`/api/executive-meetings?date=${date}`, {
        credentials: "include",
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json();
    },
  });

  const { data: me } = useQuery<MeRoles>({
    queryKey: ["/api/executive-meetings/me"],
    queryFn: () => apiJson<MeRoles>("/api/executive-meetings/me"),
  });

  const { data: fontResp } = useQuery<FontSettingsResponse>({
    queryKey: ["/api/executive-meetings/font-settings"],
    queryFn: () => apiJson<FontSettingsResponse>("/api/executive-meetings/font-settings"),
  });

  const effectiveFont: FontPrefs = useMemo(() => {
    const u = fontResp?.user;
    const g = fontResp?.global;
    const src = u ?? g;
    if (!src) return DEFAULT_FONT;
    return {
      fontFamily: src.fontFamily,
      fontSize: src.fontSize,
      fontWeight: src.fontWeight,
      alignment: src.alignment,
    };
  }, [fontResp]);

  const meetings = data?.meetings ?? [];

  return (
    <div
      className="min-h-screen bg-[#f4f6fb] text-foreground"
      dir={isRtl ? "rtl" : "ltr"}
    >
      <header className="bg-white border-b border-gray-200 shadow-sm print:hidden">
        <div className="max-w-screen-2xl mx-auto px-4 sm:px-6 py-3 flex items-center gap-3 sm:gap-4 flex-wrap">
          <Button
            variant="ghost"
            size="icon"
            onClick={() => setLocation("/")}
            aria-label={t("common.back")}
            className="text-foreground"
          >
            <BackIcon className="w-5 h-5" />
          </Button>

          <div className="flex items-center gap-2 sm:gap-3 min-w-0">
            <div className="w-10 h-10 sm:w-11 sm:h-11 rounded-md bg-[#0B1E3F] flex items-center justify-center text-white shrink-0">
              <CalendarClock className="w-6 h-6" />
            </div>
            <div className="min-w-0 leading-tight">
              <div className="text-sm sm:text-base font-semibold text-[#0B1E3F] truncate">
                {t("executiveMeetings.titleAr")}
              </div>
              <div className="text-[11px] sm:text-xs text-muted-foreground truncate">
                {t("executiveMeetings.titleEn")}
              </div>
            </div>
          </div>

          <div className="flex-1" />

          <div className="flex items-center gap-2">
            <Button
              variant="outline"
              size="sm"
              className="gap-1.5"
              onClick={() => setSection("pdf")}
              data-testid="em-export-shortcut"
            >
              <FileDown className="w-4 h-4" />
              <span className="hidden sm:inline">{t("executiveMeetings.exportPdf")}</span>
            </Button>
            <Button
              variant="outline"
              size="sm"
              className="gap-1.5"
              onClick={() => setSection("fontSettings")}
            >
              <Type className="w-4 h-4" />
              <span className="hidden sm:inline">{t("executiveMeetings.fontSettings")}</span>
            </Button>
            <Button
              variant="outline"
              size="sm"
              className="gap-1.5"
              onClick={() => i18n.changeLanguage(isRtl ? "en" : "ar")}
            >
              <Languages className="w-4 h-4" />
              <span className="text-xs">{isRtl ? "EN" : "ع"}</span>
            </Button>
          </div>
        </div>

        <nav className="border-t border-gray-100 bg-white overflow-x-auto">
          <div className="max-w-screen-2xl mx-auto px-2 sm:px-4 flex gap-1 sm:gap-2 min-w-max">
            {SECTIONS.filter(({ key }) => isSectionVisible(key, me)).map(
              ({ key, icon: Icon }) => {
                const active = section === key;
                return (
                  <button
                    key={key}
                    type="button"
                    onClick={() => setSection(key)}
                    className={
                      "flex items-center gap-1.5 px-3 sm:px-4 py-2.5 text-xs sm:text-sm whitespace-nowrap border-b-2 transition-colors " +
                      (active
                        ? "border-[#0B1E3F] text-[#0B1E3F] font-semibold"
                        : "border-transparent text-muted-foreground hover:text-foreground")
                    }
                    data-testid={`em-nav-${key}`}
                  >
                    <Icon className="w-4 h-4" />
                    {t(`executiveMeetings.nav.${key}`)}
                  </button>
                );
              },
            )}
          </div>
        </nav>
      </header>

      <main className="max-w-screen-2xl mx-auto px-3 sm:px-6 py-4 sm:py-6">
        {section === "schedule" && (
          <ScheduleSection
            meetings={meetings}
            date={date}
            onDateChange={setDate}
            isLoading={isLoading}
            isRtl={isRtl}
            t={t}
            font={effectiveFont}
            canMutate={!!me?.canMutate}
            userId={me?.userId ?? null}
          />
        )}
        {section === "manage" && me && (
          <ManageSection date={date} onDateChange={setDate} canMutate={me.canMutate} isRtl={isRtl} t={t} />
        )}
        {section === "requests" && me && (
          <RequestsSection me={me} isRtl={isRtl} t={t} />
        )}
        {section === "approvals" && me && (
          <ApprovalsSection canApprove={me.canApprove} isRtl={isRtl} t={t} />
        )}
        {section === "tasks" && me && (
          <TasksSection
            canMutate={me.canMutate}
            currentUserId={me.userId}
            canViewAllTasks={me.canViewAllTasks}
            isRtl={isRtl}
            t={t}
          />
        )}
        {section === "notifications" && me && (
          <NotificationsSection
            canView={me.canRead}
            date={date}
            isRtl={isRtl}
            t={t}
          />
        )}
        {section === "audit" && me && (
          <AuditSection canView={me.canViewAudit} isRtl={isRtl} t={t} />
        )}
        {section === "pdf" && (
          <PdfSection date={date} onDateChange={setDate} lang={lang} t={t} />
        )}
        {section === "fontSettings" && me && (
          <FontSettingsSection
            font={effectiveFont}
            canEditGlobal={me.canApprove}
            t={t}
          />
        )}
      </main>
    </div>
  );
}

// ============ SCHEDULE ============

function ScheduleSection({
  meetings,
  date,
  onDateChange,
  isLoading,
  isRtl,
  t,
  font,
  canMutate,
  userId,
}: {
  meetings: Meeting[];
  date: string;
  onDateChange: (d: string) => void;
  isLoading: boolean;
  isRtl: boolean;
  t: (k: string) => string;
  font: FontPrefs;
  canMutate: boolean;
  userId: number | null;
}) {
  const qc = useQueryClient();
  const { toast } = useToast();
  const tableStyle = buildFontStyle(font);
  const [columns, setColumns] = useState<ColumnSetting[]>(() =>
    normalizeColumns(readJsonFromStorage(COLS_STORAGE_KEY, DEFAULT_COLUMNS)),
  );
  const [rowColors, setRowColors] = useState<Record<number, string>>(() =>
    readJsonFromStorage<Record<number, string>>(ROW_COLORS_STORAGE_KEY, {}),
  );
  const [highlightPrefs, setHighlightPrefs] = useState<HighlightPrefs>(() =>
    readJsonFromStorage<HighlightPrefs>(
      HIGHLIGHT_STORAGE_KEY,
      DEFAULT_HIGHLIGHT_PREFS,
    ),
  );

  useEffect(() => {
    writeJsonToStorage(HIGHLIGHT_STORAGE_KEY, highlightPrefs);
  }, [highlightPrefs]);

  // Global "Edit / View" toggle for the schedule. The default is view mode
  // (no edit affordances). When a user with edit permission flips it on,
  // every per-cell editor and per-row action (merge, color swatch, drag
  // handle, add/delete row, inline edit, column drag, column resize) is
  // re-enabled. We persist the user's last choice in localStorage so the
  // toggle survives reloads + date changes, but we *also* always start in
  // view mode for users without permission so they never see a stale "on"
  // state from a previous session where they had access. Hydrating the
  // localStorage value happens inside an effect to keep SSR-safe and to
  // avoid reading from a non-mutator's namespace.
  //
  // The storage key is namespaced by the current user's id so a shared
  // browser profile can't leak one editor's last toggle state into the
  // next account that signs in. When userId is null (no /me yet) we
  // fall back to view mode without touching storage.
  const editModeStorageKey = useMemo(
    () => (userId != null ? `${EDIT_MODE_STORAGE_KEY}:${userId}` : null),
    [userId],
  );
  const [editMode, setEditModeState] = useState<boolean>(false);
  useEffect(() => {
    if (!canMutate || !editModeStorageKey) {
      setEditModeState(false);
      return;
    }
    try {
      const raw = window.localStorage.getItem(editModeStorageKey);
      setEditModeState(raw === "true");
    } catch {
      // localStorage may be unavailable (private mode); fall through to
      // the default false.
    }
  }, [canMutate, editModeStorageKey]);
  const setEditMode = useCallback(
    (next: boolean) => {
      if (!canMutate || !editModeStorageKey) return;
      setEditModeState(next);
      try {
        window.localStorage.setItem(editModeStorageKey, next ? "true" : "false");
      } catch {
        // Persistence is best-effort; the in-memory state still flips.
      }
    },
    [canMutate, editModeStorageKey],
  );
  // Anything the user can mutate must AND with editMode. We pass this
  // derived flag down to every row + cell so they don't each have to
  // know about the toggle. The actual `canMutate` permission stays in
  // scope for the toggle button visibility check.
  const effectiveCanMutate = canMutate && editMode;

  const [nowTick, setNowTick] = useState(() => Date.now());
  useEffect(() => {
    let intervalId: ReturnType<typeof setInterval> | null = null;
    const msToNextMinute = 60_000 - (Date.now() % 60_000);
    const timeoutId = setTimeout(() => {
      setNowTick(Date.now());
      intervalId = setInterval(() => setNowTick(Date.now()), 60_000);
    }, msToNextMinute);
    return () => {
      clearTimeout(timeoutId);
      if (intervalId) clearInterval(intervalId);
    };
  }, []);

  const currentMeetingId = useMemo(
    () => computeCurrentMeetingId(meetings, date, nowTick),
    [meetings, date, nowTick],
  );

  // Optimistic local override so we can render the new row order before the
  // server round-trip finishes. Cleared on each new meetings prop.
  const [optimisticOrder, setOptimisticOrder] = useState<number[] | null>(null);
  const [reordering, setReordering] = useState(false);
  const reorderingRef = useRef(false);
  useEffect(() => {
    setOptimisticOrder(null);
  }, [meetings, date]);

  const orderedMeetings = useMemo(() => {
    if (!optimisticOrder) return meetings;
    const byId = new Map(meetings.map((m) => [m.id, m]));
    const out: Meeting[] = [];
    for (const id of optimisticOrder) {
      const m = byId.get(id);
      if (m) out.push(m);
    }
    // Append any meetings not in the optimistic order (defensive).
    for (const m of meetings) {
      if (!optimisticOrder.includes(m.id)) out.push(m);
    }
    return out;
  }, [meetings, optimisticOrder]);

  const refreshDay = useCallback(() => {
    void qc.invalidateQueries({
      queryKey: ["/api/executive-meetings", date],
    });
  }, [qc, date]);

  const saveTitle = useCallback(
    async (meeting: Meeting, html: string) => {
      const body = isRtl ? { titleAr: html } : { titleEn: html };
      try {
        await apiJson(`/api/executive-meetings/${meeting.id}`, {
          method: "PATCH",
          body,
        });
        refreshDay();
      } catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        toast({
          title: t("common.error"),
          description: msg,
          variant: "destructive",
        });
        throw err;
      }
    },
    [isRtl, refreshDay, toast, t],
  );

  const saveAttendeeName = useCallback(
    async (meeting: Meeting, attendeeIdx: number, html: string) => {
      // Detect "truly empty" content: Tiptap empty paragraphs, &nbsp;, or
      // pure whitespace. When the attendee's name is cleared we treat the
      // edit as a delete-row gesture and remove the attendee instead of
      // saving an empty record. The same gesture deletes a custom
      // subheading row (clear the label text → row disappears on blur).
      const isEmpty =
        html
          .replace(/<[^>]*>/g, "")
          .replace(/&nbsp;/g, " ")
          .replace(/\u00a0/g, " ")
          .trim() === "";
      const next = isEmpty
        ? meeting.attendees
            .filter((_, i) => i !== attendeeIdx)
            // Re-pack sortOrder so the server stores a contiguous run; the
            // PUT endpoint takes the array as the new source of truth and
            // reassigns ids on insert.
            .map((a, i) => ({ ...a, sortOrder: i }))
        : meeting.attendees.map((a, i) =>
            i === attendeeIdx ? { ...a, name: html } : a,
          );
      try {
        await apiJson(`/api/executive-meetings/${meeting.id}/attendees`, {
          method: "PUT",
          body: {
            attendees: next.map((a) => ({
              name: a.name,
              title: a.title,
              attendanceType: a.attendanceType,
              sortOrder: a.sortOrder,
              // Round-trip the row kind so an inline edit of a person row
              // can't silently demote a sibling subheading back to default.
              kind: a.kind ?? "person",
            })),
          },
        });
        refreshDay();
      } catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        toast({
          title: t("common.error"),
          description: msg,
          variant: "destructive",
        });
        throw err;
      }
    },
    [refreshDay, toast, t],
  );

  // -------------------------------------------------------------------
  // "+ Add attendee" — optimistic ghost row
  //
  // Clicking the inline "+" button in the attendees cell does NOT hit
  // the server immediately (the API requires a non-empty name). Instead
  // we render a synthetic in-edit EditableCell next to the existing
  // attendees. When the user saves a non-empty name we PUT the full
  // array with the new attendee appended; if they leave the cell empty
  // (blur or Escape) we just discard the pending state.
  //
  // We allow at most one pending attendee at a time across the whole
  // page — the AttendeesCell hides "+" buttons elsewhere while a ghost
  // row is open so the user can't accidentally orphan their typing.
  // The same ghost is reused for "+ subheading" (kind = "subheading")
  // so the one-at-a-time rule covers both gestures uniformly.
  type PendingAttendee = {
    meetingId: number;
    attendanceType: Attendee["attendanceType"];
    kind: "person" | "subheading";
    // Bumped each time the user clicks "+" so React remounts the ghost
    // EditableCell (giving it a fresh editor and focus).
    key: number;
    // When set, insert the new row at this list position (0-based,
    // inserts BEFORE that index — i.e. `splice(insertAtIndex, 0, …)`).
    // When undefined, append to the end of the cell (legacy behavior
    // for the trailing "+" / "+ عنوان فرعي" buttons).
    insertAtIndex?: number;
  };
  const [pendingAttendee, setPendingAttendee] =
    useState<PendingAttendee | null>(null);

  // If the user toggles edit mode off (or otherwise loses edit
  // permission) while the ghost "+ Add attendee" row is open, drop the
  // pending state so the synthetic row unmounts immediately. Without
  // this the ghost row would keep its open editor in an otherwise
  // read-only schedule.
  useEffect(() => {
    if (!effectiveCanMutate && pendingAttendee !== null) {
      setPendingAttendee(null);
    }
  }, [effectiveCanMutate, pendingAttendee]);

  const startAddAttendee = useCallback(
    (
      meetingId: number,
      attendanceType: Attendee["attendanceType"],
      kind: "person" | "subheading" = "person",
      insertAtIndex?: number,
    ) => {
      // Only one ghost row may be open at a time across the whole page.
      // We refuse a new start while another is pending — the user must
      // first commit (blur with text) or cancel (Escape / blur empty).
      // The "+" buttons are already hidden in this state, but a stale
      // click that beats the re-render is still possible.
      setPendingAttendee((prev) =>
        prev
          ? prev
          : {
              meetingId,
              attendanceType,
              kind,
              key: Date.now(),
              insertAtIndex,
            },
      );
    },
    [],
  );

  const cancelAddAttendee = useCallback(() => {
    setPendingAttendee(null);
  }, []);

  const commitAddAttendee = useCallback(
    async (
      meeting: Meeting,
      attendanceType: Attendee["attendanceType"],
      html: string,
      kind: "person" | "subheading" = "person",
      insertAtIndex?: number,
    ) => {
      // Always clear pending — whether we end up persisting or not.
      setPendingAttendee(null);
      const isEmpty =
        html
          .replace(/<[^>]*>/g, "")
          .replace(/&nbsp;/g, " ")
          .replace(/\u00a0/g, " ")
          .trim() === "";
      if (isEmpty) return;
      // Build the next array. When `insertAtIndex` is set we splice the
      // new row INTO the existing list at that position and renumber every
      // sortOrder from 0 so the ordering matches array order. Otherwise
      // we keep the legacy "append with sort = max+1" behavior to leave
      // pre-existing sort gaps intact for the trailing "+" button.
      const existing = meeting.attendees.map((a) => ({
        name: a.name,
        title: a.title,
        attendanceType: a.attendanceType,
        sortOrder: a.sortOrder,
        kind: a.kind ?? "person",
      }));
      const newRow = {
        name: html,
        title: null as string | null,
        attendanceType,
        sortOrder: 0, // overwritten below
        kind,
      };
      let next: typeof existing;
      if (
        insertAtIndex !== undefined &&
        insertAtIndex >= 0 &&
        insertAtIndex <= existing.length
      ) {
        const draft = existing.slice();
        draft.splice(insertAtIndex, 0, newRow);
        next = draft.map((a, idx) => ({ ...a, sortOrder: idx }));
      } else {
        const maxSort = existing.reduce(
          (m, a) => Math.max(m, a.sortOrder ?? 0),
          -1,
        );
        next = [...existing, { ...newRow, sortOrder: maxSort + 1 }];
      }
      try {
        await apiJson(`/api/executive-meetings/${meeting.id}/attendees`, {
          method: "PUT",
          body: { attendees: next },
        });
        refreshDay();
      } catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        toast({
          title: t("common.error"),
          description: msg,
          variant: "destructive",
        });
      }
    },
    [refreshDay, toast, t],
  );

  const saveTimes = useCallback(
    async (
      meeting: Meeting,
      startTime: string | null,
      endTime: string | null,
    ) => {
      try {
        await apiJson(`/api/executive-meetings/${meeting.id}`, {
          method: "PATCH",
          body: { startTime, endTime },
        });
        refreshDay();
      } catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        // The server enforces start <= end and returns a 400 like
        // "endTime: startTime must be <= endTime". Surface the same
        // friendly localized message the client-side guard uses, and
        // throw a tagged error so TimeRangeCell can re-open edit mode
        // with the user's draft values intact instead of resetting.
        if (msg.includes("startTime must be <= endTime")) {
          toast({
            title: t("executiveMeetings.schedule.timeOrderError"),
            variant: "destructive",
          });
          const e = new Error("time-order");
          e.name = "TimeOrderError";
          throw e;
        }
        toast({
          title: t("common.error"),
          description: msg,
          variant: "destructive",
        });
        throw err;
      }
    },
    [refreshDay, toast, t],
  );

  // PATCH the cell-merge overlay on a meeting. `merge === null` clears
  // the overlay and restores the original cells; otherwise it sets a
  // new range + free text that the row will display in place of the
  // spanned cells.
  const saveMerge = useCallback(
    async (
      meeting: Meeting,
      merge:
        | { mergeStartColumn: ColumnId; mergeEndColumn: ColumnId; mergeText: string }
        | null,
    ) => {
      try {
        await apiJson(`/api/executive-meetings/${meeting.id}`, {
          method: "PATCH",
          body: { merge },
        });
        refreshDay();
      } catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        toast({
          title: t("common.error"),
          description: msg,
          variant: "destructive",
        });
        throw err;
      }
    },
    [refreshDay, toast, t],
  );

  // Inline "delete entire meeting" action exposed on each schedule row.
  // Uses the same DELETE endpoint as the Manage section, but lives on
  // the schedule itself so editors can remove a row without context-
  // switching. Confirmation surfaces the meeting title so users know
  // exactly what will be removed (titles can contain rich-text HTML so
  // we strip tags before showing the prompt).
  const [deletingMeetingId, setDeletingMeetingId] = useState<number | null>(
    null,
  );
  const deleteMeeting = useCallback(
    async (meeting: Meeting) => {
      if (deletingMeetingId != null) return;
      // Pick the localized display title with the same fallback the row
      // uses, then strip HTML so the native confirm dialog shows plain
      // text (titles are Tiptap rich text in the DB).
      const rawTitle = isRtl
        ? meeting.titleAr
        : meeting.titleEn || meeting.titleAr;
      const plainTitle = rawTitle
        .replace(/<[^>]*>/g, "")
        .replace(/&nbsp;/g, " ")
        .replace(/\u00a0/g, " ")
        .trim();
      const message = t("executiveMeetings.schedule.deleteRowConfirm").replace(
        "{{title}}",
        plainTitle,
      );
      if (!window.confirm(message)) return;
      setDeletingMeetingId(meeting.id);
      try {
        await apiJson(`/api/executive-meetings/${meeting.id}`, {
          method: "DELETE",
        });
        toast({ title: t("executiveMeetings.schedule.deleted") });
        refreshDay();
      } catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        toast({
          title: t("common.error"),
          description: msg,
          variant: "destructive",
        });
      } finally {
        setDeletingMeetingId(null);
      }
    },
    [deletingMeetingId, isRtl, refreshDay, t, toast],
  );

  // ─── Multi-select + bulk delete (edit mode only) ────────────────────
  // Lifted here (alongside the existing single-row deleteMeeting) so a
  // single source of truth for "which rows are selected" is available
  // to both the floating toolbar and every MeetingRow's checkbox. We
  // keep ids in a Set for O(1) membership lookups during render.
  const [selectedMeetingIds, setSelectedMeetingIds] = useState<Set<number>>(
    () => new Set(),
  );
  // Drop the selection whenever the schedule context changes — switching
  // dates or turning edit mode off should not leave a stale selection
  // hanging around (it would resurface the moment the user re-enters
  // edit mode and look like a bug).
  useEffect(() => {
    setSelectedMeetingIds(new Set());
  }, [date, effectiveCanMutate]);
  // Also clear the selection on every successful day refresh
  // (refetch of the meetings query). React Query hands a brand-new
  // `meetings` array reference on every refetch — including realtime
  // invalidations and post-mutation refreshes triggered by
  // refreshDay() — so detecting a reference change here means the
  // selection cannot survive past a refresh even when the refreshed
  // payload happens to contain the same ids. We skip the very first
  // value (mount load) via a ref so an initially-empty selection
  // isn't reset for nothing.
  const lastMeetingsRef = useRef<Meeting[] | null>(null);
  useEffect(() => {
    if (lastMeetingsRef.current === null) {
      lastMeetingsRef.current = meetings;
      return;
    }
    if (lastMeetingsRef.current !== meetings) {
      lastMeetingsRef.current = meetings;
      setSelectedMeetingIds(new Set());
    }
  }, [meetings]);
  // Defensive intersection: if the meetings list shrinks (e.g. after a
  // bulk delete or someone else removed a row in real time), drop any
  // ids that no longer exist so we don't try to act on ghosts.
  useEffect(() => {
    setSelectedMeetingIds((prev) => {
      if (prev.size === 0) return prev;
      const present = new Set(meetings.map((m) => m.id));
      const next = new Set<number>();
      let changed = false;
      for (const id of prev) {
        if (present.has(id)) next.add(id);
        else changed = true;
      }
      return changed ? next : prev;
    });
  }, [meetings]);
  const toggleMeetingSelected = useCallback(
    (id: number, on: boolean) => {
      setSelectedMeetingIds((prev) => {
        if (on === prev.has(id)) return prev;
        const next = new Set(prev);
        if (on) next.add(id);
        else next.delete(id);
        return next;
      });
    },
    [],
  );
  const setAllMeetingsSelected = useCallback(
    (on: boolean) => {
      setSelectedMeetingIds(
        on ? new Set(orderedMeetings.map((m) => m.id)) : new Set(),
      );
    },
    [orderedMeetings],
  );
  // Tri-state hint for the "select all" checkbox in the toolbar.
  // "all" → fully checked, "some" → indeterminate, "none" → unchecked.
  const allSelectedState: "all" | "some" | "none" = useMemo(() => {
    const total = orderedMeetings.length;
    if (total === 0) return "none";
    let count = 0;
    for (const m of orderedMeetings) if (selectedMeetingIds.has(m.id)) count++;
    if (count === 0) return "none";
    if (count === total) return "all";
    return "some";
  }, [orderedMeetings, selectedMeetingIds]);
  const [bulkDeleting, setBulkDeleting] = useState(false);
  const deleteSelectedMeetings = useCallback(async () => {
    if (bulkDeleting) return;
    const ids = Array.from(selectedMeetingIds);
    if (ids.length === 0) return;
    const message = t(
      "executiveMeetings.schedule.bulkDeleteConfirm",
    ).replace("{{n}}", String(ids.length));
    if (!window.confirm(message)) return;
    setBulkDeleting(true);
    try {
      // Deliberately allSettled, not all: a single failed DELETE shouldn't
      // skip the others. We aggregate the outcome into one toast so the
      // user sees a single, actionable result instead of N toasts.
      const results = await Promise.allSettled(
        ids.map((id) =>
          apiJson(`/api/executive-meetings/${id}`, { method: "DELETE" }),
        ),
      );
      const ok = results.filter((r) => r.status === "fulfilled").length;
      const failed = results.length - ok;
      if (failed === 0) {
        toast({
          title: t("executiveMeetings.schedule.bulkDeleteResultAll").replace(
            "{{n}}",
            String(ok),
          ),
        });
      } else if (ok === 0) {
        toast({
          title: t("executiveMeetings.schedule.bulkDeleteResultNone"),
          variant: "destructive",
        });
      } else {
        toast({
          title: t("executiveMeetings.schedule.bulkDeletePartial")
            .replace("{{ok}}", String(ok))
            .replace("{{total}}", String(results.length))
            .replace("{{failed}}", String(failed)),
          variant: "destructive",
        });
      }
      setSelectedMeetingIds(new Set());
      refreshDay();
    } finally {
      setBulkDeleting(false);
    }
  }, [bulkDeleting, selectedMeetingIds, t, toast, refreshDay]);

  const [creatingRow, setCreatingRow] = useState(false);
  // When the user clicks "Add row" we capture the new meeting id here so
  // that, once the refreshed list contains it, we can scroll the row into
  // view and auto-open the title cell in inline-edit mode.
  const [autoEditTitleId, setAutoEditTitleId] = useState<number | null>(null);
  const createRow = useCallback(async () => {
    if (creatingRow) return;
    setCreatingRow(true);
    try {
      const created = (await apiJson(`/api/executive-meetings`, {
        method: "POST",
        body: {
          titleAr: t("executiveMeetings.schedule.newMeetingDefaultAr"),
          titleEn: t("executiveMeetings.schedule.newMeetingDefaultEn"),
          meetingDate: date,
        },
      })) as Meeting | undefined;
      if (created?.id) setAutoEditTitleId(created.id);
      refreshDay();
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      toast({
        title: t("common.error"),
        description: msg,
        variant: "destructive",
      });
    } finally {
      setCreatingRow(false);
    }
  }, [creatingRow, date, refreshDay, toast, t]);

  // Once the auto-edit target meeting is in the list, scroll it into view.
  // The MeetingRow itself receives `startInEditMode` so its EditableCell
  // opens immediately and clears `autoEditTitleId` via `onStartedEditing`.
  // We also schedule a fallback clear so the state never goes stale if the
  // title cell never mounts (e.g. column hidden, row filtered out).
  useEffect(() => {
    if (autoEditTitleId == null) return;
    if (!meetings.some((m) => m.id === autoEditTitleId)) return;
    const rafId = window.requestAnimationFrame(() => {
      const row = document.querySelector(
        `[data-testid="em-row-${autoEditTitleId}"]`,
      );
      if (row && "scrollIntoView" in row) {
        (row as HTMLElement).scrollIntoView({
          block: "center",
          behavior: "smooth",
        });
      }
    });
    const fallback = window.setTimeout(() => {
      setAutoEditTitleId(null);
    }, 1500);
    return () => {
      window.cancelAnimationFrame(rafId);
      window.clearTimeout(fallback);
    };
  }, [autoEditTitleId, meetings]);
  const clearAutoEditTitleId = useCallback(() => {
    setAutoEditTitleId(null);
  }, []);

  const reorderRows = useCallback(
    async (fromId: number, toId: number) => {
      if (fromId === toId) return;
      if (reorderingRef.current) return;
      const ids = meetings.map((m) => m.id);
      const fromIdx = ids.indexOf(fromId);
      const toIdx = ids.indexOf(toId);
      if (fromIdx < 0 || toIdx < 0) return;
      const newOrder = ids.slice();
      newOrder.splice(fromIdx, 1);
      newOrder.splice(toIdx, 0, fromId);
      setOptimisticOrder(newOrder);
      reorderingRef.current = true;
      setReordering(true);
      try {
        await apiJson(`/api/executive-meetings/reorder`, {
          method: "POST",
          body: { meetingDate: date, orderedIds: newOrder },
        });
        refreshDay();
      } catch (err) {
        setOptimisticOrder(null);
        const msg = err instanceof Error ? err.message : String(err);
        toast({
          title: t("common.error"),
          description: msg,
          variant: "destructive",
        });
      } finally {
        reorderingRef.current = false;
        setReordering(false);
      }
    },
    [meetings, date, refreshDay, toast, t],
  );

  useEffect(() => {
    writeJsonToStorage(COLS_STORAGE_KEY, columns);
  }, [columns]);

  useEffect(() => {
    writeJsonToStorage(ROW_COLORS_STORAGE_KEY, rowColors);
  }, [rowColors]);

  const visibleColumns = columns.filter((c) => c.visible);

  const setColumnWidth = useCallback((id: ColumnId, width: number) => {
    setColumns((prev) =>
      prev.map((c) =>
        c.id === id
          ? {
              ...c,
              width: Math.min(MAX_COL_WIDTH, Math.max(MIN_COL_WIDTH, width)),
            }
          : c,
      ),
    );
  }, []);

  const setRowColor = useCallback((meetingId: number, colorKey: string) => {
    setRowColors((prev) => {
      const next = { ...prev };
      if (colorKey === "default") {
        delete next[meetingId];
      } else {
        next[meetingId] = colorKey;
      }
      return next;
    });
  }, []);

  // Responsive breakpoints:
  //   < md (<768px)   → stacked card layout (phones)
  //   md..<xl         → fluid table, browser shrinks columns to fit (tablets)
  //   ≥ xl (≥1280px)  → fixed-width table with resize handles (desktop)
  //   print           → fixed-width table at all viewports (preserves the
  //                      historical print layout from any screen size).
  // Whether the configured pixel widths are *enforced* is controlled purely
  // by CSS: `table-auto xl:table-fixed print:table-fixed`. We always emit the
  // colgroup and per-cell widths so they're available whenever the table is
  // in fixed-layout mode (xl screens AND any print). In `table-auto` mode,
  // the browser treats those widths as hints and shrinks columns to fit.
  // Resize handles only appear on large screens AND fine pointers (mouse).
  // Touch devices — including iPad Pro at >=1280 CSS px — get no handles
  // since drag-resizing with a finger fights touch scrolling.
  const isXl = useMediaQuery("(min-width: 1280px)");
  const isFinePointer = useMediaQuery("(pointer: fine)");
  // Resize handles only render when the user is in edit mode AND on a
  // large screen with a fine pointer. View mode hides them entirely so
  // the table feels frozen until the editor opts in.
  const showResizeHandles = isXl && isFinePointer && effectiveCanMutate;

  // dnd-kit: sortable column headers + sortable rows.
  //
  // - PointerSensor for desktop mouse: a 6px activation distance keeps a
  //   normal click on cell content from being mistaken for a drag (so the
  //   inline editors on title/attendee/time cells still open on click).
  // - TouchSensor for iPad and other touch devices: PointerSensor alone
  //   does not work on touch because the browser claims the gesture as a
  //   vertical scroll before the finger has moved 6px, so the drag never
  //   activates. A long-press activation (delay+tolerance) lets the user
  //   press-and-hold the row's grip handle to start dragging while still
  //   leaving normal scrolling intact everywhere else on the row.
  // - KeyboardSensor for accessibility / keyboard reordering.
  const sensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
    useSensor(TouchSensor, {
      activationConstraint: { delay: 200, tolerance: 8 },
    }),
    useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
  );

  const onColumnDragEnd = useCallback(
    (e: DragEndEvent) => {
      const { active, over } = e;
      if (!over || active.id === over.id) return;
      setColumns((prev) => {
        const ids = prev.map((c) => c.id);
        const fromIdx = ids.indexOf(active.id as ColumnId);
        const toIdx = ids.indexOf(over.id as ColumnId);
        if (fromIdx < 0 || toIdx < 0) return prev;
        const next = prev.slice();
        const [moved] = next.splice(fromIdx, 1);
        if (!moved) return prev;
        next.splice(toIdx, 0, moved);
        return next;
      });
    },
    [],
  );

  const onRowDragEnd = useCallback(
    (e: DragEndEvent) => {
      const { active, over } = e;
      if (!over || active.id === over.id) return;
      void reorderRows(Number(active.id), Number(over.id));
    },
    [reorderRows],
  );

  return (
    <div className="space-y-4 print:space-y-2">
      <div className="flex items-center justify-between gap-3 flex-wrap print:hidden">
        <h2
          className="text-xl sm:text-2xl font-bold text-[#0B1E3F]"
          data-testid="em-schedule-heading"
        >
          {t("executiveMeetings.scheduleHeading")}
        </h2>
        <div className="flex items-center gap-2 flex-wrap">
          {canMutate && (
            <button
              type="button"
              onClick={() => setEditMode(!editMode)}
              aria-pressed={editMode}
              aria-label={
                editMode
                  ? t("executiveMeetings.schedule.saveToggleAria")
                  : t("executiveMeetings.schedule.editToggleAria")
              }
              title={
                editMode
                  ? t("executiveMeetings.schedule.saveToggleOn")
                  : t("executiveMeetings.schedule.saveToggleOff")
              }
              data-testid="em-edit-mode-toggle"
              className={
                "inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-[#0B1E3F]/40 " +
                (editMode
                  ? "bg-[#0B1E3F] text-white border-[#0B1E3F] hover:bg-[#0B1E3F]/90"
                  : "bg-white text-[#0B1E3F] border-gray-300 hover:bg-gray-50")
              }
            >
              {editMode ? (
                <Check className="w-3.5 h-3.5" />
              ) : (
                <Pencil className="w-3.5 h-3.5" />
              )}
              <span>
                {editMode
                  ? t("executiveMeetings.schedule.saveToggle")
                  : t("executiveMeetings.schedule.editToggle")}
              </span>
            </button>
          )}
          <ColumnsCustomizer
            columns={columns}
            onChange={setColumns}
            t={t}
            highlightPrefs={highlightPrefs}
            onHighlightPrefsChange={setHighlightPrefs}
          />
          <label className="flex items-center gap-2 text-sm">
            <span className="text-muted-foreground">{t("executiveMeetings.date")}</span>
            <input
              type="date"
              value={date}
              onChange={(e) => onDateChange(e.target.value)}
              className="border border-gray-300 rounded px-2 py-1 text-sm bg-white"
            />
          </label>
        </div>
      </div>

      <div className="hidden print:block text-center mb-2">
        <div className="font-bold text-lg">{t("executiveMeetings.titleAr")}</div>
        <div className="text-sm">{date}</div>
      </div>

      {/* Floating bulk-action toolbar. Per spec it appears ONLY when
          ≥1 row is selected (and in edit mode). The tri-state
          "select all" checkbox lives in the table header (see
          SortableHeader's selectAllOverlay below), so this bar
          contains only the count + the destructive actions.
          Hidden in print so it doesn't show up in PDF exports. */}
      {effectiveCanMutate && selectedMeetingIds.size > 0 && (
        <div
          className="flex items-center gap-3 flex-wrap rounded-md border border-[#0B1E3F]/30 bg-[#0B1E3F]/5 px-3 py-2 mb-2 print:hidden"
          data-testid="em-bulk-toolbar"
        >
          <span
            className="text-sm text-[#0B1E3F]/80 font-medium"
            data-testid="em-bulk-selection-count"
          >
            {t("executiveMeetings.schedule.bulkSelectionCount")
              .replace("{{n}}", String(selectedMeetingIds.size))
              .replace("{{total}}", String(orderedMeetings.length))}
          </span>
          <div className="flex items-center gap-2 ms-auto">
            <Button
              size="sm"
              variant="outline"
              onClick={() => setSelectedMeetingIds(new Set())}
              disabled={bulkDeleting}
              data-testid="em-bulk-clear-selection"
            >
              {t("executiveMeetings.schedule.bulkClearSelection")}
            </Button>
            <Button
              size="sm"
              variant="destructive"
              onClick={deleteSelectedMeetings}
              disabled={bulkDeleting}
              className="gap-1"
              data-testid="em-bulk-delete-selected"
            >
              <Trash2 className="w-4 h-4" />
              {t("executiveMeetings.schedule.bulkDeleteSelected")}
            </Button>
          </div>
        </div>
      )}

      <div
        className="bg-white border-2 border-[#0B1E3F] rounded-md overflow-x-auto shadow-sm print:shadow-none print:border-black print:overflow-hidden"
        id="executive-schedule-printable"
      >
        <table
          className="border-collapse text-sm md:text-[15px] w-full table-auto xl:table-fixed print:table-fixed"
          dir={isRtl ? "rtl" : "ltr"}
          style={tableStyle}
        >
          {/* Always emit colgroup + th widths so they're available for print
              and for ≥xl screens. CSS classes on the <table> decide whether
              they're enforced (table-fixed) or treated as hints (table-auto). */}
          <colgroup>
            {visibleColumns.map((c) => (
              <col key={c.id} style={{ width: c.width }} />
            ))}
          </colgroup>
          <DndContext
            sensors={sensors}
            collisionDetection={closestCenter}
            onDragEnd={onColumnDragEnd}
          >
            <thead>
              <tr className="bg-[#0B1E3F] text-white">
                <SortableContext
                  items={visibleColumns.map((c) => c.id)}
                  strategy={horizontalListSortingStrategy}
                >
                  {visibleColumns.map((c, idx) => (
                    <SortableHeader
                      key={c.id}
                      col={c}
                      isLast={idx === visibleColumns.length - 1}
                      isRtl={isRtl}
                      t={t}
                      showResizeHandle={showResizeHandles}
                      dragEnabled={effectiveCanMutate}
                      onResize={(delta) => setColumnWidth(c.id, c.width + delta)}
                      selectAllOverlay={
                        // Tri-state "select all" lives in the first
                        // visible header (typically # but falls back
                        // gracefully when the user has hidden #) and
                        // only renders in edit mode with ≥1 row to
                        // act on.
                        idx === 0 &&
                        effectiveCanMutate &&
                        orderedMeetings.length > 0 ? (
                          <div
                            className={`absolute top-1 ${isRtl ? "right-1" : "left-1"} z-10 print:hidden`}
                            onClick={(e) => e.stopPropagation()}
                            onPointerDown={(e) => e.stopPropagation()}
                          >
                            <Checkbox
                              checked={
                                allSelectedState === "all"
                                  ? true
                                  : allSelectedState === "some"
                                    ? "indeterminate"
                                    : false
                              }
                              onCheckedChange={(v) =>
                                setAllMeetingsSelected(v === true)
                              }
                              aria-label={t(
                                "executiveMeetings.schedule.bulkSelectAll",
                              )}
                              data-testid="em-bulk-select-all"
                              className="h-4 w-4 bg-white shadow-sm"
                            />
                          </div>
                        ) : undefined
                      }
                    />
                  ))}
                </SortableContext>
              </tr>
            </thead>
          </DndContext>
          <DndContext
            sensors={sensors}
            collisionDetection={closestCenter}
            onDragEnd={onRowDragEnd}
          >
            <tbody>
              {isLoading && (
                <tr>
                  <td
                    colSpan={visibleColumns.length}
                    className="py-10 text-center text-muted-foreground"
                  >
                    {t("common.loading")}
                  </td>
                </tr>
              )}
              {!isLoading && orderedMeetings.length === 0 && (
                <tr>
                  <td
                    colSpan={visibleColumns.length}
                    className="py-10 text-center text-muted-foreground"
                  >
                    {t("executiveMeetings.noMeetings")}
                  </td>
                </tr>
              )}
              <SortableContext
                items={orderedMeetings.map((m) => m.id)}
                strategy={verticalListSortingStrategy}
              >
                {orderedMeetings.map((m) => (
                  <MeetingRow
                    key={m.id}
                    meeting={m}
                    isRtl={isRtl}
                    t={t}
                    visibleColumns={visibleColumns}
                    rowColorKey={rowColors[m.id] ?? "default"}
                    onPickRowColor={(key) => setRowColor(m.id, key)}
                    canMutate={effectiveCanMutate}
                    onSaveTitle={(html) => saveTitle(m, html)}
                    onSaveAttendeeName={(idx, html) =>
                      saveAttendeeName(m, idx, html)
                    }
                    onSaveTimes={(start, end) => saveTimes(m, start, end)}
                    onSaveMerge={(merge) => saveMerge(m, merge)}
                    onDeleteMeeting={() => deleteMeeting(m)}
                    // Disable every row's delete button while any delete
                    // is in flight, since deleteMeeting short-circuits
                    // concurrent calls — keeps the UI honest about which
                    // clicks will do anything.
                    isDeleting={deletingMeetingId !== null}
                    isCurrent={
                      highlightPrefs.enabled && currentMeetingId === m.id
                    }
                    highlightColor={highlightPrefs.color}
                    reordering={reordering}
                    autoEditTitle={autoEditTitleId === m.id}
                    onAutoEditTitleConsumed={clearAutoEditTitleId}
                    pendingAttendee={pendingAttendee}
                    onStartAddAttendee={startAddAttendee}
                    onCommitAddAttendee={(type, html, kind, insertAtIndex) =>
                      commitAddAttendee(m, type, html, kind, insertAtIndex)
                    }
                    onCancelAddAttendee={cancelAddAttendee}
                    bulkSelectable={effectiveCanMutate}
                    bulkSelected={selectedMeetingIds.has(m.id)}
                    onBulkSelectChange={(v) => toggleMeetingSelected(m.id, v)}
                  />
                ))}
              </SortableContext>
              {effectiveCanMutate && !isLoading && (
                <tr
                  className="print:hidden"
                  data-testid="em-add-row-tr"
                >
                  <td
                    colSpan={visibleColumns.length}
                    className="border border-gray-300 p-0"
                  >
                    <button
                      type="button"
                      onClick={createRow}
                      disabled={creatingRow}
                      aria-label={t("executiveMeetings.schedule.addRow")}
                      aria-busy={creatingRow}
                      className="w-full flex items-center justify-center gap-2 py-2.5 text-sm text-[#0B1E3F] hover:bg-blue-50/60 focus:bg-blue-50/60 focus:outline-none transition-colors disabled:opacity-60"
                      data-testid="em-add-row-button"
                    >
                      {creatingRow ? (
                        <span>{t("common.loading")}</span>
                      ) : (
                        <Plus className="w-4 h-4" aria-hidden="true" />
                      )}
                    </button>
                  </td>
                </tr>
              )}
            </tbody>
          </DndContext>
        </table>
      </div>
    </div>
  );
}

function ResizeHandle({
  isRtl,
  onResize,
}: {
  isRtl: boolean;
  onResize: (delta: number) => void;
}) {
  const startXRef = useRef<number | null>(null);

  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
    e.preventDefault();
    e.stopPropagation();
    (e.target as HTMLDivElement).setPointerCapture(e.pointerId);
    startXRef.current = e.clientX;
  };

  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
    if (startXRef.current === null) return;
    const raw = e.clientX - startXRef.current;
    // In RTL the trailing edge is on the LEFT, so dragging left makes the
    // column wider. Negate the delta in that case.
    const delta = isRtl ? -raw : raw;
    if (Math.abs(delta) >= 1) {
      startXRef.current = e.clientX;
      onResize(delta);
    }
  };

  const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
    startXRef.current = null;
    try {
      (e.target as HTMLDivElement).releasePointerCapture(e.pointerId);
    } catch {
      /* ignore */
    }
  };

  // Place handle on the trailing (logical end) edge so it works the same in
  // both LTR and RTL.
  const sideClass = isRtl ? "left-0" : "right-0";

  return (
    <div
      role="separator"
      aria-orientation="vertical"
      className={`absolute top-0 ${sideClass} h-full w-1.5 cursor-col-resize hover:bg-white/40 active:bg-white/60`}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={onPointerUp}
      onPointerCancel={onPointerUp}
    />
  );
}

function ColumnsCustomizer({
  columns,
  onChange,
  t,
  highlightPrefs,
  onHighlightPrefsChange,
}: {
  columns: ColumnSetting[];
  onChange: (next: ColumnSetting[]) => void;
  t: (k: string) => string;
  highlightPrefs: HighlightPrefs;
  onHighlightPrefsChange: (next: HighlightPrefs) => void;
}) {
  const toggle = (id: ColumnId, value: boolean) => {
    // Always keep at least one column visible.
    const visibleCount = columns.filter((c) => c.visible).length;
    if (!value && visibleCount <= 1) return;
    onChange(columns.map((c) => (c.id === id ? { ...c, visible: value } : c)));
  };

  const reset = () => onChange(DEFAULT_COLUMNS.map((c) => ({ ...c })));

  return (
    <Popover>
      <PopoverTrigger asChild>
        <Button
          variant="outline"
          size="sm"
          className="gap-1.5"
          data-testid="em-customize-columns-trigger"
        >
          <Sliders className="w-4 h-4" />
          <span className="hidden sm:inline">{t("executiveMeetings.customize.title")}</span>
        </Button>
      </PopoverTrigger>
      <PopoverContent className="w-72 p-3" align="end">
        <div className="text-sm font-semibold text-[#0B1E3F] mb-2">
          {t("executiveMeetings.customize.title")}
        </div>
        <p className="text-[11px] text-muted-foreground mb-3 leading-relaxed">
          {t("executiveMeetings.customize.hint")}
        </p>
        <ul className="space-y-1.5">
          {columns.map((c) => (
            <li
              key={c.id}
              className="flex items-center gap-2 rounded border border-gray-100 bg-gray-50 px-2 py-1.5"
              data-testid={`em-customize-row-${c.id}`}
            >
              <Checkbox
                id={`em-col-${c.id}`}
                checked={c.visible}
                onCheckedChange={(v) => toggle(c.id, !!v)}
                data-testid={`em-customize-toggle-${c.id}`}
              />
              <label
                htmlFor={`em-col-${c.id}`}
                className="flex-1 text-sm text-[#0B1E3F] cursor-pointer truncate"
              >
                {t(`executiveMeetings.col.${c.id}`)}
              </label>
              {!c.visible ? (
                <EyeOff className="w-3.5 h-3.5 text-muted-foreground" />
              ) : (
                <Eye className="w-3.5 h-3.5 text-[#0B1E3F]" />
              )}
            </li>
          ))}
        </ul>
        <div className="mt-3 flex items-center justify-between">
          <Button
            variant="ghost"
            size="sm"
            className="gap-1.5 text-xs"
            onClick={reset}
            data-testid="em-customize-reset"
          >
            <RotateCcw className="w-3.5 h-3.5" />
            {t("executiveMeetings.customize.reset")}
          </Button>
          <span className="text-[11px] text-muted-foreground">
            {t("executiveMeetings.customize.dragHint")}
          </span>
        </div>
        <div className="mt-4 pt-3 border-t border-gray-200">
          <div className="flex items-center justify-between mb-2">
            <label
              htmlFor="em-highlight-toggle"
              className="text-sm font-semibold text-[#0B1E3F] cursor-pointer"
            >
              {t("executiveMeetings.highlight.title")}
            </label>
            <Switch
              id="em-highlight-toggle"
              checked={highlightPrefs.enabled}
              onCheckedChange={(v) =>
                onHighlightPrefsChange({ ...highlightPrefs, enabled: !!v })
              }
              data-testid="em-highlight-toggle"
            />
          </div>
          <p className="text-[11px] text-muted-foreground mb-2 leading-relaxed">
            {t("executiveMeetings.highlight.hint")}
          </p>
          <div className="flex items-center gap-1.5 flex-wrap">
            {HIGHLIGHT_COLOR_SWATCHES.map((c) => {
              const selected = highlightPrefs.color === c;
              return (
                <button
                  key={c}
                  type="button"
                  onClick={() =>
                    onHighlightPrefsChange({ ...highlightPrefs, color: c })
                  }
                  className={
                    "w-6 h-6 rounded-full border-2 transition-transform " +
                    (selected
                      ? "border-[#0B1E3F] scale-110"
                      : "border-gray-300 hover:scale-110")
                  }
                  style={{ backgroundColor: c }}
                  aria-label={`highlight color ${c}`}
                  data-testid={`em-highlight-color-${c}`}
                  disabled={!highlightPrefs.enabled}
                />
              );
            })}
            <button
              type="button"
              onClick={() =>
                onHighlightPrefsChange({ ...DEFAULT_HIGHLIGHT_PREFS })
              }
              className="ml-1 text-[11px] text-[#0B1E3F] underline hover:no-underline disabled:opacity-50"
              data-testid="em-highlight-reset"
            >
              {t("executiveMeetings.highlight.reset")}
            </button>
          </div>
        </div>
      </PopoverContent>
    </Popover>
  );
}

/**
 * Sortable column header for the schedule table. The whole <th> is the
 * drag handle so it stays grabbable in any RTL/LTR mode and on touch. The
 * resize handle on the trailing edge stops drag propagation so resizing
 * still works.
 */
function SortableHeader({
  col,
  isLast,
  isRtl,
  t,
  showResizeHandle,
  dragEnabled,
  onResize,
  selectAllOverlay,
}: {
  col: ColumnSetting;
  isLast: boolean;
  isRtl: boolean;
  t: (k: string) => string;
  showResizeHandle: boolean;
  dragEnabled: boolean;
  onResize: (delta: number) => void;
  // Optional tri-state "select all" checkbox rendered as an absolute
  // overlay anchored to the start-corner of this header. The parent
  // passes it only for the first visible column so the checkbox lives
  // in <thead> per the spec, while still gracefully surviving the
  // case where the # column has been hidden via the column customizer.
  selectAllOverlay?: ReactNode;
}) {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id: col.id, disabled: !dragEnabled });
  const align =
    col.id === "number" || col.id === "time" ? "text-center" : "text-start";
  const style: CSSProperties = {
    width: col.width,
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0.5 : 1,
    // Only advertise grab cursor when drag is actually enabled — view
    // mode renders the header as static text.
    cursor: dragEnabled ? "grab" : undefined,
  };
  return (
    <th
      ref={setNodeRef}
      className={`relative border border-[#0B1E3F] px-2 py-2 ${align} font-semibold select-none`}
      style={style}
      data-testid={`em-col-header-${col.id}`}
      {...(dragEnabled ? attributes : {})}
      {...(dragEnabled ? listeners : {})}
    >
      {t(`executiveMeetings.col.${col.id}`)}
      {selectAllOverlay}
      {/* Resize handles only render on large screens AND fine (mouse)
          pointers AND when the user is in edit mode. See showResizeHandles. */}
      {!isLast && showResizeHandle && (
        <ResizeHandle isRtl={isRtl} onResize={onResize} />
      )}
    </th>
  );
}

function RowColorPickerSwatches({
  current,
  onPick,
  t,
}: {
  current: string;
  onPick: (key: string) => void;
  t: (k: string) => string;
}) {
  return (
    <>
      <div className="text-[11px] text-muted-foreground mb-2">
        {t("executiveMeetings.rowColor.label")}
      </div>
      <div className="flex items-center gap-1.5">
        {ROW_COLOR_OPTIONS.map((opt) => {
          const selected = current === opt.key;
          const isDefault = opt.key === "default";
          return (
            <button
              key={opt.key}
              type="button"
              onClick={() => onPick(opt.key)}
              className={
                "w-6 h-6 rounded-full border-2 transition-transform " +
                (selected
                  ? "border-[#0B1E3F] scale-110"
                  : "border-gray-300 hover:scale-110")
              }
              style={{
                background: isDefault
                  ? "repeating-linear-gradient(45deg,#fff,#fff 4px,#e5e7eb 4px,#e5e7eb 8px)"
                  : opt.bg,
              }}
              aria-label={opt.label}
              data-testid={`em-row-color-${opt.key}`}
            />
          );
        })}
      </div>
    </>
  );
}

// Consolidated row-level actions menu. Replaces the previous three
// separate overlay triggers (Trash, Palette, Combine icons) that all
// floated inside the same narrow cell and visually collided on small
// screens / iPad. Now there is exactly one kebab button per row, and
// it opens a small popover that exposes:
//   1. Delete row     — direct action, fires onDelete()
//   2. Row color      — expands inline to RowColorPickerSwatches
//   3. Merge cells    — expands inline to the merge options list
// Both expansion views show a "Back" link so the user can return to
// the main menu without closing the popover.
//
// Anchor / positioning rules match the old triggers exactly:
//   - Hover-reveal on desktop, low-opacity-always on touch
//   - Hidden in print
//   - Uses `inline-end-1` so RTL layouts flip the button to the
//     correct corner.
//
// The host <td> must carry `relative group` for the positioning +
// hover reveal to work — every renderCell branch already does.
//
// Test ids are preserved on the corresponding menu items so existing
// Playwright assertions about presence/absence keep working:
//   em-delete-row-{id}, em-row-color-trigger, em-merge-trigger-{id}.
function RowActionsMenu({
  meetingId,
  isDeleting,
  onDelete,
  rowColorKey,
  onPickRowColor,
  hasStoredMerge,
  existingMergeText,
  onChangeMerge,
  t,
}: {
  meetingId: number;
  isDeleting: boolean;
  onDelete: () => void;
  rowColorKey: string;
  onPickRowColor: (key: string) => void;
  hasStoredMerge: boolean;
  existingMergeText: string | null;
  onChangeMerge: (
    merge:
      | { mergeStartColumn: ColumnId; mergeEndColumn: ColumnId; mergeText: string }
      | null,
  ) => Promise<void>;
  t: (k: string) => string;
}) {
  const [open, setOpen] = useState(false);
  const [view, setView] = useState<"main" | "color" | "merge">("main");

  // Always reset to the main menu whenever the popover closes, so the
  // next open lands the user back on Delete/Color/Merge instead of a
  // half-finished sub-view.
  useEffect(() => {
    if (!open) setView("main");
  }, [open]);

  const applyMerge = async (start: ColumnId, end: ColumnId) => {
    setOpen(false);
    await onChangeMerge({
      mergeStartColumn: start,
      mergeEndColumn: end,
      mergeText: existingMergeText ?? "",
    });
  };

  const itemCls =
    "w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed";

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <button
          type="button"
          // NOTE: do NOT add onPointerDown stopPropagation here — Radix
          // Popover uses pointerdown to open and our handler would
          // swallow it. The host cell has no drag listeners (only the
          // grip button does), so nothing else needs blocking.
          aria-label={t("executiveMeetings.rowActions.label")}
          title={t("executiveMeetings.rowActions.label")}
          data-testid={`em-row-actions-${meetingId}`}
          className="absolute top-1 inline-end-1 p-1 rounded text-muted-foreground hover:text-[#0B1E3F] hover:bg-white/80 opacity-0 group-hover:opacity-100 focus:opacity-100 [@media(hover:none)_and_(pointer:coarse)]:opacity-40 [@media(hover:none)_and_(pointer:coarse)]:p-1.5 transition-opacity print:hidden"
        >
          <MoreVertical className="w-3.5 h-3.5 [@media(hover:none)_and_(pointer:coarse)]:w-4 [@media(hover:none)_and_(pointer:coarse)]:h-4" />
        </button>
      </PopoverTrigger>
      <PopoverContent className="w-56 p-1" align="end">
        {view === "main" && (
          <>
            <button
              type="button"
              className={`${itemCls} text-destructive`}
              disabled={isDeleting}
              onClick={(e) => {
                e.stopPropagation();
                setOpen(false);
                onDelete();
              }}
              data-testid={`em-delete-row-${meetingId}`}
            >
              <Trash2 className="w-3.5 h-3.5" aria-hidden="true" />
              {t("executiveMeetings.schedule.deleteRow")}
            </button>
            <button
              type="button"
              className={itemCls}
              onClick={() => setView("color")}
              data-testid="em-row-color-trigger"
            >
              <Palette className="w-3.5 h-3.5" aria-hidden="true" />
              {t("executiveMeetings.rowColor.label")}
            </button>
            <button
              type="button"
              className={itemCls}
              onClick={() => setView("merge")}
              data-testid={`em-merge-trigger-${meetingId}`}
            >
              <Combine className="w-3.5 h-3.5" aria-hidden="true" />
              {t("executiveMeetings.merge.label")}
            </button>
          </>
        )}
        {view === "color" && (
          <>
            <BackToMainButton onClick={() => setView("main")} t={t} />
            <div className="px-1 pb-1">
              <RowColorPickerSwatches
                current={rowColorKey}
                onPick={(key) => {
                  onPickRowColor(key);
                  setOpen(false);
                }}
                t={t}
              />
            </div>
          </>
        )}
        {view === "merge" && (
          <>
            <BackToMainButton onClick={() => setView("main")} t={t} />
            <button
              type="button"
              className="w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent"
              data-testid={`em-merge-opt-meeting-attendees-${meetingId}`}
              onClick={() => applyMerge("meeting", "attendees")}
            >
              {t("executiveMeetings.merge.meetingAttendees")}
            </button>
            <button
              type="button"
              className="w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent"
              data-testid={`em-merge-opt-meeting-time-${meetingId}`}
              onClick={() => applyMerge("meeting", "time")}
            >
              {t("executiveMeetings.merge.meetingTime")}
            </button>
            <button
              type="button"
              className="w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent"
              data-testid={`em-merge-opt-all-${meetingId}`}
              onClick={() => applyMerge("number", "time")}
            >
              {t("executiveMeetings.merge.all")}
            </button>
            {hasStoredMerge && (
              <button
                type="button"
                className="w-full text-start px-2 py-1.5 text-sm rounded hover:bg-accent text-destructive"
                data-testid={`em-merge-opt-unmerge-${meetingId}`}
                onClick={() => {
                  setOpen(false);
                  onChangeMerge(null);
                }}
              >
                {t("executiveMeetings.merge.unmerge")}
              </button>
            )}
          </>
        )}
      </PopoverContent>
    </Popover>
  );
}

// Small "Back" affordance shown at the top of the row-actions submenu
// views (Row color, Merge cells). RTL-aware: uses ChevronRight in
// Arabic so the arrow points back toward the main menu visually.
function BackToMainButton({
  onClick,
  t,
}: {
  onClick: () => void;
  t: (k: string) => string;
}) {
  const isRtl =
    typeof document !== "undefined" && document.documentElement.dir === "rtl";
  const ArrowIcon = isRtl ? ChevronRight : ChevronLeft;
  return (
    <button
      type="button"
      className="w-full text-start px-2 py-1.5 text-xs rounded hover:bg-accent text-muted-foreground flex items-center gap-1 mb-0.5"
      onClick={onClick}
      data-testid="em-row-actions-back"
    >
      <ArrowIcon className="w-3.5 h-3.5" aria-hidden="true" />
      {t("executiveMeetings.rowActions.back")}
    </button>
  );
}

function MeetingRow({
  meeting,
  isRtl,
  t,
  visibleColumns,
  rowColorKey,
  onPickRowColor,
  canMutate,
  onSaveTitle,
  onSaveAttendeeName,
  onSaveTimes,
  onSaveMerge,
  onDeleteMeeting,
  isDeleting,
  isCurrent,
  highlightColor,
  reordering,
  autoEditTitle = false,
  onAutoEditTitleConsumed,
  pendingAttendee,
  onStartAddAttendee,
  onCommitAddAttendee,
  onCancelAddAttendee,
  bulkSelectable,
  bulkSelected,
  onBulkSelectChange,
}: {
  meeting: Meeting;
  isRtl: boolean;
  t: (k: string) => string;
  visibleColumns: ColumnSetting[];
  rowColorKey: string;
  onPickRowColor: (key: string) => void;
  canMutate: boolean;
  onSaveTitle: (html: string) => Promise<void>;
  onSaveAttendeeName: (idx: number, html: string) => Promise<void>;
  onSaveTimes: (
    startTime: string | null,
    endTime: string | null,
  ) => Promise<void>;
  onSaveMerge: (
    merge:
      | { mergeStartColumn: ColumnId; mergeEndColumn: ColumnId; mergeText: string }
      | null,
  ) => Promise<void>;
  onDeleteMeeting: () => void;
  isDeleting: boolean;
  isCurrent: boolean;
  highlightColor: string;
  reordering: boolean;
  autoEditTitle?: boolean;
  onAutoEditTitleConsumed?: () => void;
  pendingAttendee?: {
    meetingId: number;
    attendanceType: Attendee["attendanceType"];
    kind: "person" | "subheading";
    key: number;
    insertAtIndex?: number;
  } | null;
  onStartAddAttendee?: (
    meetingId: number,
    attendanceType: Attendee["attendanceType"],
    kind?: "person" | "subheading",
    insertAtIndex?: number,
  ) => void;
  onCommitAddAttendee?: (
    attendanceType: Attendee["attendanceType"],
    html: string,
    kind?: "person" | "subheading",
    insertAtIndex?: number,
  ) => Promise<void>;
  onCancelAddAttendee?: () => void;
  bulkSelectable: boolean;
  bulkSelected: boolean;
  onBulkSelectChange: (next: boolean) => void;
}) {
  const title = isRtl ? meeting.titleAr : meeting.titleEn || meeting.titleAr;
  const isCancelled = meeting.status === "cancelled";
  const highlight = meeting.isHighlighted === 1;

  const rowColor = ROW_COLOR_OPTIONS.find((o) => o.key === rowColorKey);
  const rowBg = rowColor?.bg || "";

  // Make the row a sortable item. `attributes` get spread onto the <tr> for
  // a11y; `listeners` go on the dedicated grip handle in the # cell so a
  // normal click on the cell content (to enter inline-edit mode) is still
  // possible.
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id: meeting.id, disabled: !canMutate });

  const numberCellCls =
    "border border-gray-300 px-2 py-2 text-center align-middle font-semibold " +
    (highlight || isCancelled
      ? "bg-red-600 text-white"
      : "bg-white text-[#0B1E3F]");

  // Always provide width + overflow:hidden inline. In `table-auto` mode the
  // browser treats width as a hint and shrinks cells to fit; in `table-fixed`
  // mode (xl screens AND any print) it enforces them. overflow:hidden + the
  // existing break-words on inner divs keeps wrapping behavior correct.
  const cellStyle = (col: ColumnSetting): CSSProperties => ({
    width: col.width,
    overflow: "hidden",
  });

  // Compute the cell-merge overlay for this row. We resolve the stored
  // start/end column ids against the *canonical* schedule column order
  // (not against `visibleColumns`) so that a merge whose start or end
  // column is currently hidden still renders across whichever columns
  // in the range remain visible. This guarantees a row that has a
  // merge in the DB never silently falls back to "unmerged" just
  // because the user toggled off one of the boundary columns.
  const canonStart = canonicalMergeIndex(meeting.mergeStartColumn);
  const canonEnd = canonicalMergeIndex(meeting.mergeEndColumn);
  const hasStoredMerge =
    canonStart >= 0 && canonEnd >= canonStart && typeof meeting.mergeText === "string";
  const visibleMergeIndices: number[] = [];
  if (hasStoredMerge) {
    visibleColumns.forEach((c, idx) => {
      const ci = canonicalMergeIndex(c.id);
      if (ci >= canonStart && ci <= canonEnd) visibleMergeIndices.push(idx);
    });
  }
  // Headers are themselves drag-sortable, so users can reorder the
  // schedule columns. If a previously-set merge ends up spanning
  // *non-contiguous* visible indices after a reorder, rendering one
  // colSpan cell at the first index would silently swallow the wrong
  // columns. In that case we degrade to "unmerged" rendering — the DB
  // merge state stays intact, and unmerging or restoring canonical
  // order brings the merged cell back.
  const visibleMergeContiguous =
    visibleMergeIndices.length === 0 ||
    visibleMergeIndices[visibleMergeIndices.length - 1] -
      visibleMergeIndices[0] +
      1 ===
      visibleMergeIndices.length;
  const isMerged =
    hasStoredMerge && visibleMergeIndices.length > 0 && visibleMergeContiguous;
  const mergeFirstIdx = isMerged ? visibleMergeIndices[0] : -1;
  const mergeSkipSet = isMerged ? new Set(visibleMergeIndices.slice(1)) : null;
  // First visible cell that's NOT part of a merge. The merge trigger
  // for an unmerged row is anchored here so editors can still open the
  // merge menu even when the # column is hidden. We look for the
  // first index in visibleColumns whose canonical id exists (which is
  // every schedule column), so this collapses to `0` in practice but
  // would skip non-mergeable columns if they're added later.
  const firstUnmergedIdx = isMerged ? -1 : visibleColumns.length > 0 ? 0 : -1;

  // Light tint for the current meeting on the editable cells (meeting,
  // attendees, time). The number cell keeps its solid white background
  // and original text colour so it stays legible — only the rest of the
  // row gets a soft wash of highlightColor. We keep the original text
  // colour everywhere so the row remains readable.
  const tintBg =
    isCurrent && !(highlight || isCancelled)
      ? hexToRgba(highlightColor, 0.13)
      : null;
  const tintedCellStyle = (col: ColumnSetting): CSSProperties =>
    tintBg ? { ...cellStyle(col), backgroundColor: tintBg } : cellStyle(col);

  // Per-row bulk-select checkbox, lifted out of `renderCell` so the
  // merged-cell branch can re-use the same JSX. Anchored to the
  // top-start corner of whichever cell hosts it (the first visible
  // non-merged cell for unmerged rows, or the merged cell itself for
  // merged rows). Anchoring as an absolute overlay (rather than
  // inline inside the # cell) keeps the checkbox available even when
  // the editor has hidden the # column AND leaves the # cell's grip
  // handle sitting in its original position so dnd-kit's
  // drag-to-reorder isn't disturbed by neighbouring controls.
  const bulkSelectOverlay = bulkSelectable ? (
    <div
      className={`absolute top-1 ${isRtl ? "right-1" : "left-1"} z-10 print:hidden`}
      onClick={(e) => e.stopPropagation()}
    >
      <Checkbox
        checked={bulkSelected}
        onCheckedChange={(v) => onBulkSelectChange(v === true)}
        aria-label={t("executiveMeetings.schedule.bulkSelectRow")}
        data-testid={`em-row-select-${meeting.id}`}
        className="h-4 w-4 bg-white shadow-sm"
      />
    </div>
  ) : null;

  const renderCell = (col: ColumnSetting, idx: number): ReactNode => {
    // Anchor the consolidated row-actions menu (delete + row color +
    // merge) to whichever cell is the first visible one on an unmerged
    // row. Using "first visible cell" (instead of always the # cell)
    // keeps the trigger reachable when editors hide the number column
    // via the column customizer. The button is rendered as an
    // absolutely-positioned overlay inside the cell, so every cell type
    // needs `relative group` on its <td> for it to find them.
    const showActionsMenu = canMutate && !isMerged && idx === firstUnmergedIdx;
    const actionsOverlay = showActionsMenu ? (
      <RowActionsMenu
        meetingId={meeting.id}
        isDeleting={isDeleting}
        onDelete={onDeleteMeeting}
        rowColorKey={rowColorKey}
        onPickRowColor={onPickRowColor}
        // Offer Unmerge whenever the row has a stored merge in the DB,
        // even if we're in degraded (non-contiguous) rendering — that's
        // the only way the user can clear the stored state.
        hasStoredMerge={hasStoredMerge}
        existingMergeText={meeting.mergeText ?? null}
        onChangeMerge={onSaveMerge}
        t={t}
      />
    ) : null;
    // The bulk-select overlay only attaches to the first visible
    // non-merged cell (so it appears once per row, never twice). For
    // merged rows the overlay is rendered separately on the merged
    // <td> (see `renderCells` below) so it remains reachable even
    // when the merge swallows what would otherwise be the first
    // visible cell.
    const cellBulkOverlay =
      !isMerged && idx === firstUnmergedIdx ? bulkSelectOverlay : null;
    switch (col.id) {
      case "number":
        // Priority for the # cell background:
        //   1. highlight || isCancelled → solid red badge (className wins)
        //   2. tintBg (current meeting wash) → solid highlightColor with
        //      white text so the number stays legible.
        //   3. rowBg (user-picked row color) → match the rest of the row
        //      so the chosen color wraps the whole row including the
        //      number cell. The palette is all light tints, so the
        //      existing dark `text-[#0B1E3F]` from numberCellCls stays
        //      readable.
        //   4. otherwise → fall back to the white background already in
        //      numberCellCls.
        // We omit any inline backgroundColor in case (1) so the
        // bg-red-600 utility class isn't overridden by an inline style.
        const numberCellInlineStyle: CSSProperties = (() => {
          if (highlight || isCancelled) return cellStyle(col);
          if (tintBg) {
            return {
              ...cellStyle(col),
              backgroundColor: highlightColor,
              color: "white",
            };
          }
          if (rowBg) {
            return { ...cellStyle(col), backgroundColor: rowBg };
          }
          return cellStyle(col);
        })();
        return (
          <td
            key="number"
            className={`relative group ${numberCellCls}`}
            style={numberCellInlineStyle}
          >
            <div className="flex items-center justify-center gap-1">
              {canMutate && (
                <button
                  type="button"
                  // The grip handle is hover-only on devices that have a
                  // real hover (desktop mouse), but always visible at ~40%
                  // opacity on touch devices that have no hover state — so
                  // iPad users can actually find what to press to reorder
                  // a row. The tap target is also enlarged on touch with a
                  // small padding so the long-press lands reliably.
                  // touch-action:none stays on the handle itself so the
                  // browser hands touch events to dnd-kit instead of
                  // claiming them as a vertical scroll; the rest of the
                  // row keeps default touch-action so page scrolling works
                  // normally outside of the handle.
                  className="opacity-0 group-hover:opacity-70 hover:opacity-100 [@media(hover:none)_and_(pointer:coarse)]:opacity-40 [@media(hover:none)_and_(pointer:coarse)]:group-hover:opacity-40 [@media(hover:none)_and_(pointer:coarse)]:p-1.5 cursor-grab active:cursor-grabbing touch-none print:hidden"
                  aria-label={t("executiveMeetings.dragRow")}
                  data-testid={`em-row-grip-${meeting.id}`}
                  {...listeners}
                  {...attributes}
                >
                  <GripVertical className="w-3.5 h-3.5 [@media(hover:none)_and_(pointer:coarse)]:w-4 [@media(hover:none)_and_(pointer:coarse)]:h-4" />
                </button>
              )}
              <span>{meeting.dailyNumber}</span>
            </div>
            {cellBulkOverlay}
            {actionsOverlay}
          </td>
        );
      case "meeting":
        return (
          <td
            key="meeting"
            className="border border-gray-300 px-3 py-3 align-middle text-[#0B1E3F] relative group"
            style={tintedCellStyle(col)}
          >
            <EditableCell
              value={title}
              onSave={onSaveTitle}
              ariaLabel={t("executiveMeetings.editMeetingTitle")}
              dir={isRtl ? "rtl" : "ltr"}
              className="font-medium leading-snug break-words"
              disabled={!canMutate}
              testId={`em-edit-title-${meeting.id}`}
              startInEditMode={autoEditTitle}
              onStartedEditing={onAutoEditTitleConsumed}
            />
            {meeting.location && (
              <div className="text-[11px] text-muted-foreground mt-0.5 break-words">
                {meeting.location}
              </div>
            )}
            {cellBulkOverlay}
            {actionsOverlay}
          </td>
        );
      case "attendees":
        return (
          <td
            key="attendees"
            className="border border-gray-300 px-3 py-3 align-middle relative group"
            style={tintedCellStyle(col)}
          >
            <AttendeesCell
              meeting={meeting}
              t={t}
              canMutate={canMutate}
              onSaveAttendeeName={onSaveAttendeeName}
              pendingAttendee={pendingAttendee ?? null}
              onStartAddAttendee={onStartAddAttendee}
              onCommitAddAttendee={onCommitAddAttendee}
              onCancelAddAttendee={onCancelAddAttendee}
            />
            {cellBulkOverlay}
            {actionsOverlay}
          </td>
        );
      case "time":
        return (
          <td
            key="time"
            className="border border-gray-300 px-3 py-3 align-middle text-center relative group"
            style={tintedCellStyle(col)}
          >
            <TimeRangeCell
              meeting={meeting}
              t={t}
              lang={isRtl ? "ar" : "en"}
              canMutate={canMutate}
              onSaveTimes={onSaveTimes}
            />
            {cellBulkOverlay}
            {actionsOverlay}
          </td>
        );
    }
  };

  // When a merge is active we render a single <td colSpan=N> with an
  // EditableCell for `mergeText`, and skip the cells the merge covers
  // after the first. The merged cell hosts its own RowActionsMenu so users
  // can always unmerge or re-merge — even when the # column is hidden.
  // Originals stay in the DB so unmerge restores them.
  const renderCells = (): ReactNode[] => {
    if (!isMerged) {
      return visibleColumns.map((c, idx) => renderCell(c, idx));
    }
    const cells: ReactNode[] = [];
    const span = visibleMergeIndices.length;
    visibleColumns.forEach((col, idx) => {
      if (mergeSkipSet?.has(idx)) return;
      if (idx === mergeFirstIdx) {
        // Sum widths of the spanned visible columns so layout in
        // table-fixed mode reserves the right horizontal space.
        const totalWidth = visibleMergeIndices.reduce(
          (sum, i) => sum + (visibleColumns[i].width || 0),
          0,
        );
        cells.push(
          <td
            key="merge"
            colSpan={span}
            className="border border-gray-300 px-3 py-3 align-middle text-[#0B1E3F] relative group"
            style={{
              width: totalWidth,
              overflow: "hidden",
              ...(tintBg ? { backgroundColor: tintBg } : null),
            }}
            data-testid={`em-merge-cell-${meeting.id}`}
          >
            <EditableCell
              value={meeting.mergeText ?? ""}
              onSave={async (html) => {
                await onSaveMerge({
                  mergeStartColumn: meeting.mergeStartColumn as ColumnId,
                  mergeEndColumn: meeting.mergeEndColumn as ColumnId,
                  mergeText: html,
                });
              }}
              ariaLabel={t("executiveMeetings.merge.editLabel")}
              dir={isRtl ? "rtl" : "ltr"}
              className="font-medium leading-snug break-words"
              disabled={!canMutate}
              placeholder={t("executiveMeetings.merge.placeholder")}
              testId={`em-merge-edit-${meeting.id}`}
            />
            {bulkSelectOverlay}
            {canMutate && (
              <RowActionsMenu
                meetingId={meeting.id}
                isDeleting={isDeleting}
                onDelete={onDeleteMeeting}
                rowColorKey={rowColorKey}
                onPickRowColor={onPickRowColor}
                hasStoredMerge
                existingMergeText={meeting.mergeText ?? null}
                onChangeMerge={onSaveMerge}
                t={t}
              />
            )}
          </td>,
        );
      } else {
        cells.push(renderCell(col, idx));
      }
    });
    return cells;
  };

  // Combine row background with highlight tint. When the row is the current
  // meeting we apply a soft tint (color at low opacity) plus a coloured ring
  // via box-shadow (rings on <tr> don't render across cells; box-shadow does).
  const baseBg = rowBg || "white";
  // The "selected for bulk delete" ring stacks ON TOP of the existing
  // current-meeting ring when both apply. Inset shadows compose from
  // left to right (top of stack first), so we list the selection ring
  // first so it's the visible outer outline. Color is the same brand
  // navy used elsewhere in the page so it reads as a deliberate state.
  const selectionShadow = bulkSelected ? "inset 0 0 0 2px #0B1E3F" : null;
  const currentShadow = isCurrent
    ? `inset 0 0 0 2px ${highlightColor}`
    : null;
  const composedShadow = [selectionShadow, currentShadow]
    .filter(Boolean)
    .join(", ");
  const trStyle: CSSProperties = {
    backgroundColor: baseBg,
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0.5 : reordering ? 0.7 : 1,
    boxShadow: composedShadow || undefined,
  };

  return (
    <tr
      ref={setNodeRef}
      className="group"
      style={trStyle}
      data-testid={`em-row-${meeting.id}`}
      data-current-meeting={isCurrent ? "true" : undefined}
      data-bulk-selected={bulkSelected ? "true" : undefined}
    >
      {renderCells()}
    </tr>
  );
}

// Canonical schedule column order for cell-merge ranges. MUST stay in
// sync with the API's MERGE_COLUMNS array in
// artifacts/api-server/src/routes/executive-meetings.ts. We use this
// (instead of `visibleColumns`) when computing whether a stored merge
// overlaps the currently visible columns, so that hiding one boundary
// of the range still lets the merged cell render across whichever
// columns inside the range remain visible.
const CANONICAL_MERGE_ORDER: ColumnId[] = [
  "number",
  "meeting",
  "attendees",
  "time",
];
function canonicalMergeIndex(id: ColumnId | string | null | undefined): number {
  if (id == null) return -1;
  return CANONICAL_MERGE_ORDER.indexOf(id as ColumnId);
}

// Convert a #RRGGBB or #RGB hex color into an rgba() string with the
// given alpha. Used to derive the soft tint applied to the current
// meeting row from the user's highlight color preference. Falls back
// to the input string unchanged if it isn't a recognised hex.
function hexToRgba(hex: string, alpha: number): string {
  const m3 = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(hex);
  const m6 = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
  let r = 0;
  let g = 0;
  let b = 0;
  if (m6) {
    r = parseInt(m6[1], 16);
    g = parseInt(m6[2], 16);
    b = parseInt(m6[3], 16);
  } else if (m3) {
    r = parseInt(m3[1] + m3[1], 16);
    g = parseInt(m3[2] + m3[2], 16);
    b = parseInt(m3[3] + m3[3], 16);
  } else {
    return hex;
  }
  return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}

function TimeRangeCell({
  meeting,
  t,
  lang,
  canMutate,
  onSaveTimes,
}: {
  meeting: Meeting;
  t: (k: string) => string;
  lang: "ar" | "en";
  canMutate: boolean;
  onSaveTimes: (
    startTime: string | null,
    endTime: string | null,
  ) => Promise<void>;
}) {
  const { toast } = useToast();
  const startSaved = meeting.startTime ? meeting.startTime.slice(0, 5) : "";
  const endSaved = meeting.endTime ? meeting.endTime.slice(0, 5) : "";

  const [editing, setEditing] = useState(false);
  const [start, setStart] = useState(startSaved);
  const [end, setEnd] = useState(endSaved);
  const wrapperRef = useRef<HTMLDivElement | null>(null);
  const blurTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const startInputRef = useRef<HTMLInputElement | null>(null);

  // If the saved times change while we're not editing (e.g. another tab
  // updated them, or the user picked a different date), sync local state.
  useEffect(() => {
    if (!editing) {
      setStart(startSaved);
      setEnd(endSaved);
    }
  }, [editing, startSaved, endSaved]);

  useEffect(() => {
    if (editing) {
      startInputRef.current?.focus();
    }
  }, [editing]);

  const enterEdit = useCallback(() => {
    if (!canMutate) return;
    setEditing(true);
  }, [canMutate]);

  const cancel = useCallback(() => {
    setStart(startSaved);
    setEnd(endSaved);
    setEditing(false);
  }, [startSaved, endSaved]);

  // If the global Edit toggle flips off (or the user loses permission)
  // while the time inputs are open, drop the unsaved draft and exit
  // edit mode immediately so the cell snaps back to its read-only
  // display instead of stranding stale time inputs in view mode.
  useEffect(() => {
    if (!canMutate && editing) {
      setStart(startSaved);
      setEnd(endSaved);
      setEditing(false);
    }
  }, [canMutate, editing, startSaved, endSaved]);

  const save = useCallback(async () => {
    const newStart = start || null;
    const newEnd = end || null;
    if (newStart === (startSaved || null) && newEnd === (endSaved || null)) {
      setEditing(false);
      return;
    }
    if (newStart && newEnd && newStart > newEnd) {
      // Surface the validation error and keep the user in edit mode so they
      // can correct it without losing the values they just typed.
      toast({
        title: t("executiveMeetings.schedule.timeOrderError"),
        variant: "destructive",
      });
      startInputRef.current?.focus();
      return;
    }
    try {
      await onSaveTimes(newStart, newEnd);
      setEditing(false);
    } catch (err) {
      // The parent `saveTimes` handler is responsible for surfacing the
      // error toast. For the server-side time-order validation error we
      // stay in edit mode with the user's draft values intact so they
      // can fix the typo without re-entering both times, then refocus
      // the start input. For any other failure we drop the draft and
      // return to the previously-saved values.
      if (err instanceof Error && err.name === "TimeOrderError") {
        startInputRef.current?.focus();
        return;
      }
      setStart(startSaved);
      setEnd(endSaved);
      setEditing(false);
    }
  }, [start, end, startSaved, endSaved, onSaveTimes]);

  const onFocusOut = (e: React.FocusEvent<HTMLDivElement>) => {
    if (!editing) return;
    const next = e.relatedTarget as Node | null;
    if (next && wrapperRef.current?.contains(next)) return;
    if (blurTimerRef.current) clearTimeout(blurTimerRef.current);
    blurTimerRef.current = setTimeout(() => {
      void save();
    }, 150);
  };

  const onFocusIn = () => {
    if (blurTimerRef.current) {
      clearTimeout(blurTimerRef.current);
      blurTimerRef.current = null;
    }
  };

  if (!editing) {
    const hasAny = !!(meeting.startTime || meeting.endTime);
    const display = hasAny
      ? `${formatTime(meeting.startTime, lang)} – ${formatTime(meeting.endTime, lang)}`
      : "—";
    return (
      <div
        role={canMutate ? "button" : undefined}
        tabIndex={canMutate ? 0 : -1}
        aria-label={t("executiveMeetings.col.time")}
        // Force LTR so the time range always reads "10:00 – 11:00"
        // (start on the left, end on the right) regardless of the
        // surrounding RTL Arabic layout.
        dir="ltr"
        className={
          "font-mono text-[#0B1E3F] whitespace-nowrap leading-tight inline-block " +
          (canMutate
            ? "cursor-text hover:bg-blue-50/50 focus:bg-blue-50/50 focus:outline-none rounded transition-colors px-1"
            : "")
        }
        onClick={enterEdit}
        onKeyDown={(e) => {
          if (e.key === "Enter" || e.key === " ") {
            e.preventDefault();
            enterEdit();
          }
        }}
        data-testid={`em-time-${meeting.id}`}
      >
        {display}
      </div>
    );
  }

  const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Escape") {
      e.preventDefault();
      cancel();
    } else if (e.key === "Enter") {
      e.preventDefault();
      void save();
    }
  };

  return (
    <div
      ref={wrapperRef}
      onFocus={onFocusIn}
      onBlur={onFocusOut}
      className="flex items-center justify-center gap-1 font-mono whitespace-nowrap"
      data-testid={`em-time-${meeting.id}`}
      data-editing="true"
    >
      <input
        ref={startInputRef}
        type="time"
        value={start}
        onChange={(e) => setStart(e.target.value)}
        onKeyDown={onKeyDown}
        aria-label={t("executiveMeetings.schedule.timeStart")}
        className="w-[5rem] border border-blue-400 rounded px-1 py-0.5 text-xs bg-white"
        data-testid={`em-time-start-${meeting.id}`}
      />
      <span className="text-muted-foreground">–</span>
      <input
        type="time"
        value={end}
        onChange={(e) => setEnd(e.target.value)}
        onKeyDown={onKeyDown}
        aria-label={t("executiveMeetings.schedule.timeEnd")}
        className="w-[5rem] border border-blue-400 rounded px-1 py-0.5 text-xs bg-white"
        data-testid={`em-time-end-${meeting.id}`}
      />
      <button
        type="button"
        onMouseDown={(e) => e.preventDefault()}
        onClick={() => void save()}
        aria-label={t("common.save")}
        className="ms-0.5 p-0.5 rounded text-green-700 hover:bg-green-50 focus:bg-green-50 focus:outline-none"
        data-testid={`em-time-save-${meeting.id}`}
      >
        <Check className="w-3.5 h-3.5" />
      </button>
      <button
        type="button"
        onMouseDown={(e) => e.preventDefault()}
        onClick={cancel}
        aria-label={t("common.cancel")}
        className="p-0.5 rounded text-red-700 hover:bg-red-50 focus:bg-red-50 focus:outline-none"
        data-testid={`em-time-cancel-${meeting.id}`}
      >
        <X className="w-3.5 h-3.5" />
      </button>
    </div>
  );
}

function AttendeesCell({
  meeting,
  t,
  canMutate = false,
  onSaveAttendeeName,
  pendingAttendee = null,
  onStartAddAttendee,
  onCommitAddAttendee,
  onCancelAddAttendee,
}: {
  meeting: Meeting;
  t: (k: string) => string;
  canMutate?: boolean;
  onSaveAttendeeName?: (idx: number, html: string) => Promise<void>;
  pendingAttendee?: {
    meetingId: number;
    attendanceType: Attendee["attendanceType"];
    kind: "person" | "subheading";
    key: number;
    insertAtIndex?: number;
  } | null;
  onStartAddAttendee?: (
    meetingId: number,
    attendanceType: Attendee["attendanceType"],
    kind?: "person" | "subheading",
    insertAtIndex?: number,
  ) => void;
  onCommitAddAttendee?: (
    attendanceType: Attendee["attendanceType"],
    html: string,
    kind?: "person" | "subheading",
    insertAtIndex?: number,
  ) => Promise<void>;
  onCancelAddAttendee?: () => void;
}) {
  // Build (attendee, originalIndex) tuples so attendees can be sliced into
  // virtual/internal/external groups while still referring back to their
  // index in `meeting.attendees` (needed by the PUT save endpoint). Each
  // tuple keeps both person and subheading rows; AttendeeFlow is in charge
  // of skipping subheadings when computing the running "1-, 2-…" index.
  const tagged = meeting.attendees.map((a, i) => ({ a, i }));
  const virtual = tagged.filter(({ a }) => a.attendanceType === "virtual");
  const internal = tagged.filter(({ a }) => a.attendanceType === "internal");
  const external = tagged.filter(({ a }) => a.attendanceType === "external");
  // A subheading belongs to exactly one attendance-type group, so we
  // gate the split layout on group existence (any row, person or
  // subheading) rather than on person count. Otherwise a group that
  // currently contains only subheadings (e.g. after the user deletes
  // all of its persons) would silently fold its rows into a sibling
  // group and lose the user's intended placement.
  const hasSplit =
    virtual.length > 0 && (internal.length > 0 || external.length > 0);

  const platformLabel: Record<Meeting["platform"], string> = {
    none: "",
    webex: "Webex",
    teams: "Teams",
    zoom: "Zoom",
    other: t("executiveMeetings.virtual"),
  };

  // pendingAttendee is GLOBAL — it's the single ghost row open anywhere on
  // the page. So we must distinguish:
  //  * "is the ghost in THIS row, in THIS group?" — controls whether to
  //    render the ghost EditableCell here.
  //  * "is a ghost open ANYWHERE on the page?" — controls whether to hide
  //    the "+" buttons in this row, so the user can't orphan their typing
  //    by clicking "+" in a different row while one is already open.
  const isPendingForThisMeeting =
    !!pendingAttendee && pendingAttendee.meetingId === meeting.id;
  // True when the ghost is open in THIS meeting + group, regardless of
  // kind. Used by the parent layout to decide whether to render the
  // group at all (a brand-new "+ subheading" inside a so-far-empty
  // virtual group still needs the virtual section to mount).
  const isPendingForType = (type: Attendee["attendanceType"]) =>
    isPendingForThisMeeting && pendingAttendee!.attendanceType === type;
  const hasAnyPending = !!pendingAttendee;

  const startAdd = onStartAddAttendee
    ? (
        type: Attendee["attendanceType"],
        kind: "person" | "subheading" = "person",
        insertAtIndex?: number,
      ) => onStartAddAttendee(meeting.id, type, kind, insertAtIndex)
    : undefined;

  const commitAdd = onCommitAddAttendee;
  const cancelAdd = onCancelAddAttendee;

  const flowProps = {
    canMutate,
    onSaveAttendeeName,
    pendingKind: (pendingAttendee?.kind ?? "person") as "person" | "subheading",
    isPending: false,
    pendingKey: pendingAttendee?.key ?? 0,
    // Tells AttendeeFlow where in the full meeting.attendees array the
    // pending ghost belongs. Used both to render the ghost at the right
    // slot and to forward the value through to onCommitAdd so the splice
    // in commitAddAttendee lands in the same place.
    pendingInsertAtIndex: pendingAttendee?.insertAtIndex,
    onStartAdd: hasAnyPending ? undefined : startAdd,
    onCommitAdd: commitAdd,
    onCancelAdd: cancelAdd,
    addLabel: t("executiveMeetings.schedule.addAttendee"),
    addSubheadingLabel: t("executiveMeetings.schedule.addSubheading"),
    editAriaLabel: t("executiveMeetings.schedule.editAttendeeAria"),
    newAriaLabel: t("executiveMeetings.schedule.newAttendeeAria"),
    editSubheadingAriaLabel: t(
      "executiveMeetings.schedule.editSubheadingAria",
    ),
    newSubheadingAriaLabel: t(
      "executiveMeetings.schedule.newSubheadingAria",
    ),
    meetingId: meeting.id,
  };

  if (hasSplit) {
    // Split-mode cells render ONE cell-level "+ عنوان فرعي" chip below
    // all groups (see Task #234), so each per-group AttendeeFlow must
    // suppress its own trailing subheading chip. Per-group "+" person
    // chips and the after-section chips are unaffected.
    return (
      <div className="space-y-2">
        {(virtual.length > 0 || isPendingForType("virtual")) && (
          <AttendeeGroup
            label={
              meeting.platform !== "none"
                ? `${t("executiveMeetings.virtualAttendance")} — ${platformLabel[meeting.platform]}`
                : t("executiveMeetings.virtualAttendance")
            }
            items={virtual}
            addType="virtual"
            {...flowProps}
            isPending={isPendingForType("virtual")}
            suppressTrailingSubheadingChip
          />
        )}
        {(internal.length > 0 || isPendingForType("internal")) && (
          <AttendeeGroup
            label={t("executiveMeetings.internalAttendance")}
            items={internal}
            addType="internal"
            {...flowProps}
            isPending={isPendingForType("internal")}
            suppressTrailingSubheadingChip
          />
        )}
        {(external.length > 0 || isPendingForType("external")) && (
          <AttendeeGroup
            label={t("executiveMeetings.externalAttendance")}
            items={external}
            addType="external"
            {...flowProps}
            isPending={isPendingForType("external")}
            suppressTrailingSubheadingChip
          />
        )}
        {/* Add chips for groups that don't yet exist for this meeting. */}
        {canMutate && !hasAnyPending && startAdd && (
          <div className="flex flex-wrap justify-center gap-1.5 print:hidden text-xs">
            {virtual.length === 0 && (
              <AddAttendeeChip
                onClick={() => startAdd("virtual")}
                label={t("executiveMeetings.schedule.addVirtualAttendee")}
                testId={`em-add-attendee-virtual-${meeting.id}`}
              />
            )}
            {internal.length === 0 && (
              <AddAttendeeChip
                onClick={() => startAdd("internal")}
                label={t("executiveMeetings.schedule.addInternalAttendee")}
                testId={`em-add-attendee-internal-${meeting.id}`}
              />
            )}
            {external.length === 0 && (
              <AddAttendeeChip
                onClick={() => startAdd("external")}
                label={t("executiveMeetings.schedule.addExternalAttendee")}
                testId={`em-add-attendee-external-${meeting.id}`}
              />
            )}
          </div>
        )}
        {/* Cell-level "+ عنوان فرعي" chip (Task #234). One per cell,
            not one per group. Clicking it routes the new subheading to
            the LAST visible group in render order (virtual → internal
            → external). The chip mirrors the per-group hide rules:
            only renders when the user can edit, no add ghost is
            already in flight, and a startAdd handler exists. */}
        {canMutate && !hasAnyPending && startAdd && (
          <div className="flex justify-center print:hidden">
            <button
              type="button"
              onClick={() => {
                const lastVisibleAddType: Attendee["attendanceType"] =
                  external.length > 0
                    ? "external"
                    : internal.length > 0
                      ? "internal"
                      : "virtual";
                startAdd(lastVisibleAddType, "subheading");
              }}
              data-testid={`em-add-subheading-cell-${meeting.id}`}
              aria-label={t("executiveMeetings.schedule.addSubheading")}
              className="text-[10px] text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-full px-2 py-0.5 min-h-[1.25rem] border border-dashed border-blue-300 hover:border-blue-500 transition-colors"
            >
              {t("executiveMeetings.schedule.addSubheading")}
            </button>
          </div>
        )}
      </div>
    );
  }

  // Single-group cases: meeting.platform-only virtual list, all-internal,
  // all-external, or empty. Pick a sensible default add type so the user
  // can add a first attendee without reaching for the request modal.
  const singleGroupType: Attendee["attendanceType"] =
    meeting.platform !== "none" && virtual.length > 0
      ? "virtual"
      : tagged[0]?.a.attendanceType ?? "internal";

  if (meeting.platform !== "none" && virtual.length > 0) {
    return (
      <AttendeeGroup
        label={`${t("executiveMeetings.virtualAttendance")} — ${platformLabel[meeting.platform]}`}
        items={virtual}
        addType="virtual"
        {...flowProps}
        isPending={isPendingForType("virtual")}
      />
    );
  }

  // Empty cell or single-type cell — render flow without a header.
  return (
    <AttendeeFlow
      items={tagged}
      addType={singleGroupType}
      {...flowProps}
      isPending={isPendingForType(singleGroupType)}
    />
  );
}

type AttendeeWithIndex = { a: Attendee; i: number };

function AddAttendeeChip({
  onClick,
  label,
  testId,
}: {
  onClick: () => void;
  label: string;
  testId: string;
}) {
  return (
    <button
      type="button"
      onClick={onClick}
      data-testid={testId}
      className="inline-flex items-center rounded-full border border-dashed border-blue-400 px-2.5 py-1 min-h-[1.5rem] text-xs text-blue-700 hover:bg-blue-50 hover:border-blue-500 transition-colors"
    >
      {label}
    </button>
  );
}

type AttendeeFlowSharedProps = {
  canMutate?: boolean;
  onSaveAttendeeName?: (idx: number, html: string) => Promise<void>;
  addType: Attendee["attendanceType"];
  isPending: boolean;
  // Which kind of ghost is being added — drives where in the flex flow
  // the pending EditableCell is rendered (inline person at the end of the
  // numbered list, or a full-width subheading line).
  pendingKind: "person" | "subheading";
  pendingKey: number;
  // Position in meeting.attendees where the pending ghost should land.
  // When undefined the ghost falls back to the legacy "trailing" slot
  // (rendered after the last item in the group). When set, the ghost is
  // rendered inline before the first item whose original index is >= the
  // value, so the user sees the input materialize right where they
  // clicked an after-section "+ عنوان فرعي" chip.
  pendingInsertAtIndex?: number;
  onStartAdd?: (
    type: Attendee["attendanceType"],
    kind?: "person" | "subheading",
    insertAtIndex?: number,
  ) => void;
  onCommitAdd?: (
    type: Attendee["attendanceType"],
    html: string,
    kind?: "person" | "subheading",
    insertAtIndex?: number,
  ) => Promise<void>;
  onCancelAdd?: () => void;
  addLabel: string;
  addSubheadingLabel: string;
  editAriaLabel: string;
  newAriaLabel: string;
  editSubheadingAriaLabel: string;
  newSubheadingAriaLabel: string;
  meetingId: number;
  // When true, the trailing "+ عنوان فرعي" <li> at the very end of the
  // flow is NOT rendered. Used by split-mode cells (≥2 attendance
  // groups visible) so the cell shows ONE cell-level "+ عنوان فرعي"
  // chip below all groups, instead of one per group. The trailing
  // "+" person <li> is unaffected.
  suppressTrailingSubheadingChip?: boolean;
};

function AttendeeGroup({
  label,
  items,
  ...flowProps
}: { label: string; items: AttendeeWithIndex[] } & AttendeeFlowSharedProps) {
  return (
    <div>
      {/* The group label is purely informational — make it pointer-events:none
          so a near-miss tap on the label still falls through to whatever is
          underneath (e.g. an attendee name or the cell background). The
          subtle bottom border anchors the label to the list of attendees
          beneath it so the user reads it as a sub-section header rather
          than a stray line of text. */}
      <div
        data-testid="em-attendee-group-header"
        className="text-sm font-bold text-[#0B1E3F] mt-0.5 mb-1 pb-0.5 text-center pointer-events-none select-none border-b border-[#0B1E3F]/15 print:border-b print:border-gray-400"
      >
        {label}
      </div>
      <AttendeeFlow items={items} {...flowProps} />
    </div>
  );
}

function AttendeeFlow({
  items,
  canMutate = false,
  onSaveAttendeeName,
  addType,
  isPending,
  pendingKind,
  pendingKey,
  pendingInsertAtIndex,
  onStartAdd,
  onCommitAdd,
  onCancelAdd,
  addLabel,
  addSubheadingLabel,
  editAriaLabel,
  newAriaLabel,
  editSubheadingAriaLabel,
  newSubheadingAriaLabel,
  suppressTrailingSubheadingChip = false,
}: { items: AttendeeWithIndex[] } & AttendeeFlowSharedProps) {
  const editable = canMutate && !!onSaveAttendeeName;
  const showAdd = canMutate && !!onStartAdd && !isPending;
  // True when the in-flight ghost is targeted at a specific slot — set
  // by clicking an after-section "+ عنوان فرعي" chip. The same flag
  // drives inline rendering for the person ghost (still wired through
  // the manage dialog flow). When unset we fall back to the legacy
  // trailing render so the trailing "+" / "+ subheading" buttons keep
  // working.
  const hasInlineInsert = isPending && pendingInsertAtIndex !== undefined;

  // Each subheading starts a fresh numbered section. Within a section,
  // persons are numbered 1..N starting over after every subheading.
  // Numbering rules:
  //   - If the cell has NO subheading: keep the legacy "don't number a
  //     single name" rule (only show numbers when 2+ persons exist).
  //   - If the cell HAS any subheading: always show numbers, even for
  //     solo persons inside a section, so the grouping reads clearly.
  // Pre-compute per-list-position metadata so the render loop is O(n).
  const hasAnySubheading = items.some(
    ({ a }) => (a.kind ?? "person") === "subheading",
  );
  const sectionMeta = items.map(() => ({
    sectionPersonCount: 0,
    idxInSection: 0,
  }));
  {
    let secStart = 0;
    let secCount = 0;
    const finalize = (endExclusive: number) => {
      for (let k = secStart; k < endExclusive; k++) {
        sectionMeta[k].sectionPersonCount = secCount;
      }
    };
    items.forEach(({ a }, idx) => {
      if ((a.kind ?? "person") === "subheading") {
        finalize(idx);
        secStart = idx + 1;
        secCount = 0;
      } else {
        sectionMeta[idx].idxInSection = secCount;
        secCount += 1;
      }
    });
    finalize(items.length);
  }
  // Person count of the trailing (last) section — drives whether the
  // pending "+ name" ghost row shows a leading number.
  let lastSectionPersonCount = 0;
  for (let k = items.length - 1; k >= 0; k--) {
    if ((items[k].a.kind ?? "person") === "subheading") break;
    lastSectionPersonCount += 1;
  }

  if (items.length === 0 && !showAdd && !isPending) {
    return <span className="text-xs text-gray-500">—</span>;
  }

  // Locate the listIdx where the inline pending ghost should appear, when
  // an inline insert is in flight. The ghost lands BEFORE the first item
  // whose original index in meeting.attendees is >= pendingInsertAtIndex.
  // If no such item exists (e.g. insert after the last item), the ghost
  // is rendered after the loop. Computed once up front so the JSX is
  // straightforward.
  let inlineGhostTargetListIdx: number | null = null;
  if (hasInlineInsert) {
    for (let k = 0; k < items.length; k++) {
      if (items[k].i >= pendingInsertAtIndex!) {
        inlineGhostTargetListIdx = k;
        break;
      }
    }
  }

  // The pending subheading ghost <li>. Used for inline rendering when
  // the user clicks an after-section "+ عنوان فرعي" chip — the ghost
  // materializes at the requested slot rather than the trailing
  // fallback so the user sees their click land where expected.
  const renderPendingSubheadingLi = (key: string) => {
    if (
      !(canMutate && isPending && pendingKind === "subheading" && onCommitAdd)
    ) {
      return null;
    }
    return (
      <li
        key={key}
        className="basis-full text-center font-semibold text-[#0B1E3F]/90 mt-1.5 mb-0.5"
        data-testid={`em-add-subheading-pending-${addType}`}
      >
        <EditableCell
          key={`pending-sub-${pendingKey}`}
          value=""
          onSave={(html) =>
            onCommitAdd(addType, html, "subheading", pendingInsertAtIndex)
          }
          ariaLabel={newSubheadingAriaLabel}
          dir="auto"
          placeholder="…"
          className="inline-block align-baseline min-w-[6rem] min-h-[1.5rem] px-0.5 py-0.5 border-b border-dashed border-blue-500 data-[editing=true]:border-b-0 print:border-b-0 print:py-0 print:min-h-0"
          testId={`em-add-subheading-pending-input-${addType}`}
          startInEditMode
          forceSaveOnBlur
          onCancel={onCancelAdd}
        />
      </li>
    );
  };

  // The pending person ghost <li>. Defined once so we can render it
  // either inline (at the slot the user clicked) or as a trailing row
  // (legacy "+" button at the end of the cell). It always shows the next
  // section index when the cell has subheadings or the surrounding
  // section already has people; otherwise it stays unnumbered just like
  // a sole attendee in a no-subheading cell.
  const renderPendingPersonLi = (key: string) => {
    if (!(canMutate && isPending && pendingKind === "person" && onCommitAdd)) {
      return null;
    }
    // When inserting inline after a subheading at array index K (so
    // insertAtIndex = K + 1) we want the ghost to be numbered "1-" so the
    // user sees a fresh section starting. Compute the section-local index
    // for the inline slot from the per-item meta we already built.
    let ghostDisplayNumber: number | null = null;
    if (hasInlineInsert && inlineGhostTargetListIdx !== null) {
      const target = inlineGhostTargetListIdx;
      // The slot's section-local index = sectionMeta[target].idxInSection
      // (0-based) for the would-be inserted person. That row pushes the
      // current target row down, so the ghost takes the target's slot.
      ghostDisplayNumber = sectionMeta[target].idxInSection + 1;
    } else if (hasInlineInsert && inlineGhostTargetListIdx === null) {
      // Insert at end (after the last item). Number = persons in last
      // section + 1, mirroring the trailing case.
      ghostDisplayNumber = lastSectionPersonCount + 1;
    } else {
      // Legacy trailing case.
      ghostDisplayNumber =
        lastSectionPersonCount > 0 || hasAnySubheading
          ? lastSectionPersonCount + 1
          : null;
    }
    return (
      <li
        key={key}
        className="min-w-[6rem]"
        data-testid={`em-add-attendee-pending-${addType}`}
      >
        {ghostDisplayNumber !== null && (
          <span className="text-gray-500 me-1">{ghostDisplayNumber}-</span>
        )}
        <EditableCell
          key={`pending-${pendingKey}`}
          value=""
          onSave={(html) =>
            onCommitAdd(addType, html, "person", pendingInsertAtIndex)
          }
          ariaLabel={newAriaLabel}
          dir="auto"
          placeholder="…"
          className="inline-block align-baseline min-w-[6rem] min-h-[1.5rem] px-0.5 py-0.5 border-b border-dashed border-blue-500 data-[editing=true]:border-b-0 print:border-b-0 print:py-0 print:min-h-0"
          testId={`em-add-attendee-pending-input-${addType}`}
          startInEditMode
          forceSaveOnBlur
          onCancel={onCancelAdd}
        />
      </li>
    );
  };

  return (
    <ul className="flex flex-wrap justify-center items-baseline gap-x-4 gap-y-0.5 text-[#0B1E3F] leading-snug">
      {items.flatMap(({ a, i }, listIdx) => {
        const isSub = (a.kind ?? "person") === "subheading";
        const nodes: ReactNode[] = [];
        if (inlineGhostTargetListIdx === listIdx) {
          if (pendingKind === "subheading") {
            const ghost = renderPendingSubheadingLi(
              `pending-inline-sub-${pendingKey}`,
            );
            if (ghost) nodes.push(ghost);
          } else {
            const ghost = renderPendingPersonLi(
              `pending-inline-${pendingKey}`,
            );
            if (ghost) nodes.push(ghost);
          }
        }
        if (isSub) {
          // Subheading row: full-width line break inside the flex-wrap so
          // it visually separates the persons before/after. No leading
          // index, no inline-flex — pure label.
          nodes.push(
            <li
              key={a.id ?? `sub-${i}`}
              data-testid={`em-attendee-subheading-${i}`}
              data-attendee-kind="subheading"
              className="basis-full text-center font-semibold text-[#0B1E3F]/90 mt-1.5 mb-0.5 leading-snug"
            >
              {editable ? (
                <EditableCell
                  value={a.name}
                  onSave={(html) => onSaveAttendeeName!(i, html)}
                  ariaLabel={editSubheadingAriaLabel}
                  dir="auto"
                  placeholder="…"
                  className="inline-block align-baseline min-w-[6rem] min-h-[1.5rem] px-0.5 py-0.5 [&>span_p]:inline [&>span_p]:m-0 border-b border-dashed border-gray-400/70 hover:border-blue-500 data-[editing=true]:border-b-0 print:border-b-0 print:py-0 print:min-h-0"
                  testId={`em-edit-subheading-${i}`}
                />
              ) : (
                <span
                  data-testid={`em-subheading-name-${i}`}
                  className="[&_p]:inline [&_p]:m-0"
                  dangerouslySetInnerHTML={{ __html: safeHtml(a.name) }}
                />
              )}
            </li>,
          );
          // Empty-section boundary: this subheading starts a section
          // that has zero persons (the next item is ANOTHER subheading,
          // i.e. the section it leads is empty). The user still needs a
          // way to insert a NEW subheading between this section and the
          // next one — so render the after-section chip right after the
          // subheading itself. The trailing-section case (no next item)
          // is intentionally skipped: the trailing "+ عنوان فرعي"
          // button at the cell's end already covers that slot.
          const nextItemForSub = items[listIdx + 1];
          const nextSubIsSubheading =
            nextItemForSub &&
            (nextItemForSub.a.kind ?? "person") === "subheading";
          if (showAdd && nextSubIsSubheading) {
            nodes.push(
              <li
                key={`add-sub-after-${i}`}
                className="basis-full flex justify-center print:hidden"
                data-testid={`em-add-subheading-after-row-${i}`}
              >
                <button
                  type="button"
                  onClick={() =>
                    onStartAdd!(addType, "subheading", i + 1)
                  }
                  data-testid={`em-add-subheading-after-${i}`}
                  aria-label={addSubheadingLabel}
                  className="text-[10px] text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-full px-2 py-0.5 min-h-[1.25rem] border border-dashed border-blue-300 hover:border-blue-500 transition-colors"
                >
                  {addSubheadingLabel}
                </button>
              </li>,
            );
          }
          return nodes;
        }
        // Person row — section-local 0-based index pre-computed above.
        const meta = sectionMeta[listIdx];
        const myDisplayIdx = meta.idxInSection;
        nodes.push(
          <li
            key={a.id ?? i}
            data-testid={`em-attendee-row-${i}`}
            // `inline-flex items-baseline` keeps the index prefix and the
            // name as flex siblings on the same line. The `[&_p]` utilities
            // on the inner wrappers below flatten tiptap's block `<p>` (with
            // its default 1em margins) so the rendered name stays inline.
            className={
              "inline-flex items-baseline whitespace-nowrap" +
              (editable ? " min-w-[3rem]" : "")
            }
          >
            {(meta.sectionPersonCount > 1 || hasAnySubheading) && (
              <span
                data-testid={`em-attendee-index-${i}`}
                className="text-gray-500 me-1"
              >
                {myDisplayIdx + 1}-
              </span>
            )}
            {editable ? (
              <EditableCell
                value={a.name}
                onSave={(html) => onSaveAttendeeName!(i, html)}
                ariaLabel={editAriaLabel}
                dir="auto"
                placeholder="…"
                // `[&>span_p]:inline [&>span_p]:m-0` only matches the view-mode
                // shell (`div > span > p`); the active editor's
                // `div > div > EditorContent` does NOT match, so Enter still
                // creates a real new paragraph inside the editor.
                className="inline-block align-baseline min-w-[3rem] min-h-[1.5rem] px-0.5 py-0.5 [&>span_p]:inline [&>span_p]:m-0 border-b border-dashed border-gray-400/70 hover:border-blue-500 data-[editing=true]:border-b-0 print:border-b-0 print:py-0 print:min-h-0"
                testId={`em-edit-attendee-${i}`}
              />
            ) : (
              <span
                data-testid={`em-attendee-name-${i}`}
                className="[&_p]:inline [&_p]:m-0"
                dangerouslySetInnerHTML={{ __html: safeHtml(a.name) }}
              />
            )}
          </li>,
        );
        // After-section "+ عنوان فرعي" chip — appears AFTER the last
        // person of each section that is followed by ANOTHER section
        // (i.e. the next item is a subheading). This lets the user
        // insert a new section heading at the boundary between two
        // existing sections. The trailing section is intentionally
        // skipped: the trailing "+ عنوان فرعي" button below already
        // covers that slot, so duplicating the chip would be noise.
        // The chip's insertAtIndex is the slot immediately after this
        // person's global attendees index, so the new subheading lands
        // BEFORE the existing next-section subheading.
        const nextItemForPerson = items[listIdx + 1];
        const nextIsSubheading =
          nextItemForPerson &&
          (nextItemForPerson.a.kind ?? "person") === "subheading";
        if (showAdd && nextIsSubheading) {
          nodes.push(
            <li
              key={`add-sub-after-${i}`}
              className="basis-full flex justify-center print:hidden"
              data-testid={`em-add-subheading-after-row-${i}`}
            >
              <button
                type="button"
                onClick={() =>
                  onStartAdd!(addType, "subheading", i + 1)
                }
                data-testid={`em-add-subheading-after-${i}`}
                aria-label={addSubheadingLabel}
                className="text-[10px] text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-full px-2 py-0.5 min-h-[1.25rem] border border-dashed border-blue-300 hover:border-blue-500 transition-colors"
              >
                {addSubheadingLabel}
              </button>
            </li>,
          );
        }
        return nodes;
      })}
      {/* Inline ghost pinned to the trailing slot (insert-at-end case).
          Either kind may land here: a person from manage flows, or a
          subheading from the trailing "+ عنوان فرعي" button when the
          insertAtIndex equals items.length. */}
      {hasInlineInsert &&
        inlineGhostTargetListIdx === null &&
        pendingKind === "person" &&
        renderPendingPersonLi(`pending-inline-trailing-${pendingKey}`)}
      {hasInlineInsert &&
        inlineGhostTargetListIdx === null &&
        pendingKind === "subheading" &&
        renderPendingSubheadingLi(`pending-inline-trailing-sub-${pendingKey}`)}
      {/* Legacy trailing ghost (no inline insert in flight) — used for
          the global trailing "+" / "+ subheading" buttons. */}
      {!hasInlineInsert &&
        renderPendingPersonLi(`pending-trailing-${pendingKey}`)}
      {!hasInlineInsert &&
        renderPendingSubheadingLi(`pending-trailing-sub-${pendingKey}`)}
      {showAdd && (
        <>
          <li className="print:hidden">
            <button
              type="button"
              onClick={() => onStartAdd!(addType, "person")}
              data-testid={`em-add-attendee-${addType}`}
              aria-label={addLabel}
              className="text-xs text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded px-2 py-1 min-h-[1.5rem] border border-dashed border-blue-300 hover:border-blue-500 transition-colors"
            >
              +
            </button>
          </li>
          {!suppressTrailingSubheadingChip && (
            <li className="print:hidden">
              <button
                type="button"
                onClick={() => onStartAdd!(addType, "subheading")}
                data-testid={`em-add-subheading-${addType}`}
                aria-label={addSubheadingLabel}
                className="text-[10px] text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded-full px-2 py-0.5 min-h-[1.25rem] border border-dashed border-blue-300 hover:border-blue-500 transition-colors"
              >
                {addSubheadingLabel}
              </button>
            </li>
          )}
        </>
      )}
    </ul>
  );
}

// ============ MANAGE ============

type MeetingFormState = {
  id: number | null;
  titleAr: string;
  titleEn: string;
  meetingDate: string;
  dailyNumber: string;
  startTime: string;
  endTime: string;
  location: string;
  meetingUrl: string;
  platform: Meeting["platform"];
  status: string;
  isHighlighted: boolean;
  notes: string;
  attendees: Attendee[];
};

// Generate a stable client-only attendee id for the manage-dialog DnD.
// Combines an incrementing counter with a random suffix so we don't
// collide even when several openEdit() calls fire in the same tick (e.g.
// after a duplicate-and-edit gesture). The id never leaves the browser.
let __attendeeSidCounter = 0;
function genAttendeeSid(): string {
  __attendeeSidCounter += 1;
  return `sid-${__attendeeSidCounter}-${Math.random().toString(36).slice(2, 8)}`;
}

function emptyMeetingForm(date: string): MeetingFormState {
  return {
    id: null,
    titleAr: "",
    titleEn: "",
    meetingDate: date,
    dailyNumber: "",
    startTime: "",
    endTime: "",
    location: "",
    meetingUrl: "",
    platform: "none",
    status: "scheduled",
    isHighlighted: false,
    notes: "",
    attendees: [],
  };
}

function ManageSection({
  date,
  onDateChange,
  canMutate,
  isRtl,
  t,
}: {
  date: string;
  onDateChange: (d: string) => void;
  canMutate: boolean;
  isRtl: boolean;
  t: (k: string) => string;
}) {
  const qc = useQueryClient();
  const { toast } = useToast();
  const [editing, setEditing] = useState<MeetingFormState | null>(null);
  const [saving, setSaving] = useState(false);
  const [deletingId, setDeletingId] = useState<number | null>(null);
  const [duplicating, setDuplicating] = useState<{
    id: number;
    targetDate: string;
  } | null>(null);
  const [duplicatingBusy, setDuplicatingBusy] = useState(false);

  const { data, isLoading } = useQuery<DayResponse>({
    queryKey: ["/api/executive-meetings", date],
    queryFn: () => apiJson<DayResponse>(`/api/executive-meetings?date=${date}`),
  });
  const meetings = data?.meetings ?? [];

  function openCreate() {
    setEditing(emptyMeetingForm(date));
  }
  function openDuplicate(m: Meeting) {
    setDuplicating({ id: m.id, targetDate: date });
  }
  async function performDuplicate() {
    if (!duplicating) return;
    setDuplicatingBusy(true);
    try {
      await apiJson(`/api/executive-meetings/${duplicating.id}/duplicate`, {
        method: "POST",
        body: { targetDate: duplicating.targetDate },
      });
      toast({ title: t("executiveMeetings.manage.duplicated") });
      const newDate = duplicating.targetDate;
      setDuplicating(null);
      if (newDate === date) {
        await qc.invalidateQueries({ queryKey: ["/api/executive-meetings", date] });
      } else {
        onDateChange(newDate);
      }
    } catch (err) {
      toast({
        title: t("executiveMeetings.manage.duplicateFailed"),
        description: err instanceof Error ? err.message : String(err),
        variant: "destructive",
      });
    } finally {
      setDuplicatingBusy(false);
    }
  }
  function openEdit(m: Meeting) {
    setEditing({
      id: m.id,
      titleAr: m.titleAr,
      titleEn: m.titleEn ?? "",
      meetingDate: m.meetingDate,
      dailyNumber: String(m.dailyNumber),
      startTime: m.startTime ? m.startTime.slice(0, 5) : "",
      endTime: m.endTime ? m.endTime.slice(0, 5) : "",
      location: m.location ?? "",
      meetingUrl: m.meetingUrl ?? "",
      platform: m.platform,
      status: m.status,
      isHighlighted: m.isHighlighted === 1,
      notes: m.notes ?? "",
      // Stamp every attendee with a stable client-side id used by the
      // dnd-kit SortableContext in the manage dialog. Persisted attendees
      // also have a server `id` we could reuse, but new rows added inside
      // the dialog don't have one until after save, so we always assign
      // `_sid` here for uniformity. The save projection enumerates wire
      // fields and naturally drops `_sid`.
      attendees: m.attendees.map((a) => ({ ...a, _sid: genAttendeeSid() })),
    });
  }

  async function save() {
    if (!editing) return;
    if (!editing.titleAr.trim() || !editing.titleEn.trim()) {
      toast({
        title: t("executiveMeetings.manage.errors.titleRequired"),
        variant: "destructive",
      });
      return;
    }
    setSaving(true);
    const body: Record<string, unknown> = {
      titleAr: editing.titleAr.trim(),
      titleEn: editing.titleEn.trim(),
      meetingDate: editing.meetingDate,
      startTime: editing.startTime || null,
      endTime: editing.endTime || null,
      location: editing.location.trim() || null,
      meetingUrl: editing.meetingUrl.trim() || null,
      platform: editing.platform,
      status: editing.status,
      isHighlighted: editing.isHighlighted ? 1 : 0,
      notes: editing.notes.trim() || null,
      attendees: editing.attendees.map((a, idx) => ({
        name: a.name,
        title: a.title,
        attendanceType: a.attendanceType,
        sortOrder: idx,
        // Round-trip the row kind so subheadings created in the inline
        // schedule survive a save through the Manage dialog.
        kind: a.kind ?? "person",
      })),
    };
    if (editing.dailyNumber.trim()) body.dailyNumber = Number(editing.dailyNumber);
    try {
      if (editing.id == null) {
        await apiJson(`/api/executive-meetings`, { method: "POST", body });
      } else {
        await apiJson(`/api/executive-meetings/${editing.id}`, {
          method: "PATCH",
          body,
        });
      }
      toast({ title: t("executiveMeetings.common.saved") });
      setEditing(null);
      await qc.invalidateQueries({ queryKey: ["/api/executive-meetings", date] });
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      toast({
        title: t("executiveMeetings.manage.errors.saveFailed"),
        description: msg,
        variant: "destructive",
      });
    } finally {
      setSaving(false);
    }
  }

  async function confirmDelete(id: number) {
    if (!window.confirm(t("executiveMeetings.manage.deleteConfirm"))) return;
    setDeletingId(id);
    try {
      await apiJson(`/api/executive-meetings/${id}`, { method: "DELETE" });
      await qc.invalidateQueries({ queryKey: ["/api/executive-meetings", date] });
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      toast({ title: msg, variant: "destructive" });
    } finally {
      setDeletingId(null);
    }
  }

  if (!canMutate) {
    return <NoPermission t={t} />;
  }

  return (
    <div className="space-y-4">
      <div className="flex items-center justify-between gap-3 flex-wrap">
        <h2 className="text-xl sm:text-2xl font-bold text-[#0B1E3F]">
          {t("executiveMeetings.manage.heading")}
        </h2>
        <div className="flex items-center gap-2">
          <label className="flex items-center gap-2 text-sm">
            <span className="text-muted-foreground">{t("executiveMeetings.date")}</span>
            <input
              type="date"
              value={date}
              onChange={(e) => onDateChange(e.target.value)}
              className="border border-gray-300 rounded px-2 py-1 text-sm bg-white"
            />
          </label>
          <Button onClick={openCreate} className="bg-[#0B1E3F] gap-1" data-testid="em-add-meeting">
            <Plus className="w-4 h-4" />
            {t("executiveMeetings.manage.addMeeting")}
          </Button>
        </div>
      </div>

      <div className="bg-white border border-gray-200 rounded-md overflow-hidden">
        <table className="w-full text-sm" dir={isRtl ? "rtl" : "ltr"}>
          <thead className="bg-gray-50 text-[#0B1E3F]">
            <tr>
              <th className="px-3 py-2 text-start w-12">#</th>
              <th className="px-3 py-2 text-start">{t("executiveMeetings.col.meeting")}</th>
              <th className="px-3 py-2 text-start w-28">{t("executiveMeetings.col.time")}</th>
              <th className="px-3 py-2 text-start w-24">{t("executiveMeetings.manage.field.status")}</th>
              <th className="px-3 py-2 text-end w-32">{t("executiveMeetings.common.actions")}</th>
            </tr>
          </thead>
          <tbody>
            {isLoading && (
              <tr>
                <td colSpan={5} className="py-8 text-center text-gray-500">
                  {t("common.loading")}
                </td>
              </tr>
            )}
            {!isLoading && meetings.length === 0 && (
              <tr>
                <td colSpan={5} className="py-8 text-center text-gray-500">
                  {t("executiveMeetings.manage.noMeetings")}
                </td>
              </tr>
            )}
            {meetings.map((m) => (
              <tr key={m.id} className="border-t border-gray-100">
                <td className="px-3 py-2 align-middle">{m.dailyNumber}</td>
                <td className="px-3 py-2 align-middle">
                  <div className="font-medium text-[#0B1E3F]">
                    {isRtl ? m.titleAr : m.titleEn || m.titleAr}
                  </div>
                  <div className="text-xs text-gray-500">
                    {
                      m.attendees.filter(
                        (a) => (a.kind ?? "person") === "person",
                      ).length
                    }{" "}
                    · {m.platform}
                  </div>
                </td>
                <td className="px-3 py-2 align-middle font-mono text-xs whitespace-nowrap" dir="ltr">
                  {formatTime(m.startTime, isRtl ? "ar" : "en")} — {formatTime(m.endTime, isRtl ? "ar" : "en")}
                </td>
                <td className="px-3 py-2 align-middle text-xs">
                  {t(`executiveMeetings.manage.status.${m.status}`)}
                </td>
                <td className="px-3 py-2 align-middle text-end">
                  <div className="inline-flex gap-1">
                    <Button
                      size="sm"
                      variant="ghost"
                      onClick={() => openEdit(m)}
                      data-testid={`em-edit-${m.id}`}
                    >
                      <Pencil className="w-4 h-4" />
                    </Button>
                    <Button
                      size="sm"
                      variant="ghost"
                      onClick={() => openDuplicate(m)}
                      title={t("executiveMeetings.manage.duplicateToDate")}
                      data-testid={`em-duplicate-${m.id}`}
                    >
                      <Copy className="w-4 h-4" />
                    </Button>
                    <Button
                      size="sm"
                      variant="ghost"
                      onClick={() => confirmDelete(m.id)}
                      disabled={deletingId === m.id}
                      data-testid={`em-delete-${m.id}`}
                    >
                      <Trash2 className="w-4 h-4 text-red-600" />
                    </Button>
                  </div>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      {editing && (
        <MeetingFormDialog
          state={editing}
          onChange={setEditing}
          onSave={save}
          onClose={() => setEditing(null)}
          saving={saving}
          t={t}
        />
      )}

      <Dialog
        open={duplicating !== null}
        onOpenChange={(o) => !o && setDuplicating(null)}
      >
        <DialogContent>
          <DialogHeader>
            <DialogTitle>
              {t("executiveMeetings.manage.duplicateToDate")}
            </DialogTitle>
          </DialogHeader>
          <FormRow label={t("executiveMeetings.manage.field.meetingDate")}>
            <Input
              type="date"
              value={duplicating?.targetDate ?? ""}
              onChange={(e) =>
                setDuplicating(
                  duplicating
                    ? { ...duplicating, targetDate: e.target.value }
                    : null,
                )
              }
              data-testid="em-duplicate-date"
            />
          </FormRow>
          <DialogFooter>
            <Button
              variant="ghost"
              onClick={() => setDuplicating(null)}
              disabled={duplicatingBusy}
            >
              {t("executiveMeetings.common.cancel")}
            </Button>
            <Button
              onClick={performDuplicate}
              disabled={duplicatingBusy || !duplicating?.targetDate}
              className="bg-[#0B1E3F]"
              data-testid="em-duplicate-confirm"
            >
              {t("executiveMeetings.manage.duplicate")}
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </div>
  );
}

// One row inside the manage-dialog attendee list. Uses dnd-kit's
// useSortable so the row can be picked up by its dedicated drag handle
// (the GripVertical button on the side). The visible up/down chevrons,
// inputs and delete button continue to fire their callbacks normally —
// the drag handle is the only element that initiates a drag.
function SortableAttendeeRow({
  attendee,
  index,
  last,
  t,
  onMoveUp,
  onMoveDown,
  onChangeName,
  onChangeTitle,
  onChangeAttendanceType,
  onRemove,
}: {
  attendee: Attendee;
  index: number;
  last: boolean;
  t: (k: string) => string;
  onMoveUp: () => void;
  onMoveDown: () => void;
  onChangeName: (v: string) => void;
  onChangeTitle: (v: string) => void;
  onChangeAttendanceType: (v: Attendee["attendanceType"]) => void;
  onRemove: () => void;
}) {
  const sid = attendee._sid ?? `__missing-sid-${index}`;
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({ id: sid });
  const style: CSSProperties = {
    transform: CSS.Transform.toString(transform),
    transition,
    // Subtle lift while the row is being dragged so the user sees the
    // active item clearly above the others.
    zIndex: isDragging ? 10 : undefined,
    opacity: isDragging ? 0.85 : undefined,
  };
  const isSub = (attendee.kind ?? "person") === "subheading";
  return (
    <div
      ref={setNodeRef}
      style={style}
      className={
        "flex items-center gap-2" +
        (isSub ? " bg-blue-50/60 rounded px-2 py-1" : "")
      }
      data-testid={`em-attendee-row-${index}`}
      data-attendee-kind={attendee.kind ?? "person"}
      data-attendee-sid={sid}
    >
      {/* Drag handle — the ONLY element wired to dnd-kit listeners, so
          the row's inputs and buttons remain interactive. */}
      <button
        type="button"
        className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-700 px-1 touch-none"
        aria-label={t("executiveMeetings.manage.attendees.dragHandle")}
        data-testid={`em-attendee-drag-${index}`}
        {...attributes}
        {...listeners}
      >
        <GripVertical className="w-4 h-4" />
      </button>
      <div className="flex flex-col">
        <Button
          size="icon"
          variant="ghost"
          className="h-5 w-5"
          onClick={onMoveUp}
          disabled={index === 0}
          aria-label={t("executiveMeetings.manage.attendees.moveUp")}
          data-testid={`em-attendee-up-${index}`}
        >
          <ChevronUp className="w-3 h-3" />
        </Button>
        <Button
          size="icon"
          variant="ghost"
          className="h-5 w-5"
          onClick={onMoveDown}
          disabled={last}
          aria-label={t("executiveMeetings.manage.attendees.moveDown")}
          data-testid={`em-attendee-down-${index}`}
        >
          <ChevronDown className="w-3 h-3" />
        </Button>
      </div>
      {isSub && (
        <span
          className="inline-flex items-center text-[10px] font-semibold uppercase tracking-wide text-blue-700 bg-blue-100 rounded-full px-2 py-0.5"
          data-testid={`em-attendee-kind-badge-${index}`}
        >
          {t("executiveMeetings.manage.attendees.subheadingBadge")}
        </span>
      )}
      <Input
        className={isSub ? "flex-[2]" : "flex-1"}
        placeholder={
          isSub
            ? t("executiveMeetings.manage.attendees.subheadingName")
            : t("executiveMeetings.manage.attendees.name")
        }
        value={attendee.name}
        onChange={(e) => onChangeName(e.target.value)}
      />
      {!isSub && (
        <Input
          className="flex-1"
          placeholder={t("executiveMeetings.manage.attendees.title")}
          value={attendee.title ?? ""}
          onChange={(e) => onChangeTitle(e.target.value)}
          data-testid={`em-attendee-title-${index}`}
        />
      )}
      <Select
        value={attendee.attendanceType}
        onValueChange={(v) =>
          onChangeAttendanceType(v as Attendee["attendanceType"])
        }
      >
        <SelectTrigger className="w-32">
          <SelectValue />
        </SelectTrigger>
        <SelectContent>
          {(["internal", "virtual", "external"] as const).map((tp) => (
            <SelectItem key={tp} value={tp}>
              {t(`executiveMeetings.manage.attendanceType.${tp}`)}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
      <Button size="icon" variant="ghost" onClick={onRemove}>
        <Trash2 className="w-4 h-4 text-red-600" />
      </Button>
    </div>
  );
}

function MeetingFormDialog({
  state,
  onChange,
  onSave,
  onClose,
  saving,
  t,
}: {
  state: MeetingFormState;
  onChange: (s: MeetingFormState) => void;
  onSave: () => void;
  onClose: () => void;
  saving: boolean;
  t: (k: string) => string;
}) {
  function update<K extends keyof MeetingFormState>(k: K, v: MeetingFormState[K]) {
    onChange({ ...state, [k]: v });
  }
  function addAttendee() {
    onChange({
      ...state,
      attendees: [
        ...state.attendees,
        {
          name: "",
          title: null,
          attendanceType: "internal",
          sortOrder: state.attendees.length,
          kind: "person",
          _sid: genAttendeeSid(),
        },
      ],
    });
  }
  function addSubheading() {
    onChange({
      ...state,
      attendees: [
        ...state.attendees,
        {
          name: "",
          // Subheadings have no separate title field — they are a single
          // free-text label. Keep title null on the row to match what the
          // server stores.
          title: null,
          // Default new subheadings into "internal" — the user can switch
          // the section via the same Select that persons use.
          attendanceType: "internal",
          sortOrder: state.attendees.length,
          kind: "subheading",
          _sid: genAttendeeSid(),
        },
      ],
    });
  }
  function removeAttendee(i: number) {
    const arr = state.attendees.slice();
    arr.splice(i, 1);
    onChange({ ...state, attendees: arr });
  }
  // Bulk-clear: empties the in-memory attendee list. Persists only when
  // the user clicks the dialog's Save button (just like the per-row
  // remove). Confirmation surfaces the count so users don't nuke a long
  // list by accident.
  function removeAllAttendees() {
    const count = state.attendees.length;
    if (count === 0) return;
    const message = t(
      "executiveMeetings.manage.attendees.removeAllConfirm",
    ).replace("{{count}}", String(count));
    if (!window.confirm(message)) return;
    onChange({ ...state, attendees: [] });
  }
  function setAttendee(i: number, patch: Partial<Attendee>) {
    const arr = state.attendees.slice();
    arr[i] = { ...arr[i], ...patch } as Attendee;
    onChange({ ...state, attendees: arr });
  }
  function moveAttendee(i: number, dir: -1 | 1) {
    const j = i + dir;
    if (j < 0 || j >= state.attendees.length) return;
    const arr = state.attendees.slice();
    [arr[i], arr[j]] = [arr[j], arr[i]];
    onChange({ ...state, attendees: arr });
  }
  // Drag-and-drop reorder driven by dnd-kit. Items are keyed by their
  // client `_sid` so the SortableContext stays stable across renames,
  // attendance-type changes, etc. We resolve the over/active sids to
  // their current array positions inside the handler so consecutive
  // drags compose correctly.
  function reorderAttendeesByDrag(activeSid: string, overSid: string) {
    if (activeSid === overSid) return;
    const arr = state.attendees;
    const from = arr.findIndex((a) => a._sid === activeSid);
    const to = arr.findIndex((a) => a._sid === overSid);
    if (from < 0 || to < 0) return;
    const next = arr.slice();
    const [moved] = next.splice(from, 1);
    next.splice(to, 0, moved);
    onChange({ ...state, attendees: next });
  }
  // Pointer + keyboard sensors with a small activation distance so that
  // clicking the inputs / buttons inside a row doesn't start a drag.
  const dragSensors = useSensors(
    useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    }),
  );
  // Memoize the id list so SortableContext only re-resolves when the
  // attendee order actually changes.
  const sortableIds = useMemo(
    () =>
      state.attendees.map(
        (a, i) => a._sid ?? `__missing-sid-${i}`,
      ),
    [state.attendees],
  );

  return (
    <Dialog open={true} onOpenChange={(o) => !o && onClose()}>
      <DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
        <DialogHeader>
          <DialogTitle>
            {state.id == null
              ? t("executiveMeetings.manage.addMeeting")
              : t("executiveMeetings.manage.editMeeting")}
          </DialogTitle>
        </DialogHeader>
        <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
          <FormRow label={t("executiveMeetings.manage.field.titleAr")}>
            <Input
              value={state.titleAr}
              onChange={(e) => update("titleAr", e.target.value)}
              data-testid="em-form-titleAr"
            />
          </FormRow>
          <FormRow label={t("executiveMeetings.manage.field.titleEn")}>
            <Input value={state.titleEn} onChange={(e) => update("titleEn", e.target.value)} />
          </FormRow>
          <FormRow label={t("executiveMeetings.manage.field.meetingDate")}>
            <Input
              type="date"
              value={state.meetingDate}
              onChange={(e) => update("meetingDate", e.target.value)}
            />
          </FormRow>
          <FormRow
            label={t("executiveMeetings.manage.field.dailyNumber")}
            hint={t("executiveMeetings.manage.field.dailyNumberHint")}
          >
            <Input
              type="number"
              min={1}
              value={state.dailyNumber}
              onChange={(e) => update("dailyNumber", e.target.value)}
            />
          </FormRow>
          <FormRow label={t("executiveMeetings.manage.field.startTime")}>
            <Input
              type="time"
              value={state.startTime}
              onChange={(e) => update("startTime", e.target.value)}
            />
          </FormRow>
          <FormRow label={t("executiveMeetings.manage.field.endTime")}>
            <Input
              type="time"
              value={state.endTime}
              onChange={(e) => update("endTime", e.target.value)}
            />
          </FormRow>
          <FormRow label={t("executiveMeetings.manage.field.location")}>
            <Input value={state.location} onChange={(e) => update("location", e.target.value)} />
          </FormRow>
          <FormRow label={t("executiveMeetings.manage.field.meetingUrl")}>
            <Input
              value={state.meetingUrl}
              onChange={(e) => update("meetingUrl", e.target.value)}
            />
          </FormRow>
          <FormRow label={t("executiveMeetings.manage.field.platform")}>
            <Select
              value={state.platform}
              onValueChange={(v) => update("platform", v as Meeting["platform"])}
            >
              <SelectTrigger>
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                {(["none", "webex", "teams", "zoom", "other"] as const).map((p) => (
                  <SelectItem key={p} value={p}>
                    {t(`executiveMeetings.manage.platform.${p}`)}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </FormRow>
          <FormRow label={t("executiveMeetings.manage.field.status")}>
            <Select value={state.status} onValueChange={(v) => update("status", v)}>
              <SelectTrigger>
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                {(["scheduled", "cancelled", "completed", "postponed"] as const).map((s) => (
                  <SelectItem key={s} value={s}>
                    {t(`executiveMeetings.manage.status.${s}`)}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </FormRow>
          <FormRow label={t("executiveMeetings.manage.field.isHighlighted")}>
            <Switch
              checked={state.isHighlighted}
              onCheckedChange={(v) => update("isHighlighted", v)}
            />
          </FormRow>
          <FormRow label={t("executiveMeetings.manage.field.notes")} full>
            <Textarea
              value={state.notes}
              onChange={(e) => update("notes", e.target.value)}
              rows={3}
            />
          </FormRow>
        </div>

        <div className="border-t border-gray-200 pt-3 mt-1">
          <div className="flex items-center justify-between mb-2 gap-2 flex-wrap">
            <h4 className="font-semibold text-sm text-[#0B1E3F]">
              {t("executiveMeetings.manage.attendees.label")} (
              {
                // Persons only — subheadings are labels, not attendees.
                state.attendees.filter(
                  (a) => (a.kind ?? "person") === "person",
                ).length
              }
              )
            </h4>
            <div className="flex items-center gap-2">
              {state.attendees.length > 0 && (
                <Button
                  size="sm"
                  variant="outline"
                  onClick={removeAllAttendees}
                  className="gap-1 border-red-300 text-red-700 hover:bg-red-50 hover:text-red-800"
                  data-testid="em-attendee-remove-all"
                >
                  <Trash2 className="w-4 h-4" />
                  {t("executiveMeetings.manage.attendees.removeAll")}
                </Button>
              )}
              <Button
                size="sm"
                variant="outline"
                onClick={addSubheading}
                className="gap-1"
                data-testid="em-attendee-add-subheading"
              >
                <Plus className="w-4 h-4" />
                {t("executiveMeetings.manage.attendees.addSubheading")}
              </Button>
              <Button
                size="sm"
                variant="outline"
                onClick={addAttendee}
                className="gap-1"
                data-testid="em-attendee-add"
              >
                <Plus className="w-4 h-4" />
                {t("executiveMeetings.manage.attendees.add")}
              </Button>
            </div>
          </div>
          <div className="space-y-2">
            {/* Drag-and-drop reordering wraps the whole list. The
                up/down arrow buttons inside each row continue to work
                in parallel — DnD is an additive convenience, not a
                replacement, so keyboard-only flows keep functioning
                even if the SortableContext fails to mount. */}
            <DndContext
              sensors={dragSensors}
              collisionDetection={closestCenter}
              onDragEnd={(e: DragEndEvent) => {
                const { active, over } = e;
                if (!over) return;
                reorderAttendeesByDrag(
                  String(active.id),
                  String(over.id),
                );
              }}
            >
              <SortableContext
                items={sortableIds}
                strategy={verticalListSortingStrategy}
              >
                {state.attendees.map((a, i) => (
                  <SortableAttendeeRow
                    key={a._sid ?? `__missing-sid-${i}`}
                    attendee={a}
                    index={i}
                    last={i === state.attendees.length - 1}
                    t={t}
                    onMoveUp={() => moveAttendee(i, -1)}
                    onMoveDown={() => moveAttendee(i, 1)}
                    onChangeName={(v) => setAttendee(i, { name: v })}
                    onChangeTitle={(v) =>
                      setAttendee(i, { title: v || null })
                    }
                    onChangeAttendanceType={(v) =>
                      setAttendee(i, { attendanceType: v })
                    }
                    onRemove={() => removeAttendee(i)}
                  />
                ))}
              </SortableContext>
            </DndContext>
            {state.attendees.length === 0 && (
              <div className="text-xs text-gray-500">—</div>
            )}
          </div>
        </div>

        <DialogFooter>
          <Button variant="ghost" onClick={onClose} disabled={saving}>
            {t("executiveMeetings.common.cancel")}
          </Button>
          <Button onClick={onSave} disabled={saving} className="bg-[#0B1E3F]" data-testid="em-form-save">
            {saving ? t("common.loading") : t("executiveMeetings.common.save")}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

function FormRow({
  label,
  children,
  full,
  hint,
}: {
  label: string;
  children: React.ReactNode;
  full?: boolean;
  hint?: string;
}) {
  return (
    <div className={full ? "sm:col-span-2" : ""}>
      <Label className="text-xs text-gray-700">{label}</Label>
      <div className="mt-1">{children}</div>
      {hint && <p className="text-[11px] text-gray-500 mt-0.5">{hint}</p>}
    </div>
  );
}

// ============ REQUESTS ============

function RequestsSection({
  me,
  isRtl,
  t,
}: {
  me: MeRoles;
  isRtl: boolean;
  t: (k: string) => string;
}) {
  const qc = useQueryClient();
  const { toast } = useToast();
  const [filterStatus, setFilterStatus] = useState<string>("all");
  const [scope, setScope] = useState<"all" | "mine">(
    me.canApprove ? "all" : "mine",
  );
  const [open, setOpen] = useState(false);
  const [submitting, setSubmitting] = useState(false);
  const [form, setForm] = useState<{
    requestType: string;
    meetingId: string;
    details: string;
    meetingDate: string;
    startTime: string;
    endTime: string;
    location: string;
    meetingUrl: string;
    platform: string;
    attendeeName: string;
    attendeeTitle: string;
    attendeeType: string;
    attendeeId: string;
  }>({
    requestType: "edit",
    meetingId: "",
    details: "",
    meetingDate: "",
    startTime: "",
    endTime: "",
    location: "",
    meetingUrl: "",
    platform: "none",
    attendeeName: "",
    attendeeTitle: "",
    attendeeType: "internal",
    attendeeId: "",
  });

  const queryKey = ["/api/executive-meetings/requests", filterStatus, scope];
  const { data, isLoading } = useQuery<{ requests: RequestRow[] }>({
    queryKey,
    queryFn: () => {
      const qs = new URLSearchParams();
      if (filterStatus !== "all") qs.set("status", filterStatus);
      if (scope === "mine") qs.set("mine", "1");
      return apiJson(`/api/executive-meetings/requests?${qs}`);
    },
  });

  function buildRequestDetails(): Record<string, unknown> {
    const note = form.details.trim();
    const out: Record<string, unknown> = {};
    if (note) out.note = note;
    switch (form.requestType) {
      case "reschedule":
        if (form.meetingDate) out.meetingDate = form.meetingDate;
        if (form.startTime) out.startTime = form.startTime;
        if (form.endTime) out.endTime = form.endTime;
        break;
      case "change_location":
        if (form.location.trim()) out.location = form.location.trim();
        if (form.meetingUrl.trim()) out.meetingUrl = form.meetingUrl.trim();
        if (form.platform) out.platform = form.platform;
        break;
      case "add_attendee":
        if (form.attendeeName.trim()) out.name = form.attendeeName.trim();
        if (form.attendeeTitle.trim()) out.title = form.attendeeTitle.trim();
        if (form.attendeeType) out.attendanceType = form.attendeeType;
        break;
      case "remove_attendee":
        if (form.attendeeId.trim()) out.attendeeId = Number(form.attendeeId);
        break;
      default:
        break;
    }
    return out;
  }

  async function submit() {
    if (!me.canSubmitRequest) return;
    setSubmitting(true);
    try {
      const body: Record<string, unknown> = {
        requestType: form.requestType,
        requestDetails: buildRequestDetails(),
      };
      if (form.meetingId.trim()) body.meetingId = Number(form.meetingId);
      await apiJson(`/api/executive-meetings/requests`, { method: "POST", body });
      toast({ title: t("executiveMeetings.requests.submitted") });
      setOpen(false);
      setForm({
        requestType: "edit",
        meetingId: "",
        details: "",
        meetingDate: "",
        startTime: "",
        endTime: "",
        location: "",
        meetingUrl: "",
        platform: "none",
        attendeeName: "",
        attendeeTitle: "",
        attendeeType: "internal",
        attendeeId: "",
      });
      await qc.invalidateQueries({ queryKey: ["/api/executive-meetings/requests"] });
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      toast({
        title: t("executiveMeetings.requests.submitFailed"),
        description: msg,
        variant: "destructive",
      });
    } finally {
      setSubmitting(false);
    }
  }

  async function withdraw(id: number) {
    try {
      await apiJson(`/api/executive-meetings/requests/${id}`, {
        method: "PATCH",
        body: { action: "withdraw" },
      });
      await qc.invalidateQueries({ queryKey: ["/api/executive-meetings/requests"] });
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      toast({ title: msg, variant: "destructive" });
    }
  }

  const requests = data?.requests ?? [];

  return (
    <div className="space-y-4">
      <div className="flex items-center justify-between gap-3 flex-wrap">
        <h2 className="text-xl sm:text-2xl font-bold text-[#0B1E3F]">
          {t("executiveMeetings.requests.heading")}
        </h2>
        <div className="flex items-center gap-2 flex-wrap">
          <Select value={scope} onValueChange={(v) => setScope(v as "all" | "mine")}>
            <SelectTrigger className="w-40 h-9">
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="all">{t("executiveMeetings.requests.allRequests")}</SelectItem>
              <SelectItem value="mine">{t("executiveMeetings.requests.myRequests")}</SelectItem>
            </SelectContent>
          </Select>
          <Select value={filterStatus} onValueChange={setFilterStatus}>
            <SelectTrigger className="w-36 h-9">
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="all">{t("executiveMeetings.common.all")}</SelectItem>
              {(
                [
                  "new",
                  "needs_edit",
                  "approved",
                  "rejected",
                  "withdrawn",
                  "done",
                ] as const
              ).map((s) => (
                <SelectItem key={s} value={s}>
                  {t(`executiveMeetings.requests.status.${s}`)}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
          {me.canSubmitRequest && (
            <Button
              onClick={() => setOpen(true)}
              className="bg-[#0B1E3F] gap-1"
              data-testid="em-new-request"
            >
              <Plus className="w-4 h-4" />
              {t("executiveMeetings.requests.newRequest")}
            </Button>
          )}
        </div>
      </div>

      <div className="bg-white border border-gray-200 rounded-md overflow-hidden">
        <table className="w-full text-sm" dir={isRtl ? "rtl" : "ltr"}>
          <thead className="bg-gray-50 text-[#0B1E3F]">
            <tr>
              <th className="px-3 py-2 text-start w-32">{t("executiveMeetings.requests.requestedAt")}</th>
              <th className="px-3 py-2 text-start">{t("executiveMeetings.requests.requestedBy")}</th>
              <th className="px-3 py-2 text-start w-32">{t("executiveMeetings.manage.field.status")}</th>
              <th className="px-3 py-2 text-start">{t("executiveMeetings.requests.type.create")}</th>
              <th className="px-3 py-2 text-start">{t("executiveMeetings.requests.details")}</th>
              <th className="px-3 py-2 text-end w-24">{t("executiveMeetings.common.actions")}</th>
            </tr>
          </thead>
          <tbody>
            {isLoading && (
              <tr>
                <td colSpan={6} className="py-8 text-center text-gray-500">
                  {t("common.loading")}
                </td>
              </tr>
            )}
            {!isLoading && requests.length === 0 && (
              <tr>
                <td colSpan={6} className="py-8 text-center text-gray-500">
                  {t("executiveMeetings.requests.empty")}
                </td>
              </tr>
            )}
            {requests.map((r) => (
              <RequestListRow
                key={r.id}
                row={r}
                isRtl={isRtl}
                t={t}
                canWithdraw={me.canSubmitRequest}
                onWithdraw={withdraw}
              />
            ))}
          </tbody>
        </table>
      </div>

      <Dialog open={open} onOpenChange={setOpen}>
        <DialogContent>
          <DialogHeader>
            <DialogTitle>{t("executiveMeetings.requests.newRequest")}</DialogTitle>
          </DialogHeader>
          <div className="space-y-3">
            <FormRow label={t("executiveMeetings.requests.type.create")}>
              <Select
                value={form.requestType}
                onValueChange={(v) => setForm({ ...form, requestType: v })}
              >
                <SelectTrigger>
                  <SelectValue />
                </SelectTrigger>
                <SelectContent>
                  {(
                    [
                      "create",
                      "edit",
                      "delete",
                      "reschedule",
                      "add_attendee",
                      "remove_attendee",
                      "change_location",
                      "cancel_meeting",
                      "note",
                      "highlight",
                      "unhighlight",
                      "other",
                    ] as const
                  ).map((tp) => (
                    <SelectItem key={tp} value={tp}>
                      {t(`executiveMeetings.requests.type.${tp}`)}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </FormRow>
            <FormRow label={t("executiveMeetings.requests.submitFor")}>
              <Input
                type="number"
                placeholder={t("executiveMeetings.requests.noTargetMeeting")}
                value={form.meetingId}
                onChange={(e) => setForm({ ...form, meetingId: e.target.value })}
              />
            </FormRow>
            {form.requestType === "reschedule" && (
              <>
                <FormRow label={t("executiveMeetings.manage.field.meetingDate")}>
                  <Input
                    type="date"
                    value={form.meetingDate}
                    onChange={(e) =>
                      setForm({ ...form, meetingDate: e.target.value })
                    }
                    data-testid="em-req-date"
                  />
                </FormRow>
                <div className="grid grid-cols-2 gap-3">
                  <FormRow label={t("executiveMeetings.manage.field.startTime")}>
                    <Input
                      type="time"
                      value={form.startTime}
                      onChange={(e) =>
                        setForm({ ...form, startTime: e.target.value })
                      }
                      data-testid="em-req-start"
                    />
                  </FormRow>
                  <FormRow label={t("executiveMeetings.manage.field.endTime")}>
                    <Input
                      type="time"
                      value={form.endTime}
                      onChange={(e) =>
                        setForm({ ...form, endTime: e.target.value })
                      }
                      data-testid="em-req-end"
                    />
                  </FormRow>
                </div>
              </>
            )}
            {form.requestType === "change_location" && (
              <>
                <FormRow label={t("executiveMeetings.manage.field.location")}>
                  <Input
                    value={form.location}
                    onChange={(e) =>
                      setForm({ ...form, location: e.target.value })
                    }
                  />
                </FormRow>
                <FormRow label={t("executiveMeetings.manage.field.meetingUrl")}>
                  <Input
                    value={form.meetingUrl}
                    onChange={(e) =>
                      setForm({ ...form, meetingUrl: e.target.value })
                    }
                  />
                </FormRow>
              </>
            )}
            {form.requestType === "add_attendee" && (
              <>
                <FormRow label={t("executiveMeetings.manage.attendees.name")}>
                  <Input
                    value={form.attendeeName}
                    onChange={(e) =>
                      setForm({ ...form, attendeeName: e.target.value })
                    }
                  />
                </FormRow>
                <FormRow label={t("executiveMeetings.manage.attendees.title")}>
                  <Input
                    value={form.attendeeTitle}
                    onChange={(e) =>
                      setForm({ ...form, attendeeTitle: e.target.value })
                    }
                  />
                </FormRow>
              </>
            )}
            {form.requestType === "remove_attendee" && (
              <FormRow label={t("executiveMeetings.manage.attendees.id")}>
                <Input
                  type="number"
                  value={form.attendeeId}
                  onChange={(e) =>
                    setForm({ ...form, attendeeId: e.target.value })
                  }
                />
              </FormRow>
            )}
            <FormRow label={t("executiveMeetings.requests.details")}>
              <Textarea
                rows={3}
                value={form.details}
                placeholder={t("executiveMeetings.requests.detailsPlaceholder")}
                onChange={(e) => setForm({ ...form, details: e.target.value })}
              />
            </FormRow>
          </div>
          <DialogFooter>
            <Button variant="ghost" onClick={() => setOpen(false)} disabled={submitting}>
              {t("executiveMeetings.common.cancel")}
            </Button>
            <Button
              onClick={submit}
              disabled={submitting}
              className="bg-[#0B1E3F]"
              data-testid="em-submit-request"
            >
              {t("executiveMeetings.common.submit")}
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </div>
  );
}

function RequestListRow({
  row,
  isRtl,
  t,
  canWithdraw,
  onWithdraw,
}: {
  row: RequestRow;
  isRtl: boolean;
  t: (k: string) => string;
  canWithdraw: boolean;
  onWithdraw: (id: number) => void;
}) {
  const note =
    row.requestDetails && typeof row.requestDetails === "object"
      ? (row.requestDetails as { note?: string }).note ?? ""
      : "";
  const requesterName = row.requester
    ? isRtl
      ? row.requester.displayNameAr || row.requester.displayNameEn || row.requester.username
      : row.requester.displayNameEn || row.requester.displayNameAr || row.requester.username
    : "—";
  const showWithdraw = canWithdraw && row.status === "new";
  return (
    <tr className="border-t border-gray-100">
      <td className="px-3 py-2 align-middle text-xs text-gray-600">
        {new Date(row.createdAt).toLocaleString()}
      </td>
      <td className="px-3 py-2 align-middle">{requesterName}</td>
      <td className="px-3 py-2 align-middle text-xs">
        {t(`executiveMeetings.requests.status.${row.status}`)}
      </td>
      <td className="px-3 py-2 align-middle text-xs">
        {t(`executiveMeetings.requests.type.${row.requestType}`)}
        {row.meetingId ? <span className="text-gray-500"> · #{row.meetingId}</span> : null}
      </td>
      <td className="px-3 py-2 align-middle text-xs whitespace-pre-wrap break-words max-w-md">
        {note}
        {row.reviewNotes && (
          <div className="mt-1 text-gray-500 italic">
            ↳ {row.reviewNotes}
          </div>
        )}
      </td>
      <td className="px-3 py-2 align-middle text-end">
        {showWithdraw && (
          <Button size="sm" variant="ghost" onClick={() => onWithdraw(row.id)}>
            <X className="w-4 h-4" />
          </Button>
        )}
      </td>
    </tr>
  );
}

// ============ APPROVALS ============

function ApprovalsSection({
  canApprove,
  isRtl,
  t,
}: {
  canApprove: boolean;
  isRtl: boolean;
  t: (k: string) => string;
}) {
  const qc = useQueryClient();
  const { toast } = useToast();
  const [notes, setNotes] = useState<Record<number, string>>({});
  const { data, isLoading } = useQuery<{ requests: RequestRow[] }>({
    queryKey: ["/api/executive-meetings/requests", "new", "all"],
    queryFn: () => apiJson(`/api/executive-meetings/requests?status=new`),
  });
  const requests = data?.requests ?? [];

  async function review(
    id: number,
    status: "approved" | "rejected" | "needs_edit",
  ) {
    try {
      await apiJson(`/api/executive-meetings/requests/${id}`, {
        method: "PATCH",
        body: { status, reviewNotes: notes[id] ?? "" },
      });
      toast({
        title:
          status === "approved"
            ? t("executiveMeetings.approvals.approved")
            : status === "rejected"
              ? t("executiveMeetings.approvals.rejected")
              : t("executiveMeetings.approvals.needsEditDone"),
      });
      await Promise.all([
        qc.invalidateQueries({ queryKey: ["/api/executive-meetings/requests"] }),
        qc.invalidateQueries({ queryKey: ["/api/executive-meetings/tasks"] }),
        qc.invalidateQueries({
          predicate: (q) =>
            Array.isArray(q.queryKey) &&
            q.queryKey[0] === "/api/executive-meetings",
        }),
      ]);
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      toast({
        title: t("executiveMeetings.approvals.actionFailed"),
        description: msg,
        variant: "destructive",
      });
    }
  }

  if (!canApprove) return <NoPermission t={t} />;

  return (
    <div className="space-y-4">
      <h2 className="text-xl sm:text-2xl font-bold text-[#0B1E3F]">
        {t("executiveMeetings.approvals.heading")}
      </h2>
      {isLoading && <div className="text-center text-gray-500 py-8">{t("common.loading")}</div>}
      {!isLoading && requests.length === 0 && (
        <div className="bg-white border border-gray-200 rounded-md p-10 text-center text-gray-500">
          {t("executiveMeetings.approvals.empty")}
        </div>
      )}
      {requests.map((r) => {
        const note =
          r.requestDetails && typeof r.requestDetails === "object"
            ? (r.requestDetails as { note?: string }).note ?? ""
            : "";
        const requesterName = r.requester
          ? isRtl
            ? r.requester.displayNameAr || r.requester.username
            : r.requester.displayNameEn || r.requester.username
          : "—";
        return (
          <div
            key={r.id}
            className="bg-white border border-gray-200 rounded-md p-4 space-y-3"
            data-testid={`em-approval-${r.id}`}
          >
            <div className="flex justify-between gap-3 flex-wrap">
              <div className="text-sm">
                <div className="font-semibold text-[#0B1E3F]">
                  {t(`executiveMeetings.requests.type.${r.requestType}`)}
                  {r.meetingId ? <span className="text-gray-500"> · #{r.meetingId}</span> : null}
                </div>
                <div className="text-xs text-gray-500">
                  {requesterName} · {new Date(r.createdAt).toLocaleString()}
                </div>
              </div>
            </div>
            <div className="text-sm whitespace-pre-wrap text-gray-700">{note || "—"}</div>
            <Textarea
              placeholder={t("executiveMeetings.approvals.reviewNotesPlaceholder")}
              rows={2}
              value={notes[r.id] ?? ""}
              onChange={(e) => setNotes({ ...notes, [r.id]: e.target.value })}
            />
            <div className="flex gap-2 justify-end flex-wrap">
              <Button
                variant="outline"
                onClick={() => review(r.id, "needs_edit")}
                className="gap-1"
                data-testid={`em-needs-edit-${r.id}`}
              >
                {t("executiveMeetings.approvals.needsEdit")}
              </Button>
              <Button
                variant="outline"
                onClick={() => review(r.id, "rejected")}
                className="gap-1"
                data-testid={`em-reject-${r.id}`}
              >
                <X className="w-4 h-4" />
                {t("executiveMeetings.approvals.reject")}
              </Button>
              <Button
                onClick={() => review(r.id, "approved")}
                className="bg-[#0B1E3F] gap-1"
                data-testid={`em-approve-${r.id}`}
              >
                <Check className="w-4 h-4" />
                {t("executiveMeetings.approvals.approve")}
              </Button>
            </div>
          </div>
        );
      })}
    </div>
  );
}

// ============ TASKS ============

function TasksSection({
  canMutate,
  currentUserId,
  canViewAllTasks,
  isRtl,
  t,
}: {
  canMutate: boolean;
  currentUserId: number;
  canViewAllTasks: boolean;
  isRtl: boolean;
  t: (k: string) => string;
}) {
  const qc = useQueryClient();
  const { toast } = useToast();
  const [open, setOpen] = useState(false);
  const [form, setForm] = useState({
    taskType: "",
    meetingId: "",
    assignedTo: "",
    dueAt: "",
    notes: "",
  });
  const [filterStatus, setFilterStatus] = useState<string>("all");

  const { data, isLoading } = useQuery<{ tasks: TaskRow[] }>({
    queryKey: ["/api/executive-meetings/tasks", filterStatus],
    queryFn: () => {
      const qs = new URLSearchParams();
      if (filterStatus !== "all") qs.set("status", filterStatus);
      return apiJson(`/api/executive-meetings/tasks?${qs}`);
    },
  });
  const tasks = data?.tasks ?? [];

  async function createTask() {
    if (!form.taskType.trim()) return;
    try {
      const body: Record<string, unknown> = { taskType: form.taskType.trim() };
      if (form.meetingId) body.meetingId = Number(form.meetingId);
      if (form.assignedTo) body.assignedTo = Number(form.assignedTo);
      if (form.dueAt) body.dueAt = form.dueAt;
      if (form.notes) body.notes = form.notes;
      await apiJson(`/api/executive-meetings/tasks`, { method: "POST", body });
      toast({ title: t("executiveMeetings.tasks.saved") });
      setOpen(false);
      setForm({ taskType: "", meetingId: "", assignedTo: "", dueAt: "", notes: "" });
      await qc.invalidateQueries({ queryKey: ["/api/executive-meetings/tasks"] });
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      toast({ title: msg, variant: "destructive" });
    }
  }

  async function setStatus(id: number, status: TaskRow["status"]) {
    try {
      await apiJson(`/api/executive-meetings/tasks/${id}`, {
        method: "PATCH",
        body: { status },
      });
      await qc.invalidateQueries({ queryKey: ["/api/executive-meetings/tasks"] });
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      toast({ title: msg, variant: "destructive" });
    }
  }

  const [reassignTarget, setReassignTarget] = useState<TaskRow | null>(null);
  const [reassignForm, setReassignForm] = useState({ assignedTo: "", notes: "" });
  const [reassignBusy, setReassignBusy] = useState(false);

  function openReassign(task: TaskRow) {
    setReassignTarget(task);
    setReassignForm({
      assignedTo: task.assignedTo ? String(task.assignedTo) : "",
      notes: task.notes ?? "",
    });
  }

  async function submitReassign() {
    if (!reassignTarget) return;
    const body: Record<string, unknown> = {};
    const newAssignee = reassignForm.assignedTo.trim();
    if (newAssignee) body.assignedTo = Number(newAssignee);
    if (reassignForm.notes !== (reassignTarget.notes ?? "")) {
      body.notes = reassignForm.notes;
    }
    if (Object.keys(body).length === 0) {
      setReassignTarget(null);
      return;
    }
    setReassignBusy(true);
    try {
      await apiJson(`/api/executive-meetings/tasks/${reassignTarget.id}`, {
        method: "PATCH",
        body,
      });
      toast({ title: t("executiveMeetings.tasks.reassigned") });
      setReassignTarget(null);
      await qc.invalidateQueries({ queryKey: ["/api/executive-meetings/tasks"] });
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      toast({ title: msg, variant: "destructive" });
    } finally {
      setReassignBusy(false);
    }
  }

  async function deleteTask(id: number) {
    if (!window.confirm(t("executiveMeetings.tasks.deleteConfirm"))) return;
    try {
      await apiJson(`/api/executive-meetings/tasks/${id}`, { method: "DELETE" });
      await qc.invalidateQueries({ queryKey: ["/api/executive-meetings/tasks"] });
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      toast({ title: msg, variant: "destructive" });
    }
  }

  return (
    <div className="space-y-4">
      <div className="flex items-center justify-between gap-3 flex-wrap">
        <h2 className="text-xl sm:text-2xl font-bold text-[#0B1E3F]">
          {t("executiveMeetings.tasks.heading")}
          {!canViewAllTasks && (
            <span
              className="ms-2 inline-block rounded-full bg-gray-100 text-gray-700 text-xs px-2 py-0.5 align-middle font-normal"
              data-testid="em-tasks-mine-badge"
              title={t("executiveMeetings.tasks.myTasksOnlyHelp")}
            >
              {t("executiveMeetings.tasks.myTasksOnly")}
            </span>
          )}
        </h2>
        <div className="flex items-center gap-2">
          <Select value={filterStatus} onValueChange={setFilterStatus}>
            <SelectTrigger className="w-36 h-9">
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="all">{t("executiveMeetings.common.all")}</SelectItem>
              {(["pending", "in_progress", "completed", "cancelled"] as const).map((s) => (
                <SelectItem key={s} value={s}>
                  {t(`executiveMeetings.tasks.status.${s}`)}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
          {canMutate && (
            <Button
              onClick={() => setOpen(true)}
              className="bg-[#0B1E3F] gap-1"
              data-testid="em-add-task"
            >
              <Plus className="w-4 h-4" />
              {t("executiveMeetings.tasks.addTask")}
            </Button>
          )}
        </div>
      </div>

      <div className="bg-white border border-gray-200 rounded-md overflow-hidden">
        <table className="w-full text-sm" dir={isRtl ? "rtl" : "ltr"}>
          <thead className="bg-gray-50 text-[#0B1E3F]">
            <tr>
              <th className="px-3 py-2 text-start">{t("executiveMeetings.tasks.field.taskType")}</th>
              <th className="px-3 py-2 text-start w-28">{t("executiveMeetings.tasks.field.meeting")}</th>
              <th className="px-3 py-2 text-start">{t("executiveMeetings.tasks.field.assignee")}</th>
              <th className="px-3 py-2 text-start w-32">{t("executiveMeetings.tasks.field.dueDate")}</th>
              <th className="px-3 py-2 text-start w-32">{t("executiveMeetings.tasks.field.status")}</th>
              <th className="px-3 py-2 text-end w-44">{t("executiveMeetings.common.actions")}</th>
            </tr>
          </thead>
          <tbody>
            {isLoading && (
              <tr>
                <td colSpan={6} className="py-8 text-center text-gray-500">
                  {t("common.loading")}
                </td>
              </tr>
            )}
            {!isLoading && tasks.length === 0 && (
              <tr>
                <td colSpan={6} className="py-8 text-center text-gray-500">
                  {t("executiveMeetings.tasks.empty")}
                </td>
              </tr>
            )}
            {tasks.map((task) => {
              const assignee = task.assignee
                ? isRtl
                  ? task.assignee.displayNameAr || task.assignee.username
                  : task.assignee.displayNameEn || task.assignee.username
                : "—";
              return (
                <tr key={task.id} className="border-t border-gray-100">
                  <td className="px-3 py-2 align-middle">{task.taskType}</td>
                  <td className="px-3 py-2 align-middle text-xs text-gray-600">
                    {task.meetingId ? `#${task.meetingId}` : "—"}
                  </td>
                  <td className="px-3 py-2 align-middle">{assignee}</td>
                  <td className="px-3 py-2 align-middle text-xs">
                    {task.dueAt ? new Date(task.dueAt).toLocaleDateString() : "—"}
                  </td>
                  <td className="px-3 py-2 align-middle text-xs">
                    {t(`executiveMeetings.tasks.status.${task.status}`)}
                  </td>
                  <td className="px-3 py-2 align-middle text-end">
                    <div className="inline-flex gap-1">
                      {(canMutate || task.assignedTo === currentUserId) && (
                        <>
                          {task.status !== "completed" && (
                            <Button
                              size="sm"
                              variant="ghost"
                              onClick={() => setStatus(task.id, "completed")}
                              title={t("executiveMeetings.tasks.markCompleted")}
                            >
                              <Check className="w-4 h-4" />
                            </Button>
                          )}
                          {task.status === "pending" && (
                            <Button
                              size="sm"
                              variant="ghost"
                              onClick={() => setStatus(task.id, "in_progress")}
                              title={t("executiveMeetings.tasks.markInProgress")}
                            >
                              <ListTodo className="w-4 h-4" />
                            </Button>
                          )}
                        </>
                      )}
                      {canMutate && (
                        <>
                          <Button
                            size="sm"
                            variant="ghost"
                            onClick={() => openReassign(task)}
                            title={t("executiveMeetings.tasks.reassign")}
                            data-testid={`em-task-reassign-${task.id}`}
                          >
                            <Pencil className="w-4 h-4" />
                          </Button>
                          <Button
                            size="sm"
                            variant="ghost"
                            onClick={() => deleteTask(task.id)}
                          >
                            <Trash2 className="w-4 h-4 text-red-600" />
                          </Button>
                        </>
                      )}
                    </div>
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>

      <Dialog
        open={!!reassignTarget}
        onOpenChange={(v) => !v && setReassignTarget(null)}
      >
        <DialogContent>
          <DialogHeader>
            <DialogTitle>{t("executiveMeetings.tasks.reassign")}</DialogTitle>
          </DialogHeader>
          <div className="space-y-3">
            <FormRow label={t("executiveMeetings.tasks.field.assignee")}>
              <Input
                type="number"
                value={reassignForm.assignedTo}
                onChange={(e) =>
                  setReassignForm({ ...reassignForm, assignedTo: e.target.value })
                }
                data-testid="em-task-reassign-assignee"
              />
            </FormRow>
            <FormRow label={t("executiveMeetings.tasks.field.notes")}>
              <Textarea
                rows={3}
                value={reassignForm.notes}
                onChange={(e) =>
                  setReassignForm({ ...reassignForm, notes: e.target.value })
                }
                data-testid="em-task-reassign-notes"
              />
            </FormRow>
          </div>
          <DialogFooter>
            <Button variant="outline" onClick={() => setReassignTarget(null)}>
              {t("common.cancel")}
            </Button>
            <Button
              onClick={submitReassign}
              disabled={reassignBusy}
              data-testid="em-task-reassign-save"
            >
              {t("common.save")}
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>

      <Dialog open={open} onOpenChange={setOpen}>
        <DialogContent>
          <DialogHeader>
            <DialogTitle>{t("executiveMeetings.tasks.addTask")}</DialogTitle>
          </DialogHeader>
          <div className="space-y-3">
            <FormRow label={t("executiveMeetings.tasks.field.taskType")}>
              <Input
                value={form.taskType}
                onChange={(e) => setForm({ ...form, taskType: e.target.value })}
                data-testid="em-task-type"
              />
            </FormRow>
            <div className="grid grid-cols-2 gap-3">
              <FormRow label={t("executiveMeetings.tasks.field.meeting")}>
                <Input
                  type="number"
                  value={form.meetingId}
                  onChange={(e) => setForm({ ...form, meetingId: e.target.value })}
                />
              </FormRow>
              <FormRow label={t("executiveMeetings.tasks.field.assignee")}>
                <Input
                  type="number"
                  placeholder={t("executiveMeetings.tasks.field.assigneeIdPlaceholder")}
                  value={form.assignedTo}
                  onChange={(e) => setForm({ ...form, assignedTo: e.target.value })}
                />
              </FormRow>
            </div>
            <FormRow label={t("executiveMeetings.tasks.field.dueDate")}>
              <Input
                type="date"
                value={form.dueAt}
                onChange={(e) => setForm({ ...form, dueAt: e.target.value })}
              />
            </FormRow>
            <FormRow label={t("executiveMeetings.tasks.field.notes")}>
              <Textarea
                rows={3}
                value={form.notes}
                onChange={(e) => setForm({ ...form, notes: e.target.value })}
              />
            </FormRow>
          </div>
          <DialogFooter>
            <Button variant="ghost" onClick={() => setOpen(false)}>
              {t("executiveMeetings.common.cancel")}
            </Button>
            <Button onClick={createTask} className="bg-[#0B1E3F]" data-testid="em-task-save">
              {t("executiveMeetings.common.save")}
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </div>
  );
}

// ============ AUDIT ============

function AuditDiffSummary({
  oldValue,
  newValue,
  t,
}: {
  oldValue: unknown;
  newValue: unknown;
  t: (k: string) => string;
}) {
  const interesting = [
    "titleAr",
    "titleEn",
    "meetingDate",
    "dailyNumber",
    "startTime",
    "endTime",
    "location",
    "meetingUrl",
    "platform",
    "status",
    "isHighlighted",
    "notes",
    "assignedTo",
    "taskType",
    "dueAt",
    "reviewDecision",
    "reviewNotes",
  ] as const;

  const oldObj =
    oldValue && typeof oldValue === "object"
      ? (oldValue as Record<string, unknown>)
      : null;
  const newObj =
    newValue && typeof newValue === "object"
      ? (newValue as Record<string, unknown>)
      : null;

  const fmt = (v: unknown): string => {
    if (v === null || v === undefined || v === "") return "—";
    if (typeof v === "string") return v.length > 40 ? `${v.slice(0, 40)}…` : v;
    if (typeof v === "number" || typeof v === "boolean") return String(v);
    try {
      const s = JSON.stringify(v);
      return s.length > 40 ? `${s.slice(0, 40)}…` : s;
    } catch {
      return String(v);
    }
  };

  if (!oldObj && !newObj) return <span className="text-gray-400">—</span>;

  if (oldObj && newObj) {
    const diffs: { key: string; from: unknown; to: unknown }[] = [];
    for (const k of interesting) {
      const a = oldObj[k];
      const b = newObj[k];
      if (JSON.stringify(a) !== JSON.stringify(b)) {
        diffs.push({ key: k, from: a, to: b });
      }
    }
    if (diffs.length === 0) {
      return <span className="text-gray-400">—</span>;
    }
    return (
      <ul className="space-y-0.5">
        {diffs.slice(0, 6).map((d) => (
          <li key={d.key} className="leading-snug">
            <span className="font-semibold text-[#0B1E3F]">{d.key}</span>:{" "}
            <span className="text-red-700 line-through">{fmt(d.from)}</span>
            <span className="mx-1 text-gray-400">→</span>
            <span className="text-green-700">{fmt(d.to)}</span>
          </li>
        ))}
        {diffs.length > 6 && (
          <li className="text-gray-500">
            +{diffs.length - 6} {t("executiveMeetings.audit.moreChanges")}
          </li>
        )}
      </ul>
    );
  }

  const single = (newObj ?? oldObj) as Record<string, unknown>;
  const label = newObj
    ? t("executiveMeetings.audit.created")
    : t("executiveMeetings.audit.removed");
  const items = interesting
    .filter((k) => single[k] !== undefined && single[k] !== null && single[k] !== "")
    .slice(0, 6);
  return (
    <div>
      <div className="font-semibold text-[#0B1E3F] mb-0.5">{label}</div>
      <ul className="space-y-0.5">
        {items.map((k) => (
          <li key={k} className="leading-snug">
            <span className="font-semibold">{k}</span>: {fmt(single[k])}
          </li>
        ))}
      </ul>
    </div>
  );
}


function AuditSection({
  canView,
  isRtl,
  t,
}: {
  canView: boolean;
  isRtl: boolean;
  t: (k: string) => string;
}) {
  const [date, setDate] = useState("");
  const [dateTo, setDateTo] = useState("");
  const [entityType, setEntityType] = useState("all");
  const [actionFilter, setActionFilter] = useState("all");
  const [actorFilter, setActorFilter] = useState("");
  const [page, setPage] = useState(0);
  const PAGE_SIZE = 50;
  useEffect(() => {
    setPage(0);
  }, [date, dateTo, entityType, actionFilter, actorFilter]);
  const { data, isLoading } = useQuery<{
    entries: AuditEntry[];
    total: number;
    limit: number;
    offset: number;
    hasMore: boolean;
  }>({
    queryKey: [
      "/api/executive-meetings/audit-logs",
      date,
      dateTo,
      entityType,
      actionFilter,
      actorFilter,
      page,
    ],
    queryFn: () => {
      const qs = new URLSearchParams();
      if (date) qs.set("dateFrom", date);
      if (dateTo) qs.set("dateTo", dateTo);
      if (entityType !== "all") qs.set("entityType", entityType);
      if (actionFilter !== "all") qs.set("action", actionFilter);
      if (actorFilter.trim()) qs.set("actorId", actorFilter.trim());
      qs.set("limit", String(PAGE_SIZE));
      qs.set("offset", String(page * PAGE_SIZE));
      return apiJson(`/api/executive-meetings/audit-logs?${qs}`);
    },
    enabled: canView,
  });

  if (!canView) return <NoPermission t={t} />;
  const entries = data?.entries ?? [];

  return (
    <div className="space-y-4">
      <div className="flex items-center justify-between gap-3 flex-wrap">
        <h2 className="text-xl sm:text-2xl font-bold text-[#0B1E3F]">
          {t("executiveMeetings.audit.heading")}
        </h2>
        <div className="flex items-center gap-2 flex-wrap">
          <label className="text-xs text-gray-500 flex items-center gap-1">
            {t("executiveMeetings.audit.filter.from")}
            <input
              type="date"
              value={date}
              onChange={(e) => setDate(e.target.value)}
              className="border border-gray-300 rounded px-2 py-1 text-sm bg-white h-9"
              data-testid="em-audit-date-from"
            />
          </label>
          <label className="text-xs text-gray-500 flex items-center gap-1">
            {t("executiveMeetings.audit.filter.to")}
            <input
              type="date"
              value={dateTo}
              onChange={(e) => setDateTo(e.target.value)}
              className="border border-gray-300 rounded px-2 py-1 text-sm bg-white h-9"
              data-testid="em-audit-date-to"
            />
          </label>
          <Input
            type="number"
            placeholder={t("executiveMeetings.audit.filter.actorId")}
            value={actorFilter}
            onChange={(e) => setActorFilter(e.target.value)}
            className="w-32 h-9"
            data-testid="em-audit-actor"
          />
          <Select value={actionFilter} onValueChange={setActionFilter}>
            <SelectTrigger className="w-36 h-9" data-testid="em-audit-action">
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="all">{t("executiveMeetings.audit.all")}</SelectItem>
              {(["create", "update", "delete", "approve", "reject"] as const).map(
                (a) => (
                  <SelectItem key={a} value={a}>
                    {tWithFallback(t, `executiveMeetings.audit.action.${a}`, a)}
                  </SelectItem>
                ),
              )}
            </SelectContent>
          </Select>
          <Select value={entityType} onValueChange={setEntityType}>
            <SelectTrigger className="w-36 h-9">
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="all">{t("executiveMeetings.audit.all")}</SelectItem>
              {(["meeting", "attendee", "request", "task", "font_settings"] as const).map(
                (e) => (
                  <SelectItem key={e} value={e}>
                    {t(`executiveMeetings.audit.entity.${e}`)}
                  </SelectItem>
                ),
              )}
            </SelectContent>
          </Select>
        </div>
      </div>

      <div className="bg-white border border-gray-200 rounded-md overflow-hidden">
        <table className="w-full text-sm" dir={isRtl ? "rtl" : "ltr"}>
          <thead className="bg-gray-50 text-[#0B1E3F]">
            <tr>
              <th className="px-3 py-2 text-start w-44">{t("executiveMeetings.audit.col.when")}</th>
              <th className="px-3 py-2 text-start">{t("executiveMeetings.audit.col.actor")}</th>
              <th className="px-3 py-2 text-start w-28">{t("executiveMeetings.audit.col.action")}</th>
              <th className="px-3 py-2 text-start w-28">{t("executiveMeetings.audit.col.entity")}</th>
              <th className="px-3 py-2 text-start w-20">{t("executiveMeetings.audit.col.entityId")}</th>
              <th className="px-3 py-2 text-start">{t("executiveMeetings.audit.col.changes")}</th>
            </tr>
          </thead>
          <tbody>
            {isLoading && (
              <tr>
                <td colSpan={6} className="py-8 text-center text-gray-500">
                  {t("common.loading")}
                </td>
              </tr>
            )}
            {!isLoading && entries.length === 0 && (
              <tr>
                <td colSpan={6} className="py-8 text-center text-gray-500">
                  {t("executiveMeetings.audit.empty")}
                </td>
              </tr>
            )}
            {entries.map((e) => {
              const actor = e.actor
                ? isRtl
                  ? e.actor.displayNameAr || e.actor.username
                  : e.actor.displayNameEn || e.actor.username
                : "—";
              return (
                <tr
                  key={e.id}
                  className="border-t border-gray-100 align-top"
                  data-testid={`em-audit-row-${e.id}`}
                >
                  <td className="px-3 py-2 text-xs text-gray-600 whitespace-nowrap">
                    {new Date(e.performedAt).toLocaleString()}
                  </td>
                  <td className="px-3 py-2">{actor}</td>
                  <td className="px-3 py-2 text-xs">
                    {tWithFallback(
                      t,
                      `executiveMeetings.audit.action.${e.action}`,
                      e.action,
                    )}
                  </td>
                  <td className="px-3 py-2 text-xs">
                    {tWithFallback(
                      t,
                      `executiveMeetings.audit.entity.${e.entityType}`,
                      e.entityType,
                    )}
                  </td>
                  <td className="px-3 py-2 text-xs">{e.entityId ?? "—"}</td>
                  <td className="px-3 py-2 text-xs">
                    <AuditDiffSummary
                      oldValue={e.oldValue}
                      newValue={e.newValue}
                      t={t}
                    />
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
      <div className="flex items-center justify-between text-xs text-gray-600">
        <span data-testid="em-audit-count">
          {t("executiveMeetings.audit.pageInfo")}{" "}
          {data
            ? `${(data.offset ?? 0) + (entries.length > 0 ? 1 : 0)}–${
                (data.offset ?? 0) + entries.length
              } / ${data.total ?? 0}`
            : "—"}
        </span>
        <div className="flex gap-2">
          <Button
            size="sm"
            variant="outline"
            disabled={page === 0}
            onClick={() => setPage((p) => Math.max(0, p - 1))}
            data-testid="em-audit-prev"
          >
            {t("common.previous")}
          </Button>
          <Button
            size="sm"
            variant="outline"
            disabled={!data?.hasMore}
            onClick={() => setPage((p) => p + 1)}
            data-testid="em-audit-next"
          >
            {t("common.next")}
          </Button>
        </div>
      </div>
    </div>
  );
}

// ============ NOTIFICATIONS ============

function NotificationsSection({
  canView,
  date,
  isRtl,
  t,
}: {
  canView: boolean;
  date: string;
  isRtl: boolean;
  t: (k: string) => string;
}) {
  const { data, isLoading } = useQuery<{ notifications: NotificationRow[] }>({
    queryKey: ["/api/executive-meetings/notifications", date],
    queryFn: () =>
      apiJson(
        `/api/executive-meetings/notifications${
          date ? `?date=${encodeURIComponent(date)}` : ""
        }`,
      ),
    enabled: canView,
  });
  if (!canView) return <NoPermission t={t} />;
  const rows = data?.notifications ?? [];
  return (
    <div className="space-y-4">
      <h2 className="text-xl sm:text-2xl font-bold text-[#0B1E3F]">
        {t("executiveMeetings.notificationsPage.heading")}
      </h2>
      <p className="text-sm text-gray-600 max-w-2xl">
        {t("executiveMeetings.notificationsPage.intro")}
      </p>

      <NotificationPrefsCard isRtl={isRtl} t={t} />

      <div className="bg-white border border-gray-200 rounded-md overflow-hidden">
        <table className="w-full text-sm" dir={isRtl ? "rtl" : "ltr"}>
          <thead className="bg-gray-50 text-[#0B1E3F]">
            <tr>
              <th className="px-3 py-2 text-start w-24">{t("executiveMeetings.notificationsPage.col.meeting")}</th>
              <th className="px-3 py-2 text-start">{t("executiveMeetings.notificationsPage.col.user")}</th>
              <th className="px-3 py-2 text-start w-28">{t("executiveMeetings.notificationsPage.col.type")}</th>
              <th className="px-3 py-2 text-start w-44">{t("executiveMeetings.notificationsPage.col.scheduledAt")}</th>
              <th className="px-3 py-2 text-start w-24">{t("executiveMeetings.notificationsPage.col.status")}</th>
            </tr>
          </thead>
          <tbody>
            {isLoading && (
              <tr>
                <td colSpan={5} className="py-8 text-center text-gray-500">
                  {t("common.loading")}
                </td>
              </tr>
            )}
            {!isLoading && rows.length === 0 && (
              <tr>
                <td colSpan={5} className="py-8 text-center text-gray-500">
                  {t("executiveMeetings.notificationsPage.empty")}
                </td>
              </tr>
            )}
            {rows.map((n) => {
              const user = n.user
                ? isRtl
                  ? n.user.displayNameAr || n.user.username
                  : n.user.displayNameEn || n.user.username
                : "—";
              return (
                <tr key={n.id} className="border-t border-gray-100">
                  <td className="px-3 py-2">{n.meetingId ? `#${n.meetingId}` : "—"}</td>
                  <td className="px-3 py-2">{user}</td>
                  <td className="px-3 py-2 text-xs">
                    {tWithFallback(
                      t,
                      `executiveMeetings.notificationsPage.type.${n.notificationType}`,
                      n.notificationType,
                    )}
                  </td>
                  <td className="px-3 py-2 text-xs">
                    {n.scheduledAt ? new Date(n.scheduledAt).toLocaleString() : "—"}
                  </td>
                  <td className="px-3 py-2 text-xs">
                    {tWithFallback(
                      t,
                      `executiveMeetings.notificationsPage.status.${n.status}`,
                      n.status,
                    )}
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
    </div>
  );
}

type NotificationPref = {
  notificationType: string;
  inApp: boolean;
  email: boolean;
};

type NotificationPrefsResponse = {
  types: string[];
  prefs: NotificationPref[];
};

/**
 * Per-user notification preferences card. Toggles in-app vs email
 * delivery for each event type the Executive Meetings module fans out.
 *
 * Rendering rules:
 *   - One row per event type returned by the server (server is the
 *     source of truth for "which event types exist").
 *   - Both channels default ON; an explicit off toggle persists a
 *     mute-row server-side so the fan-out helpers skip the user.
 *   - Saves are batched: we accumulate edits in local state and flush
 *     them together via "Save preferences".
 */
function NotificationPrefsCard({
  isRtl,
  t,
}: {
  isRtl: boolean;
  t: (k: string) => string;
}) {
  const qc = useQueryClient();
  const { toast } = useToast();
  const { data, isLoading } = useQuery<NotificationPrefsResponse>({
    queryKey: ["/api/executive-meetings/notification-prefs"],
    queryFn: () =>
      apiJson<NotificationPrefsResponse>(
        "/api/executive-meetings/notification-prefs",
      ),
  });
  const [draft, setDraft] = useState<Map<string, NotificationPref> | null>(
    null,
  );
  // When the server payload arrives (or refetches), seed local edit state.
  // We only seed when local state is null so that an in-flight edit isn't
  // clobbered by a background refetch.
  useEffect(() => {
    if (!data || draft) return;
    const m = new Map<string, NotificationPref>();
    for (const p of data.prefs) m.set(p.notificationType, { ...p });
    setDraft(m);
  }, [data, draft]);

  const dirty = useMemo(() => {
    if (!data || !draft) return false;
    for (const p of data.prefs) {
      const cur = draft.get(p.notificationType);
      if (!cur) continue;
      if (cur.inApp !== p.inApp || cur.email !== p.email) return true;
    }
    return false;
  }, [data, draft]);

  function update(type: string, channel: "inApp" | "email", v: boolean) {
    setDraft((prev) => {
      if (!prev) return prev;
      const next = new Map(prev);
      const cur = next.get(type);
      if (!cur) return prev;
      next.set(type, { ...cur, [channel]: v });
      return next;
    });
  }

  async function save() {
    if (!draft) return;
    const prefs = Array.from(draft.values());
    try {
      await apiJson("/api/executive-meetings/notification-prefs", {
        method: "PUT",
        body: { prefs },
      });
      await qc.invalidateQueries({
        queryKey: ["/api/executive-meetings/notification-prefs"],
      });
      // Clear local state so the next render reseeds from the freshly
      // refetched payload — keeps client and server in lockstep.
      setDraft(null);
      toast({ title: t("executiveMeetings.notificationsPage.prefs.saved") });
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      toast({ title: msg, variant: "destructive" });
    }
  }

  function reset() {
    if (!data) return;
    const m = new Map<string, NotificationPref>();
    for (const p of data.prefs) m.set(p.notificationType, { ...p });
    setDraft(m);
  }

  const types = data?.types ?? [];

  return (
    <div
      className="bg-white border border-gray-200 rounded-md p-4"
      dir={isRtl ? "rtl" : "ltr"}
      data-testid="em-notification-prefs"
    >
      <div className="flex items-center justify-between gap-3 mb-1">
        <h3 className="text-sm sm:text-base font-semibold text-[#0B1E3F]">
          {t("executiveMeetings.notificationsPage.prefs.heading")}
        </h3>
      </div>
      <p className="text-xs text-gray-500 mb-3 leading-relaxed">
        {t("executiveMeetings.notificationsPage.prefs.intro")}
      </p>
      {isLoading || !draft ? (
        <div className="py-4 text-sm text-gray-500">{t("common.loading")}</div>
      ) : (
        <>
          <div className="overflow-x-auto">
            <table className="w-full text-sm">
              <thead className="text-xs text-gray-500">
                <tr>
                  <th className="px-2 py-1 text-start font-medium">
                    {t("executiveMeetings.notificationsPage.prefs.col.event")}
                  </th>
                  <th className="px-2 py-1 text-center font-medium w-24">
                    {t("executiveMeetings.notificationsPage.prefs.col.inApp")}
                  </th>
                  <th className="px-2 py-1 text-center font-medium w-24">
                    {t("executiveMeetings.notificationsPage.prefs.col.email")}
                  </th>
                </tr>
              </thead>
              <tbody>
                {types.map((type) => {
                  const cur = draft.get(type);
                  if (!cur) return null;
                  return (
                    <tr key={type} className="border-t border-gray-100">
                      <td className="px-2 py-2 text-[#0B1E3F]">
                        {tWithFallback(
                          t,
                          `executiveMeetings.notificationsPage.type.${type}`,
                          type,
                        )}
                      </td>
                      <td className="px-2 py-2 text-center">
                        <Switch
                          checked={cur.inApp}
                          onCheckedChange={(v) => update(type, "inApp", !!v)}
                          aria-label={t(
                            "executiveMeetings.notificationsPage.prefs.col.inApp",
                          )}
                          data-testid={`em-pref-inapp-${type}`}
                        />
                      </td>
                      <td className="px-2 py-2 text-center">
                        <Switch
                          checked={cur.email}
                          onCheckedChange={(v) => update(type, "email", !!v)}
                          aria-label={t(
                            "executiveMeetings.notificationsPage.prefs.col.email",
                          )}
                          data-testid={`em-pref-email-${type}`}
                        />
                      </td>
                    </tr>
                  );
                })}
              </tbody>
            </table>
          </div>
          <div className="flex items-center gap-2 mt-3">
            <Button
              size="sm"
              onClick={save}
              disabled={!dirty}
              className="bg-[#0B1E3F]"
              data-testid="em-pref-save"
            >
              {t("executiveMeetings.notificationsPage.prefs.save")}
            </Button>
            <Button
              size="sm"
              variant="outline"
              onClick={reset}
              disabled={!dirty}
              data-testid="em-pref-reset"
            >
              {t("executiveMeetings.notificationsPage.prefs.reset")}
            </Button>
          </div>
        </>
      )}
    </div>
  );
}

// ============ PDF / PRINT ============

function PdfSection({
  date,
  onDateChange,
  lang,
  t,
}: {
  date: string;
  onDateChange: (d: string) => void;
  lang: "ar" | "en";
  t: (k: string) => string;
}) {
  const qc = useQueryClient();
  const { toast } = useToast();
  const [busy, setBusy] = useState(false);
  const archivesQuery = useQuery<{ archives: PdfArchive[] }>({
    queryKey: ["/api/executive-meetings/pdf-archives", date],
    queryFn: async () =>
      apiJson(`/api/executive-meetings/pdf-archives?date=${date}`),
    enabled: !!date,
  });

  // Stream a binary response from the API to the browser's download
  // stack. Throws with the server's error message on a non-2xx so the
  // toast surfaces a useful reason instead of a silent failure.
  async function downloadBinary(url: string, filename: string): Promise<void> {
    const res = await fetch(url, { credentials: "include" });
    if (!res.ok) {
      let message = `HTTP ${res.status}`;
      try {
        const j = (await res.json()) as { error?: string };
        if (j.error) message = j.error;
      } catch {
        /* response wasn't JSON — keep the HTTP status */
      }
      throw new Error(message);
    }
    const blob = await res.blob();
    const objectUrl = URL.createObjectURL(blob);
    try {
      const a = document.createElement("a");
      a.href = objectUrl;
      a.download = filename;
      a.rel = "noopener";
      document.body.appendChild(a);
      a.click();
      a.remove();
    } finally {
      // Free the blob URL after the click — Safari needs a tick before
      // it actually starts the download, so defer slightly.
      setTimeout(() => URL.revokeObjectURL(objectUrl), 4000);
    }
  }

  async function downloadPdf() {
    if (!date) return;
    setBusy(true);
    try {
      const url = `/api/executive-meetings/pdf?date=${encodeURIComponent(
        date,
      )}&lang=${encodeURIComponent(lang)}`;
      await downloadBinary(url, `executive-meetings-${date}.pdf`);
      // Refresh archives so the row created by the GET appears.
      await qc.invalidateQueries({
        queryKey: ["/api/executive-meetings/pdf-archives", date],
      });
      toast({ title: t("executiveMeetings.pdf.downloaded") });
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      toast({ title: msg, variant: "destructive" });
    } finally {
      setBusy(false);
    }
  }

  async function downloadArchive(archive: PdfArchive) {
    if (!archive.filePath?.startsWith("/objects/")) return;
    try {
      // /objects/<id> resolves to GET /api/storage/objects/<id> on the
      // server. The route is auth-gated behind the same session, so
      // credentials must be included.
      const url = `/api${archive.filePath.replace(/^\/objects/, "/storage/objects")}`;
      await downloadBinary(
        url,
        `executive-meetings-${archive.archiveDate}-v${archive.version}.pdf`,
      );
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      toast({ title: msg, variant: "destructive" });
    }
  }

  const archives = archivesQuery.data?.archives ?? [];

  return (
    <div className="space-y-4 max-w-2xl">
      <h2 className="text-xl sm:text-2xl font-bold text-[#0B1E3F]">
        {t("executiveMeetings.pdf.heading")}
      </h2>
      <p className="text-sm text-gray-600">{t("executiveMeetings.pdf.intro")}</p>
      <div className="bg-white border border-gray-200 rounded-md p-4 space-y-3">
        <FormRow label={t("executiveMeetings.pdf.selectDate")}>
          <Input
            type="date"
            value={date}
            onChange={(e) => onDateChange(e.target.value)}
          />
        </FormRow>
        <div className="flex flex-wrap gap-2">
          <Button
            onClick={downloadPdf}
            disabled={busy || !date}
            className="bg-[#0B1E3F] gap-1"
            data-testid="em-download-pdf"
          >
            <FileText className="w-4 h-4" />
            {t("executiveMeetings.pdf.downloadPdf")}
          </Button>
        </div>
        <p className="text-xs text-gray-500">
          {t("executiveMeetings.pdf.downloadHint")}
        </p>
      </div>

      <div className="bg-white border border-gray-200 rounded-md">
        <div className="px-4 py-2 border-b border-gray-200 text-sm font-semibold text-[#0B1E3F]">
          {t("executiveMeetings.pdf.archivesHeading")}
        </div>
        {archivesQuery.isLoading ? (
          <div className="p-4 text-sm text-gray-500">{t("common.loading")}</div>
        ) : archives.length === 0 ? (
          <div className="p-4 text-sm text-gray-500">
            {t("executiveMeetings.pdf.noArchives")}
          </div>
        ) : (
          <ul className="divide-y divide-gray-100" data-testid="em-archives-list">
            {archives.map((a) => {
              const who =
                a.generator
                  ? lang === "ar"
                    ? a.generator.displayNameAr || a.generator.username
                    : a.generator.displayNameEn || a.generator.username
                  : "—";
              return (
                <li
                  key={a.id}
                  className="px-4 py-2 text-sm flex items-center justify-between gap-3"
                >
                  <div>
                    <div className="font-medium text-[#0B1E3F]">
                      v{a.version} — {a.archiveDate}
                    </div>
                    <div className="text-xs text-gray-500">
                      {new Date(a.generatedAt).toLocaleString(
                        lang === "ar" ? "ar" : "en",
                      )}{" "}
                      · {who}
                    </div>
                  </div>
                  <div className="flex items-center gap-2">
                    {a.filePath?.startsWith("/objects/") ? (
                      <Button
                        size="sm"
                        variant="outline"
                        onClick={() => downloadArchive(a)}
                        data-testid={`em-archive-download-${a.id}`}
                      >
                        {t("executiveMeetings.pdf.downloadArchive")}
                      </Button>
                    ) : (
                      <span
                        className="text-xs text-gray-400"
                        data-testid={`em-archive-legacy-${a.id}`}
                      >
                        {t("executiveMeetings.pdf.legacyArchive")}
                      </span>
                    )}
                    {typeof a.byteSize === "number" ? (
                      <span className="text-xs text-gray-400">
                        {formatBytesForDisplay(a.byteSize)}
                      </span>
                    ) : null}
                  </div>
                </li>
              );
            })}
          </ul>
        )}
      </div>
    </div>
  );
}

// ============ FONT SETTINGS ============

function FontSettingsSection({
  font,
  canEditGlobal,
  t,
}: {
  font: FontPrefs;
  canEditGlobal: boolean;
  t: (k: string) => string;
}) {
  const qc = useQueryClient();
  const { toast } = useToast();
  const [scope, setScope] = useState<"user" | "global">("user");
  const [prefs, setPrefs] = useState<FontPrefs>(font);
  const [saving, setSaving] = useState(false);

  useEffect(() => {
    setPrefs(font);
  }, [font]);

  async function save() {
    setSaving(true);
    try {
      await apiJson(`/api/executive-meetings/font-settings`, {
        method: "PATCH",
        body: { scope, ...prefs },
      });
      toast({ title: t("executiveMeetings.fontSettingsPage.saved") });
      await qc.invalidateQueries({ queryKey: ["/api/executive-meetings/font-settings"] });
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      toast({ title: msg, variant: "destructive" });
    } finally {
      setSaving(false);
    }
  }

  function reset() {
    setPrefs(DEFAULT_FONT);
  }

  return (
    <div className="space-y-4 max-w-2xl">
      <h2 className="text-xl sm:text-2xl font-bold text-[#0B1E3F]">
        {t("executiveMeetings.fontSettingsPage.heading")}
      </h2>
      <p className="text-sm text-gray-600">{t("executiveMeetings.fontSettingsPage.intro")}</p>

      <div className="bg-white border border-gray-200 rounded-md p-4 space-y-3">
        {canEditGlobal && (
          <FormRow label={t("executiveMeetings.fontSettingsPage.scope")}>
            <Select value={scope} onValueChange={(v) => setScope(v as "user" | "global")}>
              <SelectTrigger>
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                <SelectItem value="user">
                  {t("executiveMeetings.fontSettingsPage.scopeUser")}
                </SelectItem>
                <SelectItem value="global">
                  {t("executiveMeetings.fontSettingsPage.scopeGlobal")}
                </SelectItem>
              </SelectContent>
            </Select>
          </FormRow>
        )}
        <FormRow label={t("executiveMeetings.fontSettingsPage.fontFamily")}>
          <Select
            value={prefs.fontFamily}
            onValueChange={(v) => setPrefs({ ...prefs, fontFamily: v })}
          >
            <SelectTrigger>
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              {[
                "system",
                "Cairo",
                "Tajawal",
                "Noto Naskh Arabic",
                "Amiri",
              ].map((f) => (
                <SelectItem key={f} value={f}>
                  {f}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        </FormRow>
        <FormRow label={`${t("executiveMeetings.fontSettingsPage.fontSize")}: ${prefs.fontSize}px`}>
          <input
            type="range"
            min={12}
            max={22}
            value={prefs.fontSize}
            onChange={(e) => setPrefs({ ...prefs, fontSize: Number(e.target.value) })}
            className="w-full"
          />
        </FormRow>
        <FormRow label={t("executiveMeetings.fontSettingsPage.fontWeight")}>
          <Select
            value={prefs.fontWeight}
            onValueChange={(v) => setPrefs({ ...prefs, fontWeight: v as FontPrefs["fontWeight"] })}
          >
            <SelectTrigger>
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              {(["regular", "bold"] as const).map((w) => (
                <SelectItem key={w} value={w}>
                  {t(`executiveMeetings.fontSettingsPage.weight.${w}`)}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        </FormRow>
        <FormRow label={t("executiveMeetings.fontSettingsPage.alignment")}>
          <Select
            value={prefs.alignment}
            onValueChange={(v) => setPrefs({ ...prefs, alignment: v as FontPrefs["alignment"] })}
          >
            <SelectTrigger>
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              {(["start", "center"] as const).map((a) => (
                <SelectItem key={a} value={a}>
                  {t(`executiveMeetings.fontSettingsPage.align.${a}`)}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        </FormRow>

        <div className="border-t border-gray-200 pt-3">
          <Label className="text-xs text-gray-700">
            {t("executiveMeetings.fontSettingsPage.preview")}
          </Label>
          <div
            className="mt-2 p-4 border border-gray-200 rounded bg-[#fafbff]"
            style={buildFontStyle(prefs)}
          >
            {t("executiveMeetings.fontSettingsPage.previewText")}
          </div>
        </div>

        <div className="flex justify-end gap-2 pt-2">
          <Button variant="ghost" onClick={reset}>
            {t("executiveMeetings.fontSettingsPage.reset")}
          </Button>
          <Button
            onClick={save}
            disabled={saving}
            className="bg-[#0B1E3F]"
            data-testid="em-font-save"
          >
            {t("executiveMeetings.fontSettingsPage.save")}
          </Button>
        </div>
      </div>
    </div>
  );
}

// ============ SHARED ============

function NoPermission({ t }: { t: (k: string) => string }) {
  return (
    <div className="bg-white border border-gray-200 rounded-md p-10 sm:p-16 text-center">
      <div className="w-14 h-14 mx-auto mb-4 rounded-full bg-[#0B1E3F]/10 flex items-center justify-center text-[#0B1E3F]">
        <SettingsIcon className="w-6 h-6" />
      </div>
      <p className="text-sm text-gray-600 max-w-md mx-auto">
        {t("executiveMeetings.common.noPermission")}
      </p>
    </div>
  );
}
