import { Link, router, useForm, usePage } from '@inertiajs/react'
import {
  Download,
  FileText,
  FolderTree,
  RefreshCw,
  RotateCw,
  Search,
  Send,
  ShieldAlert,
  Snowflake,
} from 'lucide-react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner'

import { BatchProgress } from '@/components/BatchProgress'
import { accessStateLabel } from '@/components/AccessStateBadge'
import { BatchStateBadge, BatchStateHeadline } from '@/components/BatchStateBadge'
import { CoverageSummary } from '@/components/CoverageSummary'
import type { CoverageSummaryData, DateWindow } from '@/components/CoverageSummary'
import {
  CoverageFetchedAt,
  CoverageStateBadge,
  coverageStateLabel,
} from '@/components/CoverageStateBadge'
import { ClientDataTable, createClientDataTableColumns } from '@/components/ClientDataTable'
import type {
  ClientDataTableColumn,
  ClientDataTableFacetedFilter,
  RowActionsFor,
} from '@/components/ClientDataTable'
import { DataTimestamp, TimezoneFootnote } from '@/components/DataTimestamp'
import { EmptyState } from '@/components/EmptyState'
import { IndexingStateBadge } from '@/components/IndexingStateBadge'
import { DescriptionList, LabelValue } from '@/components/LabelValue'
import { SectionCard } from '@/components/SectionCard'
import { ServerErrorNotice } from '@/components/ServerErrorNotice'
import { isTerminal, useBatchPolling } from '@/hooks/useBatchPolling'
import { AppLayout } from '@/layouts/AppLayout'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog'
import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
} from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
import { Spinner } from '@/components/ui/spinner'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { splitAddress } from '@/lib/address'
import { batchOriginLabel } from '@/lib/batch'
import { formatNumber } from '@/lib/format'
import { t } from '@/lib/i18n'
import type { TranslationKey } from '@/lib/i18n'
import { route } from '@/lib/routes'

/** Una fila de la tabla, tal como la serializa `_row()` en el servidor. */
interface UrlRow {
  id: string
  loc: string
  state: string
  /**
   * A cuál de las tres familias de RT-03 pertenece, y **a una sola**.
   *
   * Es lo que filtra el eje «Recorte». Los `groups` del servidor se solapan
   * —«con dato» contiene a «no indexadas»— y por eso no sirven para un filtro
   * de tabla, que compara el valor de una columna: una fila no puede estar en
   * dos casillas del mismo eje. La partición la decide el servidor.
   */
  family: string
  /** Cuándo Google informó ese estado. Nula sólo cuando nunca se consultó. */
  fetched_at: string | null
  raw_coverage_state: string
  google_canonical: string
  user_canonical: string
  /** `lastCrawlTime`: cuándo Google visitó la página. No es `fetched_at`. */
  last_crawl_time: string | null
  in_sitemap: boolean
  /**
   * El último pedido de indexación que se hizo por esta dirección, si hubo.
   *
   * Clave propia y nunca mezclada con `state`: son las dos afirmaciones que
   * este producto tiene que mantener separadas.
   */
  indexing_state: string
}

interface Batch {
  id: string
  state: string
  origin: string
  total_items: number
  processed_items: number
  failed_items: number
  finished_at: string | null
}

interface HistoryEntry {
  id: string
  state: string
  fetched_at: string
  raw_coverage_state: string
  google_canonical: string
  user_canonical: string
  last_crawl_time: string | null
  /** El más viejo: es el primer dato que tuvimos, no un cambio. */
  first: boolean
  batch: { id: string; state: string; origin: string } | null
}

interface UrlHistory {
  url: string
  /** Falso cuando la dirección de la querystring no es de este dominio. */
  found: boolean
  in_sitemap: boolean
  current: { state: string; fetched_at: string | null } | null
  entries: HistoryEntry[]
}

interface Props {
  domain: {
    id: string
    hostname: string
    property_uri: string
    access_state: string
    access_checked_at: string | null
    is_operational: boolean
  }
  summary: CoverageSummaryData & { total: number }
  window: DateWindow
  cycle: {
    monitored_urls: number
    queries_per_day: number
    days_per_cycle: number | null
  }
  /**
   * Las filas, ya recortadas al tope del servidor.
   *
   * `total` es cuántas hay de verdad y `rows.length` cuántas llegaron. Cuando
   * no coinciden, `truncated` es verdadero y **la pantalla lo dice**: una tabla
   * que muestra 2.000 de 8.431 sin avisar se lee como que ésas son todas.
   */
  urls: {
    rows: UrlRow[]
    total: number
    limit: number
    truncated: boolean
  }
  options: {
    total: number
    /** Los diez estados. `value` es el código, que es lo que compara la tabla. */
    states: { value: string; code: string; count: number }[]
    /** Las tres familias excluyentes de RT-03. */
    families: { value: string; count: number }[]
    /** Los grupos del servidor, que **se solapan**. No filtran la tabla. */
    groups: { value: string; count: number }[]
  }
  history: UrlHistory | null
  /**
   * Cuántas URLs mira la tabla que el resumen de arriba no cuenta: las que
   * salieron de todos los sitemaps y conservan su último dato.
   */
  retired: number
  export: {
    path: string
    threshold: number
    /** La última exportación grande del dominio, con su lote. Nula si no hubo. */
    latest: CoverageExport | null
  }
  running_batch: Batch | null
  last_batch: Batch | null
  /**
   * El eje de indexación, en su propia clave.
   *
   * Lo que se le pidió a Google vive acá y **no** dentro de `urls`: son dos
   * afirmaciones distintas —«le pedimos que la mire» y «Google dice que está
   * indexada»— y meterlas en la misma fila las encimaría en la pantalla igual
   * que las encimaría en la base.
   */
  indexing: {
    /** El que está corriendo, o el último que terminó. Nulo si nunca hubo. */
    batch: IndexingBatch | null
    progress: IndexingProgress | null
    /** Las direcciones del lote, ya recortadas al tope del servidor. */
    requests: IndexingRequestRow[]
    truncated: boolean
  }
}

/** El lote de indexación vigente. */
interface IndexingBatch {
  id: string
  state: string
  origin: string
  total_items: number
  created_at: string
  finished_at: string | null
  is_draft: boolean
  is_running: boolean
  is_terminal: boolean
  /** Por qué se detuvo, en código. Vacío cuando no se detuvo. */
  stopped_by: string
}

interface IndexingProgress {
  pending: number
  sent: number
  errors: number
  removed: number
}

/** Una dirección del lote vigente, sin los cuerpos crudos. */
interface IndexingRequestRow {
  id: string
  loc: string
  position: number
  state: string
  sent_at: string | null
  response_status: number | null
  error_code: string
}

/** Una exportación que se armó —o se está armando— en segundo plano (FR-058). */
interface CoverageExport {
  id: string
  batch: Batch
  /** El motivo, cuando el lote falló. */
  error: string | null
  filters: Record<string, string>
  row_count: number
  size_bytes: number
  expires_at: string | null
  expired: boolean
  download_path: string
  requested_at: string
}

/**
 * Las tres familias excluyentes de RT-03, con el nombre que se lee en pantalla.
 *
 * No son los grupos del servidor: aquéllos se solapan —«con dato» contiene a
 * «no indexadas»— y sirven para la exportación, donde son dos consultas
 * distintas. Éstas parten el total en tres sin superposición, que es lo que un
 * eje de filtro necesita.
 */
const FAMILIES: readonly string[] = ['indexed', 'not_indexed', 'without_data']

/** Las cuatro caras de la vista. La primera es la que contesta su pregunta. */
const TABS: readonly string[] = ['urls', 'summary', 'activity', 'indexing']

/**
 * Estados donde pedir la indexación está desaconsejado, con su advertencia.
 *
 * Es la copia en pantalla de `apps/indexing/rules.py`, y lo único que hace es
 * **avisar**: no prohíbe nada, porque quien opera el sitio puede saber algo que
 * nosotros no. El servidor manda el motivo por fila en su propia clave; esta
 * lista existe para poder advertir antes de mandar, que es cuando sirve.
 */
const DISCOURAGED_STATES: readonly string[] = [
  'EXCLUDED_NOINDEX',
  'BLOCKED_ROBOTS',
  'DUPLICATE_CANONICAL',
  'REDIRECT',
  'UNKNOWN',
]

function familyLabel(family: string): string {
  if (!FAMILIES.includes(family)) return family
  return t(`coverage.family.${family}` as TranslationKey)
}

/**
 * Dónde se arregla cada error de la acción de consultar (RT-08).
 *
 * Los tres códigos que esta acción produce se resuelven en pantallas distintas,
 * y un aviso que sólo describe el problema deja a la persona buscando dónde
 * seguir. Un código fuera del mapa cae en la acción neutra de `C-13`: nunca se
 * le inventa una causa ni se culpa a la credencial.
 */
const ERROR_ACTIONS: Record<
  string,
  { label: () => string; href: (domainId: string) => string }
> = {
  CREDENTIAL_NOT_READY: {
    label: () => t('domains.reviewConnection'),
    href: () => route('settings'),
  },
  DOMAIN_NOT_OPERATIONAL: {
    label: () => t('batches.seeDomain'),
    href: (domainId) => route('domain.show', { domain_id: domainId }),
  },
  QUOTA_EXHAUSTED: {
    label: () => t('coverage.seeQuota'),
    href: (domainId) => route('domain.show', { domain_id: domainId }),
  },
}

const columnHelper = createClientDataTableColumns<UrlRow>()

/**
 * Las columnas de la tabla de cobertura.
 *
 * El estado y su fecha de obtención van en **dos celdas**, no en una con un
 * globo emergente: la fecha tiene que sobrevivir a la exportación y a una
 * captura de pantalla, que es donde este dato termina siendo leído (R-A, RT-02).
 *
 * Los identificadores tienen que coincidir con las claves que el servidor
 * declara ordenables en `COVERAGE_TABLE`. Las cuatro últimas columnas salen por
 * subconsulta al historial y por eso no se ofrecen ordenables: hacerlo sería
 * ofrecer un recorrido completo de la tabla en cada clic de encabezado.
 */
/**
 * La dirección tal como se lee en una celda de esta tabla.
 *
 * El origen se lee una vez en el título de la pantalla y se repite idéntico en
 * cada fila, así que acá no distingue nada: empuja el final de la dirección
 * —lo único que cambia— fuera del ancho de la celda. Va en `sr-only`, y entero
 * en el globo.
 *
 * **La portada es el único caso donde lo visible no es un trozo de la
 * dirección.** Su camino es `/`, que en una columna de rutas se lee como un
 * renglón a medio cargar; con nombre se reconoce de un vistazo. Por eso ahí el
 * `sr-only` lleva la dirección **entera** y el rótulo se esconde del lector: si
 * no, anunciaría «https://ejemplo.test Home», que no es ninguna dirección.
 */
function AddressLabel({ loc, hostname }: { loc: string; hostname: string }) {
  const { origin, rest } = splitAddress(loc, hostname)

  if (rest === '/') {
    return (
      <>
        <span className="sr-only">{loc}</span>
        <span aria-hidden>{t('coverage.url.home')}</span>
      </>
    )
  }

  return (
    <>
      <span className="sr-only">{origin}</span>
      {/* Sin `aria-hidden`: los dos trozos se leen seguidos y forman la
          dirección entera. Escondiendo éste, el lector anunciaría el origen y
          nada más, que es justo la mitad que no distingue. */}
      {rest}
    </>
  )
}

function coverageColumns(hostname: string): ClientDataTableColumn<UrlRow>[] {
  return [
    columnHelper.accessor('loc', {
      id: 'url',
      header: 'URL',
      cell: (info) => {
        const row = info.row.original
        return (
          /*
            Una sola línea, cortada con puntos suspensivos y con la dirección
            entera en el globo.

            Es un cambio de criterio respecto de partirla en varios renglones:
            con `wrap-anywhere` una dirección larga hacía una fila de tres
            renglones y una tabla de cincuenta filas de alto irregular, donde
            comparar dos estados obliga a recorrerla entera. Acá todas las filas
            miden lo mismo y la dirección completa está a un `hover` o a un
            `focus`.

            El precio está asumido y hay que conocerlo: en una fila truncada, el
            final de la dirección **sólo** existe en el globo, así que el globo
            es obligatorio y va sobre un elemento enfocable —el enlace— para que
            se alcance con el teclado. `title=` no serviría: no se alcanza con
            teclado ni existe en táctil.
          */
          <Tooltip>
            <TooltipTrigger asChild>
              {/* `translate="no"`: una dirección no se traduce, y el traductor
                  del navegador la reescribe si no se le dice. Lo llevan las
                  diez direcciones del archivo. */}
              <span translate="no" className="block max-w-80 truncate">
                <AddressLabel loc={row.loc} hostname={hostname} />
              </span>
              {/* <Link
                href={historyHref(row.loc)}
                preserveScroll
                preserveState
                onClick={(event) => {
                  // Con una tecla modificadora apretada el navegador abre otra
                  // pestaña y esta pantalla no navega a ningún lado: abrir el
                  // panel acá dejaría un esqueleto girando para siempre.
                  if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
                  onOpen(row.loc)
                }}
                translate="no"
                className="focus-visible:ring-ring block max-w-full truncate rounded text-left whitespace-nowrap hover:underline focus-visible:ring-2 focus-visible:outline-none"
              >
              </Link> */}
            </TooltipTrigger>
            {/* La dirección entera, y seleccionable: es el dato que alguien vino
                a leer y truncarla en el globo dejaría el recorte sin salida. */}
            <TooltipContent className="max-w-md">
              <span translate="no" className="wrap-anywhere">
                {row.loc}
              </span>
            </TooltipContent>
          </Tooltip>
        )
      },
    }),
    /*
      La familia no se dibuja: existe para que el eje «Recorte» tenga una
      columna sobre la cual filtrar.

      Arranca escondida y **no lleva `meta.label`**, que es lo que la deja
      fuera del menú de columnas: el menú ofrece lo que tiene etiqueta, así que
      sin etiqueta nadie puede encenderla y encontrarse una columna vacía. Su
      valor ya se lee, mejor dicho, en la columna de estado.
    */
    columnHelper.accessor('family', {
      id: 'family',
      header: () => null,
      enableSorting: false,
      cell: () => null,
    }),
    columnHelper.accessor('state', {
      id: 'state',
      header: t('coverageSummary.table.state'),
      meta: { label: t('coverageSummary.table.state') },
      cell: (info) => (
        <CoverageStateBadge
          state={info.row.original.state}
          fetchedAt={info.row.original.fetched_at}
        />
      ),
    }),
    columnHelper.accessor('fetched_at', {
      id: 'fetched',
      header: t('coverage.column.fetched'),
      meta: { label: t('coverage.column.fetched') },
      cell: (info) => (
        <CoverageFetchedAt
          state={info.row.original.state}
          fetchedAt={info.row.original.fetched_at}
        />
      ),
    }),
    columnHelper.accessor('raw_coverage_state', {
      id: 'reason',
      enableSorting: false,
      // «Declarado por Google» y no «motivo» a secas: es el texto que mandó
      // Google, sin traducir, y es lo que permite entender un «sin indexar,
      // otro motivo» sin volver a gastar cuota preguntándole.
      header: t('coverage.column.reason'),
      meta: { label: t('coverage.column.reason') },
      cell: (info) => <RawText value={info.getValue()} />,
    }),
    /*
      Las dos columnas de abajo arrancan **escondidas**, no borradas.

      Muestran «—» y «No lo informó» en la enorme mayoría de las filas y se
      comen el ancho que la dirección necesita, pero cuando traen algo es
      exactamente lo que alguien fue a buscar. Con etiqueta en `meta` siguen en
      el menú de columnas, así que se encienden en un clic y el estado dura lo
      que dura la visita. Su contenido, además, está siempre en el panel de
      historial y en el CSV.
    */
    columnHelper.accessor('google_canonical', {
      id: 'canonical',
      enableSorting: false,
      header: t('coverage.column.canonical'),
      meta: { label: t('coverage.column.canonical') },
      cell: (info) => <GoogleCanonical row={info.row.original} />,
    }),
    columnHelper.accessor('last_crawl_time', {
      id: 'crawl',
      enableSorting: false,
      // Cuándo Google visitó la página, que no es cuándo se lo preguntamos. Van
      // en columnas distintas porque confundirlas envejece o rejuvenece el dato
      // en la dirección equivocada.
      header: t('coverage.column.crawl'),
      meta: { label: t('coverage.column.crawl') },
      cell: (info) => (
        <DataTimestamp value={info.getValue()} emptyLabel={t('coverage.notReported')} />
      ),
    }),
    /*
      El eje de indexación: qué le pedimos nosotros a Google.

      Arranca escondida como las dos de arriba, y por un motivo más fuerte que
      el ancho: **es otra afirmación**. «Google dice que no está indexada» y «le
      pedimos que la indexe» son dos cosas distintas, y encenderla sin que nadie
      la pida pondría las dos en la misma fila para que se lean como una sola.
      Quien la enciende sabe que está mirando otra cosa.
    */
    columnHelper.accessor('indexing_state', {
      id: 'indexing',
      enableSorting: false,
      header: t('coverage.column.indexing'),
      meta: { label: t('coverage.column.indexing') },
      cell: (info) =>
        info.getValue() ? (
          <IndexingStateBadge state={info.getValue()} />
        ) : (
          <span className="text-muted-foreground text-sm">{t('coverage.notRequested')}</span>
        ),
    }),
    columnHelper.accessor('in_sitemap', {
      id: 'sitemap',
      header: t('coverage.column.sitemap'),
      meta: { label: t('coverage.column.sitemap') },
      cell: (info) =>
        info.getValue() ? (
          <span className="text-muted-foreground text-sm">{t('coverage.yes')}</span>
        ) : (
          <Badge variant="outline">{t('coverage.gone')}</Badge>
        ),
    }),
  ]
}

/**
 * Qué ofrece cada fila en su menú de tres puntos.
 *
 * `detail` es la entrada del juego cerrado que corresponde a abrir un panel
 * lateral —su icono es el del panel— y se lleva el enlace «Ver el historial»
 * que colgaba debajo de cada dirección. Ahí ocupaba un renglón en cada una de
 * las cincuenta filas de la página para decir siempre lo mismo.
 *
 * Lleva `href` y no `onSelect` porque el panel vive en la querystring: es un
 * enlace de verdad, y Cmd+clic lo abre en otra pestaña.
 */
function urlActions(row: UrlRow, historyHref: (loc: string) => string): RowActionsFor {
  return {
    // Nombre único por fila: cincuenta disparadores llamados «Acciones»
    // obligan a recorrer la tabla celda por celda para saber cuál es cuál.
    label: t('coverage.row.actions', { url: row.loc }),
    actions: {
      detail: { href: historyHref(row.loc), label: t('coverage.seeHistory') },
      // **Pedir la indexación no está acá**, y es a propósito. Estuvo, y mandaba
      // una dirección a Google directo desde el menú, sin confirmación: era una
      // segunda manera de hacer lo mismo y la única que salteaba el diálogo que
      // enumera lo que va a salir.
      //
      // La casilla de la fila está a dos centímetros. Marcar una y confirmar es
      // la misma cantidad de clics y pasa por la misma puerta.
    },
  }
}

export default function CoverageIndex({
  domain,
  summary,
  window: dateWindow,
  cycle,
  urls,
  options,
  history,
  retired,
  export: exportConfig,
  running_batch,
  last_batch,
  indexing,
}: Props) {
  const { errors, account } = usePage().props
  const inspect = useForm({})

  /*
    Qué URL tiene abierta el panel de historial.

    Manda la dirección: el parámetro `url` de la querystring es lo que hace que
    el panel sea enlazable y que el botón Atrás lo cierre. Pero entre el clic y
    la respuesta hay un viaje al servidor, y durante ese viaje la pantalla tiene
    que contestar igual: al abrir muestra el esqueleto en el acto —un clic que
    no muestra nada durante medio segundo se lee como que no pasó nada
    (RT-12)— y al cerrar se cierra en el acto, sin dejar el foco atrapado
    esperando una respuesta que todavía viaja.

    `desiredUrl` es ese adelanto. `undefined` significa «manda la dirección», y
    es a lo que vuelve en cuanto la navegación aterriza: sin ese descarte, el
    adelanto se quedaría pisando lo que el servidor devuelve y el botón Atrás
    dejaría de cerrar el panel.
  */
  const urlInAddress = history?.url ?? null
  const [desiredUrl, setDesiredUrl] = useState<string | null | undefined>(undefined)
  const [lastUrlInAddress, setLastUrlInAddress] = useState(urlInAddress)

  if (lastUrlInAddress !== urlInAddress) {
    setLastUrlInAddress(urlInAddress)
    setDesiredUrl(undefined)
  }

  // Único mecanismo de actualización del producto (RT-13): mientras el lote no
  // llegue a un estado terminal y la pestaña esté visible, se vuelven a pedir
  // sólo las props que cambian. `history` queda afuera a propósito, para que una
  // recarga no cierre ni reordene el panel abierto.
  useBatchPolling({
    // El lote de indexación entra en el sondeo por lo mismo que los otros dos:
    // la pestaña muestra su avance en vivo, y sin esto habría que recargar a
    // mano para ver si una dirección ya salió.
    states: [running_batch?.state, exportConfig.latest?.batch.state, indexing.batch?.state],
    only: [
      'urls',
      'summary',
      'window',
      'cycle',
      'options',
      'retired',
      'running_batch',
      'last_batch',
      'export',
      'indexing',
    ],
  })

  // Los dos lotes que esta vista puede estar mirando son distintos, y el botón
  // de inspección sólo habla del suyo: con el sondeo diciendo «hay algo
  // corriendo», una exportación en curso dejaría el botón de inspeccionar en
  // espera sin que hubiera ninguna inspección.
  const inspecting = Boolean(running_batch && !isTerminal(running_batch.state))

  const canOperate = account?.can_operate ?? false

  /*
    El enlace al panel de historial.

    Antes salía de `tableUrl()`, que armaba la dirección conservando el filtro y
    la página **de la tabla de servidor**. Ahora el recorte vive en el navegador
    y no hay nada de eso que conservar: el único parámetro que la dirección
    lleva es la URL abierta, y la pestaña, para que el panel siga siendo
    enlazable y el botón Atrás lo siga cerrando.
  */
  const historyHref = useCallback(
    (loc: string) => `${route('coverage', { domain_id: domain.id })}?url=${encodeURIComponent(loc)}`,
    [domain.id]
  )

  /*
    Armar un borrador de indexación con las direcciones elegidas.

    **Arma, no envía.** Lleva a la pantalla del lote, donde se revisa y recién
    ahí se aprieta «Run». Ese hueco entre armar y ejecutar es lo que separa un
    pedido de un accidente, y es la misma regla para las tres formas de crear un
    lote: a mano, por la API tras un despliegue, y solo después de un recorrido.
  */
  /*
    Volver a preguntarle a Google por lo que está marcado, y nada más.

    Antes esta pantalla sólo podía consultar el sitio entero, así que mirar si
    cuatro direcciones cambiaron costaba doscientas cuarenta y dos llamadas del
    cupo del día. El resultado práctico era no mirarlas.

    Va al mismo endpoint que «Query now» —mismo lote, mismo cupo, mismo
    recorrido—; lo único que cambia es que la selección viaja en el cuerpo.
  */
  const inspectSelected = useCallback(
    (ids: string[], { onStart, onFinish }: { onStart: () => void; onFinish: () => void }) => {
      if (ids.length === 0) return

      router.post(
        route('coverage.inspect', { domain_id: domain.id }),
        { urls: ids },
        {
          preserveScroll: true,
          onStart,
          onFinish,
          onSuccess: () => toast.success(t('coverage.inspect.queued')),
        }
      )
    },
    [domain.id]
  )

  const requestIndexing = useCallback(
    (ids: string[], { onStart, onFinish }: { onStart: () => void; onFinish: () => void }) => {
      if (ids.length === 0) return

      router.post(
        route('indexing.create', { domain_id: domain.id }),
        { urls: ids },
        {
          preserveScroll: true,
          onStart,
          onFinish,
          // El aviso se da al **saber** que el servidor tomó el pedido, y no al
          // apretar: un cartel que dice «está saliendo» antes de que el
          // servidor conteste es una afirmación sobre algo que todavía puede
          // fallar, y ésa es la clase de mentira que este producto no comete.
          //
          // `onSuccess` corre después de que la página nueva se montó, así que
          // el `Toaster` que lo dibuja ya está en pantalla.
          onSuccess: () => toast.success(t('coverage.indexing.queued')),
        }
      )
    },
    [domain.id]
  )

  // Definidas una sola vez: una identidad nueva en cada dibujado invalida los
  // modelos de TanStack, y acá el trabajo lo hace de verdad el navegador.
  //
  // Ya no dependen de nada: desde que la dirección de la primera celda dejó de
  // ser un enlace, el camino al historial es el menú de la fila y sólo
  // `urlActions` necesita saber armar esa dirección.
  const tableColumns = useMemo(() => coverageColumns(domain.hostname), [domain.hostname])

  const facetedFilters = useMemo<ClientDataTableFacetedFilter[]>(
    () => [
      /*
        El recorte, que es la pregunta de todos los días: las tres familias de
        RT-03. Es de selección única porque son excluyentes entre sí — pedir dos
        a la vez es pedir el total, que ya es no filtrar.
      */
      {
        column: 'family',
        title: t('coverage.filter.slice'),
        selection: 'single',
        options: options.families.map((family) => ({
          value: family.value,
          label: `${familyLabel(family.value)} (${formatNumber(family.count)})`,
        })),
      },
      /*
        Los diez estados, de selección múltiple: acá sí tiene sentido mirar dos
        motivos a la vez —«bloqueada por robots» y «error al descargarla» son la
        misma tarea— y el eje entero es un `O`.

        Sólo se ofrecen los que tienen filas. Un estado en cero es una opción
        que sólo puede vaciar la tabla.
      */
      {
        column: 'state',
        title: t('coverageSummary.table.state'),
        selection: 'multiple',
        options: options.states
          .filter((state) => state.count > 0)
          .map((state) => ({
            value: state.value,
            label: `${coverageStateLabel(state.code)} (${formatNumber(state.count)})`,
          })),
      },
      {
        column: 'sitemap',
        title: t('coverage.column.sitemap'),
        selection: 'single',
        options: [
          { value: 'true', label: t('coverage.yes') },
          { value: 'false', label: t('coverage.gone') },
        ],
      },
    ],
    [options.families, options.states]
  )

  const openUrl = desiredUrl === undefined ? urlInAddress : desiredUrl
  // El historial que llegó puede ser el de otra URL —o el de ninguna—: mientras
  // no coincida con la que está abierta, lo que hay para mostrar es el
  // esqueleto, no el panel de la anterior.
  const loadingHistory = openUrl !== null && history?.url !== openUrl

  /*
    Qué pestaña está abierta.

    Sale de la dirección y no de un estado de React, por lo mismo que el panel:
    para que se pueda enlazar, para que el botón Atrás vuelva a la anterior y
    para que recargar no devuelva a la primera. Un valor que no es ninguna de
    las tres cae en `urls`, que es la que contesta la pregunta de la vista.
  */
  const page = usePage()
  const tabInAddress = new URL(page.url, 'http://x').searchParams.get('tab') ?? ''
  const tab = TABS.includes(tabInAddress) ? tabInAddress : 'urls'

  const goToTab = useCallback(
    (next: string) => {
      const destination = route('coverage', { domain_id: domain.id })
      // `urls` es la pestaña por omisión, así que no se escribe: una dirección
      // con el valor que ya rige por defecto es ruido que se copia y se pega.
      const query = next === 'urls' ? '' : `?tab=${next}`
      router.get(`${destination}${query}`, {}, { preserveScroll: true, preserveState: true })
    },
    [domain.id]
  )

  return (
    <AppLayout
      title={t('coverage.title', { hostname: domain.hostname })}
      description={t('coverage.description')}
      actions={
        <div className="flex flex-wrap gap-2">
          {/*
            La exportación ocupa el lugar que tenía el enlace a Sitemaps, que se
            va: es una acción sobre lo que esta pantalla muestra, y Sitemaps es
            navegación que el menú lateral ya ofrece. Lo que era un panel de
            260 px permanentes es ahora este botón y su confirmación.
          */}
          <ExportAction domainId={domain.id} total={urls.total} exportConfig={exportConfig} />
          <InspectAction
            domain={domain}
            canOperate={canOperate}
            inspecting={inspecting}
            processing={inspect.processing}
            onInspect={() =>
              inspect.post(route('coverage.inspect', { domain_id: domain.id }), {
                preserveScroll: true,
              })
            }
          />
        </div>
      }
    >
      {errors.inspect ? (
        <ServerErrorNotice
          code={errors.inspect_code ?? 'INTERNAL_ERROR'}
          message={errors.inspect}
          action={<ErrorAction code={errors.inspect_code ?? ''} domainId={domain.id} />}
        />
      ) : null}

      {/*
        Armar el lote devuelve acá cuando el servidor lo rechaza, así que el
        motivo tiene que verse **acá**. Sin esto, un pedido rechazado se veía
        exactamente igual que un pedido que nunca salió: la pantalla se
        recargaba y no pasaba nada, que es la peor respuesta posible a un clic.
      */}
      {errors.indexing ? <ServerErrorNotice code={errors.indexing.code} message="" /> : null}

      {/*
        Con la cuenta sin poder operar el aviso ya está arriba de todo, puesto
        por `C-18` (RT-18). Éste cubre el otro caso: el dominio perdió el acceso
        y lo que se está mirando dejó de actualizarse. Los datos siguen a la
        vista porque siguen siendo ciertos —fueron ciertos en su fecha—, pero
        presentarlos sin decir desde cuándo están quietos los haría pasar por
        actuales (R-F).
      */}
      {canOperate && !domain.is_operational ? (
        <Alert>
          <Snowflake />
          <AlertTitle>{t('coverage.frozen.title')}</AlertTitle>
          <AlertDescription className="flex flex-col items-start gap-3">
            <span>
              {t('coverage.frozen.before', { state: accessStateLabel(domain.access_state) })}{' '}
              <DataTimestamp
                value={domain.access_checked_at}
                emptyLabel={t('coverage.frozen.lastCheck')}
              />
              {t('coverage.frozen.after')}
            </span>
            {/* Enlace y no botón: `AlertDescription` subraya todo `<a>`, así que
                un `Button asChild` sale con borde y subrayado a la vez. */}
            <Link
              href={route('domain.show', { domain_id: domain.id })}
              className="font-medium"
            >
              {t('batch.action.checkAccess')}
            </Link>
          </AlertDescription>
        </Alert>
      ) : null}

      {options.total === 0 ? (
        <EmptyState
          icon={FolderTree}
          title={t('coverage.empty.title')}
          description={t('coverage.empty.description')}
          action={
            <Button asChild>
              <Link href={route('sitemaps', { domain_id: domain.id })}>
                {t('batches.registerSitemap')}
              </Link>
            </Button>
          }
        />
      ) : (
        /*
          Tres pestañas, y el orden es la respuesta a «qué vine a ver».

          Cada una de las tres cosas que esta pantalla contesta es compleja por
          sí sola —las URLs, el reparto, la actividad—, y apiladas se disputaban
          el primer plano: la tabla, que es la respuesta, arrancaba a 1.398 px y
          no se veía ninguna fila al llegar. Separarlas es D10 aplicado: nada se
          borró, todo bajó de plano y está a un clic.

          La pestaña vive en la dirección y no en un estado de React: así es
          enlazable —un aviso puede mandar a la que corresponda—, el botón Atrás
          vuelve a la anterior y recargar no devuelve a la primera. Es el mismo
          criterio que el perfil (RT-11).
        */
        <Tabs value={tab} onValueChange={goToTab}>
          <TabsList>
            <TabsTrigger value="urls">{t('coverage.tab.urls')}</TabsTrigger>
            <TabsTrigger value="summary">{t('coverage.tab.summary')}</TabsTrigger>
            <TabsTrigger value="activity">{t('coverage.tab.activity')}</TabsTrigger>
            <TabsTrigger value="indexing">{t('coverage.tab.indexing')}</TabsTrigger>
          </TabsList>

          <TabsContent value="urls" className="flex flex-col gap-4">
            {/*
              El recorte del servidor, dicho donde se nota. Va arriba de la
              tabla y no al pie: quien filtra y no encuentra algo tiene que
              poder enterarse de que la lista llegó cortada **antes** de
              concluir que esa URL no existe.
            */}
            {urls.truncated ? (
              <Alert>
                <Search />
                <AlertTitle>{t('coverage.truncated.title')}</AlertTitle>
                <AlertDescription>
                  {t('coverage.truncated.description', {
                    shown: formatNumber(urls.rows.length),
                    total: formatNumber(urls.total),
                  })}
                </AlertDescription>
              </Alert>
            ) : null}

            <ClientDataTable
              data={urls.rows}
              columns={tableColumns}
              rowId={(row) => row.id}
              unit={t('coverageSummary.urls.other')}
              // Sin esto el contador diría «1 URLs», y la regla de quitar la «s»
              // final no sirve para una sigla.
              unitSingular={t('coverageSummary.urls.one')}
              caption={t('coverage.caption', { hostname: domain.hostname })}
              sort={{ column: 'url' }}
              // De `PAGE_SIZES`, y **tiene que serlo**: el desplegable de filas
              // por página dibuja esa lista, así que un tamaño que no esté ahí
              // deja el control sin opción que mostrar y el trigger en blanco.
              //
              // Veinte y no cincuenta. La acción sobre lo marcado vive al pie
              // de la tabla, y con cincuenta filas quedaba abajo de todo: se
              // marcaban cuatro direcciones y no pasaba nada a la vista. Quien
              // quiera ver más lo cambia en el control de al lado; lo que no
              // puede pasar es que el arranque esconda la acción.
              pageSize={20}
              search={{
                label: t('coverage.searchLabel'),
                placeholder: t('coverage.searchPlaceholder'),
              }}
              facetedFilters={facetedFilters}
              // Escondidas, no borradas: siguen en el menú de columnas.
              hiddenColumns={['family', 'canonical', 'crawl', 'indexing']}
              rowActions={(row) => urlActions(row, historyHref)}
              selection={{
                allLabel: t('coverage.select.all'),
                rowLabel: (row) => t('coverage.select.row', { url: row.loc }),
                // La acción masiva lleva la cantidad en el rótulo: «Pedir la
                // indexación de 12 direcciones» dice qué va a pasar sin que
                // haya que volver a contar lo marcado.
                actions: ({ rows, ids, clear }) => (
                  <>
                    {/* Preguntar va antes que pedir, y por eso está primero:
                        volver a consultar el estado no gasta del techo de
                        indexación ni le pide nada a Google, y muchas veces
                        contesta sola la pregunta que trajo a marcar las filas. */}
                    <InspectSelectedAction
                      ids={ids}
                      clear={clear}
                      canOperate={canOperate}
                      onConfirm={inspectSelected}
                    />
                    <RequestIndexingAction
                      rows={rows}
                      ids={ids}
                      clear={clear}
                      canOperate={canOperate}
                      onConfirm={requestIndexing}
                    />
                  </>
                ),
              }}
              empty={{
                icon: Search,
                title: t('coverage.empty.filtered.title'),
                description: t('coverage.empty.filtered.description'),
              }}
            />
          </TabsContent>

          <TabsContent value="summary" className="flex flex-col gap-4">
            {/*
              Lo primero que se lee es el denominador: cuántas URLs tienen dato
              sobre el total. Un porcentaje de indexación calculado sobre una
              muestra parcial es la mentira más barata de cometer y la más cara
              de sostener; si el denominador queda claro de entrada, el resto se
              interpreta bien solo.
            */}
            <CoverageSummary
              summary={summary}
              total={summary.total}
              fullCycleEstimateDays={cycle.days_per_cycle}
              dateWindow={dateWindow}
              queriesPerDay={cycle.queries_per_day}
            />

            {/*
              Las dos cifras de esta pantalla cuentan conjuntos distintos: el
              resumen mide lo que hoy se monitorea y la tabla incluye además lo
              que dejó de estar en un sitemap pero tiene historial. La
              diferencia es deliberada, pero sin esta línea son dos totales que
              no cierran, y dos números que no cierran sin explicación hacen
              dudar de todo el resto del tablero.
            */}
            {retired > 0 ? (
              <p className="text-muted-foreground text-sm text-pretty">
                {t('coverage.retired', {
                  count: retired,
                  retired: formatNumber(retired),
                  total: formatNumber(summary.total),
                })}
              </p>
            ) : null}
          </TabsContent>

          <TabsContent value="activity" className="flex flex-col gap-4">
            <Activity
              running={running_batch}
              last={last_batch}
              latestExport={exportConfig.latest}
            />
          </TabsContent>

          <TabsContent value="indexing" className="flex flex-col gap-4">
            <IndexingTab indexing={indexing} canOperate={canOperate} />
          </TabsContent>
        </Tabs>
      )}

      <HistoryPanel
        history={history}
        openUrl={openUrl}
        loading={loadingHistory}
        onClose={() => {
          setDesiredUrl(null)
          // Se vuelve a la dirección sin `url`, conservando la pestaña: cerrar
          // el panel no es cambiar de pestaña.
          const destination = route('coverage', { domain_id: domain.id })
          const query = tab === 'urls' ? '' : `?tab=${tab}`
          router.get(`${destination}${query}`, {}, { preserveScroll: true, preserveState: true })
        }}
      />

      {/*
        Fijo y debajo de la tabla, no en un aviso que se cierra (RT-05): la
        confusión que evita vuelve cada vez que se mira esta pantalla.
      */}
      <p className="text-muted-foreground text-sm">
        {t('coverage.whatQueryingMeans')}
      </p>

      <TimezoneFootnote />
    </AppLayout>
  )
}

/**
 * La acción de consultar, o lo que hay que resolver antes (RT-18).
 *
 * Consultar habla con Google. Mientras la cuenta no pueda operar o el dominio no
 * tenga acceso confirmado, la acción no se ofrece apagada con un globo que
 * explique: se **reemplaza** por la que destraba el problema. Un botón
 * deshabilitado deja a la persona mirándolo sin saber qué hacer y, peor, sugiere
 * que el problema está acá.
 */
function InspectAction({
  domain,
  canOperate,
  inspecting,
  processing,
  onInspect,
}: {
  domain: Props['domain']
  canOperate: boolean
  inspecting: boolean
  processing: boolean
  onInspect: () => void
}) {
  if (!canOperate) {
    return (
      <Button asChild>
        <Link href={route('settings')}>{t('domains.reviewConnection')}</Link>
      </Button>
    )
  }

  if (!domain.is_operational) {
    return (
      <Button asChild>
        <Link href={route('domain.show', { domain_id: domain.id })}>
          {t('batch.action.checkAccess')}
        </Link>
      </Button>
    )
  }

  return (
    <Button
      onClick={onInspect}
      // Se apaga sólo mientras hay una tanda en vuelo, y el motivo está a la
      // vista abajo: pedir otra no la apura y gasta cupo dos veces por la misma
      // URL.
      disabled={processing || inspecting}
      aria-busy={processing}
    >
      <RefreshCw aria-hidden />
      {processing ? t('batches.queueing') : t('coverage.queryNow')}
    </Button>
  )
}

function ErrorAction({ code, domainId }: { code: string; domainId: string }) {
  const action = ERROR_ACTIONS[code]
  if (!action) return null

  return (
    <Button asChild size="sm" variant="outline">
      <Link href={action.href(domainId)}>{action.label()}</Link>
    </Button>
  )
}

/**
 * Lo que está pasando ahora y de dónde salió lo que se está mirando.
 *
 * Es la pestaña de respaldo: acá no se contesta la pregunta de la vista sino
 * las dos que vienen después de dudar de ella —«¿esto es de cuándo?» y «¿está
 * corriendo algo?»—. Por eso está plegada y no arriba de la tabla (D10).
 *
 * `RunningBatchCard` **mide cero cuando no hay un lote en curso**, que es casi
 * siempre; la procedencia, en cambio, existe apenas hubo una consulta.
 */
function Activity({
  running,
  last,
  latestExport,
}: {
  running: Batch | null
  last: Batch | null
  latestExport: CoverageExport | null
}) {
  return (
    <>
      {/*
        Qué se espera encontrar acá, dicho antes de lo que hay.

        La pestaña puede estar casi vacía —sin lote corriendo y sin
        exportaciones— y sin esta línea eso se lee como que algo falta o no
        cargó, en vez de como que no hay nada pasando, que es una respuesta.
      */}
      <p className="text-muted-foreground text-sm text-pretty">
        {t('coverage.activity.description')}
      </p>

      {running ? <RunningBatchCard running={running} /> : null}
      {last ? <BatchProvenance last={last} /> : null}
      {latestExport ? <ExportStatus export={latestExport} /> : null}

      {/*
        Los tres bloques son condicionales, así que la pestaña puede quedar sin
        ninguno. Decirlo es mejor que dejar la descripción sola arriba de un
        vacío que parece un error de carga.
      */}
      {!running && !last && !latestExport ? (
        <p className="text-sm text-pretty">{t('coverage.activity.empty')}</p>
      ) : null}
    </>
  )
}

/**
 * Confirmar qué direcciones salen hacia Google, con la lista a la vista.
 *
 * **El diálogo es la confirmación humana del camino a mano.** Enumerarlas antes
 * de mandarlas es lo que reemplaza al borrador: quien aprieta ve exactamente
 * qué eligió, y por eso después no hace falta una segunda pantalla que vuelva a
 * preguntar lo mismo. Confirmar dos veces enseña a apretar sin leer.
 *
 * Y enumera de verdad, no cuenta. «Vas a mandar 4 direcciones» obliga a
 * confiar; la lista deja ver que una se coló por un filtro que quedó puesto,
 * que es el error que este diálogo tiene que atrapar.
 */
/**
 * Volver a consultar el estado de lo marcado.
 *
 * **Sin diálogo de confirmación, a diferencia de pedir la indexación.** No son
 * la misma clase de acción: pedir la indexación manda algo a Google contra un
 * techo de doscientas por día del proyecto entero y no se puede deshacer;
 * consultar sólo pregunta qué sabe, gasta del cupo del sitio —dos mil diarias— y
 * lo peor que puede pasar es enterarse de lo mismo que ya se sabía. Un paso de
 * confirmación acá sería fricción sobre la acción que queremos que sea barata.
 */
function InspectSelectedAction({
  ids,
  clear,
  canOperate,
  onConfirm,
}: {
  ids: string[]
  clear: () => void
  canOperate: boolean
  onConfirm: (ids: string[], hooks: { onStart: () => void; onFinish: () => void }) => void
}) {
  const [sending, setSending] = useState(false)

  return (
    <Button
      size="sm"
      variant="outline"
      disabled={!canOperate || sending}
      onClick={() =>
        onConfirm(ids, {
          onStart: () => setSending(true),
          onFinish: () => {
            setSending(false)
            // La selección se limpia al terminar: las filas ya salieron a
            // consultarse y dejarlas marcadas invita a mandarlas dos veces.
            clear()
          },
        })
      }
    >
      <RefreshCw aria-hidden />
      {t('coverage.inspect.selected', {
        count: ids.length,
        total: formatNumber(ids.length),
      })}
    </Button>
  )
}

function RequestIndexingAction({
  rows,
  ids,
  clear,
  canOperate,
  onConfirm,
}: {
  rows: UrlRow[]
  ids: string[]
  clear: () => void
  canOperate: boolean
  onConfirm: (ids: string[], hooks: { onStart: () => void; onFinish: () => void }) => void
}) {
  const [open, setOpen] = useState(false)
  const [sending, setSending] = useState(false)

  const discouraged = rows.filter((row) => DISCOURAGED_STATES.includes(row.state))

  return (
    <Dialog open={open} onOpenChange={(next) => (sending ? null : setOpen(next))}>
      <DialogTrigger asChild>
        <Button size="sm" variant="outline" disabled={!canOperate}>
          <Send aria-hidden />
          {t('coverage.requestIndexing.selected', {
            count: ids.length,
            total: formatNumber(ids.length),
          })}
        </Button>
      </DialogTrigger>

      <DialogContent>
        <DialogHeader>
          <DialogTitle>
            {t('coverage.requestIndexing.confirm.title', {
              count: ids.length,
              total: formatNumber(ids.length),
            })}
          </DialogTitle>
          <DialogDescription>
            {t('coverage.requestIndexing.confirm.description')}
          </DialogDescription>
        </DialogHeader>

        {/*
          La lista, con desborde propio. Marcar cien direcciones es legítimo y no
          puede empujar el diálogo fuera de la pantalla, que es como se pierde el
          botón de confirmar.
        */}
        <ul className="max-h-56 overflow-y-auto rounded-md border p-2 text-sm">
          {rows.map((row) => (
            <li key={row.id} className="flex items-baseline gap-2 py-0.5">
              <CoverageStateBadge state={row.state} fetchedAt={row.fetched_at} />
              <span translate="no" className="min-w-0 truncate" title={row.loc}>
                {row.loc}
              </span>
            </li>
          ))}
        </ul>

        {/*
          La advertencia va acá y no impide mandar: quien opera el sitio puede
          saber algo que nosotros no —que el `noindex` se acaba de sacar—. Lo que
          no puede pasar es que lo mande sin enterarse.
        */}
        {discouraged.length > 0 ? (
          <Alert>
            <ShieldAlert />
            <AlertTitle>
              {t('coverage.requestIndexing.discouraged.title', {
                count: discouraged.length,
                total: formatNumber(discouraged.length),
              })}
            </AlertTitle>
            <AlertDescription>
              {t('coverage.requestIndexing.discouraged.description')}
            </AlertDescription>
          </Alert>
        ) : null}

        <DialogFooter>
          <DialogClose asChild>
            <Button variant="outline" disabled={sending}>
              {t('common.cancel')}
            </Button>
          </DialogClose>

          <Button
            disabled={sending}
            aria-busy={sending || undefined}
            onClick={() =>
              onConfirm(ids, {
                onStart: () => setSending(true),
                onFinish: () => {
                  setSending(false)
                  setOpen(false)
                  // La selección se vacía al terminar y no al apretar: es estado
                  // de ids que sobrevive a que las filas cambien, y limpiarla
                  // antes dejaría la pantalla afirmando un envío que pudo no
                  // haber ocurrido.
                  clear()
                },
              })
            }
          >
            {sending ? (
              <>
                <Spinner />
                {t('coverage.requestIndexing.confirm.busy')}
              </>
            ) : (
              <>
                <Send aria-hidden />
                {t('coverage.requestIndexing.confirm.action')}
              </>
            )}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

/**
 * Lo que se le pidió a Google, que es el otro eje de esta pantalla.
 *
 * **Nada de acá dice «indexada».** Dice cuántas solicitudes salieron y qué
 * contestó Google, que es lo único que este producto puede afirmar. La lista
 * lleva a cada lote, donde está la evidencia cruda.
 *
 * **No es un historial.** Muestra un solo lote: el que está corriendo, o el
 * último que terminó. La pregunta de esta pestaña es «¿qué está pasando con lo
 * que le pedimos?», y una lista de veinte renglones de cosas terminadas hace
 * semanas la deja mezclada entre ruido. El historial vive en la tabla de lotes,
 * donde la indexación es una clase más y se puede filtrar y ordenar.
 */
function IndexingTab({
  indexing,
  canOperate,
}: {
  indexing: Props['indexing']
  canOperate: boolean
}) {
  const resume = useForm({ from: 'coverage' })
  const { batch, progress } = indexing

  if (!batch || !progress) {
    return (
      <EmptyState
        icon={Send}
        title={t('coverage.indexing.empty.title')}
        description={t('coverage.indexing.empty.description')}
      />
    )
  }

  // Se puede retomar lo que se detuvo y dejó algo sin mandar. Un lote que
  // terminó entero no se «reanuda»: reenviaría lo que ya salió y gastaría dos
  // veces del techo real de Google.
  const canResume = batch.is_terminal && progress.pending > 0

  return (
    <>
      <SectionCard
        title={{ text: t('coverage.indexing.current'), level: 'h3' }}
        description={t('coverage.indexing.current.description')}
        actions={
          <div className="flex flex-wrap gap-2">
            {canResume ? (
              <Button
                size="sm"
                disabled={!canOperate || resume.processing}
                aria-busy={resume.processing || undefined}
                onClick={() =>
                  resume.post(route('indexing.resume', { batch_id: batch.id }), {
                    preserveScroll: true,
                    onSuccess: () => toast.success(t('coverage.indexing.resumed')),
                  })
                }
              >
                {resume.processing ? <Spinner /> : <RotateCw aria-hidden />}
                {t('coverage.indexing.resume')}
              </Button>
            ) : null}

            <Button asChild variant="outline" size="sm">
              <Link href={route('indexing.batch', { batch_id: batch.id })}>
                {t('coverage.indexing.open')}
              </Link>
            </Button>
          </div>
        }
      >
        <BatchStateHeadline
          state={batch.state}
          reason={
            batch.stopped_by
              ? t(
                  (STOP_REASONS.includes(batch.stopped_by)
                    ? `indexing.stopped.${batch.stopped_by}`
                    : 'indexing.stopped.generic') as TranslationKey
                )
              : null
          }
        />

        {/* Las cuatro cifras crudas y no un porcentaje: «12 enviadas, 2
            rechazadas, 5 sin enviar» se verifica contra la evidencia; un
            «86 %» no se puede verificar contra nada. */}
        <DescriptionList
          columns={2}
          items={[
            { term: t('indexing.progress.sent'), value: formatNumber(progress.sent) },
            { term: t('indexing.progress.errors'), value: formatNumber(progress.errors) },
            { term: t('indexing.progress.pending'), value: formatNumber(progress.pending) },
            { term: t('indexing.batch.origin'), value: batchOriginLabel(batch.origin) },
          ]}
        />
      </SectionCard>

      {batch.is_draft ? (
        <Alert>
          <Send />
          <AlertTitle>{t('coverage.indexing.draft.title')}</AlertTitle>
          <AlertDescription>{t('coverage.indexing.draft.description')}</AlertDescription>
        </Alert>
      ) : null}

      <SectionCard
        title={{ text: t('coverage.indexing.addresses'), level: 'h3' }}
        description={
          batch.is_running
            ? t('coverage.indexing.addresses.running')
            : t('coverage.indexing.addresses.done')
        }
      >
        <ul className="flex flex-col">
          {indexing.requests.map((request) => (
            <li
              key={request.id}
              className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 border-b py-2 last:border-0"
            >
              <span translate="no" className="min-w-0 flex-1 truncate text-sm" title={request.loc}>
                {request.loc}
              </span>

              <div className="flex items-center gap-3">
                {/* El código de Google al lado del estado, y no en su lugar: un
                    «Rechazado» sin el motivo obliga a abrir la evidencia para
                    saber si hay que arreglar un permiso o una dirección. */}
                {request.error_code ? (
                  <span className="text-muted-foreground text-xs">{request.error_code}</span>
                ) : null}
                <IndexingStateBadge state={request.state} />
              </div>
            </li>
          ))}
        </ul>

        {indexing.truncated ? (
          <p className="text-muted-foreground text-sm text-pretty">
            {t('coverage.indexing.truncated', {
              shown: formatNumber(indexing.requests.length),
              total: formatNumber(batch.total_items),
            })}
          </p>
        ) : null}
      </SectionCard>

      {/*
        El log está **siempre**, no sólo cuando algo falló.

        Estaba condicionado a que hubiera errores, y eso dejaba sin puerta al
        caso más importante: la respuesta de un envío que salió bien. La
        pregunta que llega meses después —«probame que mandaste esta página»— se
        contesta con esa respuesta, no con la de un fallo.

        Lo que sí se conserva es que sea **un paso más**: el botón está a la
        vista y los cuerpos crudos no. Lo que se lee todos los días es el
        estado; el JSON se abre cuando hace falta.
      */}
      {progress.sent + progress.errors > 0 ? (
        <SectionCard
          title={{ text: t('coverage.indexing.log'), level: 'h3' }}
          description={t('coverage.indexing.log.description')}
          actions={
            <div className="flex flex-wrap gap-2">
              <Button asChild variant="outline" size="sm">
                <a href={route('indexing.evidence', { batch_id: batch.id })}>
                  <Download aria-hidden />
                  {t('indexing.evidence.download')}
                </a>
              </Button>
              <Button asChild variant="outline" size="sm">
                <Link href={route('indexing.batch', { batch_id: batch.id })}>
                  <FileText aria-hidden />
                  {t('coverage.indexing.log.open')}
                </Link>
              </Button>
            </div>
          }
        >
          <p className="text-sm text-pretty">{t('coverage.indexing.log.body')}</p>
        </SectionCard>
      ) : null}
    </>
  )
}

/** Los motivos de corte que el producto sabe explicar; el resto cae en la frase genérica. */
const STOP_REASONS: readonly string[] = [
  'QUOTA_EXCEEDED',
  'PERMISSION_DENIED',
  'API_NOT_ENABLED',
  'INVALID_KEY',
]

/** El identificador corto: para reconocer un lote entre dos alcanzan ocho caracteres. */
function shortBatchId(id: string): string {
  return id.slice(0, 8)
}

/**
 * De qué lote salieron los datos que se están mirando.
 *
 * `SectionCard` con los cuatro datos del lote como pares etiqueta/valor. Es la
 * anatomía que eligió el owner entre las tres que se probaron: nada hay que
 * deducir, y cada dato se saltea directamente sin leer una oración entera.
 *
 * Va acá y no arriba de la tabla porque es procedencia —se consulta cuando algo
 * no cuadra—, y su enlace a la ficha del lote es la puerta a «¿qué hizo
 * exactamente?», que es la pregunta que sigue.
 */
function BatchProvenance({ last }: { last: Batch }) {
  return (
    <SectionCard
      title={{ text: t('coverage.dataFrom'), level: 'h3' }}
      actions={
        <Button asChild variant="outline" size="sm">
          <Link href={route('batch.show', { batch_id: last.id })}>
            {t('dashboard.attention.seeBatch')}
          </Link>
        </Button>
      }
    >
      <div className="grid gap-3 sm:grid-cols-2">
        <LabelValue
          term={t('batches.column.state')}
          value={<BatchStateBadge state={last.state} />}
        />
        <LabelValue term={t('batches.filter.origin')} value={batchOriginLabel(last.origin)} />
        {/*
          Sin `numeric`: eso alinea a la derecha, y está para una cifra suelta
          que se compara con la de la fila de al lado. Acá el valor es una
          oración —«200 de 200 URLs consultadas»—, así que alinearla a la
          derecha la despegaba de su etiqueta y rompía la grilla de dos
          columnas.
        */}
        <LabelValue
          term={t('coverage.provenance.processed')}
          value={t('coverage.queried', {
            processed: formatNumber(last.processed_items),
            total: formatNumber(last.total_items),
          })}
        />
        <LabelValue
          term={t('coverage.provenance.finished')}
          value={<DataTimestamp value={last.finished_at} emptyLabel={t('coverage.noCloseDate')} />}
        />
      </div>
    </SectionCard>
  )
}

/**
 * El lote en curso. **Mide cero cuando no hay ninguno** (D1).
 *
 * Nunca se presenta como terminado: el avance sale de las cifras y no del
 * estado, así que una tanda cortada por cuota se ve cortada (R-C).
 */
function RunningBatchCard({ running }: { running: Batch }) {
  return (
    <SectionCard title={{ text: t('coverage.querying'), level: 'h3' }}>
      {/* La región viva abarca sólo lo que cambia cada cinco segundos.
          `role="status"` es atómico: con el enlace adentro, cada consulta
          periódica le releería el texto entero a quien usa lector. */}
      <div role="status" className="flex flex-col gap-2">
        <BatchStateBadge state={running.state} />
        <BatchProgress
          total={running.total_items}
          processed={running.processed_items}
          failed={running.failed_items}
          state={running.state}
        />
      </div>
      {/* RT-13: el lote que está corriendo se puede abrir para ver qué hizo
          exactamente y por qué se está demorando. */}
      <Link
        href={route('batch.show', { batch_id: running.id })}
        className="text-muted-foreground hover:text-foreground focus-visible:ring-ring mt-2 block w-fit rounded text-xs underline underline-offset-3 focus-visible:ring-2 focus-visible:outline-none"
      >
        {t('coverage.seeProgress')}{' '}
        <span translate="no" className="font-mono">
          {shortBatchId(running.id)}
        </span>
      </Link>
    </SectionCard>
  )
}

/**
 * La exportación: un botón en el encabezado y su confirmación (FR-058).
 *
 * Era un panel de 260 px permanentes con tres párrafos. Los párrafos no
 * sobraban —FR-058 exige decir qué va a pasar **antes** de confirmar— pero su
 * lugar no era un cartel fijo: son la letra de un diálogo, que es donde se leen
 * en el único momento en que importan.
 *
 * Lo que la pantalla no puede hacer es ofrecer un archivo grande como si fuera
 * inmediato y dejar a alguien esperando una descarga que tarda minutos en
 * arrancar. Por eso la cifra que decide cuál de los dos caminos ocurre está a
 * la vista antes de apretar, y el rótulo del botón de confirmación dice cuál de
 * los dos va a pasar.
 *
 * **Exporta el dominio entero.** Con la tabla filtrando en el navegador, el
 * recorte que se está mirando no existe del lado del servidor; el diálogo dice
 * el total del dominio para no prometer un archivo que no es el que llega.
 */
function ExportAction({
  domainId,
  total,
  exportConfig,
}: {
  domainId: string
  total: number
  exportConfig: Props['export']
}) {
  const [open, setOpen] = useState(false)
  const [queueing, setQueueing] = useState(false)

  // Espacio duro entre la cifra y la unidad (RT-16): la cantidad y lo que
  // cuenta nunca quedan en renglones distintos.
  const amountLabel = t('coverage.export.amount', {
    count: total,
    total: formatNumber(total),
  })
  const isLarge = total > exportConfig.threshold

  const queueExport = () => {
    router.post(
      route('coverage.export', { domain_id: domainId }),
      {},
      {
        preserveScroll: true,
        onStart: () => setQueueing(true),
        onFinish: () => {
          setQueueing(false)
          setOpen(false)
        },
      }
    )
  }

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button variant="outline">
          <Download aria-hidden />
          {t('coverage.export.title')}
        </Button>
      </DialogTrigger>

      <DialogContent>
        <DialogHeader>
          <DialogTitle>{t('coverage.export.title')}</DialogTitle>
          <DialogDescription>
            {t('coverage.export.all', { amount: amountLabel })} {t('coverage.export.columns')}
          </DialogDescription>
        </DialogHeader>

        {/*
          Qué va a pasar al confirmar, dicho **antes** de confirmar y con la
          cifra que decide cuál de los dos comportamientos ocurre (FR-058).
        */}
        <p className="text-muted-foreground text-sm text-pretty">
          {isLarge
            ? t('coverage.export.large', {
                amount: amountLabel,
                threshold: formatNumber(exportConfig.threshold),
              })
            : t('coverage.export.immediate')}
        </p>

        <DialogFooter>
          <DialogClose asChild>
            <Button variant="outline">{t('common.cancel')}</Button>
          </DialogClose>

          {isLarge ? (
            <Button onClick={queueExport} disabled={queueing} aria-busy={queueing}>
              <Download aria-hidden />
              {queueing ? t('batches.queueing') : t('coverage.export.prepare', { amount: amountLabel })}
            </Button>
          ) : (
            /*
              El archivo chico se baja de un enlace común, que es lo que un
              navegador sabe hacer con una descarga. El diálogo se cierra al
              tocarlo: la descarga ya arrancó y dejarlo abierto haría dudar de
              si pasó algo.
            */
            <Button asChild onClick={() => setOpen(false)}>
              <a href={exportConfig.path}>
                <Download aria-hidden />
                {t('coverage.export.action', { amount: amountLabel })}
              </a>
            </Button>
          )}
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}

/** Cuántos bytes, en la unidad que se lee de un vistazo. */
function formatSize(bytes: number): string {
  const mega = bytes / (1024 * 1024)
  if (mega >= 1) {
    return t('batchShow.export.megabytes', { value: formatNumber(Math.round(mega * 10) / 10) })
  }
  return t('batchShow.export.kilobytes', {
    value: formatNumber(Math.max(Math.round(bytes / 1024), 1)),
  })
}

/**
 * En qué quedó la última exportación grande.
 *
 * La pantalla **no finge que terminó** (RT-13): mientras el lote está en cola o
 * en curso, lo que se muestra es el progreso real y no un enlace a un archivo
 * que todavía no existe.
 */
function ExportStatus({ export: exported }: { export: CoverageExport }) {
  const { batch } = exported
  const scope =
    Object.keys(exported.filters).length === 0
      ? t('coverage.export.scope.all')
      : t('coverage.export.scope.filtered')

  if (!isTerminal(batch.state)) {
    return (
      <div className="flex flex-col gap-2 border-t pt-3">
        <p className="text-sm text-pretty">
          {t('coverage.export.status.preparing')}{' '}
          <DataTimestamp value={exported.requested_at} />{' '}
          {t('coverage.export.status.preparingAfter', { scope })}
        </p>
        <BatchProgress
          total={batch.total_items}
          processed={batch.processed_items}
          failed={batch.failed_items}
          state={batch.state}
        />
        <Link
          className="text-muted-foreground text-sm underline"
          href={route('batch.show', { batch_id: batch.id })}
        >
          {t('dashboard.attention.seeBatch')}
        </Link>
      </div>
    )
  }

  if (batch.state === 'FAILED') {
    return (
      <p className="border-t pt-3 text-sm text-pretty">
        {t('coverage.export.status.failed')} <DataTimestamp value={exported.requested_at} />.
        {exported.error ? ` ${exported.error}` : ''} {t('coverage.export.status.askAgain')}
      </p>
    )
  }

  if (exported.expired) {
    return (
      <p className="text-muted-foreground border-t pt-3 text-sm text-pretty">
        {t('coverage.export.status.expired')} <DataTimestamp value={exported.expires_at} />{' '}
        {t('coverage.export.status.expiredAfter')} {t('coverage.export.status.askAgain')}
      </p>
    )
  }

  return (
    <div className="flex flex-col items-start gap-2 border-t pt-3">
      <p className="text-sm text-pretty">
        {t('coverage.export.status.readyBefore')}{' '}
        <DataTimestamp value={exported.requested_at} />{' '}
        {t('coverage.export.status.readyAfter', {
          rows: formatNumber(exported.row_count),
          size: formatSize(exported.size_bytes),
        })}{' '}
        <DataTimestamp value={exported.expires_at} />.
      </p>
      <Button asChild>
        <a href={exported.download_path}>
          <Download aria-hidden />
          {t('batchShow.export.download')}
        </a>
      </Button>
    </div>
  )
}

/**
 * Panel lateral con la línea de tiempo de una URL (FR-060, T080).
 *
 * Se abre desde la fila sin salir de la vista ni perder el filtro, y la
 * dirección abierta viaja en la querystring: el panel se puede enlazar y el
 * botón Atrás lo cierra. Atrapa el foco mientras está abierto, se cierra con
 * `Escape` y lo devuelve al enlace que lo abrió (RT-15).
 */
function HistoryPanel({
  history,
  openUrl,
  loading,
  onClose,
}: {
  history: UrlHistory | null
  openUrl: string | null
  loading: boolean
  onClose: () => void
}) {
  const panelRef = useRef<HTMLDivElement>(null)

  return (
    <Sheet
      open={openUrl !== null}
      onOpenChange={(stillOpen) => {
        if (!stillOpen) onClose()
      }}
    >
      <SheetContent
        side="right"
        className="w-full gap-0 overflow-y-auto overscroll-contain sm:max-w-xl"
        /*
          Al abrir, el foco va al panel y no a lo primero que se pueda enfocar
          adentro.

          Radix enfoca por omisión el primer elemento enfocable del contenido, y
          acá ése es el badge del estado vigente: `StatusBadge` se hace
          enfocable cuando tiene `help` —para que su explicación se alcance con
          el teclado— y el `Tooltip` se abre al recibir foco. El resultado era
          que el panel se abría con el badge marcado y su globo desplegado, como
          si alguien lo hubiera señalado.

          Un panel de lectura no tiene una acción que ofrecer primero, así que
          lo correcto es enfocar el contenedor: el lector de pantalla anuncia el
          título, `Escape` sigue cerrando y el primer `Tab` lleva al primer
          control de verdad. No alcanza con `preventDefault()` a secas —el foco
          se quedaría en el disparador, que es un ítem de un menú que acaba de
          cerrarse—, por eso se lo mueve a mano.
        */
        ref={panelRef}
        onOpenAutoFocus={(event) => {
          event.preventDefault()
          panelRef.current?.focus()
        }}
      >
        <SheetHeader>
          <SheetTitle className="pr-8">
            {t('coverage.history.title')}{' '}
            <span translate="no" className="wrap-anywhere">
              {openUrl}
            </span>
          </SheetTitle>
          <SheetDescription>{t('coverage.history.description')}</SheetDescription>
        </SheetHeader>

        <div className="flex flex-col gap-5 p-4 pt-0">
          {loading || !history ? (
            <TimelineSkeleton />
          ) : !history.found ? (
            <p className="text-sm text-pretty">{t('coverage.history.notFound')}</p>
          ) : (
            <HistoryContent history={history} />
          )}
        </div>
      </SheetContent>
    </Sheet>
  )
}

/**
 * El historial de una URL, en tres alturas.
 *
 * Antes era una sola: el estado vigente y las cuatro líneas de cada entrada se
 * dibujaban todas en `text-xs text-muted-foreground`, así que la respuesta
 * —«¿qué dice Google de esto **hoy**?»— pesaba lo mismo que el identificador
 * del lote que la trajo. Sin jerarquía no hay dónde parar de leer.
 *
 * Las tres alturas, de más a menos:
 *
 * 1. **El estado de hoy**, con su badge y su fecha. Es la respuesta.
 * 2. **Lo que Google informó sobre ese estado**, como pares etiqueta/valor: el
 *    motivo crudo, la canónica que eligió, cuándo pasó y si sigue en el
 *    sitemap. Son los datos que se consultan, no los que se leen de corrido, y
 *    en un `DescriptionList` se saltea directamente al que interesa.
 * 3. **La línea de tiempo**, que es el respaldo. Cada entrada dice su fecha y
 *    su estado; el motivo crudo sólo aparece **cuando cambió respecto de la
 *    anterior**, que es la única vez que agrega algo.
 *
 * Lo que se fue de cada entrada —canónica, último rastreo, lote— no se borró:
 * subió al bloque de arriba para el estado vigente, que es de donde se lo mira.
 * Repetirlo en cada una de veinte entradas era lo que aplanaba todo.
 */
function HistoryContent({ history }: { history: UrlHistory }) {
  const current = history.current
  const entries = history.entries
  const latest = entries[0]

  return (
    <>
      {/* 1 · La respuesta. */}
      {current ? (
        <div className="flex flex-col items-start gap-2">
          <CoverageStateBadge state={current.state} fetchedAt={current.fetched_at} />
          <p className="text-muted-foreground text-sm">
            {/*
              Dos fechas distintas y por eso dos frases: cuándo cambió el estado
              y cuándo se lo confirmó por última vez. Con una sola, «indexada
              desde el 12» haría creer que nadie volvió a mirar.
            */}
            {t('coverage.history.lastConfirmed')}{' '}
            <DataTimestamp value={current.fetched_at} emptyLabel="—" />.
          </p>
        </div>
      ) : null}

      {/* 2 · Lo que hay que poder consultar sin leer todo. */}
      {latest ? (
        <div className="flex flex-col gap-3">
          <h3 className="text-sm font-medium">{t('coverage.history.reported')}</h3>
          <DescriptionList
            items={[
              ...(latest.raw_coverage_state
                ? [
                    {
                      term: t('coverage.column.reason'),
                      value: <RawText value={latest.raw_coverage_state} />,
                    },
                  ]
                : []),
              ...(latest.google_canonical
                ? [
                    {
                      term: t('coverage.column.canonical'),
                      value: (
                        <span translate="no" className="wrap-anywhere">
                          {latest.google_canonical}
                        </span>
                      ),
                    },
                  ]
                : []),
              {
                term: t('coverage.column.crawl'),
                value: (
                  <DataTimestamp
                    value={latest.last_crawl_time}
                    emptyLabel={t('coverage.notReported')}
                  />
                ),
              },
              {
                term: t('coverage.column.sitemap'),
                value: history.in_sitemap ? (
                  t('coverage.yes')
                ) : (
                  <Badge variant="outline">{t('coverage.history.notInSitemap')}</Badge>
                ),
              },
            ]}
          />
        </div>
      ) : null}

      {/* 3 · El respaldo. */}
      {entries.length === 0 ? (
        <p className="text-sm text-pretty">{t('coverage.history.noData')}</p>
      ) : (
        <div className="flex flex-col gap-3">
          <h3 className="text-sm font-medium">{t('coverage.history.timeline')}</h3>

          {entries.length === 1 ? (
            <p className="text-muted-foreground text-sm text-pretty">
              {t('coverage.history.singleEntry')}{' '}
              <DataTimestamp value={entries[0].fetched_at} />
              {t('coverage.history.singleEntryAfter')}
            </p>
          ) : null}

          {/* Lista ordenada con la fecha como texto, no una decoración de puntos
              y líneas sin equivalente accesible. */}
          <ol className="flex flex-col gap-4">
            {entries.map((entry, index) => {
              // El motivo crudo sólo se muestra cuando cambió: repetido en cada
              // entrada deja de ser información y pasa a ser textura.
              const previous = entries[index + 1]
              const reasonChanged =
                Boolean(entry.raw_coverage_state) &&
                entry.raw_coverage_state !== previous?.raw_coverage_state

              return (
                <li
                  key={entry.id}
                  className="border-border/70 flex flex-col gap-1.5 border-l-2 pl-3"
                >
                  <div className="flex flex-wrap items-center gap-2">
                    <CoverageStateBadge state={entry.state} fetchedAt={entry.fetched_at} />
                    {entry.first ? (
                      <Badge variant="outline">{t('coverage.history.firstEntry')}</Badge>
                    ) : null}
                  </div>

                  <span className="text-muted-foreground text-xs">
                    <DataTimestamp value={entry.fetched_at} />
                    {entry.batch ? (
                      <>
                        {' · '}
                        {t('coverage.history.batch')}{' '}
                        <span translate="no" className="font-mono">
                          {entry.batch.id.slice(0, 8)}
                        </span>
                      </>
                    ) : (
                      <>
                        {' · '}
                        {t('coverage.history.batchGone')}
                      </>
                    )}
                  </span>

                  {reasonChanged ? (
                    <span className="text-muted-foreground text-xs">
                      {t('coverage.history.googleSaid')}{' '}
                      <span translate="no" className="wrap-anywhere">
                        {entry.raw_coverage_state}
                      </span>
                    </span>
                  ) : null}
                </li>
              )
            })}
          </ol>
        </div>
      )}
    </>
  )
}

/** Esqueleto con la forma real de la línea de tiempo, para que no salte (RT-12). */
function TimelineSkeleton() {
  return (
    <div className="flex flex-col gap-4" aria-busy="true">
      <span className="sr-only" role="status">
        {t('coverage.history.loading')}
      </span>
      {[0, 1, 2].map((index) => (
        <div key={index} className="flex flex-col gap-2">
          <Skeleton className="h-4 w-32" />
          <Skeleton className="h-6 w-48" />
          <Skeleton className="h-3 w-full" />
        </div>
      ))}
    </div>
  )
}

/** Texto de Google, sin traducir y sin que el navegador lo traduzca por su cuenta. */
function RawText({ value }: { value: string }) {
  if (!value) return <span className="text-muted-foreground text-sm">—</span>
  return (
    <span translate="no" className="text-sm wrap-anywhere">
      {value}
    </span>
  )
}

/**
 * La canónica de Google, sólo cuando difiere de la declarada.
 *
 * Mostrarla siempre haría creer que Google eligió otra versión en filas donde
 * eligió la misma, que es la lectura contraria a la que corresponde.
 */
function GoogleCanonical({ row }: { row: UrlRow }) {
  const declared = row.user_canonical || row.loc

  if (!row.google_canonical) {
    return <span className="text-muted-foreground text-sm">—</span>
  }

  if (row.google_canonical === declared) {
    return (
      <span className="text-muted-foreground text-sm">{t('coverage.history.sameCanonical')}</span>
    )
  }

  return (
    <span translate="no" className="text-sm wrap-anywhere">
      {row.google_canonical}
    </span>
  )
}
