import { Link, router } from '@inertiajs/react'
import {
  columnFilteringFeature,
  createColumnHelper,
  rowPaginationFeature,
  rowSortingFeature,
  tableFeatures,
  useTable,
  type ColumnDef,
  type ColumnFiltersState,
  type OnChangeFn,
  type PaginationState,
  type RowData,
  type SortingState,
} from '@tanstack/react-table'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import type { ComponentProps, ReactNode } from 'react'
import { useMemo, useState } from 'react'

import { EmptyState } from '@/components/EmptyState'
import {
  ARIA_SORT,
  BODY_CELL,
  HEAD_CELL,
  NUMERIC_CELL,
  SkeletonRows,
  SORT_CONTROL,
  SortIndicator,
  sortHint,
  TABLE_FRAME,
  TABLE_SCROLLER,
  unitName,
  withNumeric,
} from '@/components/table'
import type { DataTableColumnMeta } from '@/components/table'
import { Button } from '@/components/ui/button'
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { formatNumber } from '@/lib/format'
import { t } from '@/lib/i18n'
import { cn } from '@/lib/utils'

// Se reexporta porque las columnas de esta tabla se declaran contra este módulo:
// que además haya que importar el `meta` de otro lado sería pedirle a cada vista
// que sepa cómo está partida la pieza por dentro.
export type { DataTableColumnMeta }

/**
 * No se registra ningún modelo de filas a propósito.
 *
 * Paginar, ordenar y filtrar los hace el servidor (`apps/core/tables.py`): con
 * decenas de miles de URLs, traerlas enteras para ordenarlas en el navegador no
 * es una opción, y un orden calculado sobre lo que alcanzó a llegar es un orden
 * que miente. Las tres capacidades se registran igual porque de ahí salen
 * `aria-sort`, el conteo de páginas y el estado de los filtros; lo que no se
 * registra es el trabajo, que en modo manual TanStack ni intenta hacer.
 */
const features = tableFeatures({
  columnFilteringFeature,
  rowPaginationFeature,
  rowSortingFeature,
  // Ranura de sólo tipos: es lo que hace que `columnDef.meta` esté tipado sin
  // fusionar declaraciones globales, o sea sin que el `meta` de esta tabla
  // signifique lo mismo para cualquier otra tabla del proyecto.
  columnMeta: {} as DataTableColumnMeta,
})

export type DataTableFeatures = typeof features

/**
 * Una columna de `DataTable`.
 *
 * El tercer parámetro es `any` porque cada columna tiene su propio tipo de
 * valor y el arreglo los mezcla; es la misma firma que devuelve
 * `columnHelper.columns()`.
 */
export type DataTableColumn<T extends RowData> = ColumnDef<DataTableFeatures, T, any>

/**
 * Constructor de columnas ya atado al juego de capacidades de esta tabla.
 *
 * Además de lo que trae TanStack, ofrece `numeric()`: la misma columna que
 * `accessor()` pero declarada como cifra, que es la única forma de que la
 * alineación y las cifras tabulares lleguen a la celda y al encabezado sin
 * escribirlas dos veces.
 */
export function createDataTableColumns<T extends RowData>() {
  return withNumeric(createColumnHelper<DataTableFeatures, T>())
}

/**
 * Una página de resultados, tal como la devuelve `table_props()` del servidor.
 *
 * Se pasa entera y sin desarmar: `path` y `query` son la dirección vigente ya
 * saneada, y de ahí sale cada enlace de la tabla. Si la vista los reconstruyera
 * por su cuenta, un filtro que el servidor descartó volvería a aparecer en el
 * primer clic.
 */
export interface TablePage<T> {
  rows: T[]
  count: number
  page: number
  pages: number
  per_page: number
  /** Clave pública del orden vigente, con `-` adelante si es descendente. */
  sort: string
  /** Claves que el servidor acepta ordenar. Fuera de esta lista no hay orden posible. */
  sortable: string[]
  filters: Record<string, string>
  path: string
  query: Record<string, string | number>
}

/**
 * La dirección de la misma tabla con algo cambiado.
 *
 * Un valor nulo o vacío saca la clave de la querystring, así que «sin filtro» y
 * «sin orden» se escriben igual que se leen: la dirección sólo dice lo que está
 * activo. Está exportada porque las vistas tienen controles propios —los
 * botones de estado de la cobertura, por ejemplo— y tienen que producir enlaces
 * que conserven el resto del estado.
 */
export function tableUrl(
  data: Pick<TablePage<never>, 'path' | 'query'>,
  changes: Record<string, string | number | null>
): string {
  const query: Record<string, string | number> = { ...data.query }

  for (const [key, value] of Object.entries(changes)) {
    if (value === null || value === '') delete query[key]
    else query[key] = value
  }

  const search = new URLSearchParams(
    Object.entries(query).map(([key, value]) => [key, String(value)])
  ).toString()

  return search ? `${data.path}?${search}` : data.path
}

interface Props<T extends RowData> {
  /** La página que devolvió `table_props()`, entera. */
  data: TablePage<T>
  /**
   * Definidas **fuera** del componente, con `createDataTableColumns<T>()`. Una
   * identidad nueva en cada render invalida los modelos de TanStack.
   *
   * Cada celda se rinde con `wrap-anywhere`, que es lo que evita que una URL
   * larga empuje la página entera hacia el costado (RT-09). Una columna que
   * necesite otra cosa —alinear a la derecha, no cortar, cifras tabulares— lo
   * resuelve envolviendo su contenido, no desde acá.
   */
  columns: DataTableColumn<T>[]
  /** Qué se está listando, en plural: «URLs», «dominios». Va en el contador. */
  unit: string
  /**
   * Lo mismo en singular, para cuando hay una sola fila.
   *
   * Sin esto el contador dice «1 sitemaps», que es de las cosas que hacen
   * dudar de todo lo demás que muestra la pantalla. Se deduce quitando la «s»
   * final, que alcanza para «dominios» y «sitemaps», y se declara a mano
   * cuando no —«URLs» no es «URL» por esa regla—.
   */
  unitSingular?: string
  /** Qué lista es. Es el `<caption>`, así que nombra también el filtro vigente. */
  caption: string
  /** El vacío se delega en `C-12`: la tabla no escribe su propio texto. */
  empty: ComponentProps<typeof EmptyState>
  /** Identidad estable de cada fila; sin esto se usa la posición, que cambia al ordenar. */
  rowId?: (row: T) => string
  /** Ya rendido por la vista (`C-13`): acá no se conocen los códigos de error. */
  error?: ReactNode
  /** Controles propios de la lista, en la misma barra visual que la tabla. */
  toolbar?: ReactNode
  /** La lista puede fluir con la página cuando el servidor entrega páginas cortas. */
  scrollable?: boolean
  /** Primera carga. Los cambios de página, orden y filtro los detecta sola. */
  loading?: boolean
  /**
   * Prefijo de los parámetros que escribe la tabla, para que dos listas
   * convivan en una página sin disputarse `page` y `sort`.
   *
   * Con `paramPrefix="revoked_"` la tabla lee y escribe `revoked_page` y
   * `revoked_sort`. Tiene que coincidir con el `prefix=` que se le pasó a
   * `table_props()` del lado del servidor: si no coinciden, el enlace lleva a
   * una página que el servidor no mira y la tabla vuelve a la primera sin que
   * nada falle.
   *
   * Los filtros no se prefijan: cada tabla los declara con el nombre que
   * quiere en su `TableSpec`, así que ya son distintos por construcción.
   */
  paramPrefix?: string
  className?: string
}

/**
 * `C-11` · Tabla larga con paginado, orden y filtrado del servidor (RT-09).
 *
 * El estado vive en la dirección y no en React. Eso es lo que hace que la vista
 * sea enlazable, que el botón Atrás del navegador funcione y que recargar no
 * pierda el filtro; y es también la razón por la que cada control es un enlace
 * de verdad y no un manejador de clic: la página siguiente y la columna
 * ordenada tienen dirección propia, así que se pueden abrir en otra pestaña,
 * copiar y guardar.
 */
export function DataTable<T extends RowData>({
  data,
  columns,
  unit,
  unitSingular,
  caption,
  empty,
  rowId,
  error,
  toolbar,
  scrollable = true,
  loading = false,
  paramPrefix = '',
  className,
}: Props<T>) {
  const [navigating, setNavigating] = useState(false)
  const busy = loading || navigating

  // Los dos únicos parámetros que escribe la tabla, con su nombre ya resuelto.
  // Se nombran una vez y se usan en todos lados: un solo literal `page` que
  // sobreviva sin prefijar alcanza para que dos listas de la misma página se
  // pisen la posición entre ellas.
  const pageParam = `${paramPrefix}page`
  const sortParam = `${paramPrefix}sort`

  const start = () => setNavigating(true)
  const finish = () => setNavigating(false)

  // Moverse por la tabla es navegar, así que se navega. `preserveState` conserva
  // lo que la vista tenga abierto —un panel lateral, un formulario a medio
  // llenar— y `preserveScroll` evita el salto al tope en cada página.
  const visit = (url: string) => {
    router.get(
      url,
      {},
      { preserveScroll: true, preserveState: true, onStart: start, onFinish: finish }
    )
  }

  const sorting = useMemo<SortingState>(() => {
    if (!data.sort) return []
    const desc = data.sort.startsWith('-')
    return [{ id: desc ? data.sort.slice(1) : data.sort, desc }]
  }, [data.sort])

  const pagination = useMemo<PaginationState>(
    () => ({ pageIndex: data.page - 1, pageSize: data.per_page }),
    [data.page, data.per_page]
  )

  const columnFilters = useMemo<ColumnFiltersState>(
    () =>
      Object.entries(data.filters)
        .filter(([, value]) => value !== '')
        .map(([id, value]) => ({ id, value })),
    [data.filters]
  )

  // El camino de vuelta del estado controlado: cuando algo llama a
  // `table.nextPage()` o a `column.toggleSorting()`, en vez de mutar React se
  // navega a la dirección que corresponde. Cambiar orden o filtro vuelve a la
  // página 1, porque la página 7 de otro conjunto de filas no significa nada.
  const applySorting: OnChangeFn<SortingState> = (updater) => {
    const next = typeof updater === 'function' ? updater(sorting) : updater
    const first = next[0]
    const sort = first ? (first.desc ? `-${first.id}` : first.id) : null
    visit(tableUrl(data, { [sortParam]: sort, [pageParam]: null }))
  }

  const applyPagination: OnChangeFn<PaginationState> = (updater) => {
    const next = typeof updater === 'function' ? updater(pagination) : updater
    visit(tableUrl(data, { [pageParam]: next.pageIndex === 0 ? null : next.pageIndex + 1 }))
  }

  const applyFilters: OnChangeFn<ColumnFiltersState> = (updater) => {
    const next = typeof updater === 'function' ? updater(columnFilters) : updater
    const changes: Record<string, string | number | null> = { [pageParam]: null }
    for (const param of Object.keys(data.filters)) changes[param] = null
    for (const filter of next) {
      changes[filter.id] = filter.value == null ? null : String(filter.value)
    }
    visit(tableUrl(data, changes))
  }

  const table = useTable({
    features,
    data: data.rows,
    columns,
    getRowId: rowId ? (row: T) => rowId(row) : undefined,
    manualPagination: true,
    manualSorting: true,
    manualFiltering: true,
    // El servidor traduce una sola clave de orden, así que ofrecer varias sería
    // ofrecer algo que se descarta en silencio.
    enableMultiSort: false,
    // Sin esto TanStack elige la primera dirección mirando el tipo del dato que
    // tiene a la vista, que en modo manual es sólo la página actual: la misma
    // columna arrancaría ascendente en una página y descendente en otra.
    sortDescFirst: false,
    rowCount: data.count,
    pageCount: data.pages,
    state: { sorting, pagination, columnFilters },
    onSortingChange: applySorting,
    onPaginationChange: applyPagination,
    onColumnFiltersChange: applyFilters,
  })

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

  if (!busy && data.rows.length === 0) {
    return (
      <div className={cn('space-y-3', className)}>
        {/* Un resultado vacío no invalida el recorte que lo produjo. Mantener
            sus controles permite aflojar un solo filtro; la acción del vacío
            sigue siendo el atajo para quitarlos todos. */}
        {toolbar ? <div className="flex flex-wrap items-center gap-2">{toolbar}</div> : null}
        <EmptyState {...empty} />
      </div>
    )
  }

  const columnCount = table.getAllLeafColumns().length
  // El esqueleto conserva la altura de lo que había, para que el diseño no salte
  // al cambiar de página (RT-12). En la primera carga todavía no hay nada, y
  // cinco filas es lo que las vistas muestran mientras tanto.
  const skeletonRows = data.rows.length || 5

  return (
    <div className={cn('space-y-3', className)}>
      {toolbar ? <div className="flex flex-wrap items-center gap-2">{toolbar}</div> : null}

      {/*
        El total, siempre visible y anunciado: al cambiar un filtro, 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">
        {/* Espacio duro entre la cifra y la unidad (RT-16): la cantidad y lo
            que cuenta nunca quedan en renglones distintos. La unidad la manda
            la vista ya traducida, porque sólo ella sabe qué está contando. */}
        {`${formatNumber(data.count)}\u00A0${unitName(data.count, unit, unitSingular)}`} ·{' '}
        {t('table.pageOf', {
          page: formatNumber(data.page),
          pages: formatNumber(data.pages),
        })}
      </p>

      {/* El porqué de cada clase del recuadro está en `components/table.tsx`. */}
      <div className={scrollable ? TABLE_SCROLLER : TABLE_FRAME}>
        <table className="w-full caption-bottom text-sm" aria-busy={busy || undefined}>
          <caption className="sr-only">
            {`${caption} — ${formatNumber(data.count)}\u00A0${unitName(data.count, unit, unitSingular)}`}
          </caption>
          <TableHeader>
            {table.getHeaderGroups().map((group) => (
              <TableRow key={group.id}>
                {group.headers.map((header) => {
                  const column = header.column
                  // Que el servidor no acepte la clave y la columna se ofrezca
                  // ordenable son dos cosas distintas: si no coinciden, el clic
                  // no haría nada y la culpa parecería del navegador.
                  const sortable = column.getCanSort() && data.sortable.includes(column.id)
                  const sorted = column.getIsSorted()
                  const next = column.getNextSortingOrder()

                  return (
                    <TableHead
                      key={header.id}
                      scope="col"
                      colSpan={header.colSpan}
                      aria-sort={sortable ? ARIA_SORT[sorted || 'none'] : undefined}
                      className={cn(
                        HEAD_CELL,
                        // Una columna de cifras alineada a la derecha con el
                        // encabezado a la izquierda deja el nombre lejos de lo
                        // que nombra: se alinean juntos o no se alinea nada.
                        column.columnDef.meta?.numeric && NUMERIC_CELL
                      )}
                    >
                      {header.isPlaceholder ? null : sortable ? (
                        // Un enlace y no un botón: acá el orden tiene dirección
                        // propia, así que se puede abrir en otra pestaña, copiar
                        // y guardar. Es lo único que cambia respecto del
                        // encabezado de la tabla de navegador.
                        <Link
                          href={tableUrl(data, {
                            [sortParam]:
                              next === false
                                ? null
                                : next === 'desc'
                                  ? `-${column.id}`
                                  : column.id,
                            [pageParam]: null,
                          })}
                          preserveScroll
                          preserveState
                          onStart={start}
                          onFinish={finish}
                          className={SORT_CONTROL}
                        >
                          <table.FlexRender header={header} />
                          <SortIndicator sorted={sorted} />
                          <span className="sr-only">{sortHint(next)}</span>
                        </Link>
                      ) : (
                        <table.FlexRender header={header} />
                      )}
                    </TableHead>
                  )
                })}
              </TableRow>
            ))}
          </TableHeader>

          <TableBody>
            {busy ? (
              <SkeletonRows rows={skeletonRows} columns={columnCount} />
            ) : (
              table.getRowModel().rows.map((row) => (
                <TableRow key={row.id}>
                  {/* `getAllCells` y no `getVisibleCells`: esta tabla no registra
                      la capacidad de esconder columnas, así que son lo mismo y
                      esta forma no depende de una capacidad que no tiene. */}
                  {row.getAllCells().map((cell) => (
                    <TableCell
                      key={cell.id}
                      className={cn(
                        BODY_CELL,
                        cell.column.columnDef.meta?.numeric && NUMERIC_CELL
                      )}
                    >
                      <table.FlexRender cell={cell} />
                    </TableCell>
                  ))}
                </TableRow>
              ))
            )}
          </TableBody>
        </table>
      </div>

      {data.pages > 1 ? (
        <nav aria-label={t('table.pagination')} className="flex items-center justify-between gap-4">
          <PageLink
            href={
              data.page > 1
                ? tableUrl(data, { [pageParam]: data.page === 2 ? null : data.page - 1 })
                : null
            }
            onStart={start}
            onFinish={finish}
          >
            <ChevronLeft data-icon="inline-start" aria-hidden />
            {t('table.previous')}
          </PageLink>

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

          <PageLink
            href={data.page < data.pages ? tableUrl(data, { [pageParam]: data.page + 1 }) : null}
            onStart={start}
            onFinish={finish}
          >
            {t('table.next')}
            <ChevronRight data-icon="inline-end" aria-hidden />
          </PageLink>
        </nav>
      ) : null}
    </div>
  )
}

/**
 * Un salto de página.
 *
 * Sin destino queda como botón deshabilitado y no como enlace inerte: en el
 * borde de la tabla no hay una dirección a la que ir, y un `<a>` sin `href` no
 * es alcanzable con el teclado ni se anuncia como algo agotado.
 */
function PageLink({
  href,
  onStart,
  onFinish,
  children,
}: {
  href: string | null
  onStart: () => void
  onFinish: () => void
  children: ReactNode
}) {
  if (href === null) {
    return (
      <Button variant="outline" size="sm" disabled>
        {children}
      </Button>
    )
  }

  return (
    <Button asChild variant="outline" size="sm">
      <Link href={href} preserveScroll preserveState onStart={onStart} onFinish={onFinish}>
        {children}
      </Link>
    </Button>
  )
}
