import { Link, useForm, usePage } from '@inertiajs/react'
import {
  CheckCircle2,
  ChevronRight,
  CircleAlert,
  CircleDashed,
  Clock,
  Route as RouteIcon,
  Send,
  UserRoundPlus,
} from 'lucide-react'
import type { FormEvent } from 'react'
import { useEffect, useRef, useState } from 'react'

import { AccessiblePropertyList } from '@/components/AccessiblePropertyList'
import { AccessStateBadge } from '@/components/AccessStateBadge'
import { addressParam, addressWithout } from '@/components/address'
import { CodeChip } from '@/components/CodeChip'
import { ConfirmDestructive } from '@/components/ConfirmDestructive'
import { ConnectionSummary, spellOut } from '@/components/ConnectionSummary'
import type { ConnectionState } from '@/components/ConnectionSummary'
import { CredentialErrorPanel } from '@/components/CredentialErrorPanel'
import { DangerZone } from '@/components/DangerZone'
import { DataTimestamp, TimezoneFootnote } from '@/components/DataTimestamp'
import { FieldError, fieldErrorProps } from '@/components/FieldError'
import { FormDialog } from '@/components/FormDialog'
import { GoogleConsoleLink } from '@/components/GoogleConsoleLink'
import { KeyFileField } from '@/components/KeyFileField'
import { LabelValue } from '@/components/LabelValue'
import { PageIntro } from '@/components/PageIntro'
import { Section } from '@/components/Section'
import { SectionCard } from '@/components/SectionCard'
import { ServiceAccountAddress } from '@/components/ServiceAccountAddress'
import { SiteFields } from '@/components/SiteFields'
import { StatusBadge } from '@/components/StatusBadge'
import type { StatusTone } from '@/components/StatusBadge'
import { TaskColumn } from '@/components/TaskColumn'
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 { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { Field, FieldDescription, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Item, ItemActions, ItemContent, ItemDescription, ItemTitle } from '@/components/ui/item'
import { Spinner } from '@/components/ui/spinner'
import { faultText } from '@/lib/errors'
import type { FieldFault } from '@/lib/errors'
import type { SiteState } from '@/types/inertia'
import { formatNumber } from '@/lib/format'
import { t } from '@/lib/i18n'
import type { TranslationKey } from '@/lib/i18n'
import { route } from '@/lib/routes'
import { cn } from '@/lib/utils'

interface Property {
  property_uri: string
  permission: string
}

interface Credential {
  id: string
  client_email: string
  project_id: string
  key_fingerprint: string
  private_key_id: string
  status: string
  is_usable: boolean
  last_checked_at: string | null
  last_error_code: string | null
  last_error_detail: string | null
  accessible_properties: Property[]
  created_at: string
}

interface Props {
  module: { code: string; name: string; required_apis: unknown[]; required_scopes: string[] }
  credential: Credential | null
  /** Lo único que sobrevive de la guía: cuántos pasos son. Los pasos viven en el recorrido. */
  tour: { steps_total: number }
  history: {
    id: string
    client_email: string
    key_fingerprint: string
    status: string
    status_label: string
    created_at: string
  }[]
  /** Las formas de propiedad del formulario. El sitio en sí baja con las props compartidas. */
  property_types: { value: string; label: string }[]
  /**
   * El acceso a la Indexing API, derivado de la evidencia y no de una consulta.
   *
   * Nulo sin credencial: firma con la misma clave que Search Console, así que
   * sin clave no hay nada que decir de ella y lo que falta es un paso anterior.
   */
  indexing: {
    state: string
    /** Cuándo salió el pedido del que sale la conclusión. Nulo si no hubo ninguno. */
    checked_at: string | null
    url: string
    batch_id: string
    project_id: string
  } | null
}

/** Formato del identificador de proyecto de Google. Espeja `validate_project_id`. */
const PROJECT_ID_PATTERN = /^[a-z][a-z0-9-]{4,28}[a-z0-9]$/

/** El rechazo local, con la misma redacción que el del servidor. */
function projectIdError(): string {
  return t('errors.PROJECT_ID_MALFORMED_LOCAL')
}

/** La clave en la dirección que abre el reemplazo, y qué campo recibe el foco. */
const REPLACE_PARAM = 'replace'

/**
 * En qué estado está la conexión, según lo único que lo determina: la credencial.
 *
 * `NO_PROPERTIES` sale del grupo de los errores a propósito. La clave funciona;
 * lo que falta es un paso que se hace en Search Console. Tratarlo como falla
 * manda a cambiar una clave que está perfecta (SC-002).
 */
function connectionState(credential: Credential | null): ConnectionState {
  if (!credential) return 'EMPTY'
  if (credential.is_usable) return 'VERIFIED'
  if (credential.last_error_code === 'NO_PROPERTIES') return 'NEEDS_AUTHORIZATION'
  if (lastErrorCode(credential)) return 'ERROR'
  return 'UNCHECKED'
}

/**
 * El motivo del último fallo, con la revocación traducida.
 *
 * Una credencial revocada desde Google Cloud queda sin código de error propio
 * —nadie la comprobó desde que la revocaron— y sin esto caería en «falta
 * comprobarla», que manda a apretar un botón que no puede funcionar.
 */
function lastErrorCode(credential: Credential | null): string | null {
  if (!credential) return null
  if (credential.last_error_code) return credential.last_error_code
  return credential.status === 'REVOKED' ? 'INVALID_KEY' : null
}

/**
 * La conexión con Google, en tres pantallas que comparten dirección.
 *
 * Los cinco estados del código son tres personas distintas: **la que nunca
 * conectó nada**, **la que ya está funcionando** y **la que se le rompió**. Antes
 * compartían una sola pantalla que mostraba todo siempre —el estado, la guía de
 * cuatro pasos, el formulario de carga, la ficha técnica, los módulos y el
 * historial— y dejaba a las tres sin saber por dónde empezar.
 *
 * Lo que decide cada estado no es sólo qué aparece: es **qué no aparece**. En el
 * estado vacío no hay módulos, ni historial, ni ficha, ni propiedades, porque no
 * hay nada que mostrar y una tarjeta vacía se lee igual que una rota. Con la
 * conexión caída no se listan las propiedades accesibles: son de la última
 * comprobación que funcionó, y leerlas ahí es leer un dato viejo como si fuera
 * de hoy.
 */
export default function SettingsIndex({
  module,
  credential,
  tour,
  history,
  property_types,
  indexing,
}: Props) {
  const page = usePage()
  const { errors, site } = page.props
  const state = connectionState(credential)
  const errorCode = lastErrorCode(credential)
  const properties = credential?.accessible_properties ?? []

  const verify = useForm({})
  const check = () => {
    if (!credential) return
    // Sin `preserveScroll` la respuesta manda arriba de todo y el resultado
    // aparece fuera de la vista de quien apretó el botón (RT-11).
    verify.post(route('credential.verify', { credential_id: credential.id }), {
      preserveScroll: true,
    })
  }

  const stateBlockRef = useRef<HTMLDivElement>(null)
  const hasFocused = useRef(false)

  useEffect(() => {
    if (hasFocused.current) return
    hasFocused.current = true
    // Esta vista es el destino de los avisos de credencial rota (RT-18): al
    // llegar desde el aviso, el bloque que dice qué pasa tiene que estar bajo el
    // foco, no esperando que alguien lo encuentre. Lo recibe el bloque y no un
    // botón de adentro: lo primero que hay que leer es el motivo.
    if (credential && !credential.is_usable) stateBlockRef.current?.focus()
  }, [credential])

  const footerSlot = <PageIntro badge={<StateBadge state={state} />} items={statusItems(credential, state)} />
  return (
    <AppLayout
      title={t('settings.title')}
      description={t('settings.description', { module: module.name })}
      footerSlot={footerSlot}
      actions={
        <>
          {/*
            La guía, en los cinco estados y no sólo en el vacío. Antes vivía
            únicamente en la tarjeta de «todavía no conectaste nada», así que
            desaparecía justo cuando hace falta: lo que se olvida no es cómo
            empezar, es en qué pantalla de Google Cloud estaba cada cosa, y eso
            se pregunta con la credencial ya cargada. El recorrido se ofrece
            siempre que se lo pida —terminado u omitido también—, así que el
            destino existe en cualquier estado.

            En `ghost` a propósito: es un atajo de consulta, no el verbo de la
            pantalla, y no compite con el botón que sí lo es.
          */}
          <Button asChild variant="outline">
            <Link href={route('onboarding')}>
              <RouteIcon aria-hidden />
              {t('settings.seeGuide')}
            </Link>
          </Button>

          {credential ? (
            // El único verbo repetible de la pantalla, y el mismo en los tres
            // estados con credencial. Sin badge, sin fecha y sin explicación al
            // lado: la cabecera es de una línea.
            <Button
              onClick={check}
              disabled={verify.processing}
              aria-busy={verify.processing || undefined}
            >
              {verify.processing ? (
                <>
                  <Spinner />
                  {t('credentialError.checking')}
                </>
              ) : credential.last_checked_at ? (
                t('credentialError.recheck')
              ) : (
                t('settings.checkConnection')
              )}
            </Button>
          ) : null}
        </>
      }
    >
      {/*
        Una sola columna angosta y no dos. La pantalla se lee de arriba abajo en
        el orden en que hay que resolverla —en qué estado está, por qué, qué
        hacer— y una barra lateral pondría el detalle a competir con la acción.
      */}
      <TaskColumn>

        {/*
          Un resultado que no llegó a guardarse —nos quedamos sin cupo de
          comprobaciones— no está en la credencial, así que lo dice la pantalla,
          debajo del botón que lo disparó.
        */}
        {errors.verify ? (
          <Alert variant="attention">
            <CircleAlert />
            <AlertTitle>{t('settings.checkFailed')}</AlertTitle>
            <AlertDescription>{faultText(errors.verify)}</AlertDescription>
          </Alert>
        ) : null}

        {state === 'EMPTY' ? (
          <NotConnected stepsTotal={tour.steps_total} errors={errors} />
        ) : (
          <>
            {/*
              El motivo va antes que la ficha: cuando la conexión está caída, es
              lo que hay que leer antes que nada. Las acciones salen del mapa
              cerrado y son las que resuelven **ese** motivo.
            */}
            {errorCode && credential ? (
              <div
                ref={stateBlockRef}
                tabIndex={-1}
                className="focus-visible:ring-ring/50 rounded-lg focus-visible:ring-3 focus-visible:outline-none"
              >
                <CredentialErrorPanel
                  errorCode={errorCode}
                  message={credential.last_error_detail}
                  projectId={credential.project_id}
                  clientEmail={credential.client_email}
                  onRecheck={needsCheck(state, errorCode) ? check : undefined}
                  checking={verify.processing}
                  announce="status"
                />
              </div>
            ) : null}

            {credential ? (
              // Sin motivo que leer, el bloque que recibe el foco al llegar desde
              // un aviso es la ficha: es lo que dice cuál es esta credencial.
              <div
                ref={errorCode ? undefined : stateBlockRef}
                tabIndex={errorCode ? undefined : -1}
                className="focus-visible:ring-ring/50 rounded-xl focus-visible:ring-3 focus-visible:outline-none"
              >
                <ConnectionSummary state={state} credential={credential} moduleName={module.name}>
                  {state === 'NEEDS_AUTHORIZATION' && (
                    <ServiceAccountAddress clientEmail={credential.client_email} />
                  )}
                </ConnectionSummary>
              </div>
            ) : null}

            {/*
              El sitio conectado se muestra **siempre**, y conectar uno sólo con
              la clave funcionando. Son dos cosas distintas: la ficha es
              información —cuál es mi sitio y cómo está su acceso—, y sigue
              haciendo falta justamente cuando la credencial se rompió; el alta,
              en cambio, es una acción que el servidor rechaza sin credencial
              usable, así que ofrecerla ahí sería ofrecer algo que no se puede
              usar (RT-18).

              Atar las dos al mismo estado dejaba la pantalla diciendo «dar de
              baja el sitio» en la zona de riesgo mientras arriba no se veía de
              qué sitio hablaba.
            */}
            <SiteSection
              site={site}
              canConnect={state === 'VERIFIED'}
              propertyTypes={property_types}
              errors={errors}
            />

            {/*
              Debajo del sitio y no al lado: es la segunda API de la misma
              conexión, y leerlas en ese orden es el orden en que se resuelven
              —primero el acceso a la propiedad, después el permiso para pedirle
              algo—. Sólo con credencial, porque sin clave lo que falta es un
              paso anterior y la tarjeta se vería rota informando eso.
            */}
            {indexing ? <IndexingAccess indexing={indexing} /> : null}

            {/*
              Sólo con la conexión verificada. Con la conexión caída, estas
              propiedades son las de la última comprobación que funcionó.
            */}
            {state === 'VERIFIED' && properties.length > 0 ? (
              <Section
                title={{ text: t('settings.properties.title') }}
                description={t('settings.properties.description')}
              >
                <AccessiblePropertyList properties={properties} showWarning />
              </Section>
            ) : null}

            {history.length > 0 ? <CredentialHistory history={history} /> : null}

            {credential ? (
              // El rótulo dejó de nombrar una acción: acá viven dos, y las dos
              // son irreversibles por motivos distintos —una corta las consultas,
              // la otra detiene el sitio—. Nombrar sólo a la credencial dejaría a
              // la baja del sitio leyéndose como parte del reemplazo de la clave.
              <DangerZone title={t('settings.dangerZone.title')}>
                {site ? <DisconnectSite site={site} /> : null}
                <Item variant="outline">
                  <ItemContent>
                    <ItemTitle>{t('settings.replace.action')}</ItemTitle>
                    <ItemDescription className="line-clamp-none text-pretty">
                      {t('settings.replace.description')}
                    </ItemDescription>
                  </ItemContent>
                  <ItemActions>
                    <Button asChild variant="destructive">
                      <Link
                        href={replaceHref(page.url, 'file')}
                        preserveScroll
                        preserveState
                      >
                        {t('settings.replace.title')}
                      </Link>
                    </Button>
                  </ItemActions>
                </Item>
              </DangerZone>
            ) : null}
          </>
        )}

        {credential ? (
          <ReplaceCredential
            credential={credential}
            errors={errors}
            address={page.url}
          />
        ) : null}

        <TimezoneFootnote />
      </TaskColumn>
    </AppLayout>
  )
}

// --- La línea de estado -----------------------------------------------------

/** Presentación del estado: su tono, su dibujo y su palabra. */
const STATES: Record<ConnectionState, { tone: StatusTone; icon: typeof CheckCircle2 }> = {
  EMPTY: { tone: 'neutral', icon: CircleDashed },
  UNCHECKED: { tone: 'unknown', icon: Clock },
  NEEDS_AUTHORIZATION: { tone: 'attention', icon: UserRoundPlus },
  VERIFIED: { tone: 'positive', icon: CheckCircle2 },
  ERROR: { tone: 'critical', icon: CircleAlert },
}

function StateBadge({ state }: { state: ConnectionState }) {
  const presentation = STATES[state]
  return (
    <StatusBadge tone={presentation.tone} icon={presentation.icon}>
      {t(`settings.state.${state}` as TranslationKey)}
    </StatusBadge>
  )
}

/**
 * Los metadatos de la línea de estado: cuándo se comprobó y qué alcanza.
 *
 * El titular que antes decía lo mismo con una oración a 18 px desapareció: el
 * badge dice el estado, esta línea lo fecha, y el motivo —cuando lo hay— lo
 * explica el panel de abajo. El mismo hecho no se escribe tres veces.
 */
function statusItems(credential: Credential | null, state: ConnectionState) {
  if (!credential) return []

  const properties = credential.accessible_properties.length

  return [
    <>
      {t('dashboard.attention.lastCheck')}{' '}
      <DataTimestamp
        value={credential.last_checked_at}
        emptyLabel={t('settings.notCheckedYet')}
      />
    </>,
    state === 'VERIFIED' && properties > 0
      ? t('settings.propertyCount', { count: properties, total: formatNumber(properties) })
      : null,
  ]
}

/** Los estados en los que lo próximo que hay que hacer es volver a preguntarle a Google. */
function needsCheck(state: ConnectionState, errorCode: string | null): boolean {
  if (state === 'UNCHECKED' || state === 'NEEDS_AUTHORIZATION') return true
  if (state !== 'ERROR') return false
  return errorCode === 'API_NOT_ENABLED' || errorCode === 'PROVIDER_UNAVAILABLE'
}

// --- El sitio ---------------------------------------------------------------

/**
 * El sitio de la cuenta: el formulario que lo conecta, o su ficha.
 *
 * Es la mitad que le faltaba a esta pantalla. «Conexión» terminaba en «la clave
 * sirve» y dejaba pendiente un paso que vivía en otra vista alcanzable sólo por
 * el menú, y ese paso es el que decide si el producto tiene algo que leer.
 *
 * Las dos formas son la misma decisión con y sin respuesta, igual que la
 * credencial de arriba: sin sitio, el formulario; con sitio, su ficha y el verbo
 * que la actualiza. La baja no está acá sino en la zona de riesgo del pie, donde
 * ya vive lo irreversible.
 *
 * `canConnect` separa **ver** de **poder conectar**: la ficha se muestra en
 * cualquier estado de la credencial —cuando la conexión se rompe es cuando más
 * hace falta saber de qué sitio hablamos— y el formulario sólo cuando la clave
 * sirve, porque sin ella el servidor rechaza el alta.
 */
function SiteSection({
  site,
  canConnect,
  propertyTypes,
  errors,
}: {
  site: SiteState | null
  canConnect: boolean
  propertyTypes: Props['property_types']
  errors: Record<string, FieldFault>
}) {
  if (site) return <ConnectedSite site={site} />
  if (!canConnect) return null
  return <ConnectSite propertyTypes={propertyTypes} errors={errors} />
}

/**
 * Las cinco caras del acceso a la Indexing API.
 *
 * `WORKING` es la única en tono positivo, y las dos de error son críticas
 * porque las dos apagan la función entera hasta que alguien vaya a una pantalla
 * de Google. Los otros dos estados son `unknown` **y no ámbar**: no sabemos, y
 * teñir de ámbar lo que nadie comprobó lo agruparía con lo que sí está mal.
 */
const INDEXING_STATES: Record<string, { tone: StatusTone; icon: typeof Send }> = {
  NEVER_ASKED: { tone: 'unknown', icon: CircleDashed },
  WORKING: { tone: 'positive', icon: Send },
  API_NOT_ENABLED: { tone: 'critical', icon: CircleAlert },
  NOT_AUTHORIZED: { tone: 'critical', icon: CircleAlert },
  INCONCLUSIVE: { tone: 'unknown', icon: CircleDashed },
}

/**
 * El acceso a la Indexing API, dicho con la evidencia y **sin botón de comprobar**.
 *
 * Es la diferencia deliberada con la tarjeta del sitio, que sí lo tiene. Ahí,
 * comprobar es una lectura: preguntarle a Google si tenemos acceso a una
 * propiedad no cambia nada y cuesta una de dos mil consultas del sitio.
 *
 * Acá no existe esa lectura. La Indexing API no tiene ningún endpoint que
 * conteste «¿podrías publicar?»: lo único que se le puede pedir es que publique
 * una dirección de verdad, y eso gasta una de las doscientas del día **del
 * proyecto entero**, que comparten todos los dominios. Un botón que dijera
 * «Comprobar el acceso» y por debajo le mandara una URL a Google sería un botón
 * que hace en silencio lo que dice estar comprobando.
 *
 * Así que la tarjeta contesta con lo que ya sabemos: la última respuesta que
 * Google nos dio, con su fecha y la dirección sobre la que salió. Donde la otra
 * tiene un verbo, ésta tiene una prueba.
 */
function IndexingAccess({ indexing }: { indexing: NonNullable<Props['indexing']> }) {
  const face = INDEXING_STATES[indexing.state] ?? INDEXING_STATES.INCONCLUSIVE
  const key = `settings.indexing.${indexing.state}` as const

  /*
    Qué se ofrece hacer, y sólo cuando hay algo real que hacer.

    `actions` ausente es una ranura que no se rinde: `SectionCard` reacciona a lo
    que tiene adentro, y un encabezado con un hueco a la derecha se lee como un
    control que no cargó.
  */
  const fixes = {
    NEVER_ASKED: ['enable', 'authorize'],
    API_NOT_ENABLED: ['enable'],
    NOT_AUTHORIZED: ['authorize'],
    WORKING: [],
    INCONCLUSIVE: [],
  }[indexing.state] ?? []

  return (
    <SectionCard
      title={{ text: t('settings.indexing.title') }}
      description={t('settings.indexing.description')}
      actions={
        fixes.length > 0 ? (
          <div className="flex flex-wrap gap-2">
            {fixes.includes('enable') ? (
              <GoogleConsoleLink screen="enable-indexing-api" projectId={indexing.project_id}>
                {t('settings.indexing.enableApi')}
              </GoogleConsoleLink>
            ) : null}
            {fixes.includes('authorize') ? (
              <GoogleConsoleLink screen="search-console">
                {t('settings.indexing.authorize')}
              </GoogleConsoleLink>
            ) : null}
          </div>
        ) : undefined
      }
    >
      <StatusBadge tone={face.tone} icon={face.icon} className="self-start">
        {t(`${key}.label` as TranslationKey)}
      </StatusBadge>

      <p className="text-sm text-pretty">{t(`${key}.body` as TranslationKey)}</p>

      {/*
        La prueba, cuando la hay. Los dos pares juntos son lo que convierte el
        badge de arriba en una afirmación verificable: sin la fecha y sin la
        dirección, «Funciona» es una palabra nuestra.
      */}
      {indexing.checked_at ? (
        <>
          <LabelValue
            term={t('settings.indexing.checkedAt')}
            value={<DataTimestamp value={indexing.checked_at} />}
          />
          <LabelValue
            term={t('settings.indexing.lastRequest')}
            value={<CodeChip value={indexing.url} />}
          />
        </>
      ) : null}

      {indexing.batch_id ? (
        <Button asChild variant="outline" size="sm" className="self-start">
          <Link href={route('indexing.batch', { batch_id: indexing.batch_id })}>
            {t('settings.indexing.seeEvidence')}
            <ChevronRight aria-hidden />
          </Link>
        </Button>
      ) : null}

      {/*
        Por qué esta tarjeta no tiene el botón que tiene la de al lado. Va fijo y
        no en un globo: la ausencia de un control que existe dos tarjetas más
        arriba es exactamente lo que alguien va a venir a preguntarse, y una
        explicación que hay que descubrir no responde a tiempo.
      */}
      <p className="text-muted-foreground text-sm text-pretty">
        {t('settings.indexing.noCheck')}
      </p>
    </SectionCard>
  )
}

/** Cuál es el sitio, con qué forma está dado de alta y cómo está su acceso. */
function ConnectedSite({ site }: { site: SiteState }) {
  // El origen viaja como dato del envío y no como opción de Inertia: es el
  // servidor quien decide adónde vuelve, y lo lee del cuerpo. Leerlo del
  // `Referer` no sirve —esa cabecera la puede recortar el navegador— y comprobar
  // desde acá dejaría a la persona en el listado sin ninguna razón visible.
  const check = useForm({ from: 'settings' })

  return (
    <SectionCard
      title={{ text: t('settings.site.title') }}
      description={t('settings.site.description')}
      actions={
        <Button
          variant="outline"
          size="sm"
          onClick={() =>
            check.post(route('domain.check', { domain_id: site.id }), {
              preserveScroll: true,
              preserveState: true,
            })
          }
          disabled={check.processing}
          aria-busy={check.processing || undefined}
        >
          {check.processing ? (
            <>
              <Spinner />
              {t('credentialError.checking')}
            </>
          ) : (
            t('domains.checkAccess')
          )}
        </Button>
      }
    >
      <LabelValue
        term={t('settings.site.hostname')}
        value={<span translate="no">{site.hostname}</span>}
      />
      <LabelValue
        term={t('settings.site.shape')}
        value={t(`propertyShape.${site.property_type}.title` as TranslationKey)}
      />
      <LabelValue
        term={t('settings.site.access')}
        value={<AccessStateBadge state={site.access_state} canOperate />}
      />
      {/*
        La fecha va en su propio par y no como aclaración del estado: tiene que
        sobrevivir a una captura de pantalla, y `DataTimestamp` trae además la
        exacta con su zona (RT-02). El `hint` de `LabelValue` es texto suelto y
        ahí sólo podría entrar un ISO crudo.
      */}
      <LabelValue
        term={t('dashboard.attention.lastCheck')}
        value={
          <DataTimestamp value={site.access_checked_at} emptyLabel={t('domains.notChecked')} />
        }
      />
    </SectionCard>
  )
}

/**
 * Conectar el sitio: los dos campos y el verbo.
 *
 * Envía a `domain.connect` y no al alta suelta porque el resultado —sobre todo
 * el que falla— tiene que volver a esta pantalla: la del alta está fuera del
 * menú, y aterrizar ahí dejaría a la persona en un recorrido que ya no existe.
 */
function ConnectSite({
  propertyTypes,
  errors,
}: {
  propertyTypes: Props['property_types']
  errors: Record<string, FieldFault>
}) {
  const form = useForm({ hostname: '', property_type: 'DOMAIN' })
  const hostnameField = useRef<HTMLInputElement>(null)

  // El foco vuelve al primer campo con error al recibir la respuesta (RT-17).
  useEffect(() => {
    if (errors.hostname) hostnameField.current?.focus()
  }, [errors.hostname])

  const submit = (event: FormEvent) => {
    event.preventDefault()
    form.post(route('domain.connect'))
  }

  return (
    <form onSubmit={submit} noValidate>
      <SectionCard
        title={{ text: t('settings.site.connect.title') }}
        description={t('settings.site.connect.description')}
        footer={
          <Button type="submit" disabled={form.processing} aria-busy={form.processing || undefined}>
            {form.processing ? (
              <>
                <Spinner />
                {t('settings.site.connect.busy')}
              </>
            ) : (
              t('settings.site.connect.action')
            )}
          </Button>
        }
      >
        <SiteFields
          hostname={form.data.hostname}
          propertyType={form.data.property_type}
          propertyTypes={propertyTypes}
          hostnameError={faultText(errors.hostname)}
          propertyTypeError={faultText(errors.property_type)}
          onHostnameChange={(value) => form.setData('hostname', value)}
          onPropertyTypeChange={(value) => form.setData('property_type', value)}
          hostnameRef={hostnameField}
        />
      </SectionCard>
    </form>
  )
}

/**
 * Dar de baja el sitio, con su confirmación.
 *
 * Vive en la zona de riesgo y no al lado de la ficha: es irreversible en el
 * sentido que importa —el sitio deja de consultarse desde ese momento— y
 * mezclarla con el verbo que sólo comprueba pondría a dos acciones de peso muy
 * distinto en la misma fila.
 *
 * La confirmación nombra el sitio y dice **qué no se pierde**: la cobertura, los
 * sitemaps y los lotes quedan, y volver a conectarlo los recupera. Sin esa
 * segunda mitad, «dar de baja» se lee como «borrar meses de datos» y nadie lo
 * aprieta (RT-10).
 */
function DisconnectSite({ site }: { site: SiteState }) {
  const [confirming, setConfirming] = useState(false)
  const form = useForm({})

  return (
    <>
      <Item variant="outline">
        <ItemContent>
          <ItemTitle>{t('settings.site.disconnect.action')}</ItemTitle>
          <ItemDescription className="line-clamp-none text-pretty">
            {t('settings.site.disconnect.description')}
          </ItemDescription>
        </ItemContent>
        <ItemActions>
          <Button variant="destructive" onClick={() => setConfirming(true)}>
            {t('settings.site.disconnect.action')}
          </Button>
        </ItemActions>
      </Item>

      <ConfirmDestructive
        open={confirming}
        onOpenChange={setConfirming}
        title={t('settings.site.disconnect.confirm.title', { hostname: site.hostname })}
        consequence={t('settings.site.disconnect.confirm.consequence')}
        confirmLabel={t('settings.site.disconnect.action')}
        busyLabel={t('settings.site.disconnect.busy')}
        busy={form.processing}
        onConfirm={() => form.post(route('domain.deactivate', { domain_id: site.id }))}
      />
    </>
  )
}

// --- A · Sin conectar -------------------------------------------------------

/**
 * La pantalla de quien todavía no conectó nada: los dos caminos, uno a la vez.
 *
 * Acá vivía una tercera copia de los mismos siete pasos del servidor —cuatro de
 * ellos, dentro de un acordeón que en este estado se abría solo— compitiendo con
 * el formulario de carga, que también se abría solo. Quien no sabe qué es Google
 * Cloud no elige entre los dos: se queda mirando.
 *
 * Ahora hay **una sola acción primaria a la vista**. El recorrido es la primera
 * mientras nadie diga otra cosa; el formulario queda detrás de «Ya tengo el
 * archivo», que es exactamente la condición que lo hace útil. Y en cuanto se
 * abre, el recorrido baja a acción secundaria: quien ya tiene el `.json`
 * declaró cuál de los dos caminos es el suyo, y dos botones del mismo peso
 * obligan a elegir de nuevo lo que ya se eligió.
 *
 * No se ofrece nada de dominios: no hay con qué consultarlos todavía.
 */
function NotConnected({
  stepsTotal,
  errors,
}: {
  stepsTotal: number
  errors: Record<string, FieldFault>
}) {
  const form = useForm<{ project_id: string; key_file: File | null }>({
    project_id: '',
    key_file: null,
  })

  const [localProjectError, setLocalProjectError] = useState<string | null>(null)
  const [localFileError, setLocalFileError] = useState<string | null>(null)
  const fromServer = Boolean(errors.project_id || errors.key_file)
  const [open, setOpen] = useState(fromServer)

  const projectError = localProjectError ?? faultText(errors.project_id)
  const fileError = localFileError ?? faultText(errors.key_file)

  const handleSubmit = (event: FormEvent) => {
    event.preventDefault()

    const value = form.data.project_id.trim()
    if (!PROJECT_ID_PATTERN.test(value)) {
      setLocalProjectError(value ? projectIdError() : t('errors.PROJECT_ID_MISSING'))
      return
    }
    setLocalProjectError(null)

    if (!form.data.key_file) {
      setLocalFileError(t('errors.KEY_FILE_REQUIRED'))
      return
    }
    setLocalFileError(null)

    // Sin credencial anterior no hay nada que reemplazar: la carga no es
    // destructiva y no pide confirmación.
    form.post(route('credential.upload'), { forceFormData: true, preserveState: true })
  }

  return (
    <>
      <Card>
        <CardHeader>
          <CardTitle asChild>
            <h2>{t('settings.notConnected.title')}</h2>
          </CardTitle>
          <CardDescription className="text-pretty">
            {t('settings.notConnected.description')}
          </CardDescription>
        </CardHeader>

        <CardContent>
          <Button asChild size="lg" variant={open ? 'outline' : 'default'}>
            <Link href={route('onboarding')}>
              <RouteIcon aria-hidden />
              {t('settings.notConnected.guideMe')}
            </Link>
          </Button>
          <p className="text-muted-foreground mt-2 text-sm text-pretty">
            {t('settings.notConnected.steps', { total: formatNumber(stepsTotal) })}
          </p>
        </CardContent>
      </Card>

      <Collapsible open={open} onOpenChange={setOpen}>
        <Section
          title={{ text: t('settings.haveFile') }}
          actions={
            <CollapsibleTrigger asChild>
              <Button variant="outline" size="sm">
                <ChevronRight
                  aria-hidden
                  className={cn(
                    'transition-transform duration-200 motion-reduce:transition-none',
                    open && 'rotate-90'
                  )}
                />
                {open ? t('settings.hideForm') : t('settings.loadCredential')}
              </Button>
            </CollapsibleTrigger>
          }
        >
          <CollapsibleContent>
            <form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
              <ProjectIdField
                value={form.data.project_id}
                error={projectError}
                onChange={(next) => {
                  setLocalProjectError(null)
                  form.setData('project_id', next)
                }}
              />

              <KeyFileField
                name="key_file"
                label={t('settings.keyFile.label')}
                file={form.data.key_file}
                error={fileError}
                help={t('settings.keyFile.help')}
                onChange={(file) => {
                  setLocalFileError(null)
                  form.setData('key_file', file)
                }}
              />

              <div>
                <Button
                  type="submit"
                  disabled={form.processing}
                  aria-busy={form.processing || undefined}
                >
                  {form.processing ? (
                    <>
                      <Spinner />
                      {t('settings.checkingKey')}
                    </>
                  ) : (
                    t('settings.loadAndCheck')
                  )}
                </Button>
              </div>
            </form>
          </CollapsibleContent>
        </Section>
      </Collapsible>
    </>
  )
}

// --- Carga y reemplazo de la credencial -------------------------------------

/** La dirección que abre el reemplazo con un campo bajo el foco. */
function replaceHref(address: string, focusOn: 'project' | 'file'): string {
  const base = addressWithout(address, REPLACE_PARAM)
  const [path, ...searchParts] = base.split('?')
  const query = new URLSearchParams(searchParts.join('?'))
  query.set(REPLACE_PARAM, focusOn)
  return `${path}?${query.toString()}`
}

/**
 * Reemplazar la credencial en uso: un formulario corto que bloquea, y su
 * confirmación.
 *
 * Está abierto o cerrado **según la dirección** (`?replace=`), que es lo que
 * permite que una respuesta con error vuelva a la misma pantalla con el diálogo
 * puesto, lo escrito adentro y el mensaje al lado de su campo. Antes se
 * desplegaba en el medio de la página y empujaba todo lo de abajo.
 *
 * La confirmación va **después** del formulario y no adentro: dos diálogos
 * abiertos a la vez es lo único que el contrato prohíbe de plano, así que el
 * formulario se cierra y la confirmación —que nombra la credencial que se va a
 * reemplazar y dice qué queda intacto— se queda sola en pantalla.
 */
function ReplaceCredential({
  credential,
  errors,
  address,
}: {
  credential: Credential
  errors: Record<string, FieldFault>
  address: string
}) {
  const focusOn = addressParam(address, REPLACE_PARAM)
  const form = useForm<{ project_id: string; key_file: File | null; replace: string }>({
    project_id: credential.project_id,
    key_file: null,
    // Viaja con el envío para que el servidor pueda devolver el diálogo abierto
    // cuando lo que manda tiene algo que corregir.
    replace: focusOn || 'file',
  })

  const [localProjectError, setLocalProjectError] = useState<string | null>(null)
  const [localFileError, setLocalFileError] = useState<string | null>(null)
  const [confirming, setConfirming] = useState(false)

  const projectError = localProjectError ?? faultText(errors.project_id)
  const fileError = localFileError ?? faultText(errors.key_file)

  const submitToServer = () =>
    form.post(route('credential.upload'), {
      forceFormData: true,
      // Lo elegido sobrevive a la respuesta: si el servidor rechaza el archivo,
      // el diálogo vuelve a abrirse con el mismo contenido y el motivo al lado.
      preserveState: true,
    })

  const handleSubmit = (event: FormEvent) => {
    event.preventDefault()

    // La forma del identificador se comprueba acá, antes de subir dos kilobytes
    // y de gastar una llamada: el error frecuente es pegar el nombre del
    // proyecto en lugar de su identificador, y eso se ve sin preguntarle a nadie.
    const value = form.data.project_id.trim()
    if (!PROJECT_ID_PATTERN.test(value)) {
      setLocalProjectError(value ? projectIdError() : t('errors.PROJECT_ID_MISSING'))
      return
    }
    setLocalProjectError(null)

    if (!form.data.key_file) {
      setLocalFileError(t('errors.KEY_FILE_REQUIRED'))
      return
    }
    setLocalFileError(null)

    // Reemplazar la credencial en uso es destructivo: la anterior deja de firmar
    // consultas en cuanto ésta se guarda (RT-10).
    setConfirming(true)
  }

  return (
    <>
      <FormDialog
        param={REPLACE_PARAM}
        title={t('settings.replace.title')}
        description={t('settings.replace.formDescription')}
        submitLabel={t('settings.replace.title')}
        pendingLabel={t('settings.checkingKey')}
        pending={form.processing}
        onSubmit={handleSubmit}
      >
        <ProjectIdField
          value={form.data.project_id}
          error={projectError}
          autoFocus={focusOn === 'project'}
          onChange={(next) => {
            setLocalProjectError(null)
            form.setData('project_id', next)
          }}
        />

        <KeyFileField
          name="key_file"
          label={t('settings.keyFile.label')}
          file={form.data.key_file}
          error={fileError}
          help={t('settings.keyFile.help')}
          onChange={(file) => {
            setLocalFileError(null)
            form.setData('key_file', file)
          }}
        />
      </FormDialog>

      <ConfirmDestructive
        open={confirming}
        onOpenChange={setConfirming}
        title={t('settings.replace.confirm.title', { email: credential.client_email })}
        consequence={t('settings.replace.confirm.consequence')}
        confirmLabel={t('settings.replace.title')}
        busyLabel={t('settings.replace.busy')}
        busy={form.processing}
        onConfirm={submitToServer}
      />
    </>
  )
}

function ProjectIdField({
  value,
  error,
  autoFocus,
  onChange,
}: {
  value: string
  error?: string
  autoFocus?: boolean
  onChange: (value: string) => void
}) {
  return (
    <Field>
      <FieldLabel htmlFor="project_id">{t('settings.projectId.label')}</FieldLabel>
      <Input
        id="project_id"
        name="project_id"
        spellCheck={false}
        autoCapitalize="none"
        autoCorrect="off"
        autoFocus={autoFocus}
        placeholder={t('settings.projectId.placeholder')}
        value={value}
        onChange={(event) => onChange(event.target.value)}
        {...fieldErrorProps('error-project', error)}
      />
      <FieldDescription className="text-pretty">
        {t('settings.projectId.looksLike')}{' '}
        <CodeChip value={t('settings.projectId.example')} className="text-xs" />
        {t('settings.projectId.help')}
      </FieldDescription>
      <FieldError id="error-project" message={error} />
    </Field>
  )
}

// --- Al pie -----------------------------------------------------------------

/**
 * Las credenciales anteriores, plegadas.
 *
 * Un solo bloque secundario, así que es un `Collapsible` y no un acordeón.
 * Queda el registro de cuál se usó y hasta cuándo: sin eso, reemplazar una clave
 * borra de la vista toda huella de la anterior y después no hay manera de
 * explicar por qué un dominio dejó de responder a partir de cierta fecha.
 */
function CredentialHistory({ history }: { history: Props['history'] }) {
  const [open, setOpen] = useState(false)

  return (
    <Collapsible open={open} onOpenChange={setOpen}>
      <Section
        title={{ text: t('settings.history.title') }}
        actions={
          <CollapsibleTrigger asChild>
            <Button variant="ghost" size="sm">
              <ChevronRight
                aria-hidden
                className={cn(
                  'transition-transform duration-200 motion-reduce:transition-none',
                  open && 'rotate-90'
                )}
              />
              {open
                ? t('settings.history.hide')
                : t('settings.history.show', { total: formatNumber(history.length) })}
            </Button>
          </CollapsibleTrigger>
        }
      >
        <CollapsibleContent className="flex flex-col gap-2">
          {history.map((item) => (
            <Item key={item.id} variant="outline">
              <ItemContent className="min-w-0">
                <span
                  className="font-mono text-xs wrap-anywhere"
                  aria-label={t('connection.field.fingerprint.spelled', {
                    value: spellOut(item.key_fingerprint),
                  })}
                  translate="no"
                >
                  {item.key_fingerprint}
                </span>
              </ItemContent>
              <ItemActions>
                <span className="text-muted-foreground text-xs">
                  {t(`credentialStatus.${item.status}` as TranslationKey)}
                </span>
                <DataTimestamp value={item.created_at} className="text-muted-foreground text-xs" />
              </ItemActions>
            </Item>
          ))}
        </CollapsibleContent>
      </Section>
    </Collapsible>
  )
}
