import {
  Alert,
  Badge,
  Box,
  Button,
  Checkbox,
  Chip,
  CircularProgress,
  Collapse,
  Dialog,
  DialogActions,
  DialogContent,
  DialogTitle,
  IconButton,
  LinearProgress,
  MenuItem,
  Paper,
  Stack,
  Table,
  TableBody,
  TableCell,
  TableContainer,
  TableHead,
  TablePagination,
  TableRow,
  TableSortLabel,
  TextField,
  Tooltip,
  Typography,
  useMediaQuery,
  useTheme,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import AutorenewIcon from "@mui/icons-material/Autorenew";
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
import DeleteIcon from "@mui/icons-material/Delete";
import EditIcon from "@mui/icons-material/Edit";
import FilterAltIcon from "@mui/icons-material/FilterAlt";
import FilterAltOffIcon from "@mui/icons-material/FilterAltOff";
import InboxRoundedIcon from "@mui/icons-material/InboxRounded";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import RefreshIcon from "@mui/icons-material/Refresh";
import RestartAltIcon from "@mui/icons-material/RestartAlt";
import SearchIcon from "@mui/icons-material/Search";
import UnfoldMoreIcon from "@mui/icons-material/UnfoldMore";
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
import dayjs, { type Dayjs } from "dayjs";
import {
  forwardRef,
  memo,
  useCallback,
  useEffect,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent,
  type ReactNode,
} from "react";
import type { Listable } from "../api/types";
import { notifyApiError, useNotify } from "../notifications/NotificationsProvider";
import { PageHeader } from "./PageHeader";

export type FieldType = "text" | "number" | "select" | "multiselect" | "ids" | "uuid";

// FILTER_WIDTH is the fixed CSS width (px) of a single filter cell in the
// filter panel. FILTER_GAP is the flex gap between cells (matches the
// `gap: 1.5` on the wrapping Box: MUI 8 px spacing × 1.5 = 12 px).
const FILTER_WIDTH = 240;
const FILTER_GAP = 12;
// "wide" filter cells (datetime-range, or any text/select cell with
// `wide: true`) span 1.5 "slots" of the grid. A 1-slot stretch across
// the gap accounts for half a gap (because skipping ½ a slot also skips
// ½ of its trailing gap), so the resulting outer box width snaps cleanly
// to the half-grid line.
const WIDE_FILTER_WIDTH = FILTER_WIDTH * 1.5 + FILTER_GAP * 0.5;

// generateUUID returns an RFC 4122 v4 UUID. Browsers > 2022 expose
// crypto.randomUUID; older ones get a manual implementation backed by
// crypto.getRandomValues (always available where fetch is).
export function generateUUID(): string {
  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
    return crypto.randomUUID();
  }
  const bytes = new Uint8Array(16);
  crypto.getRandomValues(bytes);
  bytes[6] = (bytes[6]! & 0x0f) | 0x40; // version 4
  bytes[8] = (bytes[8]! & 0x3f) | 0x80; // variant 10xxxxxx
  const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}

export interface FieldSpec<TValue = unknown> {
  name: string;
  label: string;
  type: FieldType;
  required?: boolean;
  options?: { value: string; label?: string }[];
  helperText?: string;
  // Used by select/multiselect to render labels.
  defaultValue?: TValue;
  // If set, only show in create form / only in edit form.
  only?: "create" | "update";
  // Optional dynamic visibility — receives the current form values, returns
  // false to hide the field. Hidden fields are not rendered and not
  // submitted. Mirrors `FieldOnChooseOptionsHide` from the legacy admin.
  visibleWhen?: (form: Record<string, unknown>) => boolean;
  // When this field's value changes, every name listed here is reset to its
  // empty default. Used e.g. on the user `type` select to wipe credential
  // fields when the type changes.
  clears?: string[];
  // Async option loader, fired once when the dialog opens. The resolved list
  // takes precedence over `options` while rendering. Used by squad_ids on
  // every page so the multi-select is populated from GET /squads.
  optionsLoader?: () => Promise<{ value: string; label?: string }[]>;
  // For `multiselect` fields: render the chosen values as an ordered
  // pipeline (`User → Destination → IP`) rather than as independent
  // chips. Used by ordered/queue-style fields like `flow_keys`.
  displayAsChain?: boolean;
}

// emptyValueForField returns the canonical "empty" value for a given field
// spec, matching what `emptyForm` would produce.
function emptyValueForField(f: FieldSpec): unknown {
  if (f.defaultValue !== undefined) return f.defaultValue;
  if (f.type === "multiselect") return [];
  return "";
}

export interface ColumnSpec<TEntity> {
  key: keyof TEntity | string;
  label: string;
  render?: (row: TEntity) => ReactNode;
  // Whether the user can click the column header to sort by this column.
  // Defaults to `true` — pages explicitly opt-out for non-comparable
  // columns like `squad_ids` (array) or `status` (computed remote field).
  sortable?: boolean;
}

// RowActionSpec is a per-row IconButton rendered in the actions column,
// to the *left* of the built-in Edit / Delete buttons. Pages use it for
// row-scoped operations that aren't a direct field edit — e.g. the
// Traffic Limiters page exposes a "reset traffic" action this way.
//
// `onClick` receives a small context object with `reload`, the same
// callback CrudPage uses internally after Create / Edit / Delete; calling
// it after the action completes refreshes the table so the new row state
// (e.g. `usage` ticked back to 0) appears without forcing the user to
// hit Refresh.
export interface RowActionSpec<TEntity> {
  // Stable identifier — used as the React key for the rendered button so
  // toggling `visible` on / off doesn't churn the DOM.
  key: string;
  // Tooltip text and aria-label for the button.
  label: string;
  // Icon node, e.g. `<RestartAltIcon />`.
  icon: ReactNode;
  // Hover tint preset. "default" matches the EditIcon button (primary
  // hover), "danger" matches the DeleteIcon button (red hover).
  variant?: "default" | "danger";
  // Optional predicate; when supplied, the button is hidden for rows
  // that fail the check. Defaults to "always show".
  visible?: (row: TEntity) => boolean;
  // Optional confirmation step. When provided, clicking the action
  // opens a centred Dialog (same chrome as the Delete one) with the
  // returned title / description and a primary button labelled
  // `confirmLabel`. If omitted, `onClick` runs immediately on click.
  confirm?: (row: TEntity) => {
    title: string;
    description: string;
    // Primary button label. Defaults to `action.label`.
    confirmLabel?: string;
    // Label shown while `onClick` is in flight. Defaults to
    // `confirmLabel + "…"`.
    busyLabel?: string;
    // MUI palette for the primary button. Defaults to "primary".
    // `"error"` matches the destructive Delete dialog so dangerous
    // actions can reuse the same red treatment.
    color?: "primary" | "warning" | "error";
  };
  // The action callback. May be async — pages typically await the API
  // request and then call `ctx.reload()` to refresh the table. When
  // `confirm` is set, this only runs after the user clicks the
  // primary button in the dialog.
  onClick: (row: TEntity, ctx: { reload: () => Promise<void> }) => Promise<void> | void;
}

// FilterSpec describes a column filter rendered above the table.
//
// The filter value is sent as a query parameter on the list() call. For
// `datetime-range` two parameters are sent: `${name}_start` / `${name}_end`.
export type FilterType = "text" | "select" | "datetime-range";
export interface FilterSpec {
  name: string;
  label: string;
  type: FilterType;
  options?: { value: string; label?: string }[];
  placeholder?: string;
  // Render the filter cell at 1.5× the regular width — same slot size as
  // a `datetime-range` cell. Useful for inputs whose typical content is
  // long enough that the default 240 px feels cramped (e.g. a 36-char
  // UUID or a long URL). The slot still aligns with the half-grid line
  // so the surrounding flex-wrap layout stays tidy.
  wide?: boolean;
}

// optionLabel maps a raw select value to its human-readable label, matching
// service/admin_panel/tables/utils.go's optionLabelDisplay. Falls back to
// the raw stringified value when no option matches (e.g. unknown enum
// returned by the API).
export function optionLabel(
  options: { value: string; label?: string }[] | undefined,
  value: unknown,
): string {
  if (value === null || value === undefined) return "";
  const v = String(value);
  const found = options?.find((o) => o.value === v);
  return found?.label ?? v;
}

// renderOptionLabel returns a `render` function for a ColumnSpec that maps
// the row's single-value field to its option label. Use on `select`-type
// columns so the table shows "Hysteria" instead of "hysteria".
export function renderOptionLabel<TEntity>(
  key: keyof TEntity | string,
  options: { value: string; label?: string }[],
): (row: TEntity) => ReactNode {
  return (row: TEntity) => {
    const v = (row as Record<string, unknown>)[key as string];
    if (v === null || v === undefined || v === "") return "";
    return optionLabel(options, v);
  };
}

// Module-level style constants shared by every TableRow so React doesn't
// allocate fresh sx objects (and emotion doesn't re-hash CSS classes) on
// every row render. Theme tokens (text.secondary, action.active) are
// used instead of hardcoded white-with-alpha so the row reads correctly
// in both dark and light mode.
// Body cells inherit their column's width from the header under
// `tableLayout: fixed`. We clip overflow with ellipsis so a value longer
// than the user's chosen column width doesn't bleed into the next column.
const BODY_CELL_SX = {
  overflow: "hidden",
  textOverflow: "ellipsis",
  whiteSpace: "nowrap" as const,
};
const ID_CELL_SX = {
  fontFamily:
    '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace',
  fontSize: 12.5,
  color: "text.secondary",
  overflow: "hidden",
  textOverflow: "ellipsis",
  whiteSpace: "nowrap" as const,
} as const;
// Actions are pinned to the right edge of the table so the Edit /
// Delete buttons stay reachable no matter how wide the data columns
// grow or how far the user has scrolled the table horizontally.
// `position: sticky` + `right: 0` glues the cell to the right edge
// of its scroll container; the z-index hierarchy (header > body >
// data columns) makes the pinned column win overlap so the data
// columns slide visually under it.
//
// To avoid showing the scrolled-under column text through the
// pinned cell, the body / header cells need an opaque background
// that matches whatever colour the surrounding row / head currently
// renders. The theme paints solid colours for each state (see
// theme.ts):
//
//   - resting body row → `--sb-surface`     (Table backgroundColor)
//   - hovered body row → `--sb-elevated2`   (TableRow `:hover` !important)
//   - header row       → `--sb-elevated`    (MuiTableHead bg)
//
// Selected (no hover) is the only state MUI itself paints with a
// translucent accent overlay rather than a custom solid; we mimic
// it with `color-mix` so the sticky cell ends up at the same final
// pixel colour as the surrounding cells. Selected + hover is back
// to solid `--sb-elevated2` because the `:hover` override in the
// theme uses `!important` and wins over `Mui-selected`.
// Soft inset-style shadow on the left edge of the pinned Actions
// column. The negative spread (-4px) clips the blur to a thin band
// near the boundary, so it reads as a subtle separator when other
// content sits to the left without bleeding far into the cell.
const ACTIONS_CELL_SHADOW = "-4px 0 6px -4px rgba(0,0,0,0.18)";
const ACTIONS_CELL_SX = {
  paddingLeft: "8px",
  paddingRight: "8px",
  whiteSpace: "nowrap" as const,
  position: "sticky" as const,
  right: 0,
  zIndex: 2,
  backgroundColor: "var(--sb-surface)",
  boxShadow: ACTIONS_CELL_SHADOW,
  // Mirror the timing curve of `MuiTableRow` (`background-color 0.14s
  // ease` in theme.ts). Without this the sticky cell snaps to its new
  // background instantly while the rest of the row eases in, which
  // reads as a brief asymmetric flash on hover / unhover.
  transition: "background-color 0.14s ease",
  "tr:hover &": {
    backgroundColor: "var(--sb-elevated2)",
  },
  "tr.Mui-selected &": {
    backgroundColor:
      "color-mix(in srgb, var(--sb-accent) 8%, var(--sb-surface))",
  },
  "tr.Mui-selected:hover &": {
    backgroundColor: "var(--sb-elevated2)",
  },
};
const ACTIONS_HEADER_CELL_SX = {
  width: 92,
  paddingLeft: "8px",
  paddingRight: "8px",
  textAlign: "center" as const,
  position: "sticky" as const,
  right: 0,
  zIndex: 3,
  backgroundColor: "var(--sb-elevated)",
  boxShadow: ACTIONS_CELL_SHADOW,
};
// Filler cell — the only auto-width cell in each row. Padding stripped
// so it can collapse cleanly to 0 px width once the fixed columns
// overflow the viewport (any padding would otherwise add width that
// `tableLayout: fixed` can't fully reclaim). The default
// `border-bottom` from MuiTableCell is preserved on purpose so the
// row's horizontal divider extends across the empty span — without
// it the rule would visually break wherever the filler sits.
const FILLER_CELL_SX = {
  padding: 0,
} as const;
const EDIT_BTN_SX = { width: 28, height: 28 } as const;
const DELETE_BTN_SX = {
  width: 28,
  height: 28,
  color: "text.secondary",
  "&:hover": {
    color: "error.main",
    backgroundColor: "rgba(239,68,68,0.12)",
  },
} as const;

// renderOptionChain renders an array of option values as an ordered queue,
// e.g. `User → Destination → IP` rather than a set of independent chips.
// The order of the array is preserved verbatim, so a column rendered with
// this helper visualises "first match wins" behaviour for fair-queue /
// pipeline style fields.
export function renderOptionChain<TEntity>(
  key: keyof TEntity | string,
  options: { value: string; label?: string }[],
): (row: TEntity) => ReactNode {
  return (row: TEntity) => {
    const raw = (row as Record<string, unknown>)[key as string];
    if (!Array.isArray(raw) || raw.length === 0) return "";
    return (
      <Box
        sx={{
          display: "flex",
          flexWrap: "wrap",
          alignItems: "center",
          rowGap: 0.5,
          columnGap: 0.5,
          maxWidth: "100%",
        }}
      >
        {raw.map((v, i) => (
          <Box
            key={i}
            component="span"
            sx={{ display: "inline-flex", alignItems: "center", gap: 0.5 }}
          >
            {i > 0 && (
              <Box
                component="span"
                sx={{
                  color: "text.secondary",
                  fontSize: 12,
                  userSelect: "none",
                }}
              >
                →
              </Box>
            )}
            <Chip label={optionLabel(options, v)} size="small" />
          </Box>
        ))}
      </Box>
    );
  };
}

export interface CrudConfig<TEntity, TCreate, TUpdate> {
  title: string;
  subtitle?: string;
  // Icon shown next to the page title.
  icon?: ReactNode;
  idKey: keyof TEntity;
  columns: ColumnSpec<TEntity>[];
  fields: FieldSpec[];
  // Optional filter bar. Each entry becomes an input rendered above the
  // table; the applied values are forwarded to `list()` as query params.
  filters?: FilterSpec[];
  // Optional per-row IconButtons rendered before Edit/Delete in the
  // actions column. See RowActionSpec for the contract.
  rowActions?: RowActionSpec<TEntity>[];
  // list receives the applied filter query (+ any extra query params you add
  // to defaults later). Matches the signature of the generated API client.
  list: (query?: Listable) => Promise<TEntity[]>;
  // Optional total-row count. When provided, the table renders a pagination
  // bar at the bottom and pages through the data via `offset` + `limit`. The
  // count is fetched alongside `list` so the displayed range stays in sync.
  count?: (query?: Listable) => Promise<number>;
  create: (body: TCreate) => Promise<TEntity>;
  update: (id: string | number, body: TUpdate) => Promise<TEntity>;
  remove: (id: string | number) => Promise<TEntity>;
  toCreate: (form: Record<string, unknown>) => TCreate;
  toUpdate: (form: Record<string, unknown>, original: TEntity) => TUpdate;
  // Optionally derive form values from the row when editing.
  fromEntity?: (row: TEntity) => Record<string, unknown>;
  rowKey?: (row: TEntity) => string;
  // Default sort applied on first render. `field` is the column key; the
  // direction maps to `sort_asc` / `sort_desc` query params.
  defaultSort?: { field: string; dir: "asc" | "desc" };
  // Initial / fallback page size; defaults to 10.
  defaultPageSize?: number;
  // Fired after every successful `list()` response, with the rows that
  // are about to be rendered. Pages use it to fetch auxiliary data that
  // is only needed for what is on screen — e.g. squad names for the
  // rows in view — instead of pre-fetching the full catalog on mount.
  // If the callback returns a promise, `reload` awaits it before
  // publishing the new row set, so the table never flashes raw ids
  // before the friendly labels arrive. The callback is best-effort:
  // errors thrown (or rejections) are caught so a misbehaving observer
  // can't break the table refresh.
  onRowsChange?: (rows: TEntity[]) => void | Promise<void>;
}

// formatDatetimeForFilter converts a `<input type="datetime-local">` value
// ("YYYY-MM-DDTHH:MM" or "...:SS") into the "YYYY-MM-DD HH:MM:SS" shape the
// manager API expects.
//
// The repository stores TIMESTAMP columns as text like
// "2025-01-15 14:30:00.123456789+03:00" (modernc/sqlite's default time
// serialization) and the backend compares filter values to that text
// lexicographically. ISO-8601 strings ("...T...Z") sort *after* the
// space-separated stored values so any > filter would (incorrectly) match
// nothing — sending the value with a literal space here mirrors what the
// legacy admin_panel sends and is what makes the date filter actually work.
function formatDatetimeForFilter(input: string): string {
  if (!input) return input;
  const padded = input.length === 16 ? `${input}:00` : input;
  return padded.replace("T", " ");
}

// buildFilterQuery takes the raw filter-state object and turns it into a
// Listable query suitable for the API client: empty strings are dropped,
// datetime-range fields are split into _start / _end params.
function buildFilterQuery(
  specs: FilterSpec[],
  values: Record<string, unknown>,
): Listable {
  const out: Record<string, string | number | string[] | undefined> = {};
  for (const f of specs) {
    const raw = values[f.name];
    if (f.type === "datetime-range") {
      const r = (raw ?? {}) as { start?: string; end?: string };
      if (r.start) out[`${f.name}_start`] = formatDatetimeForFilter(r.start);
      if (r.end) out[`${f.name}_end`] = formatDatetimeForFilter(r.end);
      continue;
    }
    if (typeof raw === "string" && raw.trim() !== "") {
      out[f.name] = raw.trim();
    }
  }
  return out;
}

function countActiveFilters(
  specs: FilterSpec[],
  values: Record<string, unknown>,
): number {
  let n = 0;
  for (const f of specs) {
    const raw = values[f.name];
    if (f.type === "datetime-range") {
      const r = (raw ?? {}) as { start?: string; end?: string };
      if (r.start || r.end) n++;
      continue;
    }
    if (typeof raw === "string" && raw.trim() !== "") n++;
  }
  return n;
}

// filterValuesEqual compares two filter-state objects by spec, ignoring key
// insertion order. JSON.stringify-based equality used to false-positive
// "dirty" simply because the user typed filters in a different order, even
// when the canonical applied set matched the draft.
function filterValuesEqual(
  specs: FilterSpec[],
  a: Record<string, unknown>,
  b: Record<string, unknown>,
): boolean {
  for (const f of specs) {
    const av = a[f.name];
    const bv = b[f.name];
    if (f.type === "datetime-range") {
      const ar = (av ?? {}) as { start?: string; end?: string };
      const br = (bv ?? {}) as { start?: string; end?: string };
      if ((ar.start ?? "") !== (br.start ?? "")) return false;
      if ((ar.end ?? "") !== (br.end ?? "")) return false;
      continue;
    }
    const as = typeof av === "string" ? av : "";
    const bs = typeof bv === "string" ? bv : "";
    if (as !== bs) return false;
  }
  return true;
}

function emptyForm(fields: FieldSpec[], mode: "create" | "update"): Record<string, unknown> {
  const out: Record<string, unknown> = {};
  for (const f of fields) {
    if (f.only && f.only !== mode) continue;
    if (f.defaultValue !== undefined) {
      out[f.name] = f.defaultValue;
    } else if (f.type === "multiselect") {
      out[f.name] = [];
    } else if (f.type === "ids") {
      out[f.name] = "";
    } else if (f.type === "number") {
      out[f.name] = "";
    } else {
      out[f.name] = "";
    }
  }
  return out;
}

function parseIds(v: unknown): number[] {
  if (Array.isArray(v)) {
    return v
      .map((x) => Number(x))
      .filter((n) => Number.isFinite(n));
  }
  if (typeof v === "string") {
    return v
      .split(",")
      .map((s) => s.trim())
      .filter(Boolean)
      .map((s) => Number(s))
      .filter((n) => Number.isFinite(n));
  }
  return [];
}

// fieldVisible reports whether a field should be displayed (and submitted)
// given the current mode + form values. Static `only` and dynamic
// `visibleWhen` rules are both honoured.
function fieldVisible(
  f: FieldSpec,
  mode: "create" | "update",
  form: Record<string, unknown>,
): boolean {
  if (f.only && f.only !== mode) return false;
  if (f.visibleWhen && !f.visibleWhen(form)) return false;
  return true;
}

// isFieldEmpty reports whether the user has provided no value for a field.
// The semantics match what the API would otherwise complain about: blank
// strings, missing selections, and empty arrays for multi-select / ids.
function isFieldEmpty(f: FieldSpec, value: unknown): boolean {
  if (value === undefined || value === null) return true;
  if (f.type === "multiselect" || f.type === "ids") {
    if (Array.isArray(value)) return value.length === 0;
    if (typeof value === "string") return value.trim() === "";
    return true;
  }
  if (f.type === "number") {
    if (value === "") return true;
    return !Number.isFinite(Number(value));
  }
  if (typeof value === "string") return value.trim() === "";
  return false;
}

// validateRequired returns a `{ field name -> message }` map describing
// every visible required field that the user has not filled in. The dialog
// uses this to block submit + render inline errors instead of letting the
// server reject the request.
export function validateRequired(
  fields: FieldSpec[],
  form: Record<string, unknown>,
): Record<string, string> {
  const errors: Record<string, string> = {};
  for (const f of fields) {
    if (!f.required) continue;
    if (isFieldEmpty(f, form[f.name])) errors[f.name] = "This field is required";
  }
  return errors;
}

export function normalizeFormForSubmit(
  fields: FieldSpec[],
  mode: "create" | "update",
  form: Record<string, unknown>,
): Record<string, unknown> {
  const out: Record<string, unknown> = {};
  for (const f of fields) {
    if (!fieldVisible(f, mode, form)) continue;
    const raw = form[f.name];
    if (f.type === "ids") {
      const arr = parseIds(raw);
      if (arr.length > 0 || f.required) out[f.name] = arr;
      continue;
    }
    if (f.type === "number") {
      if (raw === "" || raw === undefined || raw === null) {
        if (f.required) out[f.name] = 0;
        continue;
      }
      const n = Number(raw);
      out[f.name] = Number.isFinite(n) ? n : 0;
      continue;
    }
    if (f.type === "multiselect") {
      const arr = Array.isArray(raw) ? raw.filter(Boolean) : [];
      if (arr.length > 0 || f.required) out[f.name] = arr;
      continue;
    }
    if (typeof raw === "string") {
      const trimmed = raw.trim();
      if (trimmed === "" && !f.required) continue;
      out[f.name] = trimmed;
      continue;
    }
    if (raw !== undefined && raw !== null) out[f.name] = raw;
  }
  return out;
}

// shallowEqualRow reports whether two row objects describe the same data.
// Scalars are compared with `Object.is`; arrays are compared element-wise
// (still by `Object.is`) since every entity returned by the admin API
// uses flat fields with at most one level of array (e.g. `squad_ids`).
// The comparison is intentionally conservative — when in doubt we return
// false and let the row re-render rather than risk masking a real change.
function shallowEqualRow(
  a: Record<string, unknown>,
  b: Record<string, unknown>,
): boolean {
  if (a === b) return true;
  const ak = Object.keys(a);
  const bk = Object.keys(b);
  if (ak.length !== bk.length) return false;
  for (const k of ak) {
    const av = a[k];
    const bv = b[k];
    if (Object.is(av, bv)) continue;
    if (Array.isArray(av) && Array.isArray(bv)) {
      if (av.length !== bv.length) return false;
      let same = true;
      for (let i = 0; i < av.length; i++) {
        if (!Object.is(av[i], bv[i])) {
          same = false;
          break;
        }
      }
      if (!same) return false;
      continue;
    }
    return false;
  }
  return true;
}

// reconcileRows preserves the previous row reference for any entity whose
// content is structurally identical to the freshly fetched one. The
// memoised <CrudRow> uses `prev.row === next.row` as part of its
// equality check, so handing back the *same* reference for unchanged rows
// means a refresh that returns identical data does no row-level re-render
// at all — the table simply stays put. When *no* row has changed (length
// equal, every reconciled entry === the previous entry) we hand back the
// previous array verbatim so React's setState bail-out kicks in and the
// parent doesn't re-render either.
function reconcileRows<T extends Record<string, unknown>>(
  prev: T[],
  next: T[],
  idKey: keyof T,
): T[] {
  if (prev.length === 0) return next;
  const prevById = new Map<unknown, T>();
  for (const r of prev) prevById.set(r[idKey] as unknown, r);
  let allSame = prev.length === next.length;
  const out: T[] = new Array(next.length);
  for (let i = 0; i < next.length; i++) {
    const row = next[i]!;
    const old = prevById.get(row[idKey] as unknown);
    if (old && shallowEqualRow(old, row)) {
      out[i] = old;
      if (allSame && prev[i] !== old) allSame = false;
    } else {
      out[i] = row;
      allSame = false;
    }
  }
  return allSame ? prev : out;
}

// useColumnWidths — Excel-style resizable column state.
//
// Returns the current width (in px) for each column key, plus a `setWidth`
// setter and a `startResize` helper that wires up a mousedown handler. The
// widths are persisted in localStorage under `${storageKey}` so they
// survive page reloads. Columns with no stored width fall back to the
// browser's auto distribution under `tableLayout: fixed`.
const COL_WIDTH_PREFIX = "sing-box-admin:colw:";
const MIN_COL_WIDTH = 60;
// Default width assigned to a data column that the user hasn't resized
// yet and whose label is shorter than this. Every data column always
// gets an explicit width (this default, or a wider measured natural
// minimum, or the user's stored value) so the only auto-sized cell in
// the row is the trailing "filler" cell that absorbs any leftover
// horizontal space — that's what stops resizing one column from
// rebalancing the others under `tableLayout: fixed`.
const DEFAULT_DATA_COL_WIDTH = 160;
function useColumnWidths(storageKey: string) {
  const fullKey = COL_WIDTH_PREFIX + storageKey;
  const [widths, setWidths] = useState<Record<string, number>>(() => {
    try {
      const raw = localStorage.getItem(fullKey);
      if (raw) {
        const parsed = JSON.parse(raw);
        if (parsed && typeof parsed === "object") return parsed;
      }
    } catch {
      /* ignore */
    }
    return {};
  });

  const persist = useCallback(
    (next: Record<string, number>) => {
      try {
        localStorage.setItem(fullKey, JSON.stringify(next));
      } catch {
        /* ignore */
      }
    },
    [fullKey],
  );

  // Imperative setter used by the drag handler. We update the React state
  // *and* mirror to localStorage from a single place so both stay in sync.
  const setWidth = useCallback(
    (key: string, w: number) => {
      const clamped = Math.max(MIN_COL_WIDTH, Math.round(w));
      setWidths((prev) => {
        if (prev[key] === clamped) return prev;
        const next = { ...prev, [key]: clamped };
        persist(next);
        return next;
      });
    },
    [persist],
  );

  // Batched setter — used at resize-drag start to lock in the live widths
  // of every previously-unstored column in a single state update. Without
  // this lock-in, columns without a `stored` width auto-distribute under
  // `tableLayout: fixed`, so growing the dragged column would also shrink
  // every unstored neighbour by their share of the leftover (rubber-band
  // resize). Storing each column's current rendered width turns the
  // unstored neighbours into fixed-width columns for the duration of
  // the drag, so only the dragged column changes width while the others
  // stay pixel-stable. The cap from `getColumnMaxWidth` then keeps the
  // total table width ≤ the visible viewport, so no column ever ends up
  // tucked behind the sticky Actions cell.
  const setManyWidths = useCallback(
    (entries: Record<string, number>) => {
      setWidths((prev) => {
        let changed = false;
        const next = { ...prev };
        for (const k in entries) {
          const clamped = Math.max(MIN_COL_WIDTH, Math.round(entries[k]));
          if (prev[k] !== clamped) {
            next[k] = clamped;
            changed = true;
          }
        }
        if (!changed) return prev;
        persist(next);
        return next;
      });
    },
    [persist],
  );

  // Wipe every stored column width and drop the localStorage entry.
  // After this the table reverts to the same first-render layout new
  // users see: each data column falls back to `DEFAULT_DATA_COL_WIDTH`
  // (or its measured natural minimum, whichever is larger), and the
  // trailing filler cell soaks up the leftover space again. Used by
  // the toolbar's "Reset column widths" button.
  const resetWidths = useCallback(() => {
    setWidths((prev) => (Object.keys(prev).length === 0 ? prev : {}));
    try {
      localStorage.removeItem(fullKey);
    } catch {
      /* ignore */
    }
  }, [fullKey]);

  return { widths, setWidth, setManyWidths, resetWidths };
}

// usePersistedAppliedFilters — applied (submitted) filter values, persisted
// per-page in localStorage. `appliedFilters` is what's actually sent to the
// API; persisting it means a reload restores the user's last "Search"
// without forcing them to re-enter every filter. The draft state
// (`filterValues`) is left in component-local useState — half-typed inputs
// shouldn't survive a refresh.
const FILTERS_PREFIX = "sing-box-admin:filters:";
function usePersistedAppliedFilters(
  storageKey: string,
): [Record<string, unknown>, (next: Record<string, unknown>) => void] {
  const fullKey = FILTERS_PREFIX + storageKey;
  const [values, setValuesRaw] = useState<Record<string, unknown>>(() => {
    try {
      const raw = localStorage.getItem(fullKey);
      if (raw) {
        const parsed = JSON.parse(raw);
        if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
          return parsed as Record<string, unknown>;
        }
      }
    } catch {
      /* ignore */
    }
    return {};
  });
  const setValues = useCallback(
    (next: Record<string, unknown>) => {
      setValuesRaw(next);
      try {
        if (Object.keys(next).length === 0) {
          localStorage.removeItem(fullKey);
        } else {
          localStorage.setItem(fullKey, JSON.stringify(next));
        }
      } catch {
        /* ignore */
      }
    },
    [fullKey],
  );
  return [values, setValues];
}

// usePersistedFiltersOpen — whether the filter panel is expanded. Mirrors
// the useState API (supports `(prev) => next` updates) so the existing
// `setShowFilters((s) => !s)` toggle keeps working unchanged.
//
// Mount strategy depends on whether this is the *very first* CrudPage
// instance mounted by the running SPA:
//
//   - First app mount (cold reload, F5): the state initialises
//     synchronously with the persisted value. Collapse renders with
//     in=true on its first paint and the open animation is skipped —
//     no "filter panel slides into view, table jumps down" jolt
//     when the user reloads a page that had filters left open.
//
//   - Subsequent mounts (navigating between admin routes via the
//     sidebar): the state still starts at `false` and flips to the
//     persisted value in a post-mount effect. MUI's <Collapse>
//     observes a `false → true` transition on the second render and
//     animates open, matching the user's expectation that switching
//     to a page with persisted-open filters reads as a deliberate
//     "filters appearing" rather than a static layout swap.
//
// The "is this the first app mount" decision is a module-level flag
// flipped once by the very first hook instance to mount. localStorage
// is read synchronously in both branches; the flag only changes the
// initial render's `in` value, not what's persisted.
const FILTERS_OPEN_PREFIX = "sing-box-admin:filters-open:";
let filtersFirstAppMount = true;
function readPersistedFiltersOpen(fullKey: string): boolean {
  try {
    return localStorage.getItem(fullKey) === "1";
  } catch {
    return false;
  }
}
function usePersistedFiltersOpen(
  storageKey: string,
): [
  boolean,
  (next: boolean | ((prev: boolean) => boolean)) => void,
  // `willAnimateOpen` — true iff the panel will transition from
  // closed to open after first paint on this mount. Captured once
  // at mount and never changes. CrudPage uses this to gate the
  // very first reload() so the table doesn't pop rows in mid-
  // animation when the user navigates between admin pages with
  // persisted-open filters. False on cold mounts (the panel is
  // committed already-open without animation) and on subsequent
  // mounts where filters were left closed (no animation will play).
  boolean,
] {
  const fullKey = FILTERS_OPEN_PREFIX + storageKey;
  // `useRef` so the "first ever hook instance" decision is stable
  // across re-renders of *this* CrudPage (without it, a re-render
  // before the post-mount effect runs would re-read
  // `filtersFirstAppMount` *after* it had been flipped, and treat
  // this same instance as a "subsequent mount").
  const isFirstMountRef = useRef(filtersFirstAppMount);
  const [open, setOpenRaw] = useState<boolean>(() =>
    isFirstMountRef.current ? readPersistedFiltersOpen(fullKey) : false,
  );
  // Captured once at mount alongside the initial state so cold vs
  // animated branches stay in sync (we evaluate the same flag and
  // the same persisted value, so a later localStorage change can't
  // make the two disagree on this mount).
  const willAnimateOpenRef = useRef(
    !isFirstMountRef.current && readPersistedFiltersOpen(fullKey),
  );
  useEffect(() => {
    if (isFirstMountRef.current) {
      // The first-ever CrudPage in the SPA already committed with the
      // persisted value applied — no second-render flip needed, and
      // no open animation should play. Just record that we're done
      // with the cold-load path so any later route change (which
      // mounts a fresh CrudPage instance) takes the animated branch.
      filtersFirstAppMount = false;
      return;
    }
    if (readPersistedFiltersOpen(fullKey)) setOpenRaw(true);
  }, [fullKey]);
  const setOpen = useCallback(
    (next: boolean | ((prev: boolean) => boolean)) => {
      setOpenRaw((prev) => {
        const resolved = typeof next === "function" ? next(prev) : next;
        try {
          localStorage.setItem(fullKey, resolved ? "1" : "0");
        } catch {
          /* ignore */
        }
        return resolved;
      });
    },
    [fullKey],
  );
  return [open, setOpen, willAnimateOpenRef.current];
}

// usePersistedPageSize — TablePagination rows-per-page, persisted per-page
// in localStorage so the user's choice (10/25/50/100) survives reloads.
// Falls back to `defaultSize` when nothing is stored or the stored value
// is corrupted.
const PAGE_SIZE_PREFIX = "sing-box-admin:pagesize:";
function usePersistedPageSize(
  storageKey: string,
  defaultSize: number,
): [number, (next: number) => void] {
  const fullKey = PAGE_SIZE_PREFIX + storageKey;
  const [size, setSizeRaw] = useState<number>(() => {
    try {
      const raw = localStorage.getItem(fullKey);
      if (raw) {
        const n = Number(raw);
        if (Number.isFinite(n) && n > 0) return n;
      }
    } catch {
      /* ignore */
    }
    return defaultSize;
  });
  const setSize = useCallback(
    (next: number) => {
      setSizeRaw(next);
      try {
        localStorage.setItem(fullKey, String(next));
      } catch {
        /* ignore */
      }
    },
    [fullKey],
  );
  return [size, setSize];
}

// usePersistedSort — active sort column + direction, persisted per-page in
// localStorage so the user's chosen ordering survives reloads and tab
// switches between admin pages. Both slices are stored together as a
// single JSON blob (`{ field, dir }`) so the two-stage "set field, then
// set dir" updates in `handleSort` and the mobile sort dropdown can never
// land in a half-written intermediate state on disk. Setters mirror the
// `useState` API (each accepts either a value or an updater) so the
// existing `setSortDir((d) => …)` toggle keeps working unchanged.
//
// Falls back to `defaultSort` (or `null` / `"asc"`) only when nothing is
// stored — once the user has touched the sort, an explicit "no sort"
// state (`field === null`) is preserved across reloads too.
const SORT_PREFIX = "sing-box-admin:sort:";
type SortDir = "asc" | "desc";
function usePersistedSort(
  storageKey: string,
  defaultSort: { field: string; dir: SortDir } | undefined,
): [
  string | null,
  (next: string | null | ((prev: string | null) => string | null)) => void,
  SortDir,
  (next: SortDir | ((prev: SortDir) => SortDir)) => void,
] {
  const fullKey = SORT_PREFIX + storageKey;
  const [state, setState] = useState<{ field: string | null; dir: SortDir }>(
    () => {
      try {
        const raw = localStorage.getItem(fullKey);
        if (raw) {
          const parsed = JSON.parse(raw);
          if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
            const field =
              typeof parsed.field === "string" || parsed.field === null
                ? parsed.field
                : null;
            const dir: SortDir = parsed.dir === "desc" ? "desc" : "asc";
            return { field, dir };
          }
        }
      } catch {
        /* ignore */
      }
      return {
        field: defaultSort?.field ?? null,
        dir: defaultSort?.dir ?? "asc",
      };
    },
  );

  const persist = useCallback(
    (next: { field: string | null; dir: SortDir }) => {
      try {
        localStorage.setItem(fullKey, JSON.stringify(next));
      } catch {
        /* ignore */
      }
    },
    [fullKey],
  );

  const setField = useCallback(
    (next: string | null | ((prev: string | null) => string | null)) => {
      setState((prev) => {
        const resolved = typeof next === "function" ? next(prev.field) : next;
        if (resolved === prev.field) return prev;
        const updated = { ...prev, field: resolved };
        persist(updated);
        return updated;
      });
    },
    [persist],
  );

  const setDir = useCallback(
    (next: SortDir | ((prev: SortDir) => SortDir)) => {
      setState((prev) => {
        const resolved = typeof next === "function" ? next(prev.dir) : next;
        if (resolved === prev.dir) return prev;
        const updated = { ...prev, dir: resolved };
        persist(updated);
        return updated;
      });
    },
    [persist],
  );

  return [state.field, setField, state.dir, setDir];
}

// ResizeHandle is the thin invisible bar that lives at the right edge of
// each resizable header cell. Mousedown captures the cursor + the header
// cell's current width, then mousemove updates the column width on every
// frame as the cursor drags. The handle paints a faint accent-coloured
// guide while dragging so the user can see exactly which column they're
// adjusting.
function ResizeHandle({
  columnKey,
  getCurrentWidth,
  setWidth,
  getMinWidth,
  getMaxWidth,
  onResizeStart,
  clipToCell = false,
}: {
  columnKey: string;
  getCurrentWidth: () => number;
  setWidth: (key: string, w: number) => void;
  // Per-column lower bound, measured from the actual header content so the
  // column can never be dragged narrower than its label + sort icon + cell
  // padding (otherwise the contents would overflow into the neighbouring
  // column). Falls back to the global `MIN_COL_WIDTH` if not provided.
  getMinWidth?: () => number;
  // Per-column upper bound. CrudPage passes a getter that keeps the sum
  // of all column widths ≤ the visible scroll viewport, so a column's
  // right edge can never travel past the sticky Actions column. Without
  // it the drag is unconstrained on the upper end.
  getMaxWidth?: () => number;
  // Fires once at the start of a drag, before `getCurrentWidth` is read
  // and before any mousemove listener is attached. CrudPage uses this to
  // lock in the live widths of every previously-unstored data column so
  // dragging one column doesn't rubber-band the unstored neighbours by
  // their share of the auto-distributed leftover; instead, only the
  // dragged column changes width and the cap from `getColumnMaxWidth`
  // keeps the total ≤ the visible viewport.
  onResizeStart?: () => void;
  // When true, the grab strip is clipped to the cell's own right edge
  // instead of overflowing 7 px into the neighbouring cell. Used for
  // the rightmost data column so the grab strip doesn't extend into
  // the sticky Actions column — its left edge should not feel
  // draggable.
  clipToCell?: boolean;
}) {
  const [active, setActive] = useState(false);

  const onMouseDown = useCallback(
    (e: React.MouseEvent<HTMLDivElement>) => {
      e.preventDefault();
      e.stopPropagation();
      // Run before reading `startW` so any width lock-in performed by
      // the parent (promoting auto-distributed columns to fixed widths)
      // is reflected in the layout the drag math anchors to. The DOM
      // read inside `getCurrentWidth` happens after this; even though
      // React hasn't re-rendered yet, the cell's bounding box is
      // unchanged (we lock in the *current* live widths), so the drag
      // anchor stays pixel-stable.
      onResizeStart?.();
      const startX = e.clientX;
      const startW = getCurrentWidth();
      setActive(true);
      const prevCursor = document.body.style.cursor;
      const prevSelect = document.body.style.userSelect;
      document.body.style.cursor = "col-resize";
      document.body.style.userSelect = "none";
      const onMove = (ev: MouseEvent) => {
        const target = startW + (ev.clientX - startX);
        const min = getMinWidth ? getMinWidth() : MIN_COL_WIDTH;
        const max = getMaxWidth ? getMaxWidth() : Number.POSITIVE_INFINITY;
        // Order matters: cap by max first, then floor by min, so that
        // when min > max (an over-tight container) the lower bound
        // wins and the column at least stays legible.
        setWidth(columnKey, Math.max(min, Math.min(max, target)));
      };
      const onUp = () => {
        setActive(false);
        document.body.style.cursor = prevCursor;
        document.body.style.userSelect = prevSelect;
        document.removeEventListener("mousemove", onMove);
        document.removeEventListener("mouseup", onUp);
      };
      document.addEventListener("mousemove", onMove);
      document.addEventListener("mouseup", onUp);
    },
    [columnKey, getCurrentWidth, setWidth, getMinWidth, getMaxWidth, onResizeStart],
  );

  // Double-click "resets" the column to the smallest size that still fits
  // its header content — same lower bound the drag handler enforces. With
  // a tighter natural minimum than the previous flat 60 px floor, the
  // reset never produces a column whose label spills into a neighbour.
  return (
    <Box
      onMouseDown={onMouseDown}
      onDoubleClick={(e) => {
        e.stopPropagation();
        const min = getMinWidth ? getMinWidth() : MIN_COL_WIDTH;
        setWidth(columnKey, min);
      }}
      sx={(t) => ({
        position: "absolute",
        top: 0,
        // Grab area is by default centred on the cell's right border
        // (right: -7, width: 14 → 7 px on each side of the boundary).
        // Wider than the previous 6 px strip so the column edge is
        // easy to hit without pixel-perfect aim, while still narrow
        // enough that it doesn't overlap any reasonable header
        // content.
        //
        // For the rightmost data column we instead clip the strip to
        // the cell's own right edge (right: 0, width: 7). That keeps
        // the boundary against the sticky Actions cell completely
        // dead — no col-resize cursor, no draggable strip — while
        // still giving the user 7 px to grab on the column's own
        // side of the boundary.
        right: clipToCell ? 0 : -7,
        bottom: 0,
        width: clipToCell ? 7 : 14,
        cursor: "col-resize",
        zIndex: 2,
        // The thin coloured guide that appears on hover and stays visible
        // while the user is actively dragging. Centred inside the grab
        // strip: (14 − 2) / 2 = 6 px from the left edge — or 5 px in
        // the clipped 7 px variant so the guide still sits flush with
        // the cell's right border.
        "&::after": {
          content: '""',
          position: "absolute",
          top: 8,
          bottom: 8,
          left: clipToCell ? 5 : 6,
          width: 2,
          borderRadius: 1,
          backgroundColor: active ? "var(--sb-accent)" : "transparent",
          transition: "background-color 0.12s ease",
        },
        "&:hover::after": {
          backgroundColor: active
            ? "var(--sb-accent)"
            : t.palette.action.selected,
        },
      })}
    />
  );
}

export function CrudPage<TEntity, TCreate, TUpdate>(props: { config: CrudConfig<TEntity, TCreate, TUpdate> }) {
  const { config } = props;
  // Responsive bits in this component (filter-cell widths, etc.) are
  // handled inline via `sx` breakpoint objects. Mobile-only full-screen
  // for the create/edit dialog is decided inside `CrudDialog` itself so
  // we don't have to thread a flag through every prop.
  //
  // Below `sm` (≤ 600 px) the data is rendered as a vertical list of
  // cards instead of a horizontally-scrolling table. On a phone the
  // table's narrow per-column widths make every value clip and force
  // the user to swipe sideways through the column set just to read a
  // single row — cards display every field of one row top-to-bottom
  // with a label/value layout, which reads naturally on touch and
  // keeps the entire row visible at once.
  const tableTheme = useTheme();
  const isMobile = useMediaQuery(tableTheme.breakpoints.down("sm"));
  const notify = useNotify();
  // entityLabel is the singular form of `config.title` ("Squads" → "Squad")
  // used in user-visible toast messages like "Squad created" / "3 Users
  // deleted". The slice handles the common plural-with-trailing-s pattern
  // every page in the admin uses today; pages whose title doesn't end in
  // "s" fall through unchanged.
  const entityLabel = useMemo(() => {
    const t = config.title;
    return t.endsWith("s") ? t.slice(0, -1) : t;
  }, [config.title]);
  const [rows, setRows] = useState<TEntity[]>([]);
  const [loading, setLoading] = useState(true);
  // `loadingVisible` is the user-facing loading flag — it lags behind
  // `loading` by ~180 ms so refreshes that finish quickly (e.g. on a
  // local network, where the round-trip is well under that threshold)
  // don't flash the LinearProgress or dim the table at all. The
  // refresh-icon spinner uses `loading` directly so the click still
  // gets immediate feedback; only the heavier visual treatment of the
  // table itself is deferred.
  const [loadingVisible, setLoadingVisible] = useState(false);
  useEffect(() => {
    if (!loading) {
      setLoadingVisible(false);
      return;
    }
    const id = window.setTimeout(() => setLoadingVisible(true), 180);
    return () => window.clearTimeout(id);
  }, [loading]);
  const [refreshSpinning, setRefreshSpinning] = useState(false);
  // `paginating` flips to `true` the moment the user changes page /
  // page size / sort / filters and stays true until the new data
  // arrives. We use it for two things:
  //
  //   (a) Fade just the row body to opacity 0 — desktop TableBody and
  //       mobile row-cards Stack — so the previous page's records
  //       don't visibly linger under the in-flight request. The
  //       column headers (desktop TableHead) and the mobile
  //       select-all + sort toolbar deliberately stay at full
  //       opacity, so the structure of the table remains legible
  //       while the data cells disappear.
  //
  //       Rows stay *mounted* during this fade — clearing them would
  //       collapse the table card to the bare empty-state height,
  //       which reads as a sudden snap; keeping them and just hiding
  //       them with opacity preserves the card's current height all
  //       the way through the round-trip. Once `reload` lands the
  //       new rows in `setRows`, the same gate fades the body back
  //       to opacity 1, so the new entries cross-fade into the slot
  //       the old ones were in.
  //
  //   (b) Suppress the "No data yet" empty-state placeholder so it
  //       doesn't flash between the moment the rows are hidden and
  //       the moment the new ones land — relevant when the previous
  //       page itself was empty and the user filtered / paginated
  //       to a different empty (or pending) result.
  const [paginating, setPaginating] = useState(false);

  // configRef holds the latest config without forcing `reload` to re-fire
  // when the parent's useMemo reidentifies it. Pages that do
  //
  //     const squads = useSquads(api);
  //     const config = useMemo(() => ({ ... }), [api, squads]);
  //
  // (Nodes / Users / *Limiters) get a *new* config object the moment the
  // /squads request resolves, even though `list` / `count` / `filters` are
  // structurally identical to the ones from the previous render. Reading the
  // CRUD callbacks via `configRef.current` inside the imperative reload
  // means a content-equivalent config swap doesn't trigger a second list +
  // count round-trip — fixing the "table loads twice" symptom on every
  // squad-aware page.
  //
  // The declarative paths (column rendering, filter chips, dialog fields)
  // keep reading `config` directly so they pick up the new render functions
  // / option lists on the next paint, which is what makes squad name chips
  // appear once /squads resolves.
  const configRef = useRef(config);
  configRef.current = config;

  // Filter state. Text/select entries are strings; datetime-range entries
  // are `{ start?: string; end?: string }` objects holding the raw value of
  // <input type="datetime-local" />.
  //
  // `filterValues` is the live "draft" — whatever is currently typed in the
  // panel. `appliedFilters` is what was actually submitted via Search. The
  // table re-fetches only when `appliedFilters` changes, so typing into a
  // text filter doesn't fire one request per keystroke.
  //
  // `filterSpecs` is memoised by the JSON of `config.filters` rather than by
  // its array reference. Pages declare `filters: [...]` inline inside their
  // `useMemo` factory, so the array literal is recreated whenever the
  // factory re-runs (e.g. when a `useSquads` catalog finishes loading). The
  // contents are unchanged, but a new reference would ripple through every
  // dep array that lists `filterSpecs` — including `reload`'s — and
  // re-fetch the table for no reason. JSON-stringifying the spec keeps the
  // memo stable across content-equivalent reidentifications. Filter specs
  // are tiny (`name`/`label`/`type` plus a few flags), so the stringify
  // cost is negligible.
  const filtersJSON = JSON.stringify(config.filters ?? []);
  // eslint-disable-next-line react-hooks/exhaustive-deps
  const filterSpecs = useMemo(() => config.filters ?? [], [filtersJSON]);
  // `appliedFilters` is persisted per-page in localStorage so the user's
  // last search survives reloads. The draft (`filterValues`) starts in
  // sync with the persisted applied set so the inputs render filled with
  // whatever was previously applied; subsequent typing stays
  // component-local until the user presses "Search" (or "Reset").
  const [appliedFilters, setAppliedFilters] = usePersistedAppliedFilters(
    config.title,
  );
  const [filterValues, setFilterValues] = useState<Record<string, unknown>>(
    () => appliedFilters,
  );
  const [showFilters, setShowFilters, filtersWillAnimateOpen] =
    usePersistedFiltersOpen(config.title);
  // `filtersFirstPaintReady` gates the very first reload() so the
  // table starts loading rows AFTER the filter panel has settled
  // into its initial state on this mount. Without this gate the
  // rows pop in before / during the panel's open animation when
  // the user navigates from the sidebar to a page with persisted-
  // open filters (subsequent CrudPage mount, panel transitions
  // from closed → open over ~180 ms).
  //
  // Initial value is true unless this mount is going to animate
  // the panel open — i.e. cold mounts, mounts with no filter
  // specs, and mounts where the panel was left closed are all
  // ready immediately. Subsequent flips happen via the Collapse's
  // `onEntered` callback (animation finished) or via a small
  // fallback timeout if the event was swallowed (e.g. the user
  // toggled the panel mid-animation).
  const [filtersFirstPaintReady, setFiltersFirstPaintReady] = useState(
    () => (config.filters?.length ?? 0) === 0 || !filtersWillAnimateOpen,
  );
  const activeFilterCount = useMemo(
    () => countActiveFilters(filterSpecs, appliedFilters),
    [filterSpecs, appliedFilters],
  );
  const draftFilterCount = useMemo(
    () => countActiveFilters(filterSpecs, filterValues),
    [filterSpecs, filterValues],
  );
  const filtersDirty = useMemo(
    () => !filterValuesEqual(filterSpecs, filterValues, appliedFilters),
    [filterSpecs, filterValues, appliedFilters],
  );

  // Pagination state. `page` is 0-indexed (MUI's TablePagination contract).
  // `total` is null until the count endpoint replies — when it's null the
  // pagination bar shows "1–N of more than N" so the user can still navigate.
  // `pageSize` is persisted per-page in localStorage so the user's chosen
  // rows-per-page survives reloads.
  const [page, setPage] = useState(0);
  const [pageSize, setPageSize] = usePersistedPageSize(
    config.title,
    config.defaultPageSize ?? 10,
  );
  const [total, setTotal] = useState<number | null>(null);

  // Sort state. `field` mirrors the column key used by the API; the
  // direction is "asc" / "desc" → translated to `sort_asc` / `sort_desc`
  // query params, matching service/admin_panel/tables/*.go.
  //
  // Persisted per-page in localStorage (keyed off `config.title`, same
  // namespace as page size / filters / column widths) so switching
  // between admin tabs — or reloading the page — keeps whatever
  // column + direction the user last picked, including a deliberate
  // "no sort" choice (`sortField === null` after a third header click).
  const [sortField, setSortField, sortDir, setSortDir] = usePersistedSort(
    config.title,
    config.defaultSort,
  );

  // Each reload picks up a unique id; a response is only allowed to flip
  // state if its id still matches the latest. Without this guard a slow
  // prior reload could land *after* a newer one and overwrite the freshly
  // filtered rows with stale data.
  const reqRef = useRef(0);
  // Flips to `true` the first time a reload finishes (success or error).
  // The empty-state placeholder is rendered (and reserves its full
  // height) from mount onwards so the card never collapses to a
  // header-only strip while the first request is in flight; this flag
  // controls only its `visibility`, so the "No data yet" copy and CTA
  // never flash before we know whether the API actually has rows.
  const [hasFetched, setHasFetched] = useState(false);

  // Excel-style resizable column widths, persisted per page (keyed off the
  // page title). `headerCellRefs` tracks the live `<th>` element for each
  // column so the ResizeHandle can read the column's *actual* current
  // pixel width when the drag starts — that way the auto-distributed
  // widths from `tableLayout: fixed` on first render are honoured before
  // the user has touched anything.
  const {
    widths: columnWidths,
    setWidth: setColumnWidth,
    setManyWidths: setManyColumnWidths,
    resetWidths: resetColumnWidths,
  } = useColumnWidths(config.title);
  // True iff the user has resized at least one column away from its
  // default width — i.e. the localStorage entry would have a non-empty
  // payload. Drives the conditional render of the toolbar's "Reset
  // column widths" button so it only surfaces when there's actually
  // something to reset.
  const hasCustomColumnWidths = Object.keys(columnWidths).length > 0;
  const headerCellRefs = useRef<Record<string, HTMLTableCellElement | null>>({});
  // Inner ref to the inline-flex span that wraps each header's visible
  // content (label + sort caret). Measured to derive the per-column
  // natural minimum width — see `columnNaturalMins` below.
  const headerLabelRefs = useRef<Record<string, HTMLElement | null>>({});
  // Ref to the horizontally-scrolling container around the table. Used
  // to clamp column resize so a column's right edge can never travel
  // past the sticky Actions column — i.e. the total table width never
  // exceeds the visible scroll viewport width. Without this clamp the
  // user could grow a column wider than the viewport and end up with
  // the rightmost data columns hidden underneath the pinned Actions.
  const scrollContainerRef = useRef<HTMLDivElement | null>(null);
  const [tableViewportWidth, setTableViewportWidth] = useState<number | null>(
    null,
  );
  useEffect(() => {
    const el = scrollContainerRef.current;
    if (!el || typeof ResizeObserver === "undefined") return;
    const ro = new ResizeObserver(() => {
      setTableViewportWidth(el.clientWidth);
    });
    ro.observe(el);
    setTableViewportWidth(el.clientWidth);
    return () => ro.disconnect();
  }, []);
  const getColumnLiveWidth = useCallback(
    (key: string): number => {
      const stored = columnWidths[key];
      if (stored != null) return stored;
      const el = headerCellRefs.current[key];
      return el ? el.getBoundingClientRect().width : DEFAULT_DATA_COL_WIDTH;
    },
    [columnWidths],
  );

  // Promote every previously-unstored data column to a fixed width equal
  // to its current rendered width. Called once at the start of every
  // resize drag so the unstored neighbours don't rubber-band along with
  // the dragged column. Under `tableLayout: fixed`, columns without an
  // explicit width share the leftover horizontal space equally; without
  // this lock-in, growing the dragged column would shrink each unstored
  // neighbour by their share of the leftover instead of leaving them
  // alone. We snapshot live widths (not natural mins) so the visual
  // layout is byte-identical the instant the drag starts — the user
  // sees no jump.
  const lockInUnstoredColumnWidths = useCallback(() => {
    const entries: Record<string, number> = {};
    for (const c of config.columns) {
      const key = String(c.key);
      if (columnWidths[key] != null) continue;
      const el = headerCellRefs.current[key];
      if (!el) continue;
      entries[key] = el.getBoundingClientRect().width;
    }
    if (Object.keys(entries).length > 0) setManyColumnWidths(entries);
  }, [config.columns, columnWidths, setManyColumnWidths]);

  // columnNaturalMins is the smallest cell width that still fully shows a
  // column's header content (label text + sort caret) plus the cell's
  // horizontal padding. We measure the inner inline-flex wrapper after
  // each render — that node sizes itself to its content regardless of
  // the cell's enforced width, so it stays accurate even when the user
  // has resized the column smaller than its content.
  //
  // The state form (rather than a ref) is on purpose: when the natural
  // minimum changes (different label, font-load), we want React to
  // re-render so the cell's `width` is recomputed via the
  // `Math.max(stored, naturalMin)` rule below, and the visible column
  // can never be narrower than its content.
  const [columnNaturalMins, setColumnNaturalMins] = useState<
    Record<string, number>
  >({});
  // Buffer added on top of the measured label width — accounts for the
  // TableCell's left + right padding (16 px each in the size="small"
  // variant) plus a small safety margin so the resize handle's grab strip
  // never overlaps the last glyph of the label.
  const HEADER_PADDING_BUFFER = 32 + 8;
  useLayoutEffect(() => {
    const next: Record<string, number> = {};
    for (const c of config.columns) {
      const key = String(c.key);
      const el = headerLabelRefs.current[key];
      if (!el) continue;
      next[key] =
        Math.ceil(el.getBoundingClientRect().width) + HEADER_PADDING_BUFFER;
    }
    setColumnNaturalMins((prev) => {
      let same = Object.keys(next).length === Object.keys(prev).length;
      if (same) {
        for (const k in next) {
          if (prev[k] !== next[k]) {
            same = false;
            break;
          }
        }
      }
      return same ? prev : next;
    });
  });

  const getColumnNaturalMin = useCallback(
    (key: string): number => {
      const measured = columnNaturalMins[key];
      return measured != null ? Math.max(MIN_COL_WIDTH, measured) : MIN_COL_WIDTH;
    },
    [columnNaturalMins],
  );

  // tableMinWidth is the table's lower-bound width used to prevent column
  // overlap when the user resizes columns to a sum that exceeds the
  // container's width. With `tableLayout: fixed` and the default
  // `width: 100%`, a column that the user has dragged wider would
  // otherwise force the *other* columns to visually compress and overlap
  // their content (because the table itself can't grow past the
  // container). Setting `minWidth` to the actual sum of column widths
  // forces the table to grow when the columns no longer fit, which in
  // turn lets the surrounding `TableContainer` (which has
  // `overflow-x: auto`) provide a natural horizontal scrollbar.
  //
  // Each column contributes `max(stored, naturalMin)` so the lower bound
  // also covers cases where a previously-stored width is now narrower
  // than the column's measured content (e.g. the label text grew, or the
  // localStorage value predates this fix).
  const tableMinWidth = useMemo(() => {
    // checkbox column + actions column. The actions column starts at
    // 92 px (Edit + Delete) and grows by ~32 px per extra rowAction so
    // the sticky cell doesn't have to scroll its own children when a
    // page configures additional buttons (e.g. the "reset traffic"
    // action on the Traffic Limiters page).
    let sum = 72 + 92 + (config.rowActions?.length ?? 0) * 32;
    for (const c of config.columns) {
      const key = String(c.key);
      const stored = columnWidths[key];
      const naturalMin = getColumnNaturalMin(key);
      sum += stored != null
        ? Math.max(stored, naturalMin)
        : Math.max(naturalMin, DEFAULT_DATA_COL_WIDTH);
    }
    return sum;
  }, [config.columns, columnWidths, getColumnNaturalMin]);

  // getColumnMaxWidth — upper bound for a single column's width during
  // a resize drag. Excel-style: the only cap is the column's own
  // natural minimum (which is really a lower-bound, applied via
  // `Math.max(min, target)` in the drag handler — exposed here so the
  // ResizeHandle's max never falls below it for over-tight viewports).
  //
  // We deliberately do NOT cap by the visible scroll viewport: the
  // surrounding `TableContainer` is `overflow-x: auto`, so dragging a
  // column wider than the viewport just makes the table overflow and
  // scroll horizontally. The sticky Actions column stays pinned to
  // the right edge of the viewport regardless of how wide the data
  // columns get, which is the same behaviour spreadsheet users
  // expect.
  const getColumnMaxWidth = useCallback(
    (_key: string): number => {
      // No upper cap: the user can pull a column as wide as they like;
      // the table simply grows past the viewport and the surrounding
      // TableContainer scrolls horizontally.
      return Number.POSITIVE_INFINITY;
    },
    [],
  );
  const reload = useCallback(async () => {
    const reqId = ++reqRef.current;
    setLoading(true);
    try {
      // Read every config field through the ref so a content-equivalent
      // config swap (see `configRef` above) doesn't reach this callback's
      // dep array and trigger a duplicate fetch.
      const cfg = configRef.current;
      const filterQuery = buildFilterQuery(filterSpecs, appliedFilters);
      // Only the list endpoint cares about pagination + sort; count is
      // computed against the same filters but ignores those params.
      const listQuery: Listable = {
        ...filterQuery,
        offset: page * pageSize,
        limit: pageSize,
      };
      if (sortField) {
        if (sortDir === "asc") listQuery.sort_asc = sortField;
        else listQuery.sort_desc = sortField;
      }
      const [rowsResult, countResult] = await Promise.all([
        cfg.list(listQuery),
        cfg.count ? cfg.count(filterQuery) : Promise.resolve<number | null>(null),
      ]);
      if (reqRef.current !== reqId) return;
      // Notify observers (e.g. useSquadCatalog) with the new row set
      // *before* publishing it so they can fetch the auxiliary data
      // those rows reference (squad names, …). When the observer
      // returns a promise we await it, which is what keeps the table
      // from briefly rendering with raw ids before the friendly chip
      // labels arrive — once setRows fires, the supporting catalogs
      // are already populated and the first paint shows the final
      // names. `onRowsChange` is read through configRef — not the
      // `reload` dep array — so a new callback identity from the
      // parent's `useMemo` doesn't retrigger the fetch.
      if (cfg.onRowsChange) {
        try {
          await cfg.onRowsChange(rowsResult);
        } catch {
          /* best-effort: a misbehaving observer must not break the table */
        }
        if (reqRef.current !== reqId) return;
      }
      // Reconcile by primary key so refreshes that return identical data
      // re-use the previous row references — the memoised <CrudRow>
      // skips its render path when `prev.row === next.row`, which is
      // what keeps the table from visibly redrawing on a no-op refresh.
      // When every row is unchanged we even hand back the previous
      // array so React's setState bail-out short-circuits the parent
      // re-render entirely.
      setRows((prev) =>
        reconcileRows(
          prev as unknown as Record<string, unknown>[],
          rowsResult as unknown as Record<string, unknown>[],
          String(cfg.idKey),
        ) as unknown as TEntity[],
      );
      setTotal(countResult);
    } catch (e) {
      if (reqRef.current !== reqId) return;
      // Reload errors are surfaced exclusively through the global toast
      // stack — no inline Alert in the page chrome. The empty-state
      // placeholder still renders below (the table just stays empty)
      // so the user has both the toast and the visual cue that no rows
      // came back. `notifyApiError` picks a useful description for the
      // exception class (connection vs. HTTP vs. unauthorized).
      notifyApiError(notify, `Failed to load ${configRef.current.title}`, e);
    } finally {
      if (reqRef.current === reqId) {
        setLoading(false);
        setHasFetched(true);
        // Drop the `paginating` flag the moment the latest in-flight
        // request resolves — success or failure. The empty-state
        // placeholder reappears (if `rows` is still empty) and the
        // user's pagination/sort/filter change is fully landed.
        // Stale reloads (reqId mismatch above) leave it alone so a
        // newer pending pagination keeps the placeholder hidden.
        setPaginating(false);
      }
    }
  }, [filterSpecs, appliedFilters, page, pageSize, sortField, sortDir, notify]);

  useEffect(() => {
    if (!filtersFirstPaintReady) return;
    void reload();
  }, [reload, filtersFirstPaintReady]);

  // Safety net for the animated-open path: the Collapse's `onEntered`
  // is what normally flips `filtersFirstPaintReady` to true after the
  // panel finishes its ~180 ms open transition. If for any reason that
  // event doesn't fire — e.g. the user toggled the panel closed
  // mid-animation, or the Collapse unmounted before the transitionend
  // landed — fall back to a short timeout so the table doesn't sit
  // empty forever waiting for an event that already came and went.
  // This effect is a no-op for every mount path that initialises
  // `filtersFirstPaintReady` to true (cold mounts, no-filter pages,
  // and subsequent mounts with the panel left closed).
  useEffect(() => {
    if (filtersFirstPaintReady) return;
    const t = window.setTimeout(() => setFiltersFirstPaintReady(true), 250);
    return () => window.clearTimeout(t);
  }, [filtersFirstPaintReady]);

  const setFilterValue = (name: string, v: unknown) => {
    setFilterValues((p) => ({ ...p, [name]: v }));
  };
  // Applying / clearing filters resets the pagination to the first page
  // so the user is never left on a page that no longer exists. The row
  // list is faded to opacity 0 (but kept mounted) by the dependency-
  // driven effect that watches the same five values (search for
  // `setPaginating`), which arms the `paginating` flag the moment
  // any of `page`, `pageSize`, `sortField`, `sortDir`, or
  // `appliedFilters` change. The flag is cleared again from inside
  // `reload` once the new rows arrive, at which point the container
  // fades back to opacity 1 and the new rows cross-fade into the
  // slot the old ones occupied.
  const applyFilters = () => {
    setAppliedFilters(filterValues);
    setPage(0);
  };
  const resetFilters = () => {
    setFilterValues({});
    setAppliedFilters({});
    setPage(0);
  };

  // handleSort toggles between asc → desc → unsorted on the active column,
  // and switches to a fresh column when a different header is clicked.
  // Changing sort always returns to the first page so the user sees the
  // top of the new ordering.
  const handleSort = (key: string) => {
    if (sortField === key) {
      if (sortDir === "asc") {
        setSortDir("desc");
      } else {
        // Third click clears the sort.
        setSortField(null);
        setSortDir("asc");
      }
    } else {
      setSortField(key);
      setSortDir("asc");
    }
    setPage(0);
  };

  const [editing, setEditing] = useState<TEntity | null>(null);
  const [creating, setCreating] = useState(false);
  // `dialogOpen` drives the MUI Dialog's `open` prop directly; flipping it
  // to false starts the leave transition. `creating` / `editing` are
  // cleared only after the transition finishes (handleDialogExited) so the
  // dialog keeps rendering its current title and form contents through
  // the fade-out — same trick the delete-confirm dialog uses with
  // `lastPendingDeleteRef` below.
  const [dialogOpen, setDialogOpen] = useState(false);

  const openCreate = useCallback(() => {
    setEditing(null);
    setCreating(true);
    setDialogOpen(true);
  }, []);

  const closeDialog = () => {
    setDialogOpen(false);
  };

  const handleDialogExited = () => {
    setCreating(false);
    setEditing(null);
  };

  // Global keyboard shortcut: pressing "n" (physical key, layout-
  // independent) anywhere on the page opens the Create dialog. We use
  // `ev.code === "KeyN"` rather than `ev.key === "n"` so the shortcut
  // fires regardless of keyboard layout — a Russian / Cyrillic layout
  // would otherwise emit `ev.key === "т"`.
  // Skipped while another dialog is open, when a modifier is held
  // (so Ctrl+N etc. keep their browser meaning), and when the user is
  // typing into an input.
  // While the dialog is closing the MUI `open` prop is already false but
  // `creating`/`editing` are still set (cleared by handleDialogExited);
  // gate the shortcut on that combined "live" predicate so a stray "n"
  // press during the leave animation doesn't immediately reopen.
  const dialogLive = dialogOpen || creating || editing !== null;
  useEffect(() => {
    const handler = (ev: globalThis.KeyboardEvent) => {
      if (ev.code !== "KeyN") return;
      if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
      if (dialogLive) return;
      const target = ev.target as HTMLElement | null;
      if (target) {
        const tag = target.tagName;
        if (
          tag === "INPUT" ||
          tag === "TEXTAREA" ||
          tag === "SELECT" ||
          target.isContentEditable
        )
          return;
      }
      ev.preventDefault();
      openCreate();
    };
    window.addEventListener("keydown", handler);
    return () => window.removeEventListener("keydown", handler);
  }, [dialogLive, openCreate]);

  // Minimum visible window for the form dialog's "Saving…" busy state,
  // mirroring the delete-dialog treatment. Without it, a sub-100 ms
  // create/update round-trip causes the spinner + label to appear for
  // a single frame and disappear — the user perceives that as a glitch
  // rather than as the action having run. Both handlers gate `closeDialog`
  // (and the error toast) on this so the on-screen busy state is held
  // long enough to be perceived even on a fast network.
  const ensureMinFormBusy = async (startTs: number) => {
    const elapsed = performance.now() - startTs;
    const minBusyMs = 350;
    if (elapsed < minBusyMs) {
      await new Promise((r) => setTimeout(r, minBusyMs - elapsed));
    }
  };

  // Both handlers re-throw so the dialog's inline error UI keeps working
  // (the dialog catches and renders the message inside an Alert). The toast
  // is a complementary signal: it flashes a quick "X created" / "Failed to
  // create X" status without interfering with the dialog's per-field error
  // panel. notifyApiError silently swallows UnauthorizedError because the
  // global 401 handler in AuthContext already announces those.
  const handleCreate = async (form: Record<string, unknown>) => {
    const body = config.toCreate(form) as TCreate;
    const startTs = performance.now();
    try {
      await config.create(body);
    } catch (e) {
      await ensureMinFormBusy(startTs);
      notifyApiError(notify, `Failed to create ${entityLabel}`, e);
      throw e;
    }
    await ensureMinFormBusy(startTs);
    closeDialog();
    notify.success(`${entityLabel} created successfully`);
    await reload();
  };

  const handleUpdate = async (form: Record<string, unknown>) => {
    if (!editing) return;
    const id = editing[config.idKey] as unknown as string | number;
    const body = config.toUpdate(form, editing) as TUpdate;
    const startTs = performance.now();
    try {
      await config.update(id, body);
    } catch (e) {
      await ensureMinFormBusy(startTs);
      notifyApiError(notify, `Failed to update ${entityLabel}`, e);
      throw e;
    }
    await ensureMinFormBusy(startTs);
    closeDialog();
    notify.success(`${entityLabel} updated successfully`);
    await reload();
  };

  // -------- Delete confirmation ----------------------------------------
  // A single MUI dialog drives both the per-row Delete icon and the bulk
  // "Delete selected" button. `pendingDelete` carries the primary keys
  // about to be removed; null means the dialog is closed.
  const [pendingDelete, setPendingDelete] = useState<
    | { kind: "single"; id: string | number; label: string }
    | { kind: "bulk"; ids: (string | number)[] }
    | null
  >(null);
  const [deleteBusy, setDeleteBusy] = useState(false);
  // Hold onto the last non-null `pendingDelete` so the dialog title and
  // body keep showing the entity label/count during the MUI fade-out
  // animation that fires after `setPendingDelete(null)`. Without this,
  // the closing dialog briefly renders " will be permanently removed
  // from the database." with an empty subject — which looks like the
  // name "vanishes" the instant the user clicks Delete.
  const lastPendingDeleteRef = useRef(pendingDelete);
  if (pendingDelete !== null) {
    lastPendingDeleteRef.current = pendingDelete;
  }
  const displayPendingDelete = pendingDelete ?? lastPendingDeleteRef.current;

  const handleDelete = useCallback(
    (row: TEntity) => {
      const id = row[config.idKey] as unknown as string | number;
      setPendingDelete({
        kind: "single",
        id,
        label: `${config.title.slice(0, -1)} #${String(id)}`,
      });
    },
    [config.idKey, config.title],
  );
  // Stable `setEditing` wrapper so memoised rows don't re-render just
  // because the inline `() => setEditing(row)` arrow swaps reference
  // every parent render. setEditing itself is already stable from
  // useState, but the row needs a `(row) => void` callback. We also flip
  // `dialogOpen` here to start the enter transition — closeDialog leaves
  // it set to false, so reopening must explicitly set it back to true.
  const handleEdit = useCallback((row: TEntity) => {
    setCreating(false);
    setEditing(row);
    setDialogOpen(true);
  }, []);

  // pendingAction drives the row-action confirmation dialog (mirror of
  // pendingDelete above). Null when no dialog is open. Pages opt into
  // the dialog by setting `RowActionSpec.confirm`; actions without
  // `confirm` skip this state and run inline in `handleRunAction`.
  const [pendingAction, setPendingAction] = useState<
    { row: TEntity; action: RowActionSpec<TEntity> } | null
  >(null);
  const [actionBusy, setActionBusy] = useState(false);
  // Same trick as `lastPendingDeleteRef`: hold the last non-null
  // pendingAction so the dialog can keep displaying its title /
  // description while it animates closed (after `setPendingAction(null)`).
  const lastPendingActionRef = useRef(pendingAction);
  if (pendingAction !== null) {
    lastPendingActionRef.current = pendingAction;
  }
  const displayPendingAction = pendingAction ?? lastPendingActionRef.current;
  const displayActionConfirm = useMemo(() => {
    if (!displayPendingAction?.action.confirm) return null;
    return displayPendingAction.action.confirm(displayPendingAction.row);
  }, [displayPendingAction]);

  // handleRunAction either opens the confirmation dialog (when the
  // action declares one) or invokes the action immediately. Inline
  // errors are surfaced through the global toast stack the same way
  // the CrudPage's own Create / Edit / Delete failures are; the
  // action's `onClick` itself is responsible for emitting any success
  // toast.
  const handleRunAction = useCallback(
    (row: TEntity, action: RowActionSpec<TEntity>) => {
      if (action.confirm) {
        setPendingAction({ row, action });
        return;
      }
      void Promise.resolve(action.onClick(row, { reload })).catch((e) => {
        notifyApiError(notify, `Failed to ${action.label.toLowerCase()}`, e);
      });
    },
    [reload, notify],
  );

  // confirmAction is the primary-button handler for the row-action
  // dialog. Modeled on confirmDelete: minimum-visible busy state to
  // suppress single-frame flickers, errors keep the dialog open so
  // the user can retry, success closes it and reloads the table.
  const confirmAction = async () => {
    if (!pendingAction) return;
    setActionBusy(true);
    const startTs = performance.now();
    const minBusyMs = 350;
    let shouldClose = false;
    try {
      await Promise.resolve(
        pendingAction.action.onClick(pendingAction.row, { reload }),
      );
      shouldClose = true;
    } catch (e) {
      notifyApiError(
        notify,
        `Failed to ${pendingAction.action.label.toLowerCase()}`,
        e,
      );
    }
    const elapsed = performance.now() - startTs;
    if (elapsed < minBusyMs) {
      await new Promise((r) => setTimeout(r, minBusyMs - elapsed));
    }
    if (shouldClose) {
      setPendingAction(null);
      // Hold the busy visuals through the dialog's exit animation so
      // the button label / spinner don't snap back to idle while the
      // dialog itself is still fading away on screen — same 260 ms
      // as confirmDelete.
      setTimeout(() => setActionBusy(false), 260);
    } else {
      setActionBusy(false);
    }
  };

  // pluralEntityLabel formats the entity label for a count of things —
  // singular when count === 1, the configured plural title otherwise. Used
  // for bulk-delete toasts so 1 row reads "Squad deleted" while 5 rows
  // read "5 Squads deleted".
  const pluralEntityLabel = (count: number) =>
    count === 1 ? entityLabel : config.title;

  const confirmDelete = async () => {
    if (!pendingDelete) return;
    setDeleteBusy(true);
    const startTs = performance.now();
    // Minimum visible duration for the "Deleting…" state. Without
    // this, a sub-100 ms network round-trip causes the label /
    // spinner to flicker on for a single frame and back off, which
    // reads as "the button glitched" rather than "the action ran".
    const minBusyMs = 350;
    let shouldClose = false;
    try {
      if (pendingDelete.kind === "single") {
        try {
          await config.remove(pendingDelete.id);
        } catch (e) {
          notifyApiError(notify, `Failed to delete ${entityLabel}`, e);
          throw e;
        }
        notify.success(`${entityLabel} deleted successfully`);
      } else {
        // Run bulk deletes in parallel; settle-all so a single failure
        // doesn't hide the successes from the reload below. We then split
        // the results so the user sees both halves: a green "N deleted"
        // chip for the wins and a red "M failed" chip for the losses.
        const results = await Promise.allSettled(
          pendingDelete.ids.map((id) => config.remove(id)),
        );
        const ok = results.filter((r) => r.status === "fulfilled").length;
        const failed = results.length - ok;
        if (ok > 0) {
          notify.success(
            `${ok} ${pluralEntityLabel(ok)} deleted successfully`,
          );
        }
        if (failed > 0) {
          // Surface the first error verbatim — almost always the same
          // for every entry (permissions / FK constraint / connectivity)
          // so picking a representative one keeps the toast useful.
          const firstReason = (results.find(
            (r) => r.status === "rejected",
          ) as PromiseRejectedResult | undefined)?.reason;
          if (firstReason !== undefined) {
            notifyApiError(
              notify,
              `Failed to delete ${failed} ${pluralEntityLabel(failed)}`,
              firstReason,
            );
          } else {
            notify.error(
              `Failed to delete ${failed} ${pluralEntityLabel(failed)}`,
            );
          }
        }
      }
      shouldClose = true;
    } catch {
      // Already announced via `notifyApiError` inside the inner try.
      // Swallow here so the rejection doesn't bubble to the global
      // error handler — the dialog stays open (we never set
      // `shouldClose = true`) so the user can retry or cancel.
    }
    const elapsed = performance.now() - startTs;
    if (elapsed < minBusyMs) {
      await new Promise((r) => setTimeout(r, minBusyMs - elapsed));
    }
    if (shouldClose) {
      setPendingDelete(null);
      setSelected(new Set());
      await reload();
      // Hold the busy visuals through the dialog's exit animation
      // (~225 ms default MUI Dialog fade) so the button label /
      // spinner don't snap back to the idle "Delete" state while
      // the dialog itself is still fading away on screen.
      setTimeout(() => setDeleteBusy(false), 260);
    } else {
      setDeleteBusy(false);
    }
  };

  // -------- Selection / bulk actions ------------------------------------
  // `selected` holds the primary-key values (string | number) of every
  // currently checked row. We scope the selection to the rows that are
  // actually visible — paginating, applying filters, sorting or reloading
  // wipes it so the user can't accidentally bulk-act on rows they're no
  // longer looking at.
  const idOf = useCallback(
    (row: TEntity): string | number =>
      row[config.idKey] as unknown as string | number,
    [config.idKey],
  );
  // Stabilise the columns reference so memoised rows don't re-render
  // every time the parent's config object is reconstructed (which happens
  // on each parent render when filterValues / loading / etc. change).
  const columnsRef = useMemo(() => config.columns, [config.columns]);

  // Subset of columns the user is allowed to sort by — mirrors the
  // desktop header logic where each TableSortLabel is gated on
  // `c.sortable !== false`. Computed once per columns reference so
  // the mobile sort dropdown doesn't rebuild its option list on every
  // unrelated render.
  const sortableColumns = useMemo(
    () => config.columns.filter((c) => (c.sortable ?? true) !== false),
    [config.columns],
  );

  // -------- Smooth size transitions ------------------------------------
  // The table card's height changes every time the row count does
  // (filter applied, page changed, route switched). The Web Animations
  // API smoothly tweens between the old and new heights — useLayoutEffect
  // runs after the DOM update but before paint, so the captured height
  // represents the *new* layout while `prevHeightRef.current` still holds
  // the previous one.
  //
  // Speed (px / sec) is held constant rather than the duration: with a
  // fixed 220 ms a small +1-row change felt sluggish while a +25-row
  // jump looked rushed, because the same 220 ms had to cover wildly
  // different deltas. Sliding at a constant px/sec keeps the visual
  // velocity uniform regardless of how many rows just arrived from
  // the API. The clamp on the bottom prevents micro-deltas from
  // animating in 5 ms (effectively a snap), and the cap on top keeps
  // very large jumps from feeling laggy.
  //
  // Desktop (md+) skips this tween entirely: the layout pins the Paper
  // to `flex: 1` of the leftover viewport space, so its `offsetHeight`
  // is governed by flex, not by row count. Animating `height: Xpx`
  // through the Web Animations API would temporarily inline an
  // explicit pixel height that fights the flex algorithm — the
  // browser briefly resolves the Paper to that fixed height while
  // it would have otherwise been "fill-leftover", which translates
  // into the PageHeader / filter area visibly jumping toward the
  // top each time fetched rows arrive (because the parent flex
  // chain redistributes the leftover space differently when one
  // child has an explicit height versus a `flex-basis: 0%`). On
  // mobile / sm where the Paper is still block-laid-out and grows
  // with content, the tween stays useful and runs unchanged.
  const heightTweenTheme = useTheme();
  const heightTweenIsDesktop = useMediaQuery(
    heightTweenTheme.breakpoints.up("md"),
  );
  const tableRef = useRef<HTMLDivElement | null>(null);
  const prevHeightRef = useRef<number | null>(null);
  useLayoutEffect(() => {
    const el = tableRef.current;
    if (!el) return;
    if (heightTweenIsDesktop) {
      // Keep the ref bookkeeping in sync so a future viewport
      // resize back to mobile starts from a sane baseline rather
      // than from the height captured pre-`md`.
      prevHeightRef.current = el.offsetHeight;
      return;
    }
    const newHeight = el.offsetHeight;
    if (
      prevHeightRef.current !== null &&
      prevHeightRef.current !== newHeight &&
      typeof el.animate === "function"
    ) {
      const delta = Math.abs(newHeight - prevHeightRef.current);
      // Calibrated so big swaps don't drag: at 1300 px/sec, a
      // typical single-row delta (~50 px) still clears the
      // 240 ms floor; a 200 px change takes ~154 ms (floored);
      // a ~40-row swap (~800 px) takes ~615 ms; a full-page
      // resize (e.g. 100 → 10 rows, ~4500 px) takes ~3.5 s
      // mathematically but is hard-capped at 560 ms so very
      // large reflows never feel like waiting.
      const PX_PER_SEC = 1300;
      // Floor of 240 ms is the inflection where motion reads
      // as "deliberate movement" rather than a snap on 60 Hz
      // displays — going below ~220 ms with a symmetric easing
      // curve loses the perceptible glide.
      const MIN_MS = 240;
      // Ceiling of 560 ms keeps even the biggest page-size
      // changes from dragging — past ~600 ms a height tween
      // tips over from "the table is settling" into "the table
      // is slow", regardless of how many rows just arrived.
      const MAX_MS = 560;
      const duration = Math.max(
        MIN_MS,
        Math.min(MAX_MS, (delta / PX_PER_SEC) * 1000),
      );
      el.animate(
        [
          { height: `${prevHeightRef.current}px` },
          { height: `${newHeight}px` },
        ],
        // Symmetric S-curve (gentle ease-in-out). The previous
        // Material-standard `cubic-bezier(0.4, 0, 0.2, 1)`
        // started fast and decelerated — the card lurched out
        // of its starting height and then crawled into place.
        // `0.45, 0, 0.55, 1` is a balanced sigmoid: slow at both
        // ends and brisk through the middle, which reads as a
        // continuous, "pulled" reflow.
        { duration, easing: "cubic-bezier(0.45, 0, 0.55, 1)" },
      );
    }
    prevHeightRef.current = newHeight;
  }, [rows.length, hasFetched, heightTweenIsDesktop]);

  // -------- Row-count-scaled animation timings -------------------------
  // The dim overlay and the top progress bar both fade with a duration
  // that grows with the size of the visible table. Few rows → snappy
  // (the table is small, so the eye absorbs the change instantly);
  // many rows → graceful (a wall of cells benefits from a longer
  // breath so the dim looks like a deliberate veil rather than a
  // strobe).
  //
  // The square-root curve climbs steeply for the first few rows then
  // flattens out — perceptually we're far more sensitive to the
  // difference between 0 and 5 rows than between 50 and 100, so the
  // ramp matches that. `Math.sqrt(50) ≈ 7.07`, so the divisor of 7
  // saturates the scale near 50 rows and then plateaus.
  //
  // The height-tween animation already scales with pixel delta
  // (`PX_PER_SEC` / `MIN_MS` / `MAX_MS` above), which is itself a
  // proxy for "how many rows just appeared/disappeared", so we don't
  // need a separate row-count factor there.
  const rowAnimScale = Math.min(1, Math.sqrt(rows.length) / 7);
  // 220 ms (empty / 1 row) → 460 ms (50+ rows). The upper bound is
  // intentionally compressed: at the previous 700 ms ceiling, big
  // tables felt sluggish — a wall of 50+ rows benefits from a
  // longer-than-snappy fade, but past ~450 ms the user starts
  // *waiting* on the dim instead of perceiving it as stale-state
  // feedback.
  const dimDurationMs = Math.round(220 + rowAnimScale * 240);
  // 180 ms (empty / 1 row) → 360 ms (50+ rows). Still slightly
  // faster than the dim so the progress bar leads the dim into /
  // out of view, and tightened in lockstep so big tables don't
  // sit watching a slow strip of motion above static cells.
  const progressDurationMs = Math.round(180 + rowAnimScale * 180);

  const [selected, setSelected] = useState<Set<string | number>>(new Set());
  // Rebuild the selection so it only ever contains keys that exist on the
  // current page (e.g. a row was deleted by another tab).
  useEffect(() => {
    setSelected((prev) => {
      if (prev.size === 0) return prev;
      const visible = new Set<string | number>(rows.map(idOf));
      let changed = false;
      const next = new Set<string | number>();
      for (const k of prev) {
        if (visible.has(k)) next.add(k);
        else changed = true;
      }
      return changed ? next : prev;
    });
  }, [rows, idOf]);
  // Clear selection whenever the user paginates, sorts, or applies a new
  // filter — bulk actions should only target rows the user can see right
  // now.
  useEffect(() => {
    setSelected(new Set());
  }, [page, pageSize, sortField, sortDir, appliedFilters]);
  // Arm the `paginating` flag the moment the user paginates / sorts /
  // changes a filter. We don't clear `rows` here on purpose — clearing
  // would collapse the table card from "25 visible rows" down to the
  // bare empty-state placeholder height, which reads as a sudden
  // layout snap. Instead, the existing rows stay mounted (so the
  // card keeps its current height) but get faded to opacity 0 via
  // the gate on the desktop TableBody / mobile row-cards Stack
  // below — the column headers and mobile toolbar above them stay
  // fully visible. When the new data lands, `reload`'s
  // `setRows(reconcileRows(...))` swaps the entries in place and
  // `setPaginating(false)` lets the body fade back to opacity 1, so
  // the new rows appear to cross-fade into the slot the old ones
  // were occupying. The empty-state placeholder stays hidden for
  // the same window so "No data yet" doesn't flash mid-roundtrip.
  //
  // Skipped on the very first run (initial mount) — `rows` is already
  // `[]` and we haven't even fetched anything, so there's nothing to
  // hide and nothing to gate against. Without this guard the initial
  // empty-state placeholder would briefly arm `paginating` for the
  // duration of the first reload, keeping the "No data yet" copy
  // hidden longer than necessary on cold mounts that legitimately
  // resolve to an empty list.
  const dataDepsFirstRunRef = useRef(true);
  useEffect(() => {
    if (dataDepsFirstRunRef.current) {
      dataDepsFirstRunRef.current = false;
      return;
    }
    setPaginating(true);
  }, [page, pageSize, sortField, sortDir, appliedFilters]);

  const allSelected = rows.length > 0 && selected.size === rows.length;
  const someSelected = selected.size > 0 && !allSelected;
  const toggleAll = () => {
    setSelected(allSelected ? new Set() : new Set(rows.map(idOf)));
  };
  const toggleRow = useCallback(
    (row: TEntity) => {
      const id = idOf(row);
      setSelected((prev) => {
        const next = new Set(prev);
        if (next.has(id)) next.delete(id);
        else next.add(id);
        return next;
      });
    },
    [idOf],
  );

  const handleBulkDelete = () => {
    if (selected.size === 0) return;
    setPendingDelete({ kind: "bulk", ids: Array.from(selected) });
  };

  return (
    // Desktop (md+): flex column that fills the page-content slot
    // exactly, so the table card below can `flex: 1` and stay inside
    // the viewport regardless of how many rows / how tall a filter
    // panel is open. Mobile: default block layout — the page
    // continues to scroll naturally on phones, where a "fit to
    // viewport" table card would just feel cramped.
    <Box
      sx={{
        display: { md: "flex" },
        flexDirection: { md: "column" },
        flex: { md: 1 },
        minHeight: { md: 0 },
      }}
    >
      <PageHeader
        title={config.title}
        subtitle={config.subtitle}
        icon={config.icon}
        actions={
          // `flexWrap: "wrap"` lets the toolbar fold onto a second row
          // on very narrow phones (≤ 360 px) where Filters + Refresh +
          // New together don't fit on a single line. Stack's default
          // `spacing` doesn't apply across wrapped lines, so we add an
          // explicit `rowGap` to keep wrapped buttons from touching.
          <Stack
            direction="row"
            // Spacing + size match the topbar in Layout.tsx so the
            // page-level toolbar reads as a continuation of the
            // global header chrome: 1.5 (= 12 px) between buttons,
            // 40 × 40 footprint per button, `borderRadius: 2`,
            // `fontSize="small"` icons inside.
            spacing={1.5}
            alignItems="center"
            sx={{ flexWrap: "wrap", rowGap: 1 }}
          >
            {hasCustomColumnWidths && (
              <Tooltip title="Reset column widths">
                <IconButton
                  onClick={resetColumnWidths}
                  size="medium"
                  aria-label="Reset column widths"
                  sx={{
                    width: 40,
                    height: 40,
                    borderRadius: 2,
                    color: "text.primary",
                    // Same hover-gating as the Refresh sibling: only paint
                    // the hover tint on real hover-capable pointers at
                    // desktop widths so a tap on touch / DevTools mobile
                    // emulation doesn't leave the tint stuck on.
                    "@media (hover: hover) and (min-width: 600px)": {
                      "&:hover": { backgroundColor: "action.hover" },
                    },
                  }}
                >
                  <RestartAltIcon fontSize="small" />
                </IconButton>
              </Tooltip>
            )}
            <Tooltip title="Refresh">
              <span>
                <IconButton
                  onClick={() => {
                    setRefreshSpinning(true);
                    void reload();
                  }}
                  size="medium"
                  sx={{
                    width: 40,
                    height: 40,
                    borderRadius: 2,
                    color: "text.primary",
                    // Mobile browsers leave `:hover` stuck on the
                    // last-tapped button until the user taps elsewhere,
                    // which paints a hover tint around Refresh after
                    // every tap. Gate the hover rule on a real
                    // hover-capable pointer AND a desktop-width
                    // viewport so the tint doesn't fire either on real
                    // touch devices or in Chrome DevTools' mobile
                    // emulation mode (which keeps `(hover: hover)`
                    // true because the host machine still has a
                    // mouse, but matches the narrow viewport of a
                    // phone). 599.95 px is the upper edge of MUI's
                    // `xs` breakpoint.
                    "@media (hover: hover) and (min-width: 600px)": {
                      "&:hover": { backgroundColor: "action.hover" },
                    },
                  }}
                >
                  <RefreshIcon
                    fontSize="small"
                    onAnimationIteration={() => {
                      if (!loading) setRefreshSpinning(false);
                    }}
                    sx={{
                      animation: refreshSpinning
                        ? "sb-refresh-spin 0.35s linear infinite"
                        : "none",
                      "@keyframes sb-refresh-spin": {
                        from: { transform: "rotate(0deg)" },
                        to: { transform: "rotate(360deg)" },
                      },
                    }}
                  />
                </IconButton>
              </span>
            </Tooltip>
            {filterSpecs.length > 0 && (
              <Tooltip
                title={
                  activeFilterCount > 0
                    ? `Filters · ${activeFilterCount} active`
                    : "Filters"
                }
              >
                {/* Filters trigger collapsed to an icon-only button.
                    The active-count is rendered as a small Badge dot
                    in the top-right corner so the user still gets a
                    visual cue when filters are applied without the
                    "Filters" word taking up toolbar space. */}
                <Badge
                  color="primary"
                  badgeContent={activeFilterCount}
                  invisible={activeFilterCount === 0}
                  overlap="circular"
                  sx={{
                    "& .MuiBadge-badge": {
                      height: 16,
                      minWidth: 16,
                      fontSize: 10,
                      fontWeight: 700,
                      padding: "0 4px",
                    },
                  }}
                >
                  <IconButton
                    onClick={() => setShowFilters((s) => !s)}
                    size="medium"
                    aria-label="Filters"
                    sx={{
                      width: 40,
                      height: 40,
                      borderRadius: 2,
                      // When the panel is open OR there's an active
                      // filter, paint the button in accent so it
                      // reads as "engaged"; otherwise it sits
                      // alongside Refresh as a quiet toolbar action.
                      color:
                        showFilters || activeFilterCount > 0
                          ? "var(--sb-accent)"
                          : "text.primary",
                      // Desktop split: 12 % accent at rest, 20 % under
                      // a real hover (handled in the desktop branch
                      // below). The mobile branch promotes the
                      // engaged tint straight to 20 % so it doesn't
                      // depend on a hover state the user can't reach.
                      bgcolor:
                        showFilters || activeFilterCount > 0
                          ? "color-mix(in srgb, var(--sb-accent) 12%, transparent)"
                          : "transparent",
                      // "Mobile version" branch — matches both real
                      // touch devices (`(hover: none)`) AND Chrome
                      // DevTools' mobile-emulation mode (which keeps
                      // `(hover: hover)` true because the host
                      // machine still has a mouse, but does shrink
                      // the viewport). 599.95 px is the upper edge
                      // of MUI's `xs` breakpoint, so this catches
                      // every "phone-shaped" layout regardless of
                      // input type.
                      //
                      // On the phone breakpoint we deliberately drop
                      // the accent-tinted engaged-state background
                      // entirely — that backdrop was what the user
                      // perceived as a halo "appearing on reload"
                      // (since `usePersistedFiltersOpen` rehydrates
                      // `showFilters: true` from localStorage on
                      // remount, the engaged tint would render in
                      // the very first frame after a refresh, with
                      // no tap to attribute it to). The Badge in
                      // the upper-right corner already carries the
                      // active-filter count, and the icon glyph
                      // recolours to `var(--sb-accent)` for the
                      // engaged state, so the indication isn't
                      // lost — it just stops painting a filled
                      // background. `transition: color` (no
                      // background-color) keeps the icon recolour
                      // smooth without ever needing to animate a
                      // backdrop that no longer exists.
                      "@media (hover: none), (max-width: 599.95px)": {
                        bgcolor: "transparent",
                        transition: "color 0.14s ease",
                      },
                      // Desktop branch — only fires when the user
                      // genuinely has a hover-capable pointer AND
                      // the layout is wide enough to read as
                      // "desktop". Touch devices and the DevTools
                      // mobile preset are both excluded so a tap
                      // there doesn't leave the 20 %-tint hover
                      // background stuck on the button (which is
                      // the original "halo remains after tap" bug).
                      "@media (hover: hover) and (min-width: 600px)": {
                        "&:hover": {
                          backgroundColor:
                            showFilters || activeFilterCount > 0
                              ? "color-mix(in srgb, var(--sb-accent) 20%, transparent)"
                              : "action.hover",
                        },
                      },
                    }}
                  >
                    <FilterAltIcon fontSize="small" />
                  </IconButton>
                </Badge>
              </Tooltip>
            )}
            {/* The "create" pill is intentionally smaller (32 × 32)
                than the neutral Refresh / Filters siblings, but it's
                wrapped in a 40 × 40 layout slot so the lower toolbar
                stays exactly the same total width as the upper bar's
                3-button row (each is 3 × 40 px + 2 × 12 px gap =
                144 px). Without this slot the lower toolbar would
                end 8 px earlier and the right edges of the two bars
                wouldn't line up. */}
            <Box
              sx={{
                width: 40,
                height: 40,
                display: "grid",
                placeItems: "center",
              }}
            >
              <Tooltip
                title={`New ${config.title.slice(0, -1).toLowerCase()}`}
              >
                <IconButton
                  onClick={openCreate}
                  size="small"
                  aria-label="Create new"
                  sx={{
                    width: 32,
                    height: 32,
                    // Tighter corner radius on the primary "create"
                    // action so it reads as a distinctly different
                    // control next to the more rounded Refresh /
                    // Filters siblings.
                    borderRadius: 1,
                    color: "var(--sb-accent-contrast, #ffffff)",
                    bgcolor: "var(--sb-accent)",
                    "&:hover": {
                      backgroundColor:
                        "color-mix(in srgb, var(--sb-accent) 88%, transparent)",
                    },
                  }}
                >
                  <AddIcon fontSize="small" />
                </IconButton>
              </Tooltip>
            </Box>
          </Stack>
        }
      />
      {filterSpecs.length > 0 && (
        // `flexShrink: 0` keeps the desktop flex column from squeezing
        // the Collapse below the height it just measured for its own
        // open animation. Without it, when this CrudPage mounts on a
        // page where filters were persisted as open, the freshly
        // re-rendered Collapse and the `flex: 1` Paper sibling would
        // briefly compete for the same vertical pixels — the flex
        // algorithm shrinks the Collapse on the same frame MUI is
        // tweening its height up, the CSS `transitionend` never
        // matches the ever-moving target, and the panel snaps open
        // without animation. Forcing `flex-shrink: 0` lets the
        // Collapse own its full natural height for the duration of
        // its own transition; the Paper's `flex: 1` minus its
        // `minHeight: 0` already lets it shrink to make room.
        <Collapse
          in={showFilters}
          unmountOnExit
          timeout={180}
          // Lift the first-paint gate once the panel has finished
          // animating open — this is what makes the table wait until
          // the filter panel is fully visible before it loads rows
          // when the user navigates between admin pages with
          // persisted-open filters. After the gate has lifted once
          // it stays lifted; later open/close toggles by the user
          // re-fire `onEntered` but the setter is a no-op when
          // already true.
          onEntered={() => setFiltersFirstPaintReady(true)}
          sx={{ flexShrink: 0 }}
        >
          {/* Pressing Enter anywhere inside the filter panel submits the
              current draft — same as clicking Search. Avoids a full <form>
              wrapper which would conflict with the table form-less buttons. */}
          <Box
            onKeyDown={(e) => {
              if (e.key === "Enter") {
                e.preventDefault();
                applyFilters();
              }
            }}
            sx={{
              mb: 2,
              p: 2,
              pl: 2.25,
              borderRadius: 2.5,
              border: "1px solid",
              borderColor: "divider",
              // Custom request: dedicated surfaces per theme so the filter
              // panel reads as a distinct slab — `#f5f7fa` on light,
              // `#191919` on dark.
              bgcolor: (t) => (t.palette.mode === "light" ? "#f5f7fa" : "#191919"),
              display: "flex",
              flexDirection: "column",
              gap: 1.5,
              position: "relative",
              overflow: "hidden",
              // Subtle accent bar on the left edge — visually couples the
              // panel to the toolbar's "Filters" button without shouting.
              "&::before": {
                content: '""',
                position: "absolute",
                top: 12,
                bottom: 12,
                left: 0,
                width: 2,
                borderRadius: 2,
                bgcolor: "primary.main",
                opacity: 0.6,
              },
            }}
          >
            <Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 0.5 }}>
              <Box
                sx={{
                  width: 22,
                  height: 22,
                  borderRadius: 1,
                  display: "grid",
                  placeItems: "center",
                  bgcolor: "action.selected",
                  color: "text.secondary",
                }}
              >
                <FilterAltIcon sx={{ fontSize: 14 }} />
              </Box>
              <Typography
                variant="subtitle2"
                color="text.primary"
                sx={{ fontWeight: 600, letterSpacing: 0.2 }}
              >
                Filters
              </Typography>
              {activeFilterCount > 0 && (
                <Chip
                  size="small"
                  color="primary"
                  label={`${activeFilterCount} active`}
                  sx={{ height: 20, fontSize: 11 }}
                />
              )}
              <Box sx={{ flexGrow: 1 }} />
              <Button
                size="small"
                color="inherit"
                startIcon={<FilterAltOffIcon fontSize="small" />}
                disabled={activeFilterCount === 0 && draftFilterCount === 0}
                onClick={resetFilters}
                sx={{ textTransform: "none" }}
              >
                Clear
              </Button>
              <Button
                size="small"
                variant="contained"
                color="primary"
                startIcon={<SearchIcon fontSize="small" />}
                onClick={applyFilters}
                disabled={!filtersDirty}
              >
                Search
              </Button>
            </Stack>
            {/* Each filter has a fixed width — datetime-range cells (which
                pack two pickers side by side) and any cell explicitly
                marked `wide: true` get 1.5× the slot — and the row simply
                flex-wraps when there isn't enough horizontal space, so the
                number of filters per row adapts to the viewport without
                forcing every cell to share the same fractional column. */}
            <Box
              sx={{
                display: "flex",
                flexWrap: "wrap",
                gap: `${FILTER_GAP}px`,
              }}
            >
              {filterSpecs.map((f) => (
                <Box
                  key={f.name}
                  sx={{
                    // On mobile (< 900 px) every filter cell takes the
                    // full row — the fixed 240 / 372 px slot layout from
                    // desktop squeezes two half-height inputs into
                    // viewports too narrow to comfortably use them,
                    // especially `datetime-range` which packs two
                    // pickers side-by-side.
                    width: {
                      xs: "100%",
                      md:
                        f.type === "datetime-range" || f.wide
                          ? WIDE_FILTER_WIDTH
                          : FILTER_WIDTH,
                    },
                    flexShrink: 0,
                  }}
                >
                  <FilterField
                    spec={f}
                    value={filterValues[f.name]}
                    onChange={(v) => setFilterValue(f.name, v)}
                  />
                </Box>
              ))}
            </Box>
          </Box>
        </Collapse>
      )}
      <Paper
        ref={tableRef}
        variant="outlined"
        sx={(t) => ({
          overflow: "hidden",
          position: "relative",
          // Desktop: claim the leftover vertical space below the
          // PageHeader / filter panel and lay the inner pieces
          // (progress bar, TableContainer, TablePagination) out as a
          // flex column so the *rows* are the only part that scrolls.
          // The card stays pinned inside the browser window — no
          // page-level scroll — regardless of row count or page size.
          display: { md: "flex" },
          flexDirection: { md: "column" },
          flex: { md: 1 },
          minHeight: { md: 0 },
          // Soft drop shadow gives the table some depth without making
          // the rest of the layout look heavy. Tuned per mode so the
          // shadow stays visible on white as well as on dark.
          boxShadow:
            t.palette.mode === "light"
              ? "0 1px 0 rgba(15,23,42,0.04), 0 8px 22px rgba(15,23,42,0.10)"
              : "0 1px 0 rgba(255,255,255,0.02), 0 12px 28px rgba(0,0,0,0.28)",
        })}
      >
        {/* Thin top progress bar visible during any load (initial or reload).
            Gated on `loadingVisible` (loading + ~180 ms) for *refreshes* so
            fast roundtrips don't flash a strip of motion at all, but the
            very first load bypasses that lag (`!hasFetched`) — the user
            sees the progress bar the moment the table mounts, sitting
            on top of the empty-state placeholder we now reserve. */}
        <Box
          sx={{
            position: "absolute",
            top: 0,
            left: 0,
            right: 0,
            height: 2,
            zIndex: 2,
            // Initial load: `!hasFetched` makes the bar appear instantly so
            // the empty placeholder doesn't sit silent while the first
            // request is in flight.
            //
            // Reloads: gated on the lagging `loadingVisible` so a refresh
            // that finishes inside the ~180 ms threshold never flashes a
            // strip of motion. (We no longer additionally gate on
            // `rows.length > 0`; clicking Refresh on an empty list now
            // also shows the bar, which is the expected feedback for a
            // user-initiated action.)
            //
            // Gating on `loading` (in addition to the lagging
            // `loadingVisible`) so the bar disappears the moment the
            // fetch finishes — without it, the post-fetch render would
            // briefly keep the bar visible while `loadingVisible` waits
            // for its useEffect to flip it off, which read as a "double
            // render" flicker right when the rows appeared.
            opacity: loading && (loadingVisible || !hasFetched) ? 1 : 0,
            transition: `opacity ${progressDurationMs}ms cubic-bezier(0.45, 0, 0.55, 1)`,
            pointerEvents: "none",
          }}
        >
          <LinearProgress sx={{ height: 2 }} />
        </Box>
        {/* Bulk-action toolbar: appears as a thin top strip on the table
            card whenever at least one row is selected. Replaces the per-
            row delete button for batch operations and gives the user a
            one-click way to clear the selection. Rendered as a sibling
            *above* the scrolling TableContainer (rather than inside it)
            so the strip stays visible while the user scrolls the rows
            vertically — otherwise it would scroll out of the viewport
            together with the body and the user would lose the bulk-
            action affordance the moment they paged past the first
            screenful of selected rows. */}
        <Collapse in={selected.size > 0} unmountOnExit>
          <Stack
            direction="row"
            alignItems="center"
            spacing={1.5}
            sx={{
              px: 2,
              py: 1,
              borderBottom: "1px solid",
              borderColor: "divider",
              // Neutral tint — keeps the table chrome from flashing the
              // accent color when the user toggles a checkbox.
              bgcolor: "action.hover",
            }}
          >
            <Typography variant="body2" sx={{ fontWeight: 600 }}>
              {selected.size} selected
            </Typography>
            <Box sx={{ flexGrow: 1 }} />
            <Button
              size="small"
              color="inherit"
              onClick={() => setSelected(new Set())}
              disabled={deleteBusy}
              sx={{ textTransform: "none" }}
            >
              Clear
            </Button>
            <Button
              size="small"
              variant="contained"
              color="error"
              startIcon={<DeleteIcon fontSize="small" />}
              onClick={handleBulkDelete}
              disabled={deleteBusy}
            >
              Delete selected
            </Button>
          </Stack>
        </Collapse>
        <TableContainer
          ref={scrollContainerRef}
          sx={{
            // Desktop: take the leftover vertical space inside the
            // Paper card and scroll the rows internally — the
            // pagination bar at the bottom of the card stays pinned
            // to the bottom of the viewport instead of sitting below
            // the fold. `minHeight: 0` is required for the
            // flex-fill child to be allowed to shrink below its
            // intrinsic content height. MUI's TableContainer ships
            // with `overflow-x: auto` only; we add `overflow-y:
            // auto` here so the Y-axis scroll lives inside the
            // card too.
            flex: { md: 1 },
            minHeight: { md: 0 },
            overflowY: { md: "auto" },
            // Soft dim while a reload is in flight (only when there's
            // existing data to dim — first load stays at full opacity).
            // Gated on `loadingVisible` rather than `loading` so a
            // refresh that finishes inside the ~180 ms threshold never
            // visibly dims the table at all (no flash on fast networks).
            //
            // Resting dim of 0.78 is a gentle attenuation — the table
            // content stays clearly legible mid-reload while still
            // reading as "stale". The transition duration scales
            // with the number of currently-visible rows (`rowAnimScale`
            // above): a 1-row table dims/un-dims in ~220 ms (snappy,
            // because the table is small and the eye absorbs the
            // change instantly), a 50+ row table dims/un-dims in
            // ~700 ms (graceful, because a wall of cells benefits
            // from a longer breath). The symmetric S-curve makes the
            // change read as a deliberate "pulled" veil at every
            // size rather than a sudden flash.
            // `loadingVisible` is delayed by ~180 ms after `loading`
            // flips on, but it's also delayed by one render *off* —
            // setLoadingVisible(false) only runs in the cleanup effect
            // after `loading` flips to false. Without the extra
            // `loading &&` gate the post-fetch render would dim the
            // freshly-mounted rows for one frame before the effect
            // fired, which read as a "double render" flicker. With
            // the gate, the dim disappears the moment the fetch
            // resolves regardless of `loadingVisible`'s lag.
            opacity: loading && loadingVisible && rows.length > 0 ? 0.78 : 1,
            transition: `opacity ${dimDurationMs}ms cubic-bezier(0.45, 0, 0.55, 1)`,
            // `pointerEvents` follows the same delayed signal so a
            // sub-180 ms refresh never briefly disables hovers / clicks
            // (which used to surface as a tiny "dead zone" over the
            // table for the duration of a fast roundtrip).
            pointerEvents: loading && loadingVisible ? "none" : "auto",
          }}
        >
          {isMobile ? (
            <Box sx={{ p: 1.5 }}>
              {rows.length === 0 ? (
                // Mirror of the desktop empty state, just outside any
                // table cell so we can drop the colSpan plumbing.
                //
                // Rendered immediately on mount so the card reserves
                // the same vertical space the empty-state CTA will
                // eventually occupy. While the very first reload is
                // still in flight (`!hasFetched`) the stack is kept
                // invisible — the user doesn't see the "No data yet"
                // copy or the Create button flash before we know
                // whether the API actually has rows for them.
                <Stack
                  alignItems="center"
                  spacing={1.5}
                  sx={{
                    py: 6,
                    // Fade in once the first reload finishes — we keep the
                    // stack mounted from the start so the card reserves
                    // its full height, but cross-fade the contents in
                    // gently instead of snapping from `visibility:
                    // hidden` to `visible`. `pointerEvents` follow the
                    // same gate so the (visually invisible) Create
                    // button never receives stray taps before the
                    // empty-state has actually been confirmed.
                    //
                    // The `!paginating` clause keeps the placeholder
                    // hidden while the user's pagination / sort /
                    // filter change is in flight: in the common case
                    // the previous rows are still mounted (faded to
                    // opacity 0 by the inner row-cards Stack down
                    // below) and rows.length is non-zero, so this
                    // branch isn't even rendered. The gate matters
                    // for the case where the previous page was
                    // already empty AND the user paginates / changes
                    // a filter — then this branch IS rendered but
                    // should stay hidden during the round-trip, not
                    // flash "No data yet" mid-fetch.
                    opacity: hasFetched && !paginating ? 1 : 0,
                    transition:
                      "opacity 320ms cubic-bezier(0.22, 0.61, 0.36, 1)",
                    pointerEvents:
                      hasFetched && !paginating ? "auto" : "none",
                  }}
                >
                  <Box
                    sx={{
                      width: 56,
                      height: 56,
                      borderRadius: 2.5,
                      display: "grid",
                      placeItems: "center",
                      color: "text.secondary",
                      bgcolor: "action.hover",
                      border: "1px solid",
                      borderColor: "divider",
                    }}
                  >
                    <InboxRoundedIcon />
                  </Box>
                  <Box sx={{ color: "text.secondary", fontSize: 13 }}>
                    No data yet
                  </Box>
                  <Button
                    variant="contained"
                    color="primary"
                    size="small"
                    startIcon={<AddIcon />}
                    onClick={openCreate}
                  >
                    Create the first one
                  </Button>
                </Stack>
              ) : (
                <Stack spacing={1.25}>
                  {/* Mobile toolbar that replaces the desktop table's
                      header row: the select-all checkbox sits on the
                      left, and a compact "sort by + direction"
                      control sits on the right. The Stack uses
                      `flexWrap: wrap` so on very narrow phones the
                      sort group can drop onto its own line instead
                      of pushing into the checkbox. */}
                  {rows.length > 0 && (
                    <Stack
                      direction="row"
                      alignItems="center"
                      spacing={1}
                      sx={{
                        pl: 0.5,
                        pb: 0.5,
                        flexWrap: "wrap",
                        rowGap: 1,
                      }}
                    >
                      <Stack
                        direction="row"
                        alignItems="center"
                        spacing={0.5}
                        sx={{ color: "text.secondary", fontSize: 12.5 }}
                      >
                        <Checkbox
                          size="small"
                          color="primary"
                          indeterminate={someSelected}
                          checked={allSelected}
                          onChange={toggleAll}
                          inputProps={{ "aria-label": "select all rows" }}
                        />
                        <Box>Select all</Box>
                      </Stack>
                      <Box sx={{ flexGrow: 1 }} />
                      <Stack
                        direction="row"
                        alignItems="center"
                        spacing={0.5}
                      >
                        <TextField
                          select
                          size="small"
                          // displayEmpty + a value="" MenuItem lets us
                          // render "No sort" when the user hasn't
                          // picked a sort column. Without it the
                          // empty string would render as a blank
                          // field with no label.
                          SelectProps={{ displayEmpty: true }}
                          value={sortField ?? ""}
                          onChange={(e) => {
                            const v = e.target.value;
                            if (v === "") {
                              setSortField(null);
                              setSortDir("asc");
                            } else if (v !== sortField) {
                              setSortField(v);
                              // Default to ascending whenever the
                              // user picks a new column. The arrow
                              // button next to the field flips the
                              // direction without re-opening the
                              // menu.
                              setSortDir("asc");
                            }
                            setPage(0);
                          }}
                          aria-label="Sort by"
                          sx={{
                            minWidth: 132,
                            "& .MuiSelect-select": {
                              py: 0.5,
                              fontSize: 13,
                            },
                          }}
                        >
                          <MenuItem
                            value=""
                            sx={{
                              fontStyle: "italic",
                              color: "text.secondary",
                            }}
                          >
                            No sort
                          </MenuItem>
                          {sortableColumns.map((c) => (
                            <MenuItem
                              key={String(c.key)}
                              value={String(c.key)}
                            >
                              {c.label}
                            </MenuItem>
                          ))}
                        </TextField>
                        <Tooltip
                          title={
                            sortDir === "asc"
                              ? "Ascending — tap to flip"
                              : "Descending — tap to flip"
                          }
                        >
                          <span>
                            <IconButton
                              size="small"
                              disabled={!sortField}
                              onClick={() =>
                                setSortDir((d) =>
                                  d === "asc" ? "desc" : "asc",
                                )
                              }
                              aria-label="Toggle sort direction"
                              sx={{
                                width: 32,
                                height: 32,
                                color: "text.secondary",
                                "&:hover": { color: "var(--sb-accent)" },
                              }}
                            >
                              {sortDir === "asc" ? (
                                <ArrowUpwardIcon fontSize="small" />
                              ) : (
                                <ArrowDownwardIcon fontSize="small" />
                              )}
                            </IconButton>
                          </span>
                        </Tooltip>
                      </Stack>
                    </Stack>
                  )}
                  {/* Just the row cards live inside this inner Stack
                      so the `paginating` opacity gate hides the data
                      cards alone — the select-all + sort toolbar
                      above (this branch's mobile equivalent of the
                      desktop column headers) stays fully visible
                      during the in-flight request. The inner
                      `spacing={1.25}` matches the outer Stack's
                      spacing so the visible gap between toolbar and
                      first card is unchanged. Cards stay mounted
                      during the fade so the table card height
                      doesn't snap. */}
                  <Stack
                    spacing={1.25}
                    sx={{
                      opacity: paginating ? 0 : 1,
                      transition:
                        "opacity 320ms cubic-bezier(0.45, 0, 0.55, 1)",
                      pointerEvents: paginating ? "none" : "auto",
                    }}
                  >
                    {rows.map((row, i) => {
                      const key = config.rowKey
                        ? config.rowKey(row)
                        : String(row[config.idKey]);
                      return (
                        <CrudCard
                          key={key}
                          rowKey={key}
                          row={row}
                          index={i}
                          columns={columnsRef}
                          selected={selected.has(idOf(row))}
                          onToggle={toggleRow}
                          onEdit={handleEdit}
                          onDelete={handleDelete}
                          rowActions={config.rowActions}
                          onRunAction={handleRunAction}
                        />
                      );
                    })}
                  </Stack>
                </Stack>
              )}
            </Box>
          ) : (
          <Table
            size="small"
            // `stickyHeader` keeps the column titles (and the pinned
            // Actions header on the right) visible while the body rows
            // scroll vertically inside the TableContainer — required
            // now that the desktop layout claims a fixed viewport
            // height and scrolls the rows internally rather than
            // letting the page itself scroll. Combines with the
            // existing `ACTIONS_HEADER_CELL_SX` (which already pins
            // the Actions header to the right) to make that one cell
            // sticky on both axes simultaneously.
            stickyHeader
            // Excel-style resizing requires `tableLayout: fixed` so the
            // explicit pixel widths set on the header cells are honoured
            // strictly (otherwise the browser would still grow columns to
            // fit content). Cells without an explicit width fall back to
            // the auto-distributed remainder.
            //
            // `minWidth: tableMinWidth` keeps the table from being
            // squeezed below the sum of column widths — without it, a
            // user-resized column would visually overlap its neighbours
            // when their total exceeded the container's width.
            sx={{ tableLayout: "fixed", minWidth: tableMinWidth }}
          >
            <TableHead>
              <TableRow>
                <TableCell
                  sx={{
                    // Plain cell (no `padding="checkbox"`) so MUI's
                    // hardcoded `width: 48px` and tight 4 px padding don't
                    // collide with the resizable-column layout. Centered
                    // contents keep the Checkbox visually balanced inside
                    // the column regardless of the chosen width.
                    width: 72,
                    paddingLeft: 0,
                    paddingRight: 0,
                    textAlign: "center",
                    overflow: "visible",
                  }}
                >
                  <Checkbox
                    size="small"
                    color="primary"
                    indeterminate={someSelected}
                    checked={allSelected}
                    onChange={toggleAll}
                    disabled={rows.length === 0}
                    inputProps={{ "aria-label": "select all rows" }}
                  />
                </TableCell>
                {config.columns.map((c, idx) => {
                  const sortable = c.sortable ?? true;
                  // The last data column has no resize handle at all.
                  // Its right edge is the boundary with the trailing
                  // filler cell, so dragging it would be perceived as
                  // "resizing the spacer" — every adjustment to this
                  // column would have to come out of the filler's
                  // budget. Skipping the handle keeps that boundary
                  // visually inert; the column's width is governed
                  // solely by `effectiveWidth` (default + natural
                  // minimum), and the filler absorbs whatever space
                  // is left over.
                  const isLastDataColumn = idx === config.columns.length - 1;
                  const key = String(c.key);
                  const isActive = sortable && sortField === key;
                  const stored = columnWidths[key];
                  // Effective width = whatever the user stored, but never
                  // narrower than the measured natural minimum. This is
                  // what stops a previously-resized cell (or a fresh
                  // localStorage value from before the per-column min was
                  // enforced) from rendering smaller than its header
                  // content — without it, the label would visually spill
                  // into the neighbouring column.
                  //
                  // For unstored columns we still assign an explicit
                  // pixel width (DEFAULT_DATA_COL_WIDTH, or a measured
                  // natural minimum if it's larger). With every data
                  // column carrying an explicit width, the only
                  // auto-sized cell in the row is the trailing
                  // <td> filler — which is what stops `tableLayout:
                  // fixed` from rubber-banding the unstored neighbours
                  // when the user resizes one column. The filler
                  // soaks up all the leftover horizontal space, and
                  // shrinks to 0 when the column sum exceeds the
                  // viewport so the table can overflow horizontally.
                  const naturalMin = columnNaturalMins[key];
                  const effectiveWidth =
                    stored != null
                      ? naturalMin != null
                        ? Math.max(stored, naturalMin)
                        : stored
                      : Math.max(naturalMin ?? 0, DEFAULT_DATA_COL_WIDTH);
                  return (
                    <TableCell
                      key={key}
                      ref={(el: HTMLTableCellElement | null) => {
                        headerCellRefs.current[key] = el;
                      }}
                      sortDirection={isActive ? sortDir : false}
                      sx={{
                        // `position: sticky` (instead of the previous
                        // `relative`) is required so the data-column
                        // header tracks `stickyHeader`'s `top: 0` and
                        // stays glued to the top edge while the body
                        // rows scroll vertically. Without this override,
                        // MUI's `stickyHeader` rule would lose against
                        // the cell's own sx — and only the cells that
                        // happen to lack an explicit `position`
                        // (checkbox, filler, Actions) would end up
                        // sticky, which read as "three random columns
                        // glued to the top" while the rest of the
                        // header scrolled away with the rows.
                        //
                        // Sticky positioning also creates a containing
                        // block for the absolute-positioned
                        // `ResizeHandle` child, exactly the way the
                        // previous `relative` did, so the resize-grip
                        // geometry below is unaffected.
                        position: "sticky",
                        top: 0,
                        // Match the explicit Actions header bg so a
                        // body row scrolling under the sticky data
                        // headers doesn't show through. The theme's
                        // `MuiTableCell.head` rule already paints
                        // `--sb-elevated` here, but listing it
                        // explicitly keeps this cell's sticky
                        // background self-contained against any
                        // future theme override.
                        backgroundColor: "var(--sb-elevated)",
                        width: `${effectiveWidth}px`,
                        // Long header / cell content was overflowing the
                        // user's chosen column width — clip with ellipsis
                        // so the resize stays predictable.
                        overflow: "hidden",
                        textOverflow: "ellipsis",
                        whiteSpace: "nowrap",
                      }}
                    >
                      {/* Inline-flex measurement wrapper — the inner span
                          sizes itself to its natural content (label +
                          sort caret) regardless of the cell's enforced
                          pixel width, so its `getBoundingClientRect()`
                          gives us the smallest cell size that wouldn't
                          spill into the next column. */}
                      <Box
                        component="span"
                        ref={(el: HTMLElement | null) => {
                          headerLabelRefs.current[key] = el;
                        }}
                        sx={{
                          // Crucial: do NOT cap this with `maxWidth: 100%`.
                          // The wrapper has to keep its natural content
                          // width even when the cell is constrained
                          // narrower — otherwise `getBoundingClientRect`
                          // would return the cell's clamped width and
                          // `naturalMin` would never grow large enough to
                          // prevent the column from being resized below
                          // its header content. Visually nothing changes:
                          // when the cell is wider than the content the
                          // wrapper just sits left-aligned, when narrower
                          // the cell's `overflow: hidden` clips the
                          // overflow exactly as before.
                          display: "inline-flex",
                          alignItems: "center",
                          whiteSpace: "nowrap",
                        }}
                      >
                        {sortable ? (
                          <TableSortLabel
                            active={isActive}
                            direction={isActive ? sortDir : "asc"}
                            onClick={() => handleSort(key)}
                            // Inactive sortable columns get a symmetric
                            // up-and-down chevron icon (`UnfoldMore`) so
                            // it reads as "this column can be sorted in
                            // either direction" without committing to a
                            // default arrow. Once a direction is picked
                            // the column becomes active and MUI's default
                            // `ArrowDownward` icon takes over (rotated to
                            // match `sortDir`).
                            IconComponent={
                              isActive ? undefined : UnfoldMoreIcon
                            }
                            sx={{
                              "& .MuiTableSortLabel-icon": {
                                opacity: isActive ? 1 : 0.45,
                                // The inactive icon is symmetric, so
                                // MUI's direction-based rotation is
                                // meaningless for it — pin it at 0deg so
                                // the visual never drifts when MUI flips
                                // the rotation class on hover / mount.
                                transform: isActive
                                  ? undefined
                                  : "none !important",
                                transition:
                                  "opacity 0.14s ease, transform 0.14s ease",
                              },
                              "&:hover .MuiTableSortLabel-icon": {
                                opacity: isActive ? 1 : 0.75,
                              },
                            }}
                          >
                            {c.label}
                          </TableSortLabel>
                        ) : (
                          c.label
                        )}
                      </Box>
                      {!isLastDataColumn && (
                        <ResizeHandle
                          columnKey={key}
                          getCurrentWidth={() => getColumnLiveWidth(key)}
                          setWidth={setColumnWidth}
                          getMinWidth={() => getColumnNaturalMin(key)}
                          getMaxWidth={() => getColumnMaxWidth(key)}
                          onResizeStart={lockInUnstoredColumnWidths}
                        />
                      )}
                    </TableCell>
                  );
                })}
                {/* Filler cell — the only auto-width cell in the row.
                    Under `tableLayout: fixed` it absorbs whatever
                    horizontal space is left over after the fixed-
                    width columns (checkbox + each data column +
                    Actions). When the user resizes a column the
                    filler shrinks/grows to compensate so the data
                    columns themselves stay pixel-stable. Once the
                    sum of the fixed columns exceeds the viewport
                    the filler collapses to 0 and the surrounding
                    `TableContainer` provides a horizontal scroll. */}
                <TableCell aria-hidden sx={FILLER_CELL_SX} />
                <TableCell
                  align="center"
                  sx={{
                    ...ACTIONS_HEADER_CELL_SX,
                    width: 92 + (config.rowActions?.length ?? 0) * 32,
                  }}
                >
                  Actions
                </TableCell>
              </TableRow>
            </TableHead>
            <TableBody
              sx={{
                // Fade just the row body to opacity 0 the moment the
                // user paginates / sorts / filters. The column
                // headers above (TableHead) stay fully visible so the
                // structure of the table remains legible during the
                // round-trip — only the data cells disappear. Rows
                // stay *mounted* (we never call `setRows([])` here)
                // so the table card keeps its current height; once
                // `reload` lands the new rows in `setRows` and flips
                // `paginating` back to false, the body fades back to
                // opacity 1, cross-fading the new entries into the
                // slot the old ones occupied.
                opacity: paginating ? 0 : 1,
                transition: `opacity ${dimDurationMs}ms cubic-bezier(0.45, 0, 0.55, 1)`,
                // Lock out interaction while the body is visually
                // gone — clicking an invisible row would be
                // surprising.
                pointerEvents: paginating ? "none" : "auto",
              }}
            >
              {rows.length === 0 && (
                // Empty-state row is rendered the moment the table mounts
                // so the card reserves the same vertical space the "No
                // data yet" stack will eventually occupy — no collapse-
                // to-header-only bounce, and no layout jump when the
                // first response lands.
                //
                // Before the first reload finishes (`!hasFetched`) the
                // inner stack is kept invisible: the cell still
                // contributes its full height to the row, but the user
                // doesn't see the icon / "No data yet" label / Create
                // button flash before we even know whether there's
                // data. Once `hasFetched` flips to true and `rows` is
                // still empty, the stack becomes visible and the user
                // gets the real empty-state CTA.
                <TableRow
                  // The global `MuiTableRow` override in theme.ts paints
                  // every row with an `${elevated2} !important` tint on
                  // `:hover` so data rows feel responsive to the cursor.
                  // The empty-state row isn't a data row — there's
                  // nothing to "select" — so that highlight reads as a
                  // misleading affordance: the user moves the mouse to
                  // click "Create the first one" and the entire band
                  // behind the icon / label / button lights up. Pin
                  // the hover background to transparent (also using
                  // `!important`, since the theme rule does) so the
                  // empty state sits on the plain table surface; the
                  // small `action.hover`-tinted icon tile below stays
                  // exactly as it was — only the surrounding row band
                  // stops reacting to hover.
                  sx={{
                    "&:hover": {
                      backgroundColor: "transparent !important",
                    },
                  }}
                >
                  <TableCell
                    // Spans every cell in the row: checkbox + each
                    // data column + the auto-width filler + Actions.
                    colSpan={config.columns.length + 3}
                    sx={{ py: 8, px: 0 }}
                  >
                    <Box
                      sx={{
                        position: "sticky",
                        left: 0,
                        width: tableViewportWidth ?? "100%",
                        display: "flex",
                        justifyContent: "center",
                      }}
                    >
                      <Stack
                        alignItems="center"
                        spacing={1.5}
                        sx={{
                          // Cross-fade the empty-state contents in once
                          // the first reload finishes; the stack itself
                          // stays mounted from initial render so the
                          // table card reserves its full height. Using
                          // `opacity` (instead of `visibility: hidden`
                          // → `visible`) gives a soft entrance that
                          // doesn't snap into place the moment the
                          // first response lands. `pointerEvents`
                          // follow the same gate so taps on the
                          // (transparent) Create button can't fire
                          // before the empty-state is confirmed.
                          //
                          // The `!paginating` clause keeps the
                          // placeholder hidden while a pagination /
                          // sort / filter request is in flight: in
                          // the common case the previous rows are
                          // still mounted (the surrounding TableBody
                          // is faded to opacity 0) and rows.length
                          // is non-zero so this branch isn't
                          // rendered. The gate matters for the case
                          // where the previous page was already
                          // empty AND the user paginates / changes a
                          // filter — then this branch IS rendered
                          // but should stay hidden during the round-
                          // trip, not flash "No data yet" mid-fetch.
                          opacity: hasFetched && !paginating ? 1 : 0,
                          transition:
                            "opacity 320ms cubic-bezier(0.22, 0.61, 0.36, 1)",
                          pointerEvents:
                            hasFetched && !paginating ? "auto" : "none",
                        }}
                      >
                        <Box
                          sx={{
                            width: 56,
                            height: 56,
                            borderRadius: 2.5,
                            display: "grid",
                            placeItems: "center",
                            color: "text.secondary",
                            bgcolor: "action.hover",
                            border: "1px solid",
                            borderColor: "divider",
                          }}
                        >
                          <InboxRoundedIcon />
                        </Box>
                        <Box sx={{ color: "text.secondary", fontSize: 13 }}>
                          No data yet
                        </Box>
                        <Button
                          variant="contained"
                          color="primary"
                          size="small"
                          startIcon={<AddIcon />}
                          onClick={openCreate}
                        >
                          Create the first one
                        </Button>
                      </Stack>
                    </Box>
                  </TableCell>
                </TableRow>
              )}
              {rows.map((row, i) => {
                const key = config.rowKey
                  ? config.rowKey(row)
                  : String(row[config.idKey]);
                return (
                  <CrudRow
                    key={key}
                    rowKey={key}
                    row={row}
                    index={i}
                    columns={columnsRef}
                    selected={selected.has(idOf(row))}
                    onToggle={toggleRow}
                    onEdit={handleEdit}
                    onDelete={handleDelete}
                    rowActions={config.rowActions}
                    onRunAction={handleRunAction}
                  />
                );
              })}
            </TableBody>
          </Table>
          )}
        </TableContainer>
        {config.count !== undefined && (
          // The pagination toolbar is rendered from the first paint
          // onwards so the Paper card has a stable total height
          // before the first `list()` + `count()` round-trip
          // resolves. Without that, the bottom edge of the card
          // (and everything below it on the page) visibly jerked
          // by ~48 px the moment the toolbar appeared, which read
          // as a twitch of the table's bottom bar.
          //
          // While we're still waiting on count, `total` is `null`
          // and `total ?? -1` falls through to -1; the
          // `labelDisplayedRows` fallback below converts that into
          // a stable "0–0 of 0" placeholder (same fallback also
          // covers the edge case where `count` errors out but
          // `list()` succeeded). Pre-fetch the toolbar's controls
          // are dimmed and pointer-events disabled so the user
          // can't accidentally change the page or page size before
          // we know what's there.
          <TablePagination
            component="div"
            count={total ?? -1}
            page={page}
            onPageChange={(_, p) => setPage(p)}
            rowsPerPage={pageSize}
            onRowsPerPageChange={(e) => {
              setPageSize(Number(e.target.value));
              setPage(0);
            }}
            rowsPerPageOptions={[10, 25, 50, 100]}
            // Custom range label. MUI's default formatter renders
            // "X–Y of more than Y" whenever `count` is negative —
            // which is what we pass on the *first* render of the
            // page (before /count has resolved, `total` is still
            // `null` and `total ?? -1` falls through to -1). The
            // resulting "of more than 0" / "of more than 25"
            // flashes for a frame on every page reload until the
            // count endpoint replies, which reads as a glitch.
            //
            // While we're waiting on the count we render a stable
            // "0–0 of 0" placeholder — same shape as the eventual
            // real label, so the toolbar's width / arrow positions
            // don't shift the moment the real number arrives. Once
            // the count has resolved, this is identical to MUI's
            // default formatter ("from–to of count") so the
            // user-visible UX is unchanged in the steady state.
            labelDisplayedRows={({ from, to, count }) =>
              count < 0 ? "0–0 of 0" : `${from}–${to} of ${count}`
            }
            sx={{
              borderTop: "1px solid",
              borderColor: "divider",
              ".MuiTablePagination-toolbar": { minHeight: 48 },
              // Tabular numerals — every digit advances the same
              // width regardless of glyph. Without this the
              // "X–Y of Z" range reflows as the user paginates
              // (e.g. "1–25 of 100" → "26–50 of 100": the "26"
              // is visibly wider than "1", which pushed the
              // chevron buttons sideways). The property is
              // inherited, so applying it on the root reaches the
              // displayed-rows label and the rows-per-page select
              // value together. Every other text in the toolbar
              // is non-numeric and is unaffected.
              fontVariantNumeric: "tabular-nums",
              // Pre-fetch: dim the toolbar and disable interaction
              // so the placeholder "0–0 of 0" doesn't read as a
              // working control. Cross-fades to full opacity once
              // the first reload finishes, in lockstep with the
              // empty-state CTA / data rows materialising above.
              opacity: hasFetched ? 1 : 0.55,
              pointerEvents: hasFetched ? "auto" : "none",
              transition:
                "opacity 320ms cubic-bezier(0.22, 0.61, 0.36, 1)",
            }}
          />
        )}
      </Paper>
      {/* Mount the dialog while EITHER `dialogOpen` is true OR the
          underlying creating/editing state still has content — that
          second clause keeps the component alive through the MUI leave
          transition (open=false → onExited fires → handleDialogExited
          clears the state → next render unmounts). Without it the
          dialog would yank itself out of the DOM the moment the user
          clicks Cancel and the close animation would never play. */}
      {(dialogOpen || creating || editing !== null) && (
        <CrudDialog
          open={dialogOpen}
          mode={creating ? "create" : "update"}
          fields={config.fields}
          initial={editing ? (config.fromEntity ? config.fromEntity(editing) : (editing as unknown as Record<string, unknown>)) : undefined}
          title={creating ? `New ${config.title.slice(0, -1)}` : `Edit ${config.title.slice(0, -1)}`}
          onClose={closeDialog}
          onExited={handleDialogExited}
          onSubmit={creating ? handleCreate : handleUpdate}
        />
      )}
      {/* Delete-confirmation dialog: replaces the previous browser-native
          window.confirm and serves both per-row and bulk deletes.
          Stays a compact pop-over on mobile too (it's tiny), no need for
          the full-screen treatment the form dialog gets. */}
      <Dialog
        open={pendingDelete !== null}
        onClose={() => {
          if (!deleteBusy) setPendingDelete(null);
        }}
        maxWidth="xs"
        fullWidth
      >
        <DialogTitle>
          {displayPendingDelete?.kind === "bulk"
            ? `Delete ${displayPendingDelete.ids.length} ${
                displayPendingDelete.ids.length === 1
                  ? config.title.slice(0, -1).toLowerCase()
                  : config.title.toLowerCase()
              }?`
            : `Delete ${config.title.slice(0, -1).toLowerCase()}?`}
        </DialogTitle>
        <DialogContent dividers>
          <Typography variant="body2" color="text.secondary">
            {displayPendingDelete?.kind === "bulk"
              ? `This will permanently remove ${displayPendingDelete.ids.length} ${
                  displayPendingDelete.ids.length === 1
                    ? config.title.slice(0, -1).toLowerCase()
                    : config.title.toLowerCase()
                } from the database. This action cannot be undone.`
              : `${
                  displayPendingDelete?.kind === "single" ? displayPendingDelete.label : ""
                } will be permanently removed from the database. This action cannot be undone.`}
          </Typography>
        </DialogContent>
        <DialogActions>
          <Button
            onClick={() => setPendingDelete(null)}
            disabled={deleteBusy}
          >
            Cancel
          </Button>
          <Button
            variant="contained"
            color="error"
            startIcon={
              // Cross-fade the trash icon with a small spinner instead
              // of swapping them in a single frame. Both glyphs share
              // the same 18×18 box so the start-icon column doesn't
              // shift width when busy flips.
              <Box
                component="span"
                sx={{
                  position: "relative",
                  display: "inline-flex",
                  alignItems: "center",
                  justifyContent: "center",
                  width: 18,
                  height: 18,
                }}
              >
                <DeleteIcon
                  fontSize="small"
                  sx={{
                    position: "absolute",
                    opacity: deleteBusy ? 0 : 1,
                    transition: "opacity 180ms ease",
                  }}
                />
                <CircularProgress
                  size={14}
                  thickness={5}
                  color="inherit"
                  sx={{
                    position: "absolute",
                    opacity: deleteBusy ? 1 : 0,
                    transition: "opacity 180ms ease",
                  }}
                />
              </Box>
            }
            onClick={() => void confirmDelete()}
            disabled={deleteBusy}
            // Keep the red palette while the button is disabled mid-
            // delete instead of MUI's default washed-out gray. The
            // action is in flight, not unavailable — the spinner is
            // already telling the user it's busy, so we don't need
            // a second "this control is dead" signal on top.
            sx={{
              "&.Mui-disabled": {
                bgcolor: "error.main",
                color: "error.contrastText",
              },
            }}
          >
            {/* Cross-fade "Delete" ↔ "Deleting…" while keeping the
                button at a fixed width — the longer label is rendered
                as an invisible spacer so MUI lays the button out
                against it once and the size doesn't animate. */}
            <Box
              component="span"
              sx={{
                position: "relative",
                display: "inline-block",
                lineHeight: "inherit",
              }}
            >
              <Box component="span" sx={{ visibility: "hidden" }}>
                Deleting…
              </Box>
              <Box
                component="span"
                sx={{
                  position: "absolute",
                  inset: 0,
                  display: "flex",
                  alignItems: "center",
                  justifyContent: "center",
                  opacity: deleteBusy ? 0 : 1,
                  transition: "opacity 180ms ease",
                }}
              >
                Delete
              </Box>
              <Box
                component="span"
                sx={{
                  position: "absolute",
                  inset: 0,
                  display: "flex",
                  alignItems: "center",
                  justifyContent: "center",
                  opacity: deleteBusy ? 1 : 0,
                  transition: "opacity 180ms ease",
                }}
              >
                Deleting…
              </Box>
            </Box>
          </Button>
        </DialogActions>
      </Dialog>
      {/* Row-action confirmation dialog. Opens whenever a configured
          RowActionSpec.confirm() returns a payload — the per-page
          replacement for `window.confirm` for non-destructive actions
          like "Reset traffic". Same compact size and chrome as the
          Delete dialog above; the start icon, primary colour and
          button labels come from the action / its `confirm` payload
          so the dialog can read as either a primary "Reset" or a
          destructive "Wipe" depending on the spec. */}
      <Dialog
        open={pendingAction !== null}
        onClose={() => {
          if (!actionBusy) setPendingAction(null);
        }}
        maxWidth="xs"
        fullWidth
      >
        <DialogTitle>{displayActionConfirm?.title ?? ""}</DialogTitle>
        <DialogContent dividers>
          <Typography variant="body2" color="text.secondary">
            {displayActionConfirm?.description ?? ""}
          </Typography>
        </DialogContent>
        <DialogActions>
          <Button
            onClick={() => setPendingAction(null)}
            disabled={actionBusy}
          >
            Cancel
          </Button>
          <Button
            variant="contained"
            color={displayActionConfirm?.color ?? "primary"}
            startIcon={
              // Cross-fade the action's icon with a small spinner so
              // the start-icon column doesn't shift width when the
              // busy flag flips. Mirrors the same pattern used in the
              // Delete dialog above.
              <Box
                component="span"
                sx={{
                  position: "relative",
                  display: "inline-flex",
                  alignItems: "center",
                  justifyContent: "center",
                  width: 18,
                  height: 18,
                }}
              >
                <Box
                  component="span"
                  sx={{
                    position: "absolute",
                    display: "inline-flex",
                    alignItems: "center",
                    justifyContent: "center",
                    opacity: actionBusy ? 0 : 1,
                    transition: "opacity 180ms ease",
                  }}
                >
                  {displayPendingAction?.action.icon}
                </Box>
                <CircularProgress
                  size={14}
                  thickness={5}
                  color="inherit"
                  sx={{
                    position: "absolute",
                    opacity: actionBusy ? 1 : 0,
                    transition: "opacity 180ms ease",
                  }}
                />
              </Box>
            }
            onClick={() => void confirmAction()}
            disabled={actionBusy}
            // Same disabled-state colour preservation as the Delete
            // button: keep the configured palette so the spinner is
            // the only "I'm busy" signal, not a washed-out grey
            // background on top of it.
            sx={(t) => {
              const palette =
                t.palette[displayActionConfirm?.color ?? "primary"];
              return {
                "&.Mui-disabled": {
                  bgcolor: palette.main,
                  color: palette.contrastText,
                },
              };
            }}
          >
            {/* Cross-fade idle ↔ busy labels at a fixed width — the
                longer of the two labels acts as the spacer so MUI
                lays the button out against it once and the size
                doesn't animate. */}
            {(() => {
              const idle =
                displayActionConfirm?.confirmLabel ??
                displayPendingAction?.action.label ??
                "Confirm";
              const busy =
                displayActionConfirm?.busyLabel ?? `${idle}…`;
              const spacer = idle.length >= busy.length ? idle : busy;
              return (
                <Box
                  component="span"
                  sx={{
                    position: "relative",
                    display: "inline-block",
                    lineHeight: "inherit",
                  }}
                >
                  <Box component="span" sx={{ visibility: "hidden" }}>
                    {spacer}
                  </Box>
                  <Box
                    component="span"
                    sx={{
                      position: "absolute",
                      inset: 0,
                      display: "flex",
                      alignItems: "center",
                      justifyContent: "center",
                      opacity: actionBusy ? 0 : 1,
                      transition: "opacity 180ms ease",
                    }}
                  >
                    {idle}
                  </Box>
                  <Box
                    component="span"
                    sx={{
                      position: "absolute",
                      inset: 0,
                      display: "flex",
                      alignItems: "center",
                      justifyContent: "center",
                      opacity: actionBusy ? 1 : 0,
                      transition: "opacity 180ms ease",
                    }}
                  >
                    {busy}
                  </Box>
                </Box>
              );
            })()}
          </Button>
        </DialogActions>
      </Dialog>
    </Box>
  );
}

// CrudRowInner renders a single table row. Wrapped in React.memo with a
// custom equality fn so typing in a filter / opening dialogs / paging
// doesn't re-render every visible row through MUI's emotion CSS-in-JS
// (which was the source of the small <100 ms freezes when pressing
// buttons or typing). Only `row` data, the `selected` boolean, and the
// `columns` reference participate in equality — handler refs and any
// other parent state are intentionally ignored, since the closures
// always do the same thing.
interface CrudRowProps<TEntity> {
  row: TEntity;
  rowKey: string;
  columns: ColumnSpec<TEntity>[];
  selected: boolean;
  // Position of the row inside the currently-visible page slice.
  // Drives the staggered entrance animation below — the actual prop
  // is intentionally NOT part of the memo equality check (see
  // `CrudRow` further down) so a row simply changing position
  // (resort / page change with overlap) doesn't re-render via
  // emotion just because its index moved by one.
  index: number;
  onToggle: (row: TEntity) => void;
  onEdit: (row: TEntity) => void;
  onDelete: (row: TEntity) => void;
  // Optional extra actions (rendered before Edit / Delete). Stable
  // reference from the parent — see the memo equality fn below.
  rowActions?: RowActionSpec<TEntity>[];
  onRunAction?: (row: TEntity, action: RowActionSpec<TEntity>) => void;
}
function CrudRowInner<TEntity>(props: CrudRowProps<TEntity>) {
  const {
    row,
    rowKey,
    columns,
    selected,
    index,
    onToggle,
    onEdit,
    onDelete,
    rowActions,
    onRunAction,
  } = props;
  // Entrance animation — opacity-only fade so the row reads as
  // "just arrived" when the table fills in (initial load, page
  // change, filter applied, sort change). Driven by the Web
  // Animations API rather than a CSS keyframe rule for the same
  // reason DashboardTile uses WAAPI: emotion's class-hash churn
  // can re-trigger a CSS keyframe on an already-mounted element,
  // which surfaced as a "flash twice on cold load" glitch on the
  // dashboard. The `animatedRef` flag guarantees one play per
  // row fiber lifetime.
  //
  // We deliberately animate *opacity* only. `transform: translateY`
  // would create a containing block on the `<tr>`, which breaks
  // `position: sticky` on the Actions cell (the right-pinned
  // column would stop sticking to the scroll viewport and start
  // sticking to the row instead). Plain opacity has none of
  // those side effects.
  //
  // Stagger delay is `min(index * 22, 220)` ms: the first ~10
  // rows cascade in quick succession, every later row fires at
  // the 220 ms cap so a 100-row page doesn't take 2.2 s to
  // settle. With a 280 ms duration the last staggered row
  // finishes at ~500 ms — under the 600 ms threshold where a
  // table tween starts to feel like waiting.
  //
  // `prefers-reduced-motion` users skip the animation entirely
  // and the row mounts at full opacity from the first frame.
  const rowRef = useRef<HTMLTableRowElement | null>(null);
  const animatedRef = useRef(false);
  useLayoutEffect(() => {
    const el = rowRef.current;
    if (!el || animatedRef.current) return;
    if (typeof el.animate !== "function") return;
    animatedRef.current = true;
    if (
      typeof window !== "undefined" &&
      typeof window.matchMedia === "function" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches
    ) {
      return;
    }
    el.animate(
      [{ opacity: 0 }, { opacity: 1 }],
      {
        duration: 280,
        delay: Math.min(index * 22, 220),
        easing: "cubic-bezier(0.22, 0.61, 0.36, 1)",
        fill: "backwards",
      },
    );
    // Mount-time animation only — `index` deliberately omitted so
    // a row whose position shifts later doesn't re-trigger.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  return (
    <TableRow hover ref={rowRef}>
      <TableCell
        sx={{
          paddingLeft: 0,
          paddingRight: 0,
          textAlign: "center",
          overflow: "visible",
        }}
      >
        <Checkbox
          size="small"
          color="primary"
          checked={selected}
          onChange={() => onToggle(row)}
          inputProps={{ "aria-label": `select row ${rowKey}` }}
        />
      </TableCell>
      {columns.map((c) => {
        const isId = c.key === "id" || c.key === "uuid";
        return (
          <TableCell
            key={String(c.key)}
            sx={isId ? ID_CELL_SX : BODY_CELL_SX}
          >
            {c.render
              ? c.render(row)
              : renderDefault((row as Record<string, unknown>)[c.key as string])}
          </TableCell>
        );
      })}
      {/* Body filler cell — mirrors the auto-width filler in the
          header so each body row has the same column count and the
          column widths line up. Empty content; padding stripped so
          it can collapse to 0 px under `tableLayout: fixed` when
          the fixed columns exceed the viewport. */}
      <TableCell aria-hidden sx={FILLER_CELL_SX} />
      <TableCell align="center" sx={ACTIONS_CELL_SX}>
        <Stack
          direction="row"
          spacing={0.5}
          justifyContent="center"
          alignItems="center"
        >
          {rowActions?.map((action) => {
            if (action.visible && !action.visible(row)) return null;
            return (
              <Tooltip key={action.key} title={action.label}>
                <IconButton
                  size="small"
                  aria-label={action.label}
                  onClick={() => onRunAction?.(row, action)}
                  sx={action.variant === "danger" ? DELETE_BTN_SX : EDIT_BTN_SX}
                >
                  {action.icon}
                </IconButton>
              </Tooltip>
            );
          })}
          <Tooltip title="Edit">
            <IconButton size="small" onClick={() => onEdit(row)} sx={EDIT_BTN_SX}>
              <EditIcon fontSize="small" />
            </IconButton>
          </Tooltip>
          <Tooltip title="Delete">
            <IconButton
              size="small"
              onClick={() => onDelete(row)}
              sx={DELETE_BTN_SX}
            >
              <DeleteIcon fontSize="small" />
            </IconButton>
          </Tooltip>
        </Stack>
      </TableCell>
    </TableRow>
  );
}
const CrudRow = memo(CrudRowInner, (prev, next) => {
  return (
    prev.row === next.row &&
    prev.selected === next.selected &&
    prev.columns === next.columns &&
    prev.rowKey === next.rowKey &&
    prev.rowActions === next.rowActions
  );
}) as typeof CrudRowInner;

// CrudCardInner — phone-friendly card variant of CrudRow. Same data,
// same handlers (onToggle / onEdit / onDelete), but laid out as a
// vertical label/value list inside a bordered Paper-style Box so the
// row stays fully visible without horizontal scrolling.
//
// Memo'd with the same equality fn as CrudRow for the same reason —
// keep typing in a filter or paging through cheap.
function CrudCardInner<TEntity>(props: CrudRowProps<TEntity>) {
  const {
    row,
    rowKey,
    columns,
    selected,
    index,
    onToggle,
    onEdit,
    onDelete,
    rowActions,
    onRunAction,
  } = props;
  // Mirror of the CrudRowInner entrance animation but with the
  // upgrade an HTML <Box> permits: a small `translateY` slide
  // alongside the opacity fade. The Box doesn't host a sticky
  // sibling the way a `<tr>` does, so the new containing block
  // a transform creates is harmless here.
  const cardRef = useRef<HTMLDivElement | null>(null);
  const animatedRef = useRef(false);
  useLayoutEffect(() => {
    const el = cardRef.current;
    if (!el || animatedRef.current) return;
    if (typeof el.animate !== "function") return;
    animatedRef.current = true;
    if (
      typeof window !== "undefined" &&
      typeof window.matchMedia === "function" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches
    ) {
      return;
    }
    el.animate(
      [
        { opacity: 0, transform: "translateY(8px)" },
        { opacity: 1, transform: "translateY(0)" },
      ],
      {
        duration: 320,
        delay: Math.min(index * 28, 280),
        easing: "cubic-bezier(0.22, 0.61, 0.36, 1)",
        fill: "backwards",
      },
    );
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  return (
    <Box
      ref={cardRef}
      sx={{
        border: "1px solid",
        borderColor: selected ? "primary.main" : "divider",
        borderRadius: 1.5,
        bgcolor: "background.paper",
        transition: "border-color 0.18s ease, box-shadow 0.18s ease",
      }}
    >
      {/* Top strip — selection + actions. Compact so the card itself
          stays tight on a small screen, but tap targets are still
          large enough (Checkbox + IconButtons are 30+ px each). */}
      <Stack
        direction="row"
        alignItems="center"
        sx={{
          px: 0.5,
          py: 0.25,
          borderBottom: "1px solid",
          borderColor: "divider",
        }}
      >
        <Checkbox
          size="small"
          color="primary"
          checked={selected}
          onChange={() => onToggle(row)}
          inputProps={{ "aria-label": `select row ${rowKey}` }}
        />
        <Box sx={{ flexGrow: 1 }} />
        {rowActions?.map((action) => {
          if (action.visible && !action.visible(row)) return null;
          return (
            <Tooltip key={action.key} title={action.label}>
              <IconButton
                size="small"
                aria-label={action.label}
                onClick={() => onRunAction?.(row, action)}
                sx={action.variant === "danger" ? DELETE_BTN_SX : EDIT_BTN_SX}
              >
                {action.icon}
              </IconButton>
            </Tooltip>
          );
        })}
        <Tooltip title="Edit">
          <IconButton size="small" onClick={() => onEdit(row)} sx={EDIT_BTN_SX}>
            <EditIcon fontSize="small" />
          </IconButton>
        </Tooltip>
        <Tooltip title="Delete">
          <IconButton
            size="small"
            onClick={() => onDelete(row)}
            sx={DELETE_BTN_SX}
          >
            <DeleteIcon fontSize="small" />
          </IconButton>
        </Tooltip>
      </Stack>
      {/* Body — one row per column. Label hugs a fixed-width left
          column so values line up vertically; the value column flexes
          to the available width and wraps on long content
          (uuids, comma-separated IDs, etc.) instead of being clipped
          like the desktop table cell would. */}
      <Stack spacing={1} sx={{ px: 1.5, py: 1.25 }}>
        {columns.map((c) => {
          const isId = c.key === "id" || c.key === "uuid";
          const value = c.render
            ? c.render(row)
            : renderDefault((row as Record<string, unknown>)[c.key as string]);
          return (
            <Box
              key={String(c.key)}
              sx={{
                display: "flex",
                alignItems: "flex-start",
                gap: 1.25,
              }}
            >
              <Box
                sx={{
                  flexShrink: 0,
                  width: 96,
                  fontSize: 11,
                  fontWeight: 600,
                  letterSpacing: 0.6,
                  textTransform: "uppercase",
                  color: "text.secondary",
                  pt: 0.25,
                  // Wrap long column labels (e.g. "Bandwidth limit")
                  // onto two lines instead of forcing a wider label
                  // gutter that would steal space from values.
                  wordBreak: "break-word",
                }}
              >
                {c.label}
              </Box>
              <Box
                sx={{
                  flexGrow: 1,
                  minWidth: 0,
                  fontSize: 13.5,
                  // Allow values to break onto multiple lines on
                  // narrow screens. ID-style columns get the
                  // monospace treatment to match the table's
                  // ID_CELL_SX, but without the ellipsis (the whole
                  // value is useful here, and we have room to wrap).
                  wordBreak: "break-word",
                  ...(isId
                    ? {
                        fontFamily:
                          '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace',
                        fontSize: 12.5,
                        color: "text.secondary",
                      }
                    : {}),
                }}
              >
                {value}
              </Box>
            </Box>
          );
        })}
      </Stack>
    </Box>
  );
}
const CrudCard = memo(CrudCardInner, (prev, next) => {
  return (
    prev.row === next.row &&
    prev.selected === next.selected &&
    prev.columns === next.columns &&
    prev.rowKey === next.rowKey &&
    prev.rowActions === next.rowActions
  );
}) as typeof CrudCardInner;

// DateCell — pretty two-line date display used by `renderDefault` whenever
// it sees an ISO datetime. Top row is the day in `15 Jan 2025` form (year
// dropped if it's the current year so the cell stays compact); bottom row
// is the time in `HH:mm` form, dimmer and a touch smaller. Hovering shows
// the full localized stamp plus a relative ("2 hours ago"-style) hint;
// for entries too old to express compactly the relative half is dropped
// and only the full stamp is shown.
function DateCell({ value }: { value: string }) {
  const d = dayjs(value);
  if (!d.isValid()) return <>{value}</>;
  const now = dayjs();
  const sameYear = d.year() === now.year();
  const datePart = sameYear ? d.format("D MMM") : d.format("D MMM YYYY");
  const timePart = d.format("HH:mm");
  const fullStamp = d.format("D MMMM YYYY, HH:mm:ss");
  // Cheap "Xy ago" formatter — avoids pulling in dayjs's relativeTime
  // plugin just for one column. For entries older than 30 days we omit
  // the relative half and let the tooltip show the full stamp on its own.
  const diffMs = now.diff(d);
  const sec = Math.round(diffMs / 1000);
  const min = Math.round(sec / 60);
  const hr = Math.round(min / 60);
  const day = Math.round(hr / 24);
  let relative = "";
  if (Math.abs(sec) < 60) relative = "just now";
  else if (Math.abs(min) < 60) relative = `${min} min ago`;
  else if (Math.abs(hr) < 24) relative = `${hr}h ago`;
  else if (Math.abs(day) < 30) relative = `${day}d ago`;
  const tooltipTitle = relative ? `${fullStamp} · ${relative}` : fullStamp;
  return (
    <Tooltip title={tooltipTitle} placement="top">
      <Box
        sx={{
          display: "inline-flex",
          flexDirection: "column",
          lineHeight: 1.1,
          fontVariantNumeric: "tabular-nums",
        }}
      >
        <Box component="span" sx={{ fontWeight: 500 }}>
          {datePart}
        </Box>
        <Box
          component="span"
          sx={{ fontSize: 12, color: "text.secondary", mt: 0.25 }}
        >
          {timePart}
        </Box>
      </Box>
    </Tooltip>
  );
}

function renderDefault(v: unknown): ReactNode {
  if (v === null || v === undefined) return "";
  if (Array.isArray(v)) {
    // gap (instead of Stack spacing) gives the chips a real two-axis
    // gutter when the column is narrow enough that the chips have to wrap
    // onto multiple lines — so e.g. `squad_ids` no longer reads as two
    // crammed-together rows of chips when the page is squeezed.
    return (
      <Box
        sx={{
          display: "flex",
          flexWrap: "wrap",
          rowGap: 0.5,
          columnGap: 0.5,
          maxWidth: "100%",
        }}
      >
        {v.map((x, i) => (
          <Chip key={i} label={String(x)} size="small" />
        ))}
      </Box>
    );
  }
  if (typeof v === "object") return JSON.stringify(v);
  if (typeof v === "string" && /\d{4}-\d{2}-\d{2}T/.test(v)) {
    return <DateCell value={v} />;
  }
  return String(v);
}

interface DialogProps {
  // Controls MUI's `open` directly so the parent can flip it to false to
  // trigger the leave transition while the component stays mounted; the
  // parent then waits for `onExited` before clearing its own state.
  open: boolean;
  mode: "create" | "update";
  fields: FieldSpec[];
  initial?: Record<string, unknown>;
  title: string;
  onClose: () => void;
  onExited?: () => void;
  onSubmit: (form: Record<string, unknown>) => Promise<void>;
}

function CrudDialog({
  open,
  mode,
  fields,
  initial,
  title,
  onClose,
  onExited,
  onSubmit,
}: DialogProps) {
  // Mirror the mobile detection used in CrudPage so the create / edit
  // form dialog goes full-screen on phones + portrait tablets — with a
  // narrow viewport a width-sm modal covers most of the screen anyway,
  // but full-screen gives the long squad / user forms room to breathe
  // and avoids awkward double-scroll (modal scroll + page scroll).
  const dialogTheme = useTheme();
  const dialogIsMobile = useMediaQuery(dialogTheme.breakpoints.down("md"));
  const [form, setForm] = useState<Record<string, unknown>>(() => {
    const base = emptyForm(fields, mode);
    if (initial) {
      // Seed every initial value, including those for fields that are
      // currently hidden (e.g. `type` on the user edit form). Hidden values
      // are still read by `visibleWhen` predicates and dropped at submit
      // time by `normalizeFormForSubmit`.
      for (const f of fields) {
        const v = initial[f.name];
        if (v === undefined) continue;
        if (f.type === "ids" && Array.isArray(v)) base[f.name] = v.join(",");
        else if (f.type === "multiselect" && Array.isArray(v)) {
          // The multi-select stores values as strings (so `Squad.id` numbers
          // become "1", "2"…); coerce here so editing pre-selects the right
          // entries and the submit step can re-parse them.
          base[f.name] = (v as unknown[]).map(String);
        } else base[f.name] = v as unknown;
      }
    }
    return base;
  });
  // Re-evaluate visibility every render so dynamic `visibleWhen` predicates
  // reflect the latest form values (e.g. switching the user `type`).
  const visibleFields = useMemo(
    () => fields.filter((f) => fieldVisible(f, mode, form)),
    [fields, mode, form],
  );

  // Async option loaders are fired once when the dialog mounts. Each entry
  // overrides the field's static `options` while rendering. Loaders are
  // skipped for fields hidden in the current mode (e.g. a create-only
  // squad picker when the dialog was opened for edit) so the API isn't
  // hit for options no one will see.
  type LoadedOpts = Record<string, { value: string; label?: string }[]>;
  const [loadedOptions, setLoadedOptions] = useState<LoadedOpts>({});
  useEffect(() => {
    const targets = fields.filter(
      (f) =>
        typeof f.optionsLoader === "function" && (!f.only || f.only === mode),
    );
    if (targets.length === 0) return;
    let cancelled = false;
    (async () => {
      try {
        const entries = await Promise.all(
          targets.map(async (f) => {
            const opts = await f.optionsLoader!();
            return [f.name, opts] as const;
          }),
        );
        if (cancelled) return;
        const next: LoadedOpts = {};
        for (const [name, opts] of entries) next[name] = opts;
        setLoadedOptions(next);
      } catch {
        /* loaders are best-effort; fall back to whatever static options exist */
      }
    })();
    return () => {
      cancelled = true;
    };
    // Run only once per dialog instance.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);
  const optionsFor = (f: FieldSpec) => loadedOptions[f.name] ?? f.options ?? [];
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState<string | null>(null);
  // Per-field validation messages. A non-empty entry causes the field to
  // render in error state with the message as helper text. Cleared as soon
  // as the user provides a value for the offending field.
  const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});

  // set updates a single field's value. If the field declares `clears`, every
  // listed dependent field is reset to its empty default at the same time —
  // this is what "switching the user type clears the credential fields"
  // relies on.
  const set = (name: string, value: unknown) => {
    setForm((p) => {
      const next: Record<string, unknown> = { ...p, [name]: value };
      const source = fields.find((f) => f.name === name);
      if (source?.clears && source.clears.length > 0) {
        for (const target of source.clears) {
          const f = fields.find((x) => x.name === target);
          next[target] = f ? emptyValueForField(f) : "";
        }
      }
      return next;
    });
    // Clear the error for the field as soon as the user touches it; also
    // clear any errors on dependent fields that just got reset.
    setFieldErrors((prev) => {
      if (!prev[name] && !fields.find((f) => f.name === name)?.clears) return prev;
      const next = { ...prev };
      delete next[name];
      const source = fields.find((f) => f.name === name);
      if (source?.clears) for (const t of source.clears) delete next[t];
      return next;
    });
  };

  const submit = async () => {
    // Validate visible required fields first. If anything is missing we
    // surface it inline (per-field) and via a top-level alert, and skip the
    // network round-trip entirely.
    const errors = validateRequired(visibleFields, form);
    if (Object.keys(errors).length > 0) {
      setFieldErrors(errors);
      const missing = visibleFields
        .filter((f) => errors[f.name])
        .map((f) => f.label);
      setErr(`Please fill in the required field${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}`);
      return;
    }
    setFieldErrors({});
    setBusy(true);
    setErr(null);
    try {
      await onSubmit(normalizeFormForSubmit(fields, mode, form));
    } catch {
      // Server-side failures are surfaced via the global toast stack
      // by the parent's `notifyApiError` call inside handleCreate /
      // handleUpdate. The dialog stays open (we never advance past
      // the throw) so the user can retry without re-typing the form.
    } finally {
      setBusy(false);
    }
  };

  // submitOnEnter forwards a top-level Enter key inside the dialog to the
  // submit handler. We deliberately ignore Enter coming from elements that
  // own their own keyboard semantics:
  //
  //   - <textarea> (would lose newline support)
  //   - the buttons themselves (they invoke their own click handler)
  //   - any element inside a portal — open <Select> menus and the
  //     DateTimePicker popover render outside this Dialog node, so their
  //     Enter never reaches us in the first place.
  const submitOnEnter = (e: KeyboardEvent<HTMLDivElement>) => {
    if (e.key !== "Enter" || e.shiftKey || e.nativeEvent.isComposing) return;
    const target = e.target as HTMLElement;
    const tag = target.tagName;
    if (tag === "TEXTAREA" || tag === "BUTTON" || tag === "A") return;
    e.preventDefault();
    void submit();
  };

  return (
    <Dialog
      open={open}
      onClose={onClose}
      fullWidth
      maxWidth="sm"
      fullScreen={dialogIsMobile}
      onKeyDown={submitOnEnter}
      // MUI's default Fade transition runs both directions, but the dialog
      // used to unmount the moment `open` flipped to false (parent gated
      // rendering on `dialogOpen`), so the leave half never had a chance.
      // The parent now keeps us mounted until this callback fires; once
      // it does, it's safe to drop the form state and unmount us.
      TransitionProps={{ onExited }}
    >
      <DialogTitle>{title}</DialogTitle>
      <DialogContent dividers>
        {/* Inline alert reserved for client-side validation summaries
            ("Please fill in the required field…"): server errors are
            announced via the global toast stack instead, so the dialog
            never grows a red bar at the top in response to a failed
            CRUD round-trip. */}
        {err && (
          <Alert severity="error" sx={{ mb: 2 }}>
            {err}
          </Alert>
        )}
        <Stack spacing={2} sx={{ mt: 1 }}>
          {visibleFields.map((f) => {
            const value = form[f.name];
            // Error / helper-text wiring shared across every field type:
            // when the field has a validation error we show it in MUI's
            // error state and override the helper text; otherwise the
            // configured helperText (if any) is shown.
            const fieldErr = fieldErrors[f.name];
            const errored = !!fieldErr;
            if (f.type === "select") {
              return (
                <TextField
                  key={f.name}
                  select
                  fullWidth
                  size="small"
                  required={f.required}
                  label={f.label}
                  error={errored}
                  helperText={fieldErr ?? f.helperText}
                  value={(value as string) ?? ""}
                  onChange={(e) => set(f.name, e.target.value)}
                >
                  {/* Optional fields keep an explicit "(none)" choice so the
                      user can clear them. Required fields skip it — there is
                      always a real selection. */}
                  {!f.required && (
                    <MenuItem value="">
                      <em>(none)</em>
                    </MenuItem>
                  )}
                  {(optionsFor(f)).map((o) => (
                    <MenuItem key={o.value} value={o.value}>
                      {o.label ?? o.value}
                    </MenuItem>
                  ))}
                </TextField>
              );
            }
            if (f.type === "multiselect") {
              const arr = Array.isArray(value) ? (value as string[]) : [];
              const opts = optionsFor(f);
              const labelOf = (v: string) =>
                opts.find((o) => o.value === v)?.label ?? v;
              return (
                <TextField
                  key={f.name}
                  select
                  fullWidth
                  size="small"
                  required={f.required}
                  label={f.label}
                  error={errored}
                  helperText={fieldErr ?? f.helperText}
                  SelectProps={{
                    multiple: true,
                    value: arr,
                    onChange: (e) => set(f.name, e.target.value as string[]),
                    // Two display modes:
                    //   - default chips, one per selection (no implied
                    //     ordering between them);
                    //   - chain mode (`displayAsChain: true`): the same
                    //     chips but joined by `→` arrows so an ordered
                    //     pipeline like flow_keys reads as
                    //     "User → Destination → IP".
                    renderValue: (selected) => {
                      const values = selected as string[];
                      if (f.displayAsChain) {
                        return (
                          <Box
                            sx={{
                              display: "flex",
                              flexWrap: "wrap",
                              alignItems: "center",
                              gap: 0.5,
                            }}
                          >
                            {values.map((v, i) => (
                              <Box
                                key={v}
                                sx={{
                                  display: "inline-flex",
                                  alignItems: "center",
                                  gap: 0.5,
                                }}
                              >
                                {i > 0 && (
                                  <Box
                                    component="span"
                                    sx={{
                                      color: "text.secondary",
                                      fontSize: 12,
                                      userSelect: "none",
                                    }}
                                  >
                                    →
                                  </Box>
                                )}
                                <Chip
                                  label={labelOf(v)}
                                  size="small"
                                  color="primary"
                                  sx={{ height: 22 }}
                                />
                              </Box>
                            ))}
                          </Box>
                        );
                      }
                      return (
                        <Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
                          {values.map((v) => (
                            <Chip
                              key={v}
                              label={labelOf(v)}
                              size="small"
                              color="primary"
                              sx={{ height: 22 }}
                            />
                          ))}
                        </Box>
                      );
                    },
                  }}
                  value={arr}
                >
                  {opts.map((o) => (
                    <MenuItem key={o.value} value={o.value}>
                      {o.label ?? o.value}
                    </MenuItem>
                  ))}
                </TextField>
              );
            }
            if (f.type === "uuid") {
              return (
                <TextField
                  key={f.name}
                  fullWidth
                  size="small"
                  required={f.required}
                  label={f.label}
                  error={errored}
                  helperText={fieldErr ?? f.helperText}
                  value={(value as string | undefined) ?? ""}
                  onChange={(e) => set(f.name, e.target.value)}
                  InputProps={{
                    endAdornment: (
                      <Tooltip title="Generate UUID">
                        <IconButton
                          size="small"
                          edge="end"
                          onClick={() => set(f.name, generateUUID())}
                        >
                          <AutorenewIcon fontSize="small" />
                        </IconButton>
                      </Tooltip>
                    ),
                  }}
                />
              );
            }
            const isNumber = f.type === "number";
            // Numeric value of the current cell. Falls back to 0 for the
            // empty state so the up-arrow always has a sensible base to
            // increment from. Decrement is clamped at 0 because the
            // input pattern only accepts non-negative integers anyway.
            const numericValue = isNumber
              ? (() => {
                  const v = (value as string | number | undefined) ?? "";
                  const n = parseInt(String(v), 10);
                  return Number.isFinite(n) ? n : 0;
                })()
              : 0;
            const stepValue = (delta: number) => {
              const next = Math.max(0, numericValue + delta);
              set(f.name, String(next));
            };
            return (
              <TextField
                key={f.name}
                fullWidth
                size="small"
                required={f.required}
                label={f.label}
                error={errored}
                helperText={
                  fieldErr ??
                  f.helperText ??
                  (f.type === "ids" ? "Comma-separated IDs" : undefined)
                }
                // Number fields render as plain `text` (rather than the
                // native `type="number"`) so we can fully control which
                // characters are accepted: HTML5 number inputs still let
                // through "e", "+", "-", scientific notation and even some
                // localized separators on certain locales. We strip every
                // non-digit on input and on paste, and block letter keys
                // before they reach the value at all.
                type="text"
                inputMode={isNumber ? "numeric" : undefined}
                // The keyboard / paste handlers go through `inputProps` so
                // they attach directly to the underlying <input> element.
                // Putting `onKeyDown` on the TextField itself isn't always
                // forwarded to the inner input in MUI v6 — this is the
                // reliable spot.
                inputProps={
                  isNumber
                    ? {
                        pattern: "[0-9]*",
                        onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {
                          // ArrowUp / ArrowDown step the value by 1 even
                          // though we render as `type="text"` (where the
                          // browser would otherwise just move the caret).
                          // This matches the behaviour users expect from
                          // a numeric field with spinner arrows.
                          if (e.key === "ArrowUp") {
                            e.preventDefault();
                            stepValue(1);
                            return;
                          }
                          if (e.key === "ArrowDown") {
                            e.preventDefault();
                            stepValue(-1);
                            return;
                          }
                          // Allow Backspace, Tab, Enter, Home, End,
                          // Delete (key.length > 1) and any combo with
                          // a modifier (Ctrl+A / Cmd+V / etc.). Block
                          // every other single-character key that isn't
                          // 0-9.
                          if (e.key.length > 1) return;
                          if (e.ctrlKey || e.metaKey || e.altKey) return;
                          if (!/^[0-9]$/.test(e.key)) e.preventDefault();
                        },
                        onPaste: (e: React.ClipboardEvent<HTMLInputElement>) => {
                          // Strip non-digits from clipboard text before it
                          // reaches the input. Without this the onChange
                          // sanitiser would still clean it, but the input
                          // would briefly flash the raw paste contents.
                          const txt = e.clipboardData.getData("text");
                          const cleaned = txt.replace(/\D+/g, "");
                          if (cleaned !== txt) {
                            e.preventDefault();
                            const target = e.currentTarget;
                            const start = target.selectionStart ?? target.value.length;
                            const end = target.selectionEnd ?? target.value.length;
                            const next =
                              target.value.slice(0, start) +
                              cleaned +
                              target.value.slice(end);
                            set(f.name, next);
                          }
                        },
                      }
                    : undefined
                }
                // Spinner arrows for numeric fields. Two stacked
                // IconButtons sized to fit the small TextField height
                // (~32 px) — 14 px each, 0 px padding — with the up
                // arrow on top and the down arrow underneath. They
                // share the `var(--sb-accent)` hover treatment used
                // elsewhere in the panel.
                InputProps={
                  isNumber
                    ? {
                        endAdornment: (
                          <Box
                            sx={{
                              display: "flex",
                              flexDirection: "column",
                              alignItems: "stretch",
                              ml: 0.5,
                              mr: -0.75,
                            }}
                          >
                            <IconButton
                              aria-label={`Increase ${f.label}`}
                              tabIndex={-1}
                              onClick={() => stepValue(1)}
                              sx={{
                                width: 22,
                                height: 14,
                                p: 0,
                                borderRadius: 0.75,
                                color: "text.secondary",
                                transition:
                                  "color 0.16s ease, background-color 0.16s ease",
                                "&:hover": { color: "var(--sb-accent)" },
                              }}
                            >
                              <KeyboardArrowUpIcon sx={{ fontSize: 16 }} />
                            </IconButton>
                            <IconButton
                              aria-label={`Decrease ${f.label}`}
                              tabIndex={-1}
                              disabled={numericValue <= 0}
                              onClick={() => stepValue(-1)}
                              sx={{
                                width: 22,
                                height: 14,
                                p: 0,
                                borderRadius: 0.75,
                                color: "text.secondary",
                                transition:
                                  "color 0.16s ease, background-color 0.16s ease",
                                "&:hover": { color: "var(--sb-accent)" },
                              }}
                            >
                              <KeyboardArrowDownIcon sx={{ fontSize: 16 }} />
                            </IconButton>
                          </Box>
                        ),
                      }
                    : undefined
                }
                value={(value as string | number | undefined) ?? ""}
                onChange={(e) => {
                  const v = isNumber
                    ? e.target.value.replace(/\D+/g, "")
                    : e.target.value;
                  set(f.name, v);
                }}
              />
            );
          })}
        </Stack>
      </DialogContent>
      <DialogActions>
        <Button onClick={onClose} disabled={busy}>
          Cancel
        </Button>
        <Button
          variant="contained"
          onClick={() => void submit()}
          disabled={busy}
          // Same treatment as the delete dialog's primary action: keep
          // the active palette while disabled mid-submit so the spinner
          // is the only "I'm working" cue, instead of layering a washed-
          // out gray on top of it.
          sx={{
            "&.Mui-disabled": {
              bgcolor: "primary.main",
              color: "primary.contrastText",
            },
          }}
        >
          {/* Cross-fade idle ("Create" / "Save") with the busy state
              ("[spinner] Saving…") at a fixed button width — the busy
              version is rendered as an invisible spacer once so MUI
              lays the button out against it and the size doesn't
              animate as `busy` flips. */}
          <Box
            component="span"
            sx={{
              position: "relative",
              display: "inline-flex",
              alignItems: "center",
              justifyContent: "center",
              lineHeight: "inherit",
            }}
          >
            <Box
              component="span"
              sx={{
                visibility: "hidden",
                display: "inline-flex",
                alignItems: "center",
              }}
            >
              <Box component="span" sx={{ width: 14, height: 14, mr: 1 }} />
              Saving…
            </Box>
            <Box
              component="span"
              sx={{
                position: "absolute",
                inset: 0,
                display: "flex",
                alignItems: "center",
                justifyContent: "center",
                opacity: busy ? 0 : 1,
                transition: "opacity 180ms ease",
              }}
            >
              {mode === "create" ? "Create" : "Save"}
            </Box>
            <Box
              component="span"
              sx={{
                position: "absolute",
                inset: 0,
                display: "flex",
                alignItems: "center",
                justifyContent: "center",
                opacity: busy ? 1 : 0,
                transition: "opacity 180ms ease",
              }}
            >
              <CircularProgress
                size={14}
                thickness={5}
                color="inherit"
                sx={{ mr: 1 }}
              />
              Saving…
            </Box>
          </Box>
        </Button>
      </DialogActions>
    </Dialog>
  );
}

// FilterField renders a single filter input based on its spec type. Kept
// inside this file because the filter model is tightly coupled to
// `FilterSpec` and the surrounding CrudPage.
function FilterField({
  spec,
  value,
  onChange,
}: {
  spec: FilterSpec;
  value: unknown;
  onChange: (v: unknown) => void;
}) {
  if (spec.type === "select") {
    return (
      <TextField
        select
        fullWidth
        size="small"
        label={spec.label}
        value={(value as string) ?? ""}
        onChange={(e) => onChange(e.target.value)}
      >
        <MenuItem value="">
          <em>(any)</em>
        </MenuItem>
        {(spec.options ?? []).map((o) => (
          <MenuItem key={o.value} value={o.value}>
            {o.label ?? o.value}
          </MenuItem>
        ))}
      </TextField>
    );
  }
  if (spec.type === "datetime-range") {
    return <DateRangeFilterField spec={spec} value={value} onChange={onChange} />;
  }
  return (
    <TextField
      fullWidth
      size="small"
      label={spec.label}
      placeholder={spec.placeholder}
      value={(value as string) ?? ""}
      onChange={(e) => onChange(e.target.value)}
    />
  );
}

// DateRangeFilterField — the datetime-range filter as a single bordered
// box that visually reads like one "calendar button" filling the whole
// cell. Two halves (start / end) are themselves clickable: clicking
// either half opens its DateTimePicker calendar in a popper. The picker
// is rendered headless (its field replaced via `slots.field`) so MUI X
// doesn't draw any input, format mask, or `YYYY-MM-DD` placeholder
// inside the cell.
function DateRangeFilterField({
  spec,
  value,
  onChange,
}: {
  spec: FilterSpec;
  value: unknown;
  onChange: (v: unknown) => void;
}) {
  const r = (value ?? {}) as { start?: string; end?: string };
  const toDayjs = (s: string | undefined): Dayjs | null => {
    if (!s) return null;
    const d = dayjs(s);
    return d.isValid() ? d : null;
  };
  const fromDayjs = (d: Dayjs | null): string | undefined =>
    d && d.isValid() ? d.format("YYYY-MM-DDTHH:mm") : undefined;
  const formatDisplay = (s: string | undefined): string | null => {
    const d = toDayjs(s);
    return d ? d.format("YYYY-MM-DD HH:mm") : null;
  };
  const [openStart, setOpenStart] = useState(false);
  const [openEnd, setOpenEnd] = useState(false);
  // Anchors used by the picker popper. We point them at the visible
  // half-buttons (instead of the headless field) so the calendar opens
  // attached to whichever side the user clicked.
  const startAnchorRef = useRef<HTMLElement | null>(null);
  const endAnchorRef = useRef<HTMLElement | null>(null);
  const halfSx = {
    flex: 1,
    minWidth: 0,
    height: "100%",
    display: "flex",
    alignItems: "center",
    // Centre the [calendar icon + date text] group horizontally
    // inside each half so the value reads as visually centred
    // rather than left-aligned against the inner edge of the cell.
    // The inner text span loses its `flex: 1` (see below) so the
    // group keeps its natural width and `justifyContent: center`
    // can actually move it.
    justifyContent: "center",
    gap: 0.75,
    paddingInline: "10px",
    cursor: "pointer",
    fontSize: 13.5,
    overflow: "hidden",
    userSelect: "none" as const,
  };
  const startDisplay = formatDisplay(r.start);
  const endDisplay = formatDisplay(r.end);
  return (
    <Box
      sx={(t) => ({
        position: "relative",
        display: "flex",
        alignItems: "center",
        width: "100%",
        height: 40,
        borderRadius: `${t.shape.borderRadius}px`,
        border: `1px solid ${t.palette.divider}`,
        backgroundColor:
          t.palette.mode === "light" ? "#ffffff" : "rgba(255,255,255,0.02)",
        transition: "border-color 0.16s ease, box-shadow 0.16s ease",
        "&:hover": { borderColor: t.palette.text.secondary },
        "&:focus-within": {
          borderColor: "var(--sb-accent)",
          boxShadow: `0 0 0 1px var(--sb-accent)`,
        },
      })}
    >
      <Box
        ref={(el: HTMLElement | null) => {
          startAnchorRef.current = el;
        }}
        role="button"
        tabIndex={0}
        onClick={() => setOpenStart(true)}
        onKeyDown={(e) => {
          if (e.key === "Enter" || e.key === " ") setOpenStart(true);
        }}
        sx={(t) => ({
          ...halfSx,
          color: startDisplay ? t.palette.text.primary : t.palette.text.secondary,
        })}
      >
        <CalendarTodayIcon
          sx={{ fontSize: 16, opacity: 0.7, flexShrink: 0 }}
        />
        <Box
          component="span"
          sx={{
            overflow: "hidden",
            textOverflow: "ellipsis",
            whiteSpace: "nowrap",
            // No `flex: 1` — keep the span at its content width so
            // the parent's `justifyContent: center` actually centres
            // the [icon + date] group instead of stretching the span
            // to fill the half. `minWidth: 0` still lets the span
            // shrink (with ellipsis) if the date string is wider
            // than the available space.
            minWidth: 0,
          }}
        >
          {startDisplay ?? `${spec.label} from`}
        </Box>
      </Box>
      <Box
        sx={(t) => ({
          flex: "0 0 auto",
          color: t.palette.text.secondary,
          fontSize: 13,
          userSelect: "none",
          paddingInline: "4px",
        })}
      >
        —
      </Box>
      <Box
        ref={(el: HTMLElement | null) => {
          endAnchorRef.current = el;
        }}
        role="button"
        tabIndex={0}
        onClick={() => setOpenEnd(true)}
        onKeyDown={(e) => {
          if (e.key === "Enter" || e.key === " ") setOpenEnd(true);
        }}
        sx={(t) => ({
          ...halfSx,
          color: endDisplay ? t.palette.text.primary : t.palette.text.secondary,
        })}
      >
        <CalendarTodayIcon
          sx={{ fontSize: 16, opacity: 0.7, flexShrink: 0 }}
        />
        <Box
          component="span"
          sx={{
            overflow: "hidden",
            textOverflow: "ellipsis",
            whiteSpace: "nowrap",
            // See the matching comment on the `start` half above —
            // the span is intentionally content-width so the parent
            // can centre the [icon + date] pair.
            minWidth: 0,
          }}
        >
          {endDisplay ?? `${spec.label} to`}
        </Box>
      </Box>
      {/* Headless pickers — their fields are stripped via the `field`
          slot so they exist purely to render the calendar popper when
          opened. The popper is explicitly anchored to whichever
          half-button is active so the calendar lines up under it. */}
      <DateTimePicker
        open={openStart}
        onClose={() => setOpenStart(false)}
        value={toDayjs(r.start)}
        onChange={(d) => onChange({ ...r, start: fromDayjs(d) })}
        ampm={false}
        format="YYYY-MM-DD HH:mm"
        slots={{ field: HiddenField }}
        slotProps={{
          popper: { anchorEl: () => startAnchorRef.current ?? document.body },
        }}
      />
      <DateTimePicker
        open={openEnd}
        onClose={() => setOpenEnd(false)}
        value={toDayjs(r.end)}
        onChange={(d) => onChange({ ...r, end: fromDayjs(d) })}
        ampm={false}
        format="YYYY-MM-DD HH:mm"
        slots={{ field: HiddenField }}
        slotProps={{
          popper: { anchorEl: () => endAnchorRef.current ?? document.body },
        }}
      />
    </Box>
  );
}

// HiddenField — a no-op replacement for MUI X's default field. We still
// `forwardRef` so the picker's internal ref-pinning logic gets a real
// node (the picker would warn / fail to render otherwise). The element
// itself takes zero layout space, the popper anchors to our explicit
// half-button refs.
const HiddenField = forwardRef<HTMLSpanElement>(function HiddenField(_, ref) {
  return <span ref={ref} style={{ display: "none" }} aria-hidden="true" />;
});
