import { Link, router, useForm, usePage } from '@inertiajs/react'
import { Activity, CircleAlert, FileText, Layers, Search, TriangleAlert } from 'lucide-react'
import type { FormEvent } from 'react'
import { useEffect, useRef, useState } from 'react'

import { AccessStateBadge, accessStateLabel } from '@/components/AccessStateBadge'
import { batchStateLabel } from '@/components/BatchStateBadge'
import { ConfirmDestructive } from '@/components/ConfirmDestructive'
import { CopyButton } from '@/components/CopyButton'
import { DataTimestamp, TimezoneFootnote } from '@/components/DataTimestamp'
import { FieldError, fieldErrorProps } from '@/components/FieldError'
import { QuotaMeter } from '@/components/QuotaMeter'
import { ServerErrorNotice } from '@/components/ServerErrorNotice'
import { useBatchPolling } from '@/hooks/useBatchPolling'
import { AppLayout } from '@/layouts/AppLayout'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { RichText } from '@/components/RichText'
import { faultText } from '@/lib/errors'
import { formatNumber } from '@/lib/format'
import { t } from '@/lib/i18n'
import type { TranslationKey } from '@/lib/i18n'
import { route } from '@/lib/routes'

interface DomainProps {
  id: string
  hostname: string
  property_type: string
  property_uri: string
  access_state: string
  access_checked_at: string | null
  access_error: string | null
  access_error_code: string | null
  is_operational: boolean
  daily_inspection_budget: number
  manual_reserve: number
  notifications_enabled: boolean
}

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

interface Props {
  domain: DomainProps
  client_email: string | null
  quota: {
    date: string
    limit_total: number
    manual_reserve: number
    used_automatic: number
    used_manual: number
    automatic_remaining: number
    manual_remaining: number
  }
  cycle: { monitored_urls: number; queries_per_day: number; days_per_cycle: number | null }
  coverage: { total: number; with_data: number; last_checked_at: string | null }
  sitemaps_count: number
  pending_urls: number
  google_daily_limit: number
  last_batch: Batch | null
  running_batch: Batch | null
}

/**
 * Instrucción de autorización, palabra por palabra.
 *
 * El permiso exigido va en negrita y con su consecuencia porque es el error más
 * caro del recorrido: agregar la cuenta de servicio como lectora deja la
 * pantalla de Search Console diciendo que está todo bien, y la inspección de
 * URLs sigue fallando sin que nada explique por qué.
 */
function searchConsolePath(): string {
  return t('domainShow.searchConsolePath')
}

/**
 * Los tres motivos por los que falla comprobar el acceso (RT-08).
 *
 * Es un mapa cerrado y ramifica por **código**, no por el texto del error: los
 * tres se arreglan en lugares distintos, y mandar a revisar el permiso cuando
 * lo que pasa es que la propiedad no existe con esa forma hace perder la tarde.
 * Un código que no esté acá cae en el mapa base de `C-13`, que muestra el
 * mensaje del servidor sin inventarle una causa.
 */
const ACCESS_ERRORS: readonly string[] = [
  'PERMISSION_DENIED',
  'PROPERTY_NOT_FOUND',
  'PROVIDER_UNAVAILABLE',
]

export default function DomainsShow({
  domain,
  client_email,
  quota,
  cycle,
  coverage,
  sitemaps_count,
  pending_urls,
  google_daily_limit,
  last_batch,
  running_batch,
}: Props) {
  const { errors, account } = usePage().props
  const canQuery = account?.can_operate ?? false

  /*
    Las dos condiciones se miran juntas antes de decir que algo funciona
    (RT-18). El estado del dominio describe su propiedad en Search Console; el
    de la cuenta, si tenemos con qué consultarla. Con la credencial rota, un
    dominio en OPERATIONAL sigue mostrando datos que ya no se actualizan.
  */
  const operational = domain.is_operational && canQuery

  // Único mecanismo de actualización del producto (RT-13): mientras haya un
  // lote sin terminar y la pestaña esté visible, la ficha se vuelve a pedir.
  useBatchPolling({ states: [running_batch?.state] })

  return (
    <AppLayout
      title={domain.hostname}
      description={`${t(`propertyShape.${domain.property_type}.title` as TranslationKey)} · ${domain.property_uri}`}
    >
      {/*
        El estado va en la columna y no en el slot de acciones de la cabecera:
        ésa es una franja de alto fijo alineada al centro, y el par estado más
        fecha son dos renglones que se le salen por arriba.

        Y van juntos, no uno arriba y otro acá: un estado sin la fecha en que se
        obtuvo no es una afirmación verificable —«operativo» podría ser de hoy o
        de hace tres meses— y separarlos es exactamente lo que R-A prohíbe.
      */}
      <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
        <AccessStateBadge state={domain.access_state} canOperate={canQuery} />
        <p className="text-muted-foreground text-xs">
          {t('dashboard.attention.lastCheck')}{' '}
          <DataTimestamp value={domain.access_checked_at} emptyLabel={t('time.never')} />
        </p>
      </div>

      {/*
        El banner va pegado al estado y antes que todo lo demás, no sólo arriba
        en la pantalla sino en el orden del DOM: quien navega con lector no ve
        la disposición, y enterarse de que el dominio no está operativo después
        de leer el cupo es enterarse tarde.
      */}
      {operational ? null : (
        <StateBanner domain={domain} canQuery={canQuery} />
      )}

      {operational ? null : (
        <AccessPanel
          domain={domain}
          clientEmail={client_email}
          canQuery={canQuery}
        />
      )}

      <Card>
        <CardHeader>
          <CardTitle>{t('domainShow.quota.title')}</CardTitle>
          <CardDescription>{t('domainShow.quota.description')}</CardDescription>
        </CardHeader>
        <CardContent className="space-y-4">
          <QuotaMeter
            limitTotal={quota.limit_total}
            manualReserve={quota.manual_reserve}
            usedAutomatic={quota.used_automatic}
            usedManual={quota.used_manual}
            date={quota.date}
          />
          <InspectNowAction
            domain={domain}
            operational={operational}
            pending={pending_urls}
            manualRemaining={quota.manual_remaining}
            reserveRemaining={Math.max(quota.manual_reserve - quota.used_manual, 0)}
          />
          <FieldError id="error-inspect" message={faultText(errors.inspect)} />
        </CardContent>
      </Card>

      <ChildViews
        coverage={coverage}
        sitemapsCount={sitemaps_count}
        lastBatch={last_batch}
        runningBatch={running_batch}
      />

      <DomainSettings
        domain={domain}
        cycle={cycle}
        googleDailyLimit={google_daily_limit}
      />

      {operational ? (
        <AccessPanel
          domain={domain}
          clientEmail={client_email}
          canQuery={canQuery}
        />
      ) : null}

      <TimezoneFootnote />
    </AppLayout>
  )
}

/**
 * Qué pasa y qué revisar cuando el dominio no está operativo.
 *
 * Cada estado trae su propia explicación y su propia acción. Un banner único
 * que dijera «hay un problema con este dominio» obligaría a adivinar si falta
 * autorizar, si se perdió el permiso o si el monitoreo está detenido a
 * propósito, que son tres situaciones con tres salidas distintas.
 */
function StateBanner({
  domain,
  canQuery,
}: {
  domain: DomainProps
  canQuery: boolean
}) {
  // Cuando el problema es de la cuenta, el aviso de `C-18` ya está arriba con
  // su acción y este banner no la repite: dos botones que llevan al mismo lado
  // sugieren dos problemas distintos (RT-18).
  if (domain.is_operational && !canQuery) {
    return (
      <Alert>
        <TriangleAlert />
        <AlertTitle>{t('domainShow.banner.paused.title')}</AlertTitle>
        <AlertDescription>{t('domainShow.banner.paused.body')}</AlertDescription>
      </Alert>
    )
  }

  if (domain.access_state === 'AWAITING_ACCESS') {
    return (
      <Alert>
        <CircleAlert />
        <AlertTitle>{t('domainShow.banner.awaiting.title')}</AlertTitle>
        <AlertDescription>{t('domainShow.banner.awaiting.body')}</AlertDescription>
      </Alert>
    )
  }

  if (domain.access_state === 'ACCESS_LOST') {
    return (
      <Alert variant="critical">
        <CircleAlert />
        <AlertTitle>{t('domainShow.banner.lost.title')}</AlertTitle>
        <AlertDescription className="flex flex-col items-start gap-2">
          <span>
            {t('domainShow.banner.lost.detected')}{' '}
            <DataTimestamp value={domain.access_checked_at} emptyLabel="—" />
            {t('domainShow.banner.lost.body')}
          </span>
          <span>{t('domainShow.banner.lost.question')}</span>
        </AlertDescription>
      </Alert>
    )
  }

  // Revocado y suspendido: la ficha queda en sólo lectura. No se ofrece ninguna
  // acción sobre Google porque todas fallarían, y un botón que falla siempre es
  // peor que ningún botón.
  return (
    <Alert variant="critical">
      <CircleAlert />
      <AlertTitle>{accessStateLabel(domain.access_state)}</AlertTitle>
      <AlertDescription>{t('domainShow.banner.stopped.body')}</AlertDescription>
    </Alert>
  )
}

/**
 * Cómo autorizarnos, y qué pasó en el último intento de comprobarlo.
 *
 * El resultado del último intento queda **escrito y fechado** (RT-11): un
 * mensaje que se desvanece deja a quien vuelve mañana sin saber si el error que
 * recuerda sigue vigente o ya se resolvió.
 */
function AccessPanel({
  domain,
  clientEmail,
  canQuery,
}: {
  domain: DomainProps
  clientEmail: string | null
  canQuery: boolean
}) {
  const { errors } = usePage().props
  const checkForm = useForm({ from: 'show' })
  const stopped = domain.access_state === 'ACCESS_REVOKED' || domain.access_state === 'SUSPENDED'

  const check = () =>
    checkForm.post(route('domain.check', { domain_id: domain.id }), {
      preserveScroll: true,
      preserveState: true,
    })

  const actions = [
    // La forma de la propiedad no se edita: `sc-domain:` y el prefijo de URL
    // son dos propiedades distintas en Search Console, cada una con su
    // historial. Ofrecer «cambiar la forma» sería ofrecer algo que no existe,
    // así que la acción es la real: dar de alta la otra (RT-07).
    domain.access_error_code === 'PROPERTY_NOT_FOUND' ? (
      <Button asChild key="other-shape" size="sm" variant="outline">
        <Link href={route('domain.new')}>{t('domainShow.addOtherShape')}</Link>
      </Button>
    ) : null,
    canQuery && !stopped ? (
      <Button
        key="recheck"
        size="sm"
        variant="outline"
        onClick={check}
        disabled={checkForm.processing}
        aria-busy={checkForm.processing}
      >
        {checkForm.processing ? t('credentialError.checking') : t('credentialError.recheck')}
      </Button>
    ) : null,
  ].filter(Boolean)

  return (
    <Card>
      <CardHeader>
        <CardTitle>{t('domainShow.access.title')}</CardTitle>
        <CardDescription>
          {t('domainShow.access.description', { property: domain.property_uri })}
        </CardDescription>
      </CardHeader>
      <CardContent className="space-y-5">
        {clientEmail ? (
          <div className="space-y-2">
            <p className="text-sm text-pretty">
              <RichText
                text={t('domainShow.access.copyAddress', { path: searchConsolePath() })}
              />
            </p>
            <div className="flex flex-wrap items-center gap-2">
              <code className="bg-muted/60 rounded px-2 py-1 text-sm break-all" translate="no">
                {clientEmail}
              </code>
              <CopyButton value={clientEmail} label={t('serviceAccount.copy')} />
            </div>
          </div>
        ) : (
          <p className="text-sm text-pretty">{t('domainShow.access.noCredential')}</p>
        )}

        {/*
          El resultado del último intento, con su código traducido a algo que se
          pueda hacer. Persiste en la pantalla y no se borra solo.
        */}
        {domain.access_error ? (
          <ServerErrorNotice
            code={domain.access_error_code ?? 'UNEXPECTED'}
            message={domain.access_error}
            codes={ACCESS_ERRORS}
            scope="domainAccess"
            action={
              /*
                Sin ninguna acción propia se deja pasar `undefined` en vez de un
                contenedor vacío: `C-13` pone entonces la acción neutra, y un
                aviso que sólo describe el problema deja a la persona sin
                siguiente paso (RT-08).
              */
              actions.length === 0 ? undefined : (
                <div className="flex flex-wrap gap-2">{actions}</div>
              )
            }
          />
        ) : null}

        <div className="flex flex-wrap items-center gap-3 border-t pt-4">
          {/*
            Con la cuenta sin poder operar, la acción que llama a Google no se
            deshabilita: se reemplaza por la que resuelve el problema (RT-18).
            Un botón apagado invita a insistir sobre algo que no depende de acá.
          */}
          {canQuery ? (
            stopped ? (
              <p className="text-muted-foreground text-sm">{t('domainShow.access.stopped')}</p>
            ) : (
              <Button
                onClick={check}
                disabled={checkForm.processing}
                aria-busy={checkForm.processing}
              >
                {checkForm.processing ? t('credentialError.checking') : t('domains.checkAccess')}
              </Button>
            )
          ) : (
            <Button asChild variant="outline">
              <Link href={route('settings')}>{t('domainShow.access.reviewConnection')}</Link>
            </Button>
          )}

          <p className="text-muted-foreground text-sm">
            {t('domainShow.access.lastAttempt')}{' '}
            <DataTimestamp value={domain.access_checked_at} emptyLabel={t('time.never')} />
          </p>
        </div>

        {/*
          El intento que ni siquiera llegó a Google —sin credencial comprobada,
          por ejemplo— también deja su mensaje acá, y no en la pantalla
          anterior: la comprobación se disparó desde este bloque y acá es donde
          se la está esperando (RT-11).
        */}
        <FieldError id="error-check" message={faultText(errors.check)} />
      </CardContent>
    </Card>
  )
}

/**
 * Disparar una tanda de inspecciones a mano.
 *
 * La confirmación dice el número exacto y contra qué bolsillo se cobra. «Vas a
 * consultar algunas URLs» no permite decidir nada: la reserva manual es lo que
 * queda para el resto del día y gastarla sin saber cuánto es gastarla a ciegas.
 */
function InspectNowAction({
  domain,
  operational,
  pending,
  manualRemaining,
  reserveRemaining,
}: {
  domain: DomainProps
  operational: boolean
  pending: number
  /** Techo real de una tanda manual: la reserva **más** lo que el ciclo no gastó. */
  manualRemaining: number
  /** Sólo el bolsillo reservado. Es el piso garantizado, y no coincide con el techo. */
  reserveRemaining: number
}) {
  const form = useForm({ from: 'show' })

  if (!operational) return null

  if (pending === 0) {
    return (
      <p className="text-muted-foreground text-sm text-pretty">
        {t('domainShow.inspect.nothingPending')}
      </p>
    )
  }

  if (manualRemaining === 0) {
    return (
      <p className="text-sm text-pretty">{t('domainShow.inspect.noManualQuota')}</p>
    )
  }

  const toInspect = Math.min(pending, manualRemaining)
  const surplus = manualRemaining - reserveRemaining

  /*
    La confirmación nombra los dos bolsillos por separado porque no son lo
    mismo: la reserva es lo que está garantizado y el sobrante es lo que el
    ciclo automático puede gastar mañana temprano. Dar una sola cifra haría
    creer que todo eso está apartado para uso manual.
  */
  // La frase se arma con dos claves y no con pedazos pegados: el inciso del
  // sobrante del ciclo automático no va en el mismo lugar de la oración en los
  // dos idiomas.
  const consequence =
    surplus > 0
      ? t('domainShow.inspect.consequence.withSurplus', {
          count: formatNumber(toInspect),
          reserve: formatNumber(reserveRemaining),
          surplus: formatNumber(surplus),
        })
      : t('domainShow.inspect.consequence', {
          count: formatNumber(toInspect),
          reserve: formatNumber(reserveRemaining),
        })

  return (
    <ConfirmDestructive
      title={t('domainShow.inspect.title', {
        count: formatNumber(toInspect),
        hostname: domain.hostname,
      })}
      consequence={consequence}
      confirmLabel={t('domainShow.inspect.confirm', { count: formatNumber(toInspect) })}
      busy={form.processing}
      busyLabel={t('domainShow.inspect.busy')}
      onConfirm={() =>
        form.post(route('coverage.inspect', { domain_id: domain.id }), { preserveScroll: true })
      }
      trigger={
        <Button>
          <Search aria-hidden />
          {t('batches.inspectNow')}
        </Button>
      }
    />
  )
}

/**
 * Las cifras de lo que cuelga del sitio: cobertura, sitemaps y el último lote.
 *
 * **Eran accesos y ahora son cifras.** Cobertura, sitemaps y lotes viven en el
 * menú desde que la interfaz trabaja contra un solo sitio, así que repetir acá
 * los mismos tres enlaces daría dos caminos idénticos a un clic de distancia.
 *
 * Las cifras se quedan porque esta pantalla pasó a ser la de los ajustes, y son
 * justo el contexto que hace falta para decidirlos: el presupuesto diario se
 * elige mirando cuántas URLs hay. El de cobertura lleva su denominador —cuántas
 * tienen dato sobre el total— porque un conteo suelto se lee como si fuera todo
 * el sitio (RT-03).
 *
 * El lote **sí** conserva su enlace: lleva a *ese* lote, que es un destino que
 * el menú no puede ofrecer —ahí sólo está la lista—.
 */
function ChildViews({
  coverage,
  sitemapsCount,
  lastBatch,
  runningBatch,
}: {
  coverage: Props['coverage']
  sitemapsCount: number
  lastBatch: Batch | null
  runningBatch: Batch | null
}) {
  const batch = runningBatch ?? lastBatch

  return (
    <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
      <Card>
        <CardHeader>
          <CardDescription className="flex items-center gap-2">
            <Layers className="size-4" aria-hidden />
            {t('domainShow.child.coverage')}
          </CardDescription>
          <CardTitle className="text-2xl tabular-nums">
            {coverage.total === 0
              ? '—'
              : t('dashboard.coverage.value', {
                  indexed: formatNumber(coverage.with_data),
                  total: formatNumber(coverage.total),
                })}
          </CardTitle>
        </CardHeader>
        <CardContent className="space-y-3">
          <p className="text-muted-foreground text-xs text-pretty">
            {coverage.total === 0
              ? t('domainShow.child.coverage.empty')
              : t('domainShow.child.coverage.note')}
          </p>
          <p className="text-muted-foreground text-xs">
            {t('dashboard.panel.coverage.lastQuery')}{' '}
            <DataTimestamp value={coverage.last_checked_at} emptyLabel={t('time.never')} />
          </p>
        </CardContent>
      </Card>

      <Card>
        <CardHeader>
          <CardDescription className="flex items-center gap-2">
            <FileText className="size-4" aria-hidden />
            {t('domainShow.child.sitemaps')}
          </CardDescription>
          <CardTitle className="text-2xl tabular-nums">{formatNumber(sitemapsCount)}</CardTitle>
        </CardHeader>
        <CardContent className="space-y-3">
          <p className="text-muted-foreground text-xs text-pretty">
            {sitemapsCount === 0
              ? t('domainShow.child.sitemaps.empty')
              : t('domainShow.child.sitemaps.note')}
          </p>
        </CardContent>
      </Card>

      {/*
        La acción primaria cambia con lo que hay: habiendo lote, lo que se quiere
        abrir es ése —el que acaba de correr o el que está corriendo—, y no una
        lista donde volver a buscarlo. Sin lote, la lista es el único destino
        posible y además es donde se explica cuándo corre el ciclo automático.
      */}
      <Card>
        <CardHeader>
          <CardDescription className="flex items-center gap-2">
            <Activity className="size-4" aria-hidden />
            {/*
              Un lote encolado no es «el último»: es el próximo. Con el rótulo
              fijo, la tarjeta anunciaba «Último lote: En cola» y hacía leer como
              resultado de lo que ya corrió algo que todavía no empezó.
            */}
            {runningBatch ? t('domainShow.child.batchRunning') : t('domainShow.child.lastBatch')}
          </CardDescription>
          <CardTitle className="text-2xl">
            {batch ? batchStateLabel(batch.state) : '—'}
          </CardTitle>
        </CardHeader>
        <CardContent className="space-y-3">
          {batch ? (
            <>
              <p className="text-muted-foreground text-xs tabular-nums">
                {batch.total_items === 0
                  ? t('domainShow.child.batch.noFigures')
                  : t('domainShow.child.batch.figures', {
                      processed: formatNumber(batch.processed_items),
                      total: formatNumber(batch.total_items),
                    })}
              </p>
              <p className="text-muted-foreground text-xs">
                {/*
                  «En curso» sobre un lote encolado es la misma imprecisión que
                  «Listo» sobre uno que corre, sólo que al revés: los dos hacen
                  esperar algo que no está pasando (RT-14).
                */}
                {runningBatch ? (
                  batch.state === 'QUEUED' ? (
                    t('domainShow.child.batch.queued')
                  ) : (
                    t('domainShow.child.batch.running')
                  )
                ) : (
                  <>
                    {t('dashboard.panel.activity.finished')}{' '}
                    <DataTimestamp value={batch.finished_at} emptyLabel={t('time.noDate')} />
                  </>
                )}
              </p>
              {/*
                Lleva a *ese* lote, que es lo que el menú no puede ofrecer: ahí
                está la lista, y volver a buscarlo adentro es el paso que este
                botón ahorra. El enlace a la lista entera salió por eso mismo:
                era el destino que el menú ya tiene.
              */}
              <Button asChild variant="outline" size="sm">
                <Link href={route('batch.show', { batch_id: batch.id })}>
                  {t('dashboard.attention.seeBatch')}
                </Link>
              </Button>
            </>
          ) : (
            <p className="text-muted-foreground text-xs text-pretty">
              {t('domainShow.child.batch.none')}
            </p>
          )}
        </CardContent>
      </Card>
    </div>
  )
}

/**
 * Configuración editable del dominio (FR-057).
 *
 * El interruptor guarda solo y los dos números guardan con un botón, y la
 * diferencia no es de gusto: apagar un aviso por correo es reversible y se nota
 * en el acto, mientras que mover el presupuesto cambia el ritmo del ciclo
 * automático desde el día siguiente. Un cambio así no puede dispararse mientras
 * alguien todavía está tipeando la cifra.
 */
function DomainSettings({
  domain,
  cycle,
  googleDailyLimit,
}: {
  domain: DomainProps
  cycle: Props['cycle']
  googleDailyLimit: number
}) {
  const { errors } = usePage().props

  // Los números viajan como texto para que el campo se pueda dejar vacío
  // mientras se escribe. Con un número, borrar el contenido lo convierte en
  // cero y el texto de efecto empieza a decir cosas que nadie pidió.
  const quotas = useForm({
    daily_inspection_budget: String(domain.daily_inspection_budget),
    manual_reserve: String(domain.manual_reserve),
  })

  /*
    El interruptor lleva su propio estado en vez de leer la prop directamente.
    Sin él, el control vuelve a su posición anterior mientras el `PATCH` está en
    vuelo y salta de nuevo al llegar la respuesta: dos movimientos para un solo
    cambio, y el del medio dice lo contrario de lo que la persona pidió.
  */
  const [notifications, setNotifications] = useState(domain.notifications_enabled)
  const [savingNotifications, setSavingNotifications] = useState(false)
  const [notificationsSaved, setNotificationsSaved] = useState('')
  const [quotasSaved, setQuotasSaved] = useState('')
  const [localErrors, setLocalErrors] = useState<{ budget?: string; reserve?: string }>({})

  useEffect(() => setNotifications(domain.notifications_enabled), [domain.notifications_enabled])

  const budgetField = useRef<HTMLInputElement>(null)
  const reserveField = useRef<HTMLInputElement>(null)

  const budgetError = localErrors.budget ?? errors.daily_inspection_budget
  const reserveError = localErrors.reserve ?? errors.manual_reserve

  // El foco va al primer campo con error al volver del servidor: sin esto, quien
  // navega con teclado recorre el formulario entero buscando qué salió mal
  // (RT-17).
  useEffect(() => {
    if (errors.daily_inspection_budget) budgetField.current?.focus()
    else if (errors.manual_reserve) reserveField.current?.focus()
  }, [errors.daily_inspection_budget, errors.manual_reserve])

  const budget = toInteger(quotas.data.daily_inspection_budget)
  const reserve = toInteger(quotas.data.manual_reserve)
  const changed =
    quotas.data.daily_inspection_budget !== String(domain.daily_inspection_budget) ||
    quotas.data.manual_reserve !== String(domain.manual_reserve)

  const saveNotifications = (enabled: boolean) => {
    setNotifications(enabled)
    setNotificationsSaved('')
    setSavingNotifications(true)

    router.patch(
      route('domain.show', { domain_id: domain.id }),
      { notifications_enabled: enabled },
      {
        preserveScroll: true,
        preserveState: true,
        onSuccess: () =>
          setNotificationsSaved(
            enabled
              ? t('domainShow.notifications.on')
              : t('domainShow.notifications.off')
          ),
        // Si el guardado falla, el interruptor vuelve a donde estaba. Dejarlo
        // en la posición nueva mostraría como guardado algo que no se guardó.
        onError: () => setNotifications(!enabled),
        onFinish: () => setSavingNotifications(false),
      }
    )
  }

  const saveQuotas = (event: FormEvent) => {
    event.preventDefault()
    setQuotasSaved('')

    // Se valida antes de enviar y con los mismos límites que el servidor. No lo
    // reemplaza: el servidor sigue siendo la autoridad y vuelve a comprobar
    // todo. Acá sólo se ahorra un viaje para decir algo que ya se sabe.
    const problems = validate(budget, reserve)
    setLocalErrors(problems)

    if (problems.budget) {
      budgetField.current?.focus()
      return
    }
    if (problems.reserve) {
      reserveField.current?.focus()
      return
    }

    quotas.patch(route('domain.show', { domain_id: domain.id }), {
      preserveScroll: true,
      preserveState: true,
      onSuccess: () =>
        setQuotasSaved(t('domainShow.settings.saved')),
    })
  }

  return (
    <Card>
      <CardHeader>
        <CardTitle>{t('domainShow.settings.title')}</CardTitle>
        <CardDescription>{t('domainShow.settings.description')}</CardDescription>
      </CardHeader>

      <CardContent className="space-y-8">
        <div className="space-y-2">
          <div className="flex items-start justify-between gap-4">
            <Label htmlFor="notifications" className="flex-1 items-start">
              {t('domainShow.settings.notifications.label')}
            </Label>
            {/*
              Sólo este control queda en espera mientras se guarda; el resto de
              la ficha sigue usable (RT-12).
            */}
            <Switch
              id="notifications"
              className="motion-reduce:transition-none"
              checked={notifications}
              onCheckedChange={saveNotifications}
              disabled={savingNotifications}
              aria-busy={savingNotifications || undefined}
              aria-describedby="help-notifications"
            />
          </div>
          {/*
            Decía «por correo», y no sale ningún correo: los avisos viven en la
            plataforma, con su número en el menú. Prometer un canal que no
            existe es peor que no ofrecerlo (RT-07).
          */}
          <p id="help-notifications" className="text-muted-foreground text-sm text-pretty">
            {t('domainShow.settings.notifications.help')}
          </p>
          {/*
            La confirmación aparece junto al control que la produjo y se anuncia
            a lector de pantalla: un interruptor que cambia de posición no le
            dice nada a quien no lo ve (RT-11).
          */}
          <p role="status" aria-live="polite" className="min-h-5 text-sm">
            {savingNotifications ? t('common.saving') : notificationsSaved}
          </p>
          <FieldError
            id="error-notifications"
            message={faultText(errors.notifications_enabled)}
          />
        </div>

        <form onSubmit={saveQuotas} className="space-y-5 border-t pt-6" noValidate>
          <div className="grid gap-5 sm:grid-cols-2">
            <div className="space-y-2">
              <Label htmlFor="budget">{t('domainShow.settings.budget.label')}</Label>
              <Input
                id="budget"
                name="daily_inspection_budget"
                ref={budgetField}
                type="number"
                inputMode="numeric"
                min={1}
                step={1}
                autoComplete="off"
                className="tabular-nums"
                value={quotas.data.daily_inspection_budget}
                onChange={(event) => {
                  setLocalErrors({})
                  quotas.setData('daily_inspection_budget', event.target.value)
                }}
                /*
                  La ayuda va primero y el error se le suma cuando existe: quien
                  usa lector tiene que oír la regla y, si la rompió, también en
                  qué. El orden importa porque el segundo pisa al primero.
                */
                aria-describedby="help-budget"
                {...fieldErrorProps('help-budget error-budget', budgetError)}
              />
              <p id="help-budget" className="text-muted-foreground text-sm text-pretty">
                {t('domainShow.settings.budget.help', {
                  limit: formatNumber(googleDailyLimit),
                })}
              </p>
              <FieldError id="error-budget" message={budgetError} />
            </div>

            <div className="space-y-2">
              <Label htmlFor="reserve">{t('quota.manual.title')}</Label>
              <Input
                id="reserve"
                name="manual_reserve"
                ref={reserveField}
                type="number"
                inputMode="numeric"
                min={0}
                step={1}
                autoComplete="off"
                className="tabular-nums"
                value={quotas.data.manual_reserve}
                onChange={(event) => {
                  setLocalErrors({})
                  quotas.setData('manual_reserve', event.target.value)
                }}
                aria-describedby="help-reserve"
                {...fieldErrorProps('help-reserve error-reserve', reserveError)}
              />
              <p id="help-reserve" className="text-muted-foreground text-sm text-pretty">
                {t('domainShow.settings.reserve.help')}
              </p>
              <FieldError id="error-reserve" message={reserveError} />
            </div>
          </div>

          <ChangePreview
            budget={budget}
            reserve={reserve}
            urls={cycle.monitored_urls}
            googleDailyLimit={googleDailyLimit}
          />

          <div className="flex flex-wrap items-center gap-3">
            <Button type="submit" disabled={quotas.processing || !changed} aria-busy={quotas.processing}>
              {quotas.processing ? t('common.saving') : t('domainShow.settings.save')}
            </Button>
            <p role="status" aria-live="polite" className="text-sm">
              {quotasSaved}
            </p>
          </div>
          <FieldError id="error-settings" message={faultText(errors.settings)} />
        </form>
      </CardContent>
    </Card>
  )
}

/**
 * Qué cambia, en cifras, antes de apretar el botón.
 *
 * «Regula la frecuencia de consulta» no permite decidir nada. Las dos cifras
 * que sí lo permiten son cuántas consultas le quedan al ciclo automático por
 * día y cuánto pasa a tardar una vuelta completa al sitio. Y son eso: nuestra
 * capacidad de consulta, no una predicción sobre cuándo Google va a rastrear.
 */
function ChangePreview({
  budget,
  reserve,
  urls,
  googleDailyLimit,
}: {
  budget: number | null
  reserve: number | null
  urls: number
  googleDailyLimit: number
}) {
  if (budget === null || reserve === null || reserve > budget) return null

  const automatic = budget - reserve
  const days = automatic > 0 && urls > 0 ? Math.ceil(urls / automatic) : null
  const exceedsLimit = budget > googleDailyLimit

  return (
    /*
      Sin región viva a propósito: el texto cambia con cada tecla, y anunciarlo
      en cada una convierte la ayuda en ruido continuo para quien usa lector. Se
      lee cuando se lo busca, como el resto del formulario.
    */
    <div className="bg-muted/40 space-y-2 rounded-lg border p-3 text-sm">
      <p className="text-pretty">
        <RichText
          text={
            urls === 0
              ? t('domainShow.preview.noUrls', {
                  budget: formatNumber(budget),
                  reserve: formatNumber(reserve),
                  automatic: formatNumber(automatic),
                })
              : days === null
                ? t('domainShow.preview.noAutomatic', {
                    budget: formatNumber(budget),
                    reserve: formatNumber(reserve),
                    automatic: formatNumber(automatic),
                    urls: formatNumber(urls),
                  })
                : t('domainShow.preview.cycle', {
                    count: days,
                    budget: formatNumber(budget),
                    reserve: formatNumber(reserve),
                    automatic: formatNumber(automatic),
                    urls: formatNumber(urls),
                    days: formatNumber(days),
                  })
          }
        />
      </p>
      {exceedsLimit ? (
        <p className="flex items-start gap-2 text-pretty text-amber-800 dark:text-amber-300">
          <TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden />
          {t('domainShow.preview.overLimit', { limit: formatNumber(googleDailyLimit) })}
        </p>
      ) : null}
    </div>
  )
}

/** Un entero, o nulo si lo escrito todavía no lo es. */
function toInteger(value: string): number | null {
  const cleaned = value.trim()
  if (cleaned === '' || !/^-?\d+$/.test(cleaned)) return null
  return Number(cleaned)
}

/**
 * Las mismas reglas que aplica el servidor, dichas con los números en la mano.
 *
 * El mensaje trae las dos cifras y no sólo la regla: «la reserva no puede
 * superar el presupuesto» obliga a mirar los dos campos para saber cuál está
 * mal (RT-08, RT-17).
 */
function validate(
  budget: number | null,
  reserve: number | null
): { budget?: string; reserve?: string } {
  if (budget === null) return { budget: t('errors.BUDGET_NOT_INTEGER') }
  if (reserve === null) return { reserve: t('errors.RESERVE_NOT_INTEGER') }
  if (budget < 1) return { budget: t('errors.BUDGET_TOO_LOW') }
  if (reserve < 0) return { reserve: t('errors.RESERVE_NEGATIVE') }
  if (reserve > budget) {
    return {
      reserve: t('errors.RESERVE_OVER_BUDGET', {
        reserve: formatNumber(reserve),
        budget: formatNumber(budget),
      }),
    }
  }
  return {}
}
