import { Link } from '@inertiajs/react'
import { ArrowLeft, ArrowRight, Download } from 'lucide-react'
import type { ReactNode } from 'react'

import { BatchProgress } from '@/components/BatchProgress'
import { BatchStateBadge, BatchStateHeadline } from '@/components/BatchStateBadge'
import { coverageStateLabel } from '@/components/CoverageStateBadge'
import { DataTimestamp, TimezoneFootnote } from '@/components/DataTimestamp'
import { IndexingStateBadge } from '@/components/IndexingStateBadge'
import { QuotaFigure } from '@/components/QuotaDialog'
import { UrlListDialog } from '@/components/UrlListDialog'
import { useBatchPolling } from '@/hooks/useBatchPolling'
import { AppLayout } from '@/layouts/AppLayout'
import { Button } from '@/components/ui/button'
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from '@/components/ui/card'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { formatNumber } from '@/lib/format'
import { t } from '@/lib/i18n'
import type { TranslationKey } from '@/lib/i18n'
import { route } from '@/lib/routes'
import {
  batchAction,
  batchDuration,
  batchKindLabel,
  batchOriginLabel,
  batchPath,
  batchReasonText,
  batchesPath,
  lowercaseFirst,
  shortBatchName,
} from '@/lib/batch'
import type { Batch } from '@/lib/batch'

/**
 * Lo que un lote de sincronización dejó anotado, tal como lo escribe
 * `SyncSummary.as_dict()`.
 *
 * Todos los campos son opcionales porque el resumen se guarda cuando el lote
 * cierra: uno en cola no tiene ninguno, y leerlos como cero mostraría «se
 * enviaron 0 sitemaps» sobre un trabajo que todavía no empezó.
 */
interface SitemapSyncSummary {
  sitemaps_read?: number
  sitemaps_submitted?: number
  sitemaps_unchanged?: number
  new_urls?: number
  seen_urls?: number
  urls_out_of_sitemap?: number
  foreign_urls?: number
}

interface BatchWithSummary extends Batch {
  summary: SitemapSyncSummary & Record<string, unknown>
}

interface Neighbour {
  id: string
  kind: string
  state: string
  created_at: string
}

interface Props {
  domain: {
    id: string
    hostname: string
    property_uri: string
    is_operational: boolean
  }
  batch: BatchWithSummary
  quota: {
    date: string
    limit_total: number
    manual_reserve: number
    used_automatic: number
    used_manual: number
  } | null
  failures: { item: string | null; reason: string }[]
  /**
   * Cuántos motivos guardó el lote en total.
   *
   * `failures` viene recortada: un lote de dos mil URLs que falla entero
   * mandaría dos mil cadenas en cada consulta del sondeo. El total viaja aparte
   * para poder decir que lo que se ve es un recorte.
   */
  failures_total: number
  inspection: {
    fetched_at: string | null
    /** `urls` viene recortada y sólo cuando el lote cerró; `total` es el real. */
    by_state: { state: string; total: number; urls: string[] }[]
    transitions: { from_state: string; to_state: string; total: number; urls: string[] }[]
    unchanged: number
    changed: number
    /** Cuántas cayeron del índice. Es el total, no el largo de la lista. */
    lost_indexing: number
    /**
     * Cuáles, con la dirección tal como estaba ese día.
     *
     * Viene recortada y por eso `lost_indexing` viaja al lado: la pantalla
     * compara las dos para decir cuántas quedaron afuera. Vacía en los lotes
     * anteriores a que esto se guardara.
     */
    lost_indexing_urls: { loc: string; state: string }[]
  } | null
  /** El archivo, sólo en un lote de exportación. Nulo en los otros dos tipos. */
  export: {
    id: string
    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
  } | null
  /** Lo pedido a Google, sólo en un lote de indexación. Nulo en los otros tres. */
  indexing: {
    progress: { pending: number; sent: number; errors: number; removed: number }
    /** Por qué se detuvo, en código. Vacío cuando no se detuvo. */
    stopped_by: string
    requests: {
      id: string
      loc: string
      position: number
      state: string
      sent_at: string | null
      response_status: number | null
      error_code: string
    }[]
    total: number
    truncated: boolean
    evidence_path: string
    detail_path: string
  } | null
  neighbours: { previous: Neighbour | null; next: Neighbour | null }
}

export default function BatchesShow({
  domain,
  batch,
  quota,
  failures,
  failures_total,
  inspection,
  export: exported,
  indexing,
  neighbours,
}: Props) {
  const reason = batchReasonText(batch)
  const action = batchAction(batch, domain.id, 'detail')

  // Ú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
  // las props que cambian. Corta sola al terminar.
  const polling = useBatchPolling({
    states: [batch.state],
    only: ['batch', 'failures', 'failures_total', 'inspection', 'quota', 'export', 'indexing'],
  })

  return (
    <AppLayout
      breadcrumb={
        <Breadcrumb>
          <BreadcrumbList className="flex-nowrap">
            <BreadcrumbItem>
              <BreadcrumbLink asChild>
                <Link href={batchesPath(domain.id)}>{t('batchShow.breadcrumb.index')}</Link>
              </BreadcrumbLink>
            </BreadcrumbItem>
            <BreadcrumbSeparator />
            <BreadcrumbItem>
              <BreadcrumbPage>
                {t('batchShow.breadcrumb.detail', { name: shortBatchName(batch.id) })}
              </BreadcrumbPage>
            </BreadcrumbItem>
          </BreadcrumbList>
        </Breadcrumb>
      }
      title={t('batchShow.title', {
        kind: batchKindLabel(batch.kind),
        name: shortBatchName(batch.id),
      })}
      description={`${batchOriginLabel(batch.origin)} · ${domain.hostname}`}
      actions={
        <div className="flex flex-wrap gap-2">
          {/*
            El enlace a la cobertura vive acá y no sólo dentro del reparto por
            estado: un lote en cola, uno fallido o uno de sitemaps no tienen
            reparto, y sin este botón la ficha se queda sin camino hacia las URLs
            que el lote tocó.
          */}
          <Button asChild variant="outline">
            <Link href={route('coverage', { domain_id: domain.id })}>
              {t('batches.seeCoverage')}
            </Link>
          </Button>
          <Button asChild variant="outline">
            <Link href={route('domain.show', { domain_id: domain.id })}>
              {t('batches.seeDomain')}
            </Link>
          </Button>
        </div>
      }
    >
      {/*
        El estado terminal **y su motivo** van antes que cualquier número, y en
        el mismo bloque: «Parcial — se agotó el cupo del día» responde la
        pregunta entera. El estado solo deja a la persona buscando el porqué al
        pie de la pantalla, cuando ya sacó su conclusión.
      */}
      <Card>
        <CardHeader>
          <CardTitle>{t('batchShow.howItEnded')}</CardTitle>
          <CardDescription>
            {t('batchShow.howItEnded.description', {
              kind: batchKindLabel(batch.kind),
              hostname: domain.hostname,
              origin: lowercaseFirst(batchOriginLabel(batch.origin)),
            })}
          </CardDescription>
        </CardHeader>
        <CardContent className="space-y-4">
          <BatchStateHeadline state={batch.state} reason={reason} />

          {action ? (
            <Button asChild variant="outline">
              <Link href={action.href}>{action.label}</Link>
            </Button>
          ) : null}

          <BatchProgress
            total={batch.total_items}
            processed={batch.processed_items}
            failed={batch.failed_items}
            state={batch.state}
            className="max-w-md"
          />
        </CardContent>
      </Card>

      <BatchFigures batch={batch} polling={polling} quota={quota} />

      {batch.kind === 'URL_INSPECTION' ? (
        <InspectionSummary inspection={inspection} batch={batch} />
      ) : null}
      {batch.kind === 'SITEMAP_SYNC' ? <SyncSummary batch={batch} domainId={domain.id} /> : null}
      {batch.kind === 'COVERAGE_EXPORT' ? (
        <ExportFile batch={batch} exported={exported} domainId={domain.id} />
      ) : null}
      {batch.kind === 'URL_INDEXING' ? <IndexingSummary indexing={indexing} /> : null}

      <FailureList failures={failures} total={failures_total} batch={batch} />

      {/* El cupo del día vivía acá como tarjeta desplegada. Ahora es un botón
          junto a la cifra que este lote gastó, que es la única pregunta con la
          que alguien llega a mirarlo. Ver `QuotaDialog`. */}

      <NeighbourNav neighbours={neighbours} />

      <TimezoneFootnote />
    </AppLayout>
  )
}

/**
 * Qué cuenta cada tipo de lote, para el rótulo de la primera cifra.
 *
 * Sale del mismo lugar que el resto del vocabulario de lotes: dos listas
 * separadas de lo mismo terminan divergiendo.
 */
function unitLabel(kind: string): string {
  const known = ['SITEMAP_SYNC', 'URL_INSPECTION', 'COVERAGE_EXPORT']
  return t(`batch.unit.${known.includes(kind) ? kind : 'URL_INSPECTION'}` as TranslationKey)
}

/**
 * Las cifras del lote.
 *
 * La región viva envuelve **sólo** este bloque: es lo único que crece mientras
 * el sondeo corre, y anunciar la pantalla entera cada cinco segundos la
 * convierte en ruido continuo para quien usa lector.
 */
function BatchFigures({
  batch,
  polling,
  quota,
}: {
  batch: BatchWithSummary
  polling: boolean
  quota: Props['quota']
}) {
  const unit = unitLabel(batch.kind)
  const queued = batch.state === 'QUEUED'

  return (
    <Card>
      <CardHeader>
        <CardTitle>{t('batchShow.figures')}</CardTitle>
        <CardDescription>{t('batchShow.figures.description')}</CardDescription>
      </CardHeader>
      <CardContent>
        {/*
          La región viva se enciende **sólo mientras se sondea**. Un lote
          terminado ya no cambia, y dejar la región activa haría que cualquier
          repintado de React —cambiar de pestaña, volver atrás— se anunciara como
          si el lote se hubiera movido.
        */}
        <dl
          aria-live={polling ? 'polite' : undefined}
          className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3 lg:grid-cols-4"
        >
          <Figure title={t('batchShow.figure.inBatch', { unit })}>
            {formatNumber(batch.total_items)}
          </Figure>
          <Figure title={t('batchShow.figure.processed')}>
            {formatNumber(batch.processed_items)}
          </Figure>
          <Figure title={t('batchShow.figure.failed')}>
            {formatNumber(batch.failed_items)}
          </Figure>
          {/*
            Lo pendiente se muestra recién cuando el lote cerró. Antes no es un
            saldo sino lo que todavía falta hacer, y presentarlo como resultado
            haría parecer incompleto a un lote que está avanzando normalmente.
          */}
          {batch.is_terminal && batch.pending_items > 0 ? (
            <Figure title={t('batchShow.figure.pending')}>
              {formatNumber(batch.pending_items)}
              <span className="text-muted-foreground block text-xs font-normal text-pretty">
                {t('batchShow.figure.pending.note')}
              </span>
            </Figure>
          ) : null}
          {/* La nota que estaba acá abajo se fue al diálogo: eran tres renglones
              de prosa debajo de una cifra, en una cuadrícula donde todas las
              demás son una sola línea. */}
          <Figure title={t('batches.column.quota')}>
            <QuotaFigure used={batch.quota_consumed} quota={quota} />
          </Figure>
          <Figure title={queued ? t('batches.queuedSince') : t('batches.column.started')}>
            <DataTimestamp value={queued ? batch.created_at : batch.started_at} emptyLabel="—" />
          </Figure>
          <Figure title={t('batches.column.finished')}>
            {batch.finished_at ? (
              <DataTimestamp value={batch.finished_at} />
            ) : (
              <span className="text-muted-foreground">
                {batch.state === 'RUNNING' ? t('batches.stillRunning') : '—'}
              </span>
            )}
          </Figure>
          <Figure title={t('batches.column.duration')}>
            {batchDuration(batch.duration_seconds)}
          </Figure>
        </dl>
      </CardContent>
    </Card>
  )
}

function Figure({ title, children }: { title: string; children: ReactNode }) {
  return (
    <div className="min-w-0">
      <dt className="text-muted-foreground text-xs">{title}</dt>
      <dd className="text-sm font-medium tabular-nums wrap-anywhere">{children}</dd>
    </div>
  )
}

/**
 * Qué registró un lote de inspección, con la fecha en que Google lo dijo.
 *
 * El reparto **no** es el estado de todas las URLs consultadas: el historial
 * guarda una fila sólo cuando el estado cambia, más la primera lectura de cada
 * URL. Presentarlo como el reparto completo convertiría una foto parcial en una
 * afirmación sobre el sitio entero, así que el texto dice de qué es el conteo y
 * muestra al lado cuántas seguían igual.
 *
 * La fecha de obtención va siempre junto al conteo (R-A): un estado de
 * indexación sin la fecha en que Google lo informó no es un dato verificable.
 */
// Ya no recibe `domainId`: era para armar los enlaces a la tabla de cobertura,
// que ahora son modales sobre los datos del propio lote.
function InspectionSummary({
  inspection,
  batch,
}: {
  inspection: Props['inspection']
  batch: BatchWithSummary
}) {
  const hasBreakdown = (inspection?.by_state.length ?? 0) > 0

  return (
    <Card>
      <CardHeader>
        <CardTitle>{t('batchShow.recorded.title')}</CardTitle>
        <CardDescription>{t('batchShow.recorded.description')}</CardDescription>
      </CardHeader>
      <CardContent className="space-y-4">
        {!hasBreakdown || !inspection ? (
          <p className="text-muted-foreground text-sm text-pretty">
            {batch.is_terminal
              ? t('batchShow.recorded.none')
              : t('batchShow.recorded.pending')}
          </p>
        ) : (
          <>
            <p className="text-sm text-pretty">
              {t('batchShow.recorded.fetchedOn')}{' '}
              <DataTimestamp
                value={inspection.fetched_at}
                emptyLabel={t('batchShow.recorded.noFetchDate')}
              />
              {t('batchShow.recorded.onlyChanged')}
            </p>

            <ul className="grid gap-2 sm:grid-cols-2">
              {inspection.by_state.map((row) => (
                <li key={row.state} className="flex flex-wrap items-baseline gap-x-2">
                  <span className="text-sm font-medium tabular-nums">
                    {formatNumber(row.total)}
                  </span>
                  <UrlListDialog
                    trigger={
                      <button
                        type="button"
                        className="focus-visible:ring-ring rounded text-sm underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:outline-none"
                      >
                        {lowercaseFirst(coverageStateLabel(row.state))}
                        <span className="sr-only"> — {t('batchShow.recorded.seeUrls')}</span>
                      </button>
                    }
                    title={t('batchShow.recorded.stateTitle')}
                    description={t('batchShow.recorded.inThisBatch')}
                    after={row.state}
                    fetchedAt={inspection.fetched_at}
                    urls={row.urls}
                    total={row.total}
                  />
                </li>
              ))}
            </ul>

            {inspection.unchanged > 0 ? (
              <p className="text-muted-foreground text-sm text-pretty">
                {t('batchShow.recorded.unchanged', {
                  count: inspection.unchanged,
                  total: formatNumber(inspection.unchanged),
                })}
              </p>
            ) : null}

            {inspection.transitions.length > 0 ? (
              <div className="space-y-2 border-t pt-4">
                <h3 className="text-sm font-medium">{t('batchShow.recorded.changes')}</h3>
                <ul className="space-y-1">
                  {inspection.transitions.map((change) => (
                    <li
                      key={`${change.from_state}-${change.to_state}`}
                      className="text-sm text-pretty"
                    >
                      {t('batchShow.recorded.transition', {
                        count: change.total,
                        total: formatNumber(change.total),
                        from: lowercaseFirst(coverageStateLabel(change.from_state)),
                      })}{' '}
                      <UrlListDialog
                        trigger={
                          <button
                            type="button"
                            className="focus-visible:ring-ring rounded underline underline-offset-4 focus-visible:ring-2 focus-visible:outline-none"
                          >
                            {lowercaseFirst(coverageStateLabel(change.to_state))}
                          </button>
                        }
                        title={t('batchShow.recorded.changes')}
                        description={t('batchShow.recorded.inThisBatch')}
                        before={change.from_state}
                        after={change.to_state}
                        fetchedAt={inspection.fetched_at}
                        urls={change.urls}
                        total={change.total}
                      />
                      .
                    </li>
                  ))}
                </ul>
              </div>
            ) : null}
          </>
        )}

        <LostIndexing inspection={inspection} />
      </CardContent>
    </Card>
  )
}

/**
 * Las direcciones que este recorrido vio caer del índice.
 *
 * Es el destino del aviso `COVERAGE_DROP`, y existe porque la cifra sola no se
 * puede atender: el aviso llevaba a la tabla de cobertura, donde el estado ya
 * fue pisado por el recorrido siguiente y la dirección que cayó figura indexada
 * otra vez. Acá la dirección está congelada como estaba ese día.
 *
 * Va fuera del reparto por estado a propósito: quien llega desde la campana
 * tiene que encontrar la lista aunque el lote no haya dejado reparto.
 */
/**
 * Cuántas direcciones se muestran sin pedir nada antes de pasar al modal.
 *
 * Cinco entran en la tarjeta sin empujar hacia abajo lo que viene después. Más
 * que eso convierte una nota al pie en el cuerpo principal de la pantalla.
 */
const INLINE_URLS = 5

function LostIndexing({ inspection }: { inspection: Props['inspection'] }) {
  const total = inspection?.lost_indexing ?? 0
  if (!inspection || total === 0) return null

  const rows = inspection.lost_indexing_urls
  const hidden = Math.max(total - rows.length, 0)

  return (
    <div className="space-y-2 border-t pt-4">
      <h3 className="text-sm font-medium">
        {t('batchShow.lostIndexing.title', { count: total, total: formatNumber(total) })}
      </h3>

      {rows.length === 0 ? (
        // Un lote anterior a que esto se guardara. Decirlo es más útil que
        // dejar el encabezado con una lista vacía debajo, que se lee como que
        // no hubo ninguna cuando la cifra de arriba dice lo contrario.
        <p className="text-muted-foreground text-sm text-pretty">
          {t('batchShow.lostIndexing.notRecorded')}
        </p>
      ) : (
        <ul className="space-y-1">
          {rows.slice(0, INLINE_URLS).map((row) => (
            <li key={row.loc} className="flex flex-wrap items-baseline gap-x-2 text-sm">
              <span className="font-mono break-all">{row.loc}</span>
              <span className="text-muted-foreground">
                {lowercaseFirst(coverageStateLabel(row.state))}
              </span>
            </li>
          ))}
        </ul>
      )}

      {/* Éste es el destino del aviso de la campana, así que las primeras se
          ven sin pedir nada: quien llega ya está donde quería. El modal aparece
          recién cuando la lista dejaría de ser legible dentro de la tarjeta. */}
      {total > INLINE_URLS ? (
        <UrlListDialog
          trigger={
            <button
              type="button"
              className="focus-visible:ring-ring rounded text-sm underline underline-offset-4 focus-visible:ring-2 focus-visible:outline-none"
            >
              {t('batchShow.lostIndexing.seeAll', {
                count: total,
                total: formatNumber(total),
              })}
            </button>
          }
          title={t('batchShow.lostIndexing.dialogTitle')}
          description={t('batchShow.recorded.inThisBatch')}
          urls={rows.map((row) => row.loc)}
          total={total}
          fetchedAt={inspection.fetched_at}
        />
      ) : hidden > 0 ? (
        <p className="text-muted-foreground text-sm text-pretty">
          {t('batchShow.lostIndexing.truncated', {
            count: hidden,
            total: formatNumber(hidden),
          })}
        </p>
      ) : null}
    </div>
  )
}

/**
 * Qué hizo un lote de sincronización.
 *
 * «Omitido porque no cambió» no es una falla: no reenviar un sitemap idéntico
 * ahorra cupo del dominio, y contarlo como error empuja a forzar envíos
 * inútiles. Por eso va en la misma frase que los enviados y no cerca de los
 * fallidos.
 */
function SyncSummary({
  batch,
  domainId,
}: {
  batch: BatchWithSummary
  domainId: string
}) {
  const summary = batch.summary
  const submitted = summary.sitemaps_submitted ?? 0
  const skipped = summary.sitemaps_unchanged ?? 0
  const read = summary.sitemaps_read ?? 0
  const newUrls = summary.new_urls ?? 0
  const outside = summary.urls_out_of_sitemap ?? 0

  return (
    <Card>
      <CardHeader>
        <CardTitle>{t('batchShow.sync.title')}</CardTitle>
        <CardDescription>{t('batchShow.sync.description')}</CardDescription>
      </CardHeader>
      <CardContent className="space-y-3">
        {batch.is_terminal ? (
          <p className="text-sm text-pretty">
            {t('batchShow.sync.summary', {
              read: formatNumber(read),
              submitted: formatNumber(submitted),
              skipped: formatNumber(skipped),
            })}
            {batch.failed_items > 0
              ? ` ${t('batchShow.sync.failed', {
                  count: batch.failed_items,
                  total: formatNumber(batch.failed_items),
                })}`
              : ''}
          </p>
        ) : (
          <p className="text-muted-foreground text-sm text-pretty">
            {t('batchShow.sync.pending')}
          </p>
        )}

        {newUrls > 0 || outside > 0 ? (
          <p className="text-muted-foreground text-sm text-pretty">
            {t('batchShow.sync.urlChanges', {
              newUrls: formatNumber(newUrls),
              outside: formatNumber(outside),
            })}
          </p>
        ) : null}

        <Button asChild variant="outline" size="sm">
          <Link href={route('sitemaps', { domain_id: domainId })}>
            {t('dashboard.sitemaps.action')}
          </Link>
        </Button>
      </CardContent>
    </Card>
  )
}

/** 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)),
  })
}

/**
 * Qué se le pidió a Google en este lote y qué contestó, dirección por dirección.
 *
 * Es la mitad histórica de la función: la pestaña de cobertura muestra el lote
 * de ahora, y acá se reconstruye cualquiera, del que sea.
 *
 * **Los cuerpos crudos no están acá.** Se ve el estado, el código HTTP y el
 * motivo clasificado, que es lo que contesta «¿qué pasó?». El pedido y la
 * respuesta enteros son un paso más —el panel del lote, o el archivo— porque
 * quien abre esta ficha viene a leer un resumen, y una pantalla llena de JSON no
 * es un resumen.
 */
function IndexingSummary({ indexing }: { indexing: Props['indexing'] }) {
  if (!indexing) return null

  return (
    <Card>
      <CardHeader>
        <CardTitle>{t('batchShow.indexing.title')}</CardTitle>
        <CardDescription>{t('batchShow.indexing.description')}</CardDescription>
        <CardAction>
          <div className="flex flex-wrap gap-2">
            {/* La evidencia entera, como archivo. Es el entregable: lo que se
                adjunta a un informe cuando hay que probar que el envío salió. */}
            <Button asChild variant="outline" size="sm">
              <a href={indexing.evidence_path}>
                <Download aria-hidden />
                {t('indexing.evidence.download')}
              </a>
            </Button>
            <Button asChild variant="outline" size="sm">
              <Link href={indexing.detail_path}>{t('batchShow.indexing.open')}</Link>
            </Button>
          </div>
        </CardAction>
      </CardHeader>

      <CardContent className="flex flex-col gap-4">
        {indexing.stopped_by ? (
          <p className="text-sm text-pretty">
            {t(
              (STOP_REASONS.includes(indexing.stopped_by)
                ? `indexing.stopped.${indexing.stopped_by}`
                : 'indexing.stopped.generic') as TranslationKey
            )}
          </p>
        ) : null}

        <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">
                {request.response_status !== null ? (
                  <span className="text-muted-foreground text-xs tabular-nums">
                    {request.response_status}
                    {request.error_code ? ` · ${request.error_code}` : ''}
                  </span>
                ) : null}
                <IndexingStateBadge state={request.state} />
              </div>
            </li>
          ))}
        </ul>

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

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

/**
 * El archivo que produjo un lote de exportación.
 *
 * Esta ficha es el destino del aviso, así que es acá donde tiene que estar el
 * botón de bajarlo, con el tamaño y hasta cuándo dura. Mientras el lote no
 * terminó no se ofrece ningún enlace: prometer un archivo que todavía se está
 * escribiendo lleva a una descarga rota (RT-13).
 */
function ExportFile({
  batch,
  exported,
  domainId,
}: {
  batch: BatchWithSummary
  exported: Props['export']
  domainId: string
}) {
  const filterCount = exported ? Object.keys(exported.filters).length : 0

  return (
    <Card>
      <CardHeader>
        <CardTitle>{t('batchShow.export.title')}</CardTitle>
        <CardDescription>
          {filterCount === 0
            ? t('batchShow.export.description.all')
            : t('batchShow.export.description.filtered')}
        </CardDescription>
      </CardHeader>
      <CardContent className="space-y-3">
        {!batch.is_terminal ? (
          <p className="text-muted-foreground text-sm text-pretty">
            {t('batchShow.export.writing')}
          </p>
        ) : null}

        {batch.state === 'FAILED' ? (
          <p className="text-sm text-pretty">
            {t('batchShow.export.failed')}
            {exported?.error ? ` ${exported.error}` : ''} {t('batchShow.export.askAgain')}
          </p>
        ) : null}

        {batch.is_terminal && batch.state !== 'FAILED' && exported ? (
          exported.expired ? (
            <p className="text-muted-foreground text-sm text-pretty">
              {t('batchShow.export.expired')} <DataTimestamp value={exported.expires_at} />{' '}
              {t('batchShow.export.expiredAfter')} {t('batchShow.export.askAgain')}
            </p>
          ) : (
            <>
              <p className="text-sm text-pretty">
                {t('batchShow.export.ready', {
                  rows: formatNumber(exported.row_count),
                  size: formatSize(exported.size_bytes),
                })}{' '}
                <DataTimestamp value={exported.expires_at} />.
              </p>
              <Button asChild>
                <a href={exported.download_path}>{t('batchShow.export.download')}</a>
              </Button>
            </>
          )
        ) : null}

        <Button asChild variant="outline" size="sm">
          <Link href={route('coverage', { domain_id: domainId })}>{t('batchShow.seeCoverage')}</Link>
        </Button>
      </CardContent>
    </Card>
  )
}

/**
 * Lo que el lote dejó anotado, una fila por anotación y con su motivo al lado.
 *
 * Es una tabla con encabezados y no un párrafo con comas: el motivo de una URL
 * concreta es lo que permite arreglarla, y mezclado en prosa no se lee ni se
 * copia.
 *
 * No todas estas anotaciones son fallas. El servidor guarda en la misma lista
 * los intentos que fallaron de verdad y las notas que explican por qué el lote
 * no siguió —«no quedaba cupo diario», «se dejó de seguir este índice»—, que no
 * incrementan `failed_items`. Titular todo como «lo que falló» convertiría un
 * corte por cupo en un error, que es exactamente lo que R-C prohíbe: un lote
 * `PARTIAL` no falló, quedó a mitad de camino y sigue mañana.
 *
 * Lo que separa una cosa de la otra es `item`: el servidor lo deja en nulo en
 * todo lo que no tenga forma de dirección, y los tres sitios que incrementan
 * `failed_items` son exactamente los tres que escriben «dirección: motivo». Así
 * que el título se decide **fila por fila** y no con `failed_items`, que es una
 * cifra del lote entero: un lote cerrado en `FAILED` antes de intentar nada
 * tiene `failed_items` en cero, y con esa cifra la pantalla anunciaba «No falló
 * ningún ítem» debajo del badge «Falló».
 *
 * Se exporta para que la suite pueda dibujarlo y comprobar ese invariante sobre
 * el marcado real; en la página se usa sólo desde acá.
 */
export function FailureList({
  failures,
  total,
  batch,
}: {
  failures: Props['failures']
  total: number
  batch: BatchWithSummary
}) {
  if (failures.length === 0) return null

  const notes = failures.filter((failure) => failure.item === null).length
  const itemFailures = failures.length - notes
  const hasFailures = itemFailures > 0
  const truncated = total > failures.length

  return (
    <Card>
      <CardHeader>
        <CardTitle>
          {hasFailures
            ? t('batchShow.failures.title', { count: itemFailures })
            : t('batchShow.failures.title.notes')}
        </CardTitle>
        <CardDescription>
          {hasFailures ? (
            <>
              {t('batchShow.failures.description')}
              {/*
                Una nota mezclada entre las fallas se lee como una falla más, y
                un corte por cupo presentado así convierte trabajo pendiente en
                algo roto (R-C).
              */}
              {notes > 0
                ? ` ${t('batchShow.failures.notesMixed', {
                    count: notes,
                    total: formatNumber(notes),
                  })}`
                : null}
            </>
          ) : batch.state === 'FAILED' ? (
            /*
              «No falló ningún ítem» es cierto y suena a que no pasó nada, justo
              debajo del badge que dice «Falló». El lote se cortó antes de tener
              ítems que pudieran fallar, y eso es lo que hay que decir.
            */
            t('batchShow.failures.cutBefore')
          ) : (
            t('batchShow.failures.onlyNotes')
          )}
          {/*
            Un recorte que no se anuncia se lee como el total, y quien busca su
            URL entre los motivos la daría por ausente.
          */}
          {truncated ? (
            <>
              {' '}
              {t('batchShow.failures.truncated', {
                shown: formatNumber(failures.length),
                total: formatNumber(total),
              })}
            </>
          ) : null}
        </CardDescription>
      </CardHeader>
      <CardContent>
        <div className="max-h-[60vh] overflow-auto overscroll-contain rounded-lg border">
          <Table>
            <caption className="sr-only">
              {hasFailures
                ? t('batchShow.failures.caption', { name: shortBatchName(batch.id) })
                : t('batchShow.failures.caption.notes', { name: shortBatchName(batch.id) })}
            </caption>
            <TableHeader>
              <TableRow>
                <TableHead scope="col" className="bg-background sticky top-0 z-10">
                  {t('batchShow.failures.column.item')}
                </TableHead>
                <TableHead scope="col" className="bg-background sticky top-0 z-10">
                  {hasFailures
                    ? t('batchShow.failures.column.reason')
                    : t('batchShow.failures.column.whatHappened')}
                </TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {failures.map((failure, index) => (
                <TableRow key={`${failure.item ?? 'sin-item'}-${index}`}>
                  <TableCell className="align-top wrap-anywhere">
                    {failure.item ?? (
                      <span className="text-muted-foreground">
                        {t('batchShow.failures.wholeBatch')}
                      </span>
                    )}
                  </TableCell>
                  <TableCell className="align-top wrap-anywhere">{failure.reason}</TableCell>
                </TableRow>
              ))}
            </TableBody>
          </Table>
        </div>
      </CardContent>
    </Card>
  )
}

/**
 * El lote anterior y el siguiente del mismo dominio.
 *
 * Cada enlace lleva el estado del lote vecino: sin él, moverse por el historial
 * es abrir fichas a ciegas hasta encontrar la que se estaba buscando.
 */
function NeighbourNav({ neighbours }: { neighbours: Props['neighbours'] }) {
  if (!neighbours.previous && !neighbours.next) return null

  return (
    <nav
      aria-label={t('batchShow.neighbours')}
      className="flex flex-wrap items-stretch justify-between gap-3"
    >
      {neighbours.previous ? (
        <NeighbourLink neighbour={neighbours.previous} direction="previous" />
      ) : (
        <span />
      )}
      {neighbours.next ? <NeighbourLink neighbour={neighbours.next} direction="next" /> : null}
    </nav>
  )
}

function NeighbourLink({
  neighbour,
  direction,
}: {
  neighbour: Neighbour
  direction: 'previous' | 'next'
}) {
  const isPrevious = direction === 'previous'

  return (
    <Link
      href={batchPath(neighbour.id)}
      className="focus-visible:ring-ring hover:bg-accent flex max-w-full min-w-0 flex-col gap-1 rounded-lg border px-3 py-2 transition-colors duration-150 focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none"
    >
      <span className="text-muted-foreground flex items-center gap-1 text-xs">
        {isPrevious ? <ArrowLeft className="size-3.5" aria-hidden /> : null}
        {isPrevious ? t('batchShow.previousBatch') : t('batchShow.nextBatch')}
        {isPrevious ? null : <ArrowRight className="size-3.5" aria-hidden />}
      </span>
      <span className="truncate text-sm font-medium">{batchKindLabel(neighbour.kind)}</span>
      {/*
        El badge ya escribe la etiqueta del estado como texto, así que no se
        repite en una copia para lector de pantalla: el enlace se anunciaría dos
        veces con la misma palabra.
      */}
      <span className="flex flex-wrap items-center gap-2">
        <BatchStateBadge state={neighbour.state} />
        <span className="text-muted-foreground text-xs">
          <DataTimestamp value={neighbour.created_at} />
        </span>
      </span>
    </Link>
  )
}
