import {
  columnFilteringFeature,
  columnVisibilityFeature,
  createColumnHelper,
  createExpandedRowModel,
  createFilteredRowModel,
  createPaginatedRowModel,
  createSortedRowModel,
  filterFn_includesString,
  globalFilteringFeature,
  rowExpandingFeature,
  rowPaginationFeature,
  rowSelectionFeature,
  rowSortingFeature,
  sortFn_alphanumeric,
  sortFn_datetime,
  sortFn_text,
  tableFeatures,
  useTable,
  type ColumnDef,
  type RowData,
} from '@tanstack/react-table'
import {
  ChevronDown,
  ChevronLeft,
  ChevronRight,
  ChevronsLeft,
  ChevronsRight,
  Search,
  SearchX,
  Trash2,
} from 'lucide-react'
import type { ComponentProps, ReactNode } from 'react'
import { Fragment, useMemo } from 'react'

import { EmptyState } from '@/components/EmptyState'
import { MultiFacetedFilter, SingleFacetedFilter } from '@/components/FacetedFilter'
import {
  ARIA_SORT,
  BODY_CELL,
  COMPACT_CELL,
  NUMERIC_CELL,
  SkeletonRows,
  SORT_CONTROL,
  SortIndicator,
  sortHint,
  TABLE_FRAME,
  unitName,
  withNumeric,
} from '@/components/table'
import type { DataTableColumnMeta } from '@/components/table'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuLabel,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { RowActions } from '@/components/RowActions'
import type { RowActionMap } from '@/components/RowActions'
import { InputGroup, InputGroupAddon, InputGroupInput } from '@/components/ui/input-group'
import { Label } from '@/components/ui/label'
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select'
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { formatNumber } from '@/lib/format'
import { t } from '@/lib/i18n'
import type { TranslationKey } from '@/lib/i18n'
import { cn } from '@/lib/utils'

/**
 * Acá sí se registra el trabajo, y ésa es la única diferencia de fondo con la
 * otra tabla.
 *
 * `DataTable` declara las capacidades **sin** sus modelos de fila porque el
 * trabajo lo hace el servidor. Acá pasa lo contrario: las filas llegan todas, y
 * ordenarlas o filtrarlas en el navegador no miente, porque no hay un resto que
 * no se esté mirando. Eso es también lo que autoriza a paginar de este lado: la
 * página 3 de este conjunto es la página 3 del conjunto entero.
 *
 * `filteredRowModel` no está sólo por el filtro. El orden automático elige
 * comparador mirando las primeras filas del modelo **filtrado**; sin el modelo,
 * cae en un valor por omisión que nadie eligió.
 *
 * Los comparadores se registran de a uno y no con el juego completo: el juego
 * entero mete los seis en el paquete. Van estos tres porque son los tres nombres
 * que el modo automático puede resolver.
 */
/**
 * Si el valor de una celda es uno de los elegidos en un filtro facetado.
 *
 * **Los valores de un mismo eje se combinan como alternativas**, no como
 * requisitos: pedir «indexadas» y «descubiertas» es pedir las dos cosas, no las
 * filas que sean las dos a la vez, que no existen. Sin nada elegido no filtra.
 *
 * La comparación es por **igualdad exacta** y es la mitad que importa. Comparar
 * por subcadena —que es a lo que cae el comparador automático— hace que
 * «indexadas» se traiga también `CRAWLED_NOT_INDEXED` y
 * `DISCOVERED_NOT_INDEXED`, y eso no se ve roto: se ve como una tabla con filas.
 *
 * Vive suelta y exportada para poder ejercitarla sin pantalla: es una regla, no
 * un dibujo.
 */
export function isSelected(value: string, selected: string[]): boolean {
  return selected.length === 0 || selected.includes(value)
}

const features = tableFeatures({
  columnFilteringFeature,
  globalFilteringFeature,
  filteredRowModel: createFilteredRowModel(),
  filterFns: {
    includesString: filterFn_includesString,
    includesSelected: (row, columnId, value: string[]) =>
      isSelected(String(row.getValue(columnId) ?? ''), value),
  },

  rowSortingFeature,
  sortedRowModel: createSortedRowModel(),
  sortFns: {
    alphanumeric: sortFn_alphanumeric,
    text: sortFn_text,
    datetime: sortFn_datetime,
  },

  rowPaginationFeature,
  paginatedRowModel: createPaginatedRowModel(),

  columnVisibilityFeature,
  rowSelectionFeature,

  /*
    Filas que se abren. Va con su modelo aunque ninguna fila tenga subfilas: sin
    `expandedRowModel`, `getIsExpanded()` responde pero el estado no se recalcula
    con el resto de los modelos, y la fila abierta sobrevive a un filtro que la
    dejó fuera —queda un detalle dibujado bajo una fila que ya no está—.

    Lo que se dibuja **no es una subfila** sino una fila de detalle: el modelo se
    usa para el estado, no para aplanar hijos. Por eso la tabla declara
    `getRowCanExpand` en vez de esperar `subRows`.
  */
  rowExpandingFeature,
  expandedRowModel: createExpandedRowModel(),

  columnMeta: {} as DataTableColumnMeta,
  tableMeta: {} as ClientDataTableMeta,
})

export type ClientDataTableFeatures = typeof features

/**
 * Una columna de `ClientDataTable`.
 *
 * No es intercambiable con la de `DataTable` y no es un descuido: el tipo de una
 * columna está atado al juego de capacidades de su tabla, y ésta registra tres
 * más —buscar en todo, esconder columnas y marcar filas—. Que no compilen
 * cruzadas es lo que impide que una columna con casilla termine en una tabla que
 * no tiene selección.
 */
export type ClientDataTableColumn<T extends RowData> = ColumnDef<ClientDataTableFeatures, T, any>

/**
 * Le impone `includesSelected` a toda columna que tenga un filtro facetado.
 *
 * **La tabla lo decide, no la vista.** El filtro facetado guarda un arreglo de
 * valores elegidos, y sólo `includesSelected` sabe leer eso; sin declararlo,
 * TanStack elige el comparador mirando el tipo del **dato de la celda** —una
 * cadena— y cae en `includesString`, que compara por subcadena contra el
 * arreglo convertido a texto.
 *
 * Eso falla de dos maneras, y las dos en silencio:
 *
 * - **Con dos valores elegidos no queda ninguna fila.** `['A','B']` se vuelve
 *   `'A,B'`, y ninguna celda contiene esa cadena. La tabla se ve vacía con los
 *   contadores del filtro diciendo que hay decenas.
 * - **Con uno solo parece funcionar y filtra mal.** `['INDEXED']` compara por
 *   subcadena, así que «indexadas» también se trae `CRAWLED_NOT_INDEXED` y
 *   `DISCOVERED_NOT_INDEXED`. Éste es el peor de los dos: no se ve roto.
 *
 * Declararlo en cada vista era la manera de que en algún momento faltara, y
 * efectivamente faltó. Acá no puede faltar: la tabla ya sabe qué columnas tienen
 * filtro facetado, porque las recibe en `facetedFilters`.
 */
export function withSelectedFilter<T extends RowData>(
  columns: ClientDataTableColumn<T>[],
  facetedFilters: ClientDataTableFacetedFilter[] | undefined
): ClientDataTableColumn<T>[] {
  if (!facetedFilters?.length) return columns

  const faceted = new Set(facetedFilters.map((filter) => filter.column))

  return columns.map((column) => {
    // `id` cuando la columna lo declara, y si no la clave del acceso: es el
    // mismo par con el que TanStack la resuelve en `getColumn()`, que es contra
    // lo que se compara `filter.column`.
    const id = column.id ?? (column as { accessorKey?: string }).accessorKey
    if (!id || !faceted.has(id)) return column
    return { ...column, filterFn: 'includesSelected' } as ClientDataTableColumn<T>
  })
}

/** Constructor de columnas ya atado a las capacidades de esta tabla. */
export function createClientDataTableColumns<T extends RowData>() {
  return withNumeric(createColumnHelper<ClientDataTableFeatures, T>())
}

/**
 * Los rótulos de las casillas, que viajan por las opciones y no por la columna.
 *
 * La columna de casillas la construye la tabla, así que tiene que ser **una**,
 * armada una vez y a nivel de módulo: hecha adentro del componente cambiaría de
 * identidad en cada dibujado, y con ella el arreglo de columnas, que es
 * justamente lo que invalida los cuatro modelos de fila. Pero el rótulo es
 * distinto en cada fila y lo escribe la vista, así que necesita otro canal: las
 * opciones se vuelven a fijar en cada dibujado por diseño, las columnas no.
 */
interface ClientDataTableMeta {
  selection?: { rowLabel: (row: any) => string; allLabel: string }
  rowActions?: (row: any) => RowActionsFor | null
}

/** Lo que una fila ofrece hacer, con su nombre accesible. */
export interface RowActionsFor {
  /**
   * El nombre del disparador, que **nombra la fila**: «Acciones de la sesión en
   * Chrome iniciada el 3 de marzo». Veinte botones llamados «Acciones» obligan a
   * recorrer la tabla celda por celda para saber cuál es cuál.
   */
  label: string
  /** Del juego cerrado de `RowActions`. La vista pone la funcionalidad, no el dibujo. */
  actions: RowActionMap
}

/** Cuántas filas por página se pueden pedir. */
const PAGE_SIZES = [10, 20, 30, 50, 100] as const

/**
 * El tamaño de página cuando no hay paginado: una sola que contiene todo.
 *
 * La capacidad de paginar sigue registrada —sacarla del juego cambiaría el tipo
 * de las columnas— y lo que se hace es dejarla sin trabajo. Diez mil está muy
 * por encima de cualquier lista que entre en una respuesta, que es la condición
 * para usar esta tabla.
 */
const ALL_ROWS = 10_000

/** El orden con el que la tabla arranca. */
interface ClientDataTableSort {
  /** El id de la columna, tal como lo declara `columns`. */
  column: string
  descending?: boolean
}

/** El buscador de la barra. */
interface ClientDataTableSearch {
  /** El nombre accesible del campo, ya traducido. Se dibuja fuera de la vista. */
  label: string
  /**
   * La pista adentro del campo, ya traducida.
   *
   * Dice **sobre qué** se busca —«Buscar por dispositivo o dirección…»— porque
   * un campo que dice «Buscar…» a secas obliga a probar para averiguar qué mira.
   * Qué columnas participan lo declara cada columna con `enableGlobalFilter`.
   */
  placeholder: string
}

export interface ClientDataTableFacetedFilter {
  /** Columna cuyo valor crudo se compara con las opciones. */
  column: string
  title: string
  options: { label: string; value: string }[]
  selection: 'single' | 'multiple'
}

/** Lo que hay marcado, listo para usar. */
interface ClientDataTableSelected<T> {
  rows: T[]
  ids: string[]
  /** Vacía la selección. Existe porque no se vacía sola: es estado de ids. */
  clear: () => void
}

interface ClientDataTableSelection<T> {
  /** Qué filas se pueden marcar. Sin esto, todas. */
  canSelect?: (row: T) => boolean
  /**
   * Cómo se llama la casilla de **esta** fila, ya traducida y nombrando la fila.
   *
   * Es obligatoria: veinte casillas llamadas «Seleccionar la fila» obligan a
   * recorrer la tabla celda por celda para saber cuál es cuál, que es el mismo
   * defecto que `RowActions` existe para evitar.
   */
  rowLabel: (row: T) => string
  /** Cómo se llama la casilla del encabezado, ya traducida. */
  allLabel: string
  /**
   * Qué se ofrece hacer con lo marcado. Se llama sólo cuando hay algo marcado.
   *
   * Es una función y no un nodo porque el rótulo de la acción lleva la cantidad
   * —«Cerrar las 3 sesiones seleccionadas»—: con un nodo suelto, la vista
   * tendría que espejar la selección hacia arriba y habría dos dueños del mismo
   * estado.
   */
  actions?: (selected: ClientDataTableSelected<T>) => ReactNode
}

interface Props<T extends RowData> {
  /**
   * Las filas, **todas**.
   *
   * Se llama `data` y no `rows` para que la diferencia con `TablePage.rows` se
   * vea en el lugar de la llamada: quien lee `data={sessions}` sabe que llegaron
   * enteras. Tiene que ser estable —la prop de Inertia tal cual, o memoizada—:
   * un `?? []` escrito en línea invalida los modelos en cada dibujado.
   */
  data: T[]
  /**
   * Definidas **fuera** del componente, con `createClientDataTableColumns<T>()`.
   * Una identidad nueva en cada dibujado invalida los modelos de TanStack, y acá
   * pega más fuerte que en la otra tabla porque acá el trabajo lo hace de verdad
   * el navegador.
   */
  columns: ClientDataTableColumn<T>[]
  /**
   * Identidad estable de cada fila. **Obligatoria**, al revés que en `DataTable`.
   *
   * La selección es estado de ids, independiente de los datos: con ids
   * posicionales, cerrar la sesión de la segunda fila hace que el id «1» pase a
   * nombrar a otra sesión y la selección apunte, en silencio, a una que nadie
   * marcó. Y el orden y el filtro reordenan las filas todo el tiempo.
   */
  rowId: (row: T) => string
  /** Qué se está listando, en plural. Va en el contador del pie. */
  unit: string
  unitSingular?: string
  /** Qué lista es. Es el `<caption>`. */
  caption: string
  /** El vacío se delega en `EmptyState`: la tabla no escribe su propio texto. */
  empty: ComponentProps<typeof EmptyState>
  error?: ReactNode
  loading?: boolean
  /**
   * Filas por página al arrancar. Diez por omisión, y quien mira puede cambiarlo.
   *
   * En la otra tabla el tamaño es una constante del servidor, para que el total
   * sea comparable entre pantallas; acá no hay nada que comparar entre pantallas
   * y sí una lista que a veces se quiere ver entera.
   *
   * **Tiene que ser uno de `PAGE_SIZES`**, y lo sostiene el compilador. El
   * desplegable de filas por página dibuja exactamente esa lista, así que un
   * tamaño de fuera paginaba bien pero dejaba el control sin ninguna opción que
   * mostrar: el trigger salía en blanco y parecía roto. Era un `number` y el
   * error no daba ninguna señal hasta verlo en pantalla.
   */
  pageSize?: (typeof PAGE_SIZES)[number]
  /**
   * Apaga el paginado: **una sola página, con todas las filas**.
   *
   * Para las listas de largo fijo que decide el servidor —el top de diez del
   * inicio—. Ahí el pie completo son cuatro controles que no hacen nada: un
   * selector de filas por página sobre diez filas, un «Página 1 de 1» y cuatro
   * botones apagados.
   *
   * **El contador se queda.** «10 keywords» sigue diciendo algo; «Página 1 de 1»
   * no. Y muestra todas: una tabla sin paginador que igual recortara escondería
   * lo que no entra sin que nada lo diga.
   *
   * Va al revés que el resto de las opciones de esta tabla, que se prenden por
   * su presencia. Es porque el paginado está prendido por omisión en todas las
   * tablas del producto, así que lo que hay que poder escribir es apagarlo.
   */
  noPagination?: boolean
  /**
   * El orden inicial. Sin esto, las filas salen en el orden en que llegaron.
   *
   * Es un objeto y no una lista porque la tabla no ordena por dos columnas a la
   * vez: ofrecer una lista sería ofrecer algo que se descarta.
   */
  sort?: ClientDataTableSort
  /** El buscador. Sin esto, no hay campo. */
  search?: ClientDataTableSearch
  /** Filtros enumerados que comparten el estado interno de la tabla. */
  facetedFilters?: ClientDataTableFacetedFilter[]
  /**
   * Qué queda recortado al abrir, por id de columna.
   *
   * Existe para que una cifra de otra pantalla pueda enlazar acá **con su
   * recorte puesto**. Es la única forma de que enlazar no viole la regla de que
   * una cifra se abre donde se la lee: la excepción de la regla es que el
   * destino conteste *la misma pregunta con el mismo conjunto*, y sin esto el
   * destino contesta siempre con el conjunto entero.
   *
   * Es **estado inicial y no estado**: quien mira puede sacar el filtro y la
   * tabla no se lo vuelve a poner. Lo que llega por la dirección es de dónde
   * viene la persona, no una condición que la pantalla tenga que sostener.
   */
  filters?: Record<string, string[]>
  /**
   * Qué columnas arrancan escondidas, por id.
   *
   * Una lista de ids y no un mapa a booleanos: en el mapa, «ausente» significa
   * visible y sólo `false` esconde, y ese tercer estado se escribe mal una vez
   * cada tanto. Acá no hay forma de escribirlo mal.
   */
  hiddenColumns?: string[]
  /** Sin esto no hay casillas: una tabla sin nada que hacer con lo marcado no lo ofrece. */
  selection?: ClientDataTableSelection<T>
  /**
   * Qué se ve al abrir una fila. **Sin esto, las filas no se abren.**
   *
   * Es una ranura ciega: la tabla reserva el ancho entero debajo de la fila y no
   * pregunta qué va adentro. Que sea una grilla de tarjetas, otra tabla o algo
   * que se carga por red lo decide quien la usa —y lo de la red es el caso que
   * la trajo: la pantalla de canibalización lista decenas de consultas y pide
   * las URLs de la que alguien abre—.
   *
   * **Una sola prop y no una prop más una bandera.** Con las dos hay dos
   * combinaciones que no significan nada: bandera sin ranura dibuja un control
   * que no abre nada, y ranura sin bandera es código que no se ejecuta. Es el
   * mismo criterio de `search`, `selection` y `facetedFilters` —su ausencia
   * apaga la función entera— y el mismo por el que `hiddenColumns` es una lista
   * de ids y no un mapa a booleanos.
   *
   * **Abrir no agrega una columna.** El control envuelve la primera celda de
   * datos: el chevron a la izquierda, el contenido de la celda al lado, y la
   * celda entera es el área de clic. Una columna propia para un chevron se
   * lleva su ancho y deja un encabezado vacío arriba, y en una tabla de cuatro
   * columnas eso se nota. Lo envuelve **la tabla y no la vista**, que es lo que
   * permite mudar el relleno del `<td>` al botón en un solo lugar: hecho por
   * pantalla, la primera que se olvide deja un anillo de celda donde el clic no
   * hace nada.
   *
   * **Con esto, la primera columna no puede traer un enlace ni un botón
   * adentro.** La tabla envuelve sin mirar qué hay, así que un `<a>` ahí queda
   * dentro de un `<button>`: HTML inválido y un clic con dos destinos, que no
   * rompe ninguna prueba y se ve perfecto. Hoy no pasa —la única tabla que abre
   * filas tiene texto en esa columna— y la salida cuando pase es mover el enlace
   * a otra columna, no envolver menos.
   */
  renderExpanded?: (row: T) => ReactNode
  /**
   * Qué filas se pueden abrir. Sin esto, todas.
   *
   * Para cuando el detalle de algunas filas está vacío por definición: un
   * control que abre una región sin nada adentro es peor que la ausencia del
   * control (RT-07).
   */
  canExpand?: (row: T) => boolean
  /**
   * Qué ofrece hacer cada fila. Devolver `null` deja esa fila sin menú.
   *
   * La columna la arma la tabla, al principio y junto a la casilla, y del juego
   * cerrado de `RowActions`: la vista entrega la funcionalidad de cada entrada
   * —un destino, un manejador, una confirmación— y no elige el icono, el rótulo
   * por omisión ni la posición. Así el menú se ve igual en todas las tablas del
   * producto y cada una hace lo suyo.
   */
  rowActions?: (row: T) => RowActionsFor | null
  className?: string
}

/** El nombre de la columna que la tabla agrega sola. Queda reservado. */
const ROW_CONTROLS_COLUMN_ID = 'controls'

const selectionHelper = createColumnHelper<ClientDataTableFeatures, RowData>()

/**
 * Los controles de la fila —la casilla y el menú— en **una sola columna**.
 *
 * Van juntos y no en dos columnas contiguas porque son dos controles de la misma
 * fila y no dos datos: en dos celdas hay que emparejar dos alineaciones
 * verticales sobre dos elementos de distinta altura —una casilla de 16 px al
 * lado de un botón de 28— y quedan con los centros a seis píxeles uno del otro.
 * En una sola celda eso lo resuelve un `items-center` y no vuelve a discutirse.
 *
 * Van **al principio de la fila** y no al final. En una tabla ancha el final está
 * detrás de un desplazamiento horizontal, así que llegar a lo que se puede hacer
 * con una fila costaba arrastrar la tabla hasta el borde —y volver—.
 *
 * La arma la tabla y no la vista: es la posición la que tiene que ser igual en
 * todas las tablas del producto, y una columna que cada pantalla declara termina
 * en un lugar distinto en cada una.
 *
 * Ni la casilla ni el menú se dibujan cuando la fila no los tiene. La sesión en
 * curso es el caso: es la única que no se puede cerrar, así que es la única sin
 * controles, y eso se lee más rápido que un «Cerrar» apagado —que además sería
 * ofrecer algo que no existe (RT-07)—.
 */
const ROW_CONTROLS_COLUMN = selectionHelper.display({
  id: ROW_CONTROLS_COLUMN_ID,
  enableSorting: false,
  // No se puede esconder: es por donde se marca una fila y por donde se hace lo
  // que ofrece, y sin la columna esas dos cosas no tienen ninguna otra puerta.
  enableHiding: false,
  meta: { compact: true },
  header: ({ table }) => (
    <div className="flex items-center gap-1">
      {table.options.meta?.selection ? (
        <Checkbox
          // Marca **lo de la página** y no todo: marcar filas que el buscador
          // escondió es la forma más rápida de tocar algo que nunca se vio.
          checked={
            table.getIsAllPageRowsSelected()
              ? true
              : table.getIsSomePageRowsSelected()
                ? 'indeterminate'
                : false
          }
          onCheckedChange={(checked) => table.toggleAllPageRowsSelected(checked === true)}
          aria-label={table.options.meta.selection.allLabel}
        />
      ) : null}

      {/*
        El encabezado del menú no se dibuja —una palabra arriba de una columna de
        tres puntos es ruido— pero existe: una columna cuyo `<th>` no dice nada
        se anuncia como «columna 1» y no dice de qué es.
      */}
      {table.options.meta?.rowActions ? (
        <span className="sr-only">{t('table.rowActions')}</span>
      ) : null}
    </div>
  ),
  cell: ({ row, table }) => {
    const meta = table.options.meta
    const actions = meta?.rowActions?.(row.original)

    return (
      <div className="flex items-center gap-1">
        {row.getCanSelect() ? (
          <Checkbox
            checked={row.getIsSelected()}
            onCheckedChange={(checked) => row.toggleSelected(checked === true)}
            aria-label={meta?.selection?.rowLabel(row.original)}
          />
        ) : meta?.selection && actions ? (
          // El hueco de la casilla se reserva cuando la tabla tiene selección y
          // esta fila igual ofrece acciones: sin él, el menú de esa fila se
          // corre a la izquierda y deja de estar en la misma columna que los
          // demás. No es una ranura vacía sino un espaciador con trabajo, y por
          // eso está escondido del lector de pantalla.
          <span aria-hidden className="size-4 shrink-0" />
        ) : null}

        {actions ? <RowActions label={actions.label} actions={actions.actions} /> : null}
      </div>
    )
  },
})

/**
 * Tabla que busca, ordena, esconde columnas y pagina **en el navegador**.
 *
 * La hermana de `DataTable`, para las listas que entran enteras en una
 * respuesta. Lo que cambia no es el dibujo —eso se comparte en
 * `components/table.tsx`— sino dónde vive el estado: allá en la dirección, así
 * que cada control es un enlace con destino propio; acá en React, así que cada
 * control es un botón. Un enlace con `href="#"` mentiría sobre lo que hace.
 *
 * **No tiene altura acotada ni encabezado fijo.** La otra la necesita porque
 * trae cincuenta filas de decenas de miles y un recuadro con su propio
 * desplazamiento es lo que evita que la página crezca sin fin. Acá la lista es
 * corta por definición y quien mira elige cuántas filas quiere: un recuadro con
 * barra interna adentro de una página que ya se desplaza son dos
 * desplazamientos peleándose por la misma rueda.
 *
 * `search`, `selection` y el menú de columnas son opcionales, y **su ausencia
 * apaga la función entera** en vez de dejarla visible sin efecto.
 */
export function ClientDataTable<T extends RowData>({
  data,
  columns,
  rowId,
  unit,
  unitSingular,
  caption,
  empty,
  error,
  loading = false,
  pageSize = 10,
  noPagination = false,
  sort,
  search,
  facetedFilters,
  filters,
  hiddenColumns,
  selection,
  renderExpanded,
  canExpand,
  rowActions,
  className,
}: Props<T>) {
  const hasSelection = Boolean(selection)
  const hasRowActions = Boolean(rowActions)
  const hasExpandedRows = Boolean(renderExpanded)

  // Los controles primero, y recién después lo que la vista declaró. La columna
  // es de la tabla: es la que tiene que estar en el mismo lugar en todas las
  // pantallas, y una que cada vista declara termina en un sitio distinto en cada
  // una.
  // Abrir filas **no** agrega columna: el control vive adentro de la primera,
  // envolviéndola. Una columna entera para un chevron se lleva su ancho y deja
  // un encabezado vacío arriba, y en una tabla de cuatro columnas eso se nota.
  const allColumns = useMemo(() => {
    const declared = withSelectedFilter(columns, facetedFilters)
    return hasSelection || hasRowActions
      ? [ROW_CONTROLS_COLUMN as ClientDataTableColumn<T>, ...declared]
      : declared
  }, [columns, facetedFilters, hasSelection, hasRowActions])

  const hiddenState = useMemo(
    () => Object.fromEntries((hiddenColumns ?? []).map((id) => [id, false])),
    [hiddenColumns]
  )

  // Los ejes vacíos se descartan: un filtro con la lista de valores vacía no
  // recorta nada pero sí cuenta como filtro puesto, y entonces la tabla arranca
  // ofreciendo el botón de limpiar y el vacío por recorte sobre un conjunto que
  // nadie recortó.
  const initialFilters = useMemo(
    () =>
      Object.entries(filters ?? {})
        .filter(([, values]) => values.length > 0)
        .map(([id, value]) => ({ id, value })),
    [filters]
  )

  const table = useTable({
    features,
    data,
    columns: allColumns,
    getRowId: (row: T) => rowId(row),

    // Ninguna bandera `manual*`: acá el trabajo lo hace TanStack. Y tampoco se
    // pasa `state` ni ningún `on*Change`, porque `useTable` monta su propio
    // almacén y se suscribe solo; un `useState` al lado sería un segundo dueño
    // del mismo dato.
    globalFilterFn: 'includesString',

    // Una columna que ordena y además desempata por otra es un orden que la
    // persona no pidió y no puede ver.
    enableMultiSort: false,
    // Sin esto TanStack elige la primera dirección mirando el tipo del dato.
    // Podría hacerlo bien —tiene todas las filas—, pero entonces la misma
    // columna arrancaría ascendente en una tabla y descendente en otra según lo
    // que hubiera adentro, y quien hace clic no tiene cómo anticiparlo.
    sortDescFirst: false,

    enableRowSelection: selection
      ? (row) => (selection.canSelect ? selection.canSelect(row.original) : true)
      : false,
    // Ninguna tabla del producto tiene subfilas. Sin esto, marcar el encabezado
    // recorre descendientes que no existen en cada clic.
    enableSubRowSelection: false,

    // El detalle es una fila que la tabla dibuja, no una subfila que TanStack
    // aplana: ninguna fila del producto tiene hijos, así que sin esto
    // `getCanExpand()` sería siempre falso y el chevron no aparecería nunca.
    enableExpanding: hasExpandedRows,
    getRowCanExpand: hasExpandedRows
      ? (row) => (canExpand ? canExpand(row.original as T) : true)
      : () => false,

    meta: {
      selection: selection
        ? { rowLabel: selection.rowLabel, allLabel: selection.allLabel }
        : undefined,
      rowActions,
    },

    initialState: {
      sorting: sort ? [{ id: sort.column, desc: sort.descending ?? false }] : [],
      pagination: { pageIndex: 0, pageSize: noPagination ? ALL_ROWS : pageSize },
      columnVisibility: hiddenState,
      columnFilters: initialFilters,
    },
  })

  if (error) {
    return <div className={className}>{error}</div>
  }

  // Vacío de verdad: no hay nada que buscar, así que tampoco hay barra ni
  // encabezados. Se distingue del vacío por búsqueda, que sí conserva todo.
  if (!loading && data.length === 0) {
    return (
      <div className={className}>
        <EmptyState {...empty} />
      </div>
    )
  }

  const shown = table.getFilteredRowModel().rows.length
  const pages = table.getPageCount()
  const page = table.state.pagination.pageIndex + 1
  const visibleColumns = table.getVisibleLeafColumns()
  const hideable = hideableColumns(table)
  const query = table.state.globalFilter ?? ''
  const hasColumnFilters = table.state.columnFilters.length > 0

  // Qué celda abre la fila: la primera **de datos**, saltando la de controles.
  // Se resuelve sobre las visibles, no sobre las declaradas: esconder la primera
  // columna dejaría el control adentro de una celda que no se dibuja, y la fila
  // se volvería imposible de abrir sin que nada avise.
  const expanderColumnId = visibleColumns.find(
    (column) => column.id !== ROW_CONTROLS_COLUMN_ID
  )?.id

  const selectedRows = hasSelection
    ? table.getSelectedRowModel().rows.map((row) => row.original as T)
    : []

  return (
    <div className={cn('flex flex-col gap-4', className)}>
      {/* La barra: buscar a la izquierda, elegir columnas a la derecha. */}
      {search || hideable.length > 0 ? (
        <div className="flex flex-wrap items-center justify-between gap-2">
          {search ? (
            <SearchField
              label={search.label}
              placeholder={search.placeholder}
              value={query}
              onChange={(value) => table.setGlobalFilter(value)}
            />
          ) : (
            <span />
          )}

          {hideable.length > 0 ? <ColumnsMenu columns={hideable} /> : null}
        </div>
      ) : null}


      {facetedFilters?.length ? (
        <div className="flex flex-wrap items-center gap-2">
          {facetedFilters.map((filter) => {
            const column = table.getColumn(filter.column)
            if (!column) return null

            return filter.selection === 'multiple' ? (
              <MultiFacetedFilter
                key={filter.column}
                column={column}
                title={filter.title}
                options={filter.options}
                triggerClassName="h-8 max-w-full justify-start overflow-hidden"
              />
            ) : (
              <SingleFacetedFilter
                key={filter.column}
                column={column}
                title={filter.title}
                options={filter.options}
                triggerClassName="h-8 max-w-full justify-start overflow-hidden"
              />
            )
          })}

          {hasColumnFilters ? (
            <Tooltip>
              <TooltipTrigger asChild>
                <Button
                  type="button"
                  variant="ghost-destructive"
                  size="icon"
                  aria-label={t('table.clearFiltersLabel')}
                  onClick={() => table.resetColumnFilters(true)}
                >
                  <Trash2 aria-hidden />
                </Button>
              </TooltipTrigger>
              <TooltipContent>{t('table.clearFiltersLabel')}</TooltipContent>
            </Tooltip>
          ) : null}
        </div>
      ) : null}

      <div className={TABLE_FRAME}>
        <table className="w-full caption-bottom text-sm" aria-busy={loading || undefined}>
          <caption className="sr-only">
            {`${caption} — ${formatNumber(shown)} ${unitName(shown, unit, unitSingular)}`}
          </caption>

          <TableHeader>
            {table.getHeaderGroups().map((group) => (
              <TableRow key={group.id}>
                {group.headers.map((header) => {
                  const column = header.column
                  const sortable = column.getCanSort()
                  const sorted = column.getIsSorted()

                  return (
                    <TableHead
                      key={header.id}
                      scope="col"
                      colSpan={header.colSpan}
                      aria-sort={sortable ? ARIA_SORT[sorted || 'none'] : undefined}
                      className={cn(
                        column.columnDef.meta?.numeric && NUMERIC_CELL,
                        column.columnDef.meta?.compact && COMPACT_CELL
                      )}
                    >
                      {header.isPlaceholder ? null : sortable ? (
                        // Un botón y no un enlace: acá el orden vive en el
                        // navegador y no hay ninguna dirección adonde ir.
                        <button
                          type="button"
                          className={SORT_CONTROL}
                          onClick={() => column.toggleSorting()}
                        >
                          <table.FlexRender header={header} />
                          <SortIndicator sorted={sorted} />
                          <span className="sr-only">{sortHint(column.getNextSortingOrder())}</span>
                        </button>
                      ) : (
                        <table.FlexRender header={header} />
                      )}
                    </TableHead>
                  )
                })}
              </TableRow>
            ))}
          </TableHeader>

          <TableBody>
            {loading ? (
              <SkeletonRows rows={pageSize} columns={visibleColumns.length} />
            ) : shown === 0 ? (
              // El vacío por recorte conserva encabezados y barra: el texto y
              // los filtros que dejaron la tabla así tienen que seguir
              // alcanzables, y la salida se ofrece acá mismo en vez de obligar a
              // buscarla.
              <TableRow className="hover:bg-transparent">
                <TableCell colSpan={visibleColumns.length} className="p-0">
                  <NoMatches
                    hasQuery={Boolean(query)}
                    hasFilters={hasColumnFilters}
                    onClear={() => {
                      table.setGlobalFilter('')
                      table.resetColumnFilters(true)
                    }}
                  />
                </TableCell>
              </TableRow>
            ) : (
              table.getRowModel().rows.map((row) => (
                <Fragment key={row.id}>
                  <TableRow data-state={row.getIsSelected() ? 'selected' : undefined}>
                    {/* `getVisibleCells` y no `getAllCells`: esta tabla sí puede
                        esconder columnas, y con `getAllCells` las escondidas se
                        dibujarían igual. */}
                    {row.getVisibleCells().map((cell) => {
                      // La primera columna de datos es la que abre la fila. Se
                      // busca por id y no por posición porque la columna de
                      // controles va antes cuando existe, y contar índices
                      // haría que la casilla y el chevron se peleen la celda.
                      const opens =
                        hasExpandedRows &&
                        row.getCanExpand() &&
                        cell.column.id === expanderColumnId

                      return (
                        <TableCell
                          key={cell.id}
                          className={cn(
                            BODY_CELL,
                            cell.column.columnDef.meta?.numeric && NUMERIC_CELL,
                            // Gana sobre el `align-top` de `BODY_CELL`, que es lo
                            // correcto para texto y lo incorrecto para controles.
                            cell.column.columnDef.meta?.compact && COMPACT_CELL,
                            // El relleno se muda al botón. Dejándolo acá queda un
                            // anillo alrededor del control donde el clic no hace
                            // nada, que es la mitad de la celda.
                            opens && 'p-0'
                          )}
                        >
                          {opens ? (
                            <button
                              type="button"
                              className="focus-visible:ring-ring flex w-full items-center gap-2 rounded px-2 py-2.5 text-left focus-visible:ring-2 focus-visible:outline-none"
                              aria-expanded={row.getIsExpanded()}
                              onClick={row.getToggleExpandedHandler()}
                            >
                              <ChevronRight
                                aria-hidden
                                className={cn(
                                  'text-muted-foreground size-4 shrink-0 transition-transform',
                                  row.getIsExpanded() && 'rotate-90'
                                )}
                              />
                              <span className="min-w-0 flex-1">
                                <table.FlexRender cell={cell} />
                              </span>
                              {/*
                                Al final y no como `aria-label`: la etiqueta
                                reemplazaría el nombre del botón, y el nombre
                                tiene que ser lo que dice la celda. Así se
                                anuncia «pools patios, ver el detalle».
                              */}
                              <span className="sr-only">
                                {row.getIsExpanded()
                                  ? t('table.collapseRow')
                                  : t('table.expandRow')}
                              </span>
                            </button>
                          ) : (
                            <table.FlexRender cell={cell} />
                          )}
                        </TableCell>
                      )
                    })}
                  </TableRow>

                  {/*
                    El detalle: una fila más, a lo ancho de las columnas que se
                    ven.

                    `colSpan` sobre las **visibles** y no sobre todas: esta tabla
                    esconde columnas, y con el total la celda declararía más
                    columnas de las que hay y el navegador ensancharía la tabla.

                    Se dibuja sólo mientras está abierta, y eso es lo que hace
                    que la carga bajo demanda funcione: al cerrar la fila, el
                    componente de adentro se desmonta, y al reabrirla vuelve a
                    montarse y a pedir. Guardar lo cargado sería memoria que
                    crece con cada fila que alguien abrió.

                    `hover:bg-transparent`: la fila de detalle no es una fila que
                    se pueda elegir, y resaltarla al pasar por encima la haría
                    parecer una.
                  */}
                  {renderExpanded && row.getIsExpanded() ? (
                    <TableRow className="hover:bg-transparent">
                      <TableCell colSpan={visibleColumns.length} className="p-0">
                        {renderExpanded(row.original as T)}
                      </TableCell>
                    </TableRow>
                  ) : null}
                </Fragment>
              ))
            )}
          </TableBody>
        </table>
      </div>

      {/* El pie: qué hay a la izquierda, cómo moverse a la derecha. */}
      <div className="flex flex-wrap items-center justify-between gap-4">
        <div className="flex flex-wrap items-center gap-3">
          {/*
            El total, siempre visible y anunciado: al escribir en el buscador, un
            lector de pantalla tiene que enterarse de cuántas filas quedaron sin
            ir a buscarlo.
          */}
          <p role="status" aria-live="polite" className="text-muted-foreground text-sm">
            {hasSelection
              ? t('table.selected', { count: shown, selected: selectedRows.length })
              : // Espacio duro entre la cifra y la unidad (RT-16): la cantidad y
                // lo que cuenta nunca quedan en renglones distintos.
                `${formatNumber(shown)} ${unitName(shown, unit, unitSingular)}`}
          </p>

          {selectedRows.length > 0 && selection?.actions
            ? selection.actions({
                rows: selectedRows,
                ids: selectedRows.map((row) => rowId(row)),
                // La selección no se vacía sola: es estado de ids y sobrevive a
                // que las filas desaparezcan. Después de cerrar tres sesiones,
                // sus ids seguirían marcados sobre filas que ya no existen.
                clear: () => table.resetRowSelection(true),
              })
            : null}
        </div>

        {noPagination ? null : (
        <div className="flex flex-wrap items-center gap-4">
          <PageSize value={table.state.pagination.pageSize} onChange={(n) => table.setPageSize(n)} />

          <span className="text-muted-foreground text-sm tabular-nums">
            {t('table.pageOfCapitalized', {
              page: formatNumber(page),
              pages: formatNumber(Math.max(pages, 1)),
            })}
          </span>

          <nav aria-label={t('table.pagination')} className="flex items-center gap-1">
            <Button
              variant="outline"
              size="icon-sm"
              disabled={!table.getCanPreviousPage()}
              onClick={() => table.setPageIndex(0)}
            >
              <ChevronsLeft aria-hidden />
              <span className="sr-only">{t('table.first')}</span>
            </Button>

            <Button
              variant="outline"
              size="icon-sm"
              disabled={!table.getCanPreviousPage()}
              onClick={() => table.previousPage()}
            >
              <ChevronLeft aria-hidden />
              <span className="sr-only">{t('table.previous')}</span>
            </Button>

            <Button
              variant="outline"
              size="icon-sm"
              disabled={!table.getCanNextPage()}
              onClick={() => table.nextPage()}
            >
              <ChevronRight aria-hidden />
              <span className="sr-only">{t('table.next')}</span>
            </Button>

            <Button
              variant="outline"
              size="icon-sm"
              disabled={!table.getCanNextPage()}
              onClick={() => table.setPageIndex(pages - 1)}
            >
              <ChevronsRight aria-hidden />
              <span className="sr-only">{t('table.last')}</span>
            </Button>
          </nav>
        </div>
        )}
      </div>
    </div>
  )
}

/** Cuántas filas se ven de una. */
/**
 * La tabla quedó vacía por lo que alguien recortó, no porque no haya nada.
 *
 * Se dibuja con `EmptyState`, igual que el vacío de verdad: es la misma
 * situación desde el lado de quien mira —una tabla sin filas— y resolverla con
 * dos anatomías distintas hacía que la de acá, un renglón de texto con un
 * enlace pegado, se leyera como un error de carga en vez de como una respuesta.
 *
 * **Y ofrece limpiar lo que efectivamente está puesto.** Antes decía siempre
 * «no coincide con esa búsqueda» y ofrecía vaciar el buscador, aunque el
 * recorte lo hubieran hecho los filtros: quien vaciaba la búsqueda no veía
 * cambiar nada y se quedaba sin salida a la vista.
 */
function NoMatches({
  hasQuery,
  hasFilters,
  onClear,
}: {
  hasQuery: boolean
  hasFilters: boolean
  onClear: () => void
}) {
  const kind = hasQuery && hasFilters ? 'both' : hasQuery ? 'search' : 'filters'

  return (
    <EmptyState
      icon={SearchX}
      title={t('table.noMatches.title')}
      description={t(`table.noMatches.${kind}` as TranslationKey)}
      action={
        <Button variant="outline" size="sm" onClick={onClear}>
          {t(`table.noMatches.${kind}.action` as TranslationKey)}
        </Button>
      }
      // Sin borde propio: el marco de la tabla ya lo dibujó, y dos bordes
      // pegados se leen como una caja adentro de otra.
      className="border-0"
    />
  )
}

function PageSize({ value, onChange }: { value: number; onChange: (size: number) => void }) {
  return (
    <div className="flex items-center gap-2">
      <Label htmlFor="page-size" className="text-muted-foreground text-sm font-normal">
        {t('table.rowsPerPage')}
      </Label>
      <Select value={String(value)} onValueChange={(next) => onChange(Number(next))}>
        <SelectTrigger id="page-size" size="sm" className="w-20">
          <SelectValue />
        </SelectTrigger>
        <SelectContent>
          {PAGE_SIZES.map((size) => (
            <SelectItem key={size} value={String(size)}>
              {formatNumber(size)}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
    </div>
  )
}

/** Lo que se puede nombrar en palabras es lo único que el menú de columnas ofrece. */
interface HideableColumn {
  id: string
  label: string
  visible: boolean
  toggle: (visible: boolean) => void
}

/**
 * Las columnas que el menú puede ofrecer.
 *
 * Una columna sin nombre en palabras **no aparece**. Lo único que quedaría para
 * nombrarla es su `id`, que es un identificador en inglés, y dibujarlo sería
 * escribir texto de pantalla fuera del catálogo.
 */
function hideableColumns(table: {
  getAllLeafColumns: () => {
    id: string
    getCanHide: () => boolean
    getIsVisible: () => boolean
    toggleVisibility: (visible: boolean) => void
    columnDef: { meta?: DataTableColumnMeta }
  }[]
}): HideableColumn[] {
  return table
    .getAllLeafColumns()
    .filter((column) => column.getCanHide() && Boolean(column.columnDef.meta?.label))
    .map((column) => ({
      id: column.id,
      label: column.columnDef.meta?.label ?? '',
      visible: column.getIsVisible(),
      toggle: (visible: boolean) => column.toggleVisibility(visible),
    }))
}

function ColumnsMenu({ columns }: { columns: HideableColumn[] }) {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button variant="outline" size="sm">
          {t('table.columns')}
          <ChevronDown data-icon="inline-end" aria-hidden />
        </Button>
      </DropdownMenuTrigger>

      <DropdownMenuContent align="end">
        <DropdownMenuLabel>{t('table.columnsMenu')}</DropdownMenuLabel>

        {columns.map((column) => (
          <DropdownMenuCheckboxItem
            key={column.id}
            checked={column.visible}
            onCheckedChange={column.toggle}
            // Sin esto el menú se cierra en cada columna y hay que reabrirlo
            // cinco veces para esconder cinco.
            onSelect={(event) => event.preventDefault()}
          >
            {column.label}
          </DropdownMenuCheckboxItem>
        ))}
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

/**
 * El buscador.
 *
 * Su rótulo va fuera de la vista y la pista adentro del campo: un `placeholder`
 * desaparece al escribir, así que solo no alcanza como nombre. Los dos salen del
 * catálogo y los escribe la vista, porque sólo ella sabe sobre qué se busca.
 */
function SearchField({
  label,
  placeholder,
  value,
  onChange,
}: {
  label: string
  placeholder: string
  value: string
  onChange: (value: string) => void
}) {
  return (
    <>
      <Label htmlFor="table-search" className="sr-only">
        {label}
      </Label>
      <InputGroup className="w-full sm:w-80">
        <InputGroupAddon>
          <Search aria-hidden />
        </InputGroupAddon>
        <InputGroupInput
          id="table-search"
          type="search"
          value={value}
          placeholder={placeholder}
          onChange={(event) => onChange(event.target.value)}
        />
      </InputGroup>
    </>
  )
}
