import { Link, router, useForm, usePage } from '@inertiajs/react'
import { ArrowRight, Check, ChevronRight, CircleAlert, Lock } from 'lucide-react'
import type { FormEvent } from 'react'
import { useEffect, useRef, useState } from 'react'

import { AccessiblePropertyList } from '@/components/AccessiblePropertyList'
import { BatchStateBadge } from '@/components/BatchStateBadge'
import { CredentialErrorPanel } from '@/components/CredentialErrorPanel'
import { DataTimestamp, TimezoneFootnote } from '@/components/DataTimestamp'
import { FieldError, fieldErrorProps } from '@/components/FieldError'
import { GoogleConsoleLink } from '@/components/GoogleConsoleLink'
import type { GoogleScreen } from '@/components/GoogleConsoleLink'
import { KeyFileExample } from '@/components/KeyFileExample'
import { KeyFileField } from '@/components/KeyFileField'
import { PageIntro } from '@/components/PageIntro'
import { RichText } from '@/components/RichText'
import { Section } from '@/components/Section'
import { ServerErrorNotice } from '@/components/ServerErrorNotice'
import { ServiceAccountAddress } from '@/components/ServiceAccountAddress'
import { StepItem, StepList } from '@/components/StepList'
import { StatusBadge } from '@/components/StatusBadge'
import { TaskColumn } from '@/components/TaskColumn'
import { useBatchPolling } from '@/hooks/useBatchPolling'
import { AppLayout } from '@/layouts/AppLayout'
import { AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { Field, FieldLabel } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Item } from '@/components/ui/item'
import { faultText } from '@/lib/errors'
import type { FieldFault } from '@/lib/errors'
import { formatNumber } from '@/lib/format'
import { hasTranslation, t } from '@/lib/i18n'
import type { TranslationKey } from '@/lib/i18n'
import { route } from '@/lib/routes'
import { cn } from '@/lib/utils'

interface StepInput {
  name: string
  /** `text` · `url` · `file` · `choice`. Lo decide el paso, no el formulario. */
  type: string
  required: boolean
  default?: string
  /**
   * Los valores que ofrece un campo de opciones. Cómo se llama cada uno lo dice
   * el catálogo: el servidor manda cuáles hay, no cómo se leen.
   */
  options?: string[]
}

interface WizardStep {
  code: string
  position: number
  /**
   * Si el paso trae un ejemplo del dato que hay que buscar.
   *
   * El texto —el título, qué se consigue, la ruta dentro de Google, el ejemplo y
   * su aclaración— sale del catálogo por el código del paso. Lo que decide el
   * servidor es cuáles son los pasos y en qué orden, no cómo se dicen.
   */
  has_example: boolean
  /** `PASSED` · `MISSING` · `BLOCKED` · `UNCONFIRMED`. */
  state: string
  completed: boolean
  completed_at: string | null
  is_current: boolean
  /**
   * El **código** de lo que falta, con los datos que su frase necesita.
   *
   * De él salen las dos frases del paso —qué falta y dónde se resuelve—, que
   * juntas son el requisito de FR-017. Vienen como código porque el recorrido se
   * lee en el idioma de quien lo hace, y el servidor no sabe cuál es.
   */
  reason: string
  reason_params: Record<string, string | number>
  blocked_by: string
  evidence: Record<string, unknown>
  inputs: StepInput[]
  /** La pantalla equivalente del modo directo. El recorrido es un atajo, no una llave. */
  form_path: string
}

interface Created {
  project_id: string | null
  client_email: string | null
  credential_status: string | null
  properties: { property_uri: string; permission: string }[]
  domain_id: string | null
  hostname: string | null
  property_uri: string | null
  access_state: string | null
  sitemap_id: string | null
  sitemap_location: string | null
  batch_id: string | null
  batch_state: string | null
  /**
   * Los mismos destinos, ya resueltos por el servidor.
   *
   * Están para quien consume `GET /onboarding` desde afuera y no tiene la tabla
   * de rutas del navegador. Esta pantalla no los usa: resuelve por nombre.
   */
  batch_path: string | null
  coverage_path: string | null
  sitemaps_path: string | null
}

interface Onboarding {
  should_offer: boolean
  current_step: string
  /** El paso que se acaba de comprobar. Vacío cuando la pantalla se abre sin comprobar nada. */
  checked_step: string
  completed_steps: string[]
  is_finished: boolean
  is_dismissed: boolean
  completed_at: string | null
  dismissed_at: string | null
  started_at: string | null
  steps: WizardStep[]
  created: Created
  key_file_example: Record<string, string>
  key_file_highlights: string[]
}

interface Props {
  onboarding: Onboarding
}

/**
 * El cierre, que ocupa un lugar en el acordeón sin ser un paso.
 *
 * Tiene código propio para poder abrirse desde la dirección igual que los siete,
 * y no entra en ninguna cuenta: «7 de 7 cumplidos» habla de pasos.
 */
const COMPLETION = 'COMPLETION'

/**
 * Las pantallas de Google donde se resuelve cada paso.
 *
 * Lo que queda acá es el reparto —qué paso se resuelve en qué pantalla—, no las
 * direcciones (ésas viven una sola vez en `GoogleConsoleLink`) ni el rótulo del
 * enlace, que sale del catálogo bajo `step.<CODE>.open`. Los tres últimos pasos
 * no figuran porque pasan en esta aplicación.
 */
const GOOGLE_SCREENS: Record<string, GoogleScreen> = {
  GOOGLE_PROJECT: 'project',
  ENABLE_API: 'enable-api',
  SERVICE_ACCOUNT_KEY: 'service-accounts',
  AUTHORIZE_PROPERTY: 'search-console',
}

/**
 * Qué paso está abierto, leído de la dirección.
 *
 * Antes vivía en `useState`, y por eso el botón Atrás no volvía al paso anterior
 * y no se podía enlazar «mirá el paso 4». Con el paso en `?step=`, la pantalla
 * no tiene estado propio que defender: lo que se ve sale de la URL.
 *
 * Un `?step=` que no nombra ningún paso se ignora en silencio —casi siempre es
 * un enlace de otra versión de la pantalla— y se cae al paso vigente, que es lo
 * que el servidor calculó como el primero en el que se puede hacer algo.
 */
export function openStepCode(
  address: string,
  codes: string[],
  currentStep: string,
  isFinished: boolean
): string {
  const known = [...codes, ...(isFinished ? [COMPLETION] : [])]
  const asked = new URLSearchParams(address.split('#')[0].split('?').slice(1).join('?')).get('step')

  if (asked && known.includes(asked)) return asked
  // Con el recorrido terminado no hay ningún paso que resolver, y el único lugar
  // donde queda algo para hacer es el cierre. Dejarlo cerrado abriría el último
  // paso ya cumplido, o sea una pantalla cuya acción principal sería volver a
  // comprobar algo que ya está.
  if (isFinished) return COMPLETION
  if (codes.includes(currentStep)) return currentStep
  return codes[0] ?? ''
}

/** La misma dirección con otro paso abierto. Conserva lo demás y el ancla. */
export function stepHref(address: string, code: string): string {
  const [withoutHash, ...hashParts] = address.split('#')
  const hash = hashParts.length > 0 ? `#${hashParts.join('#')}` : ''
  const [path, ...searchParts] = withoutHash.split('?')
  const query = new URLSearchParams(searchParts.join('?'))
  query.set('step', code)
  return `${path}?${query.toString()}${hash}`
}

export default function OnboardingWizard({ onboarding }: Props) {
  const { url, props } = usePage()
  const errors = props.errors as unknown as Record<string, FieldFault>
  const { steps, created, current_step, checked_step, is_finished } = onboarding

  const codes = steps.map((step) => step.code)
  const openCode = openStepCode(url, codes, current_step, is_finished)
  const openStep = steps.find((step) => step.code === openCode) ?? null

  /*
    El último paso muestra el primer lote de la cuenta mientras corre, así que
    esta pantalla también tiene trabajo en curso a la vista.

    Sin esto el badge quedaba clavado en el estado que tenía al cargar, y peor:
    el paso se da por terminado leyendo `batch_state`, así que el recorrido no
    se completaba hasta que alguien recargara a mano. Justo en la pantalla de
    quien todavía no sabe que hay algo que recargar.

    Se recarga sólo `onboarding`, que es lo único que se mueve.
  */
  useBatchPolling({ states: [created.batch_state], only: ['onboarding'] })

  // Abrir un paso es navegar. Se conserva lo que la pantalla tenga a medio hacer
  // y la posición: cambiar de paso no es volver a cargar el recorrido.
  const openAt = (code: string) =>
    router.get(stepHref(url, code), {}, { preserveScroll: true, preserveState: true })

  return (
    <AppLayout title={t('tour.title')} description={description(onboarding)}>
      {/*
        Una sola columna angosta. El recorrido se lee de arriba abajo en el orden
        en que se resuelve —cuánto falta, qué hay que lograr ahora, cómo— y una
        barra lateral con los siete pasos pondría el progreso a competir con el
        objetivo del paso, que es lo único que necesita quien no sabe qué es
        Google Cloud.
      */}
      <TaskColumn>
        <PageIntro items={progressItems(onboarding, openStep)} />

        {errors.verify ? (
          <ServerErrorNotice
            code={errors.verify.code}
            message=""
            action={
              <Button
                size="sm"
                variant="outline"
                onClick={() => router.post(route('onboarding.verify', { step: openCode }))}
              >
                {t('tour.recheck')}
              </Button>
            }
          />
        ) : null}

        {/*
          El título de la región no se dibuja: la línea de estado que está justo
          arriba ya dice «Paso 3 de 7», así que verlo escrito otra vez sería el
          mismo dato dos veces en la misma pantalla, y son treinta y seis píxeles
          entre la persona y el campo que vino a completar. El encabezado sigue
          existiendo: lo que se oculta es el dibujo, no el nivel.
        */}
        <Section title={{ text: t('tour.steps'), isHidden: true }}>
          <StepList openStep={openCode}>
            {steps.map((step) => (
              <StepItem
                key={step.code}
                step={step}
                onOpen={() => openAt(step.code)}
                current={step.code === openCode}
              >
                {/*
                  El panel se monta sólo para el paso abierto. Los otros seis son
                  una línea cerrada: su número, su título, su estado y su fecha.
                  Montarlos todos era lo que hacía que la pantalla dibujara siete
                  pasos completos para resolver uno.
                */}
                {step.code === openCode ? (
                  <StepPanel
                    key={step.code}
                    step={step}
                    created={created}
                    errors={errors}
                    address={url}
                    keyFileExample={onboarding.key_file_example}
                    keyFileHighlights={onboarding.key_file_highlights}
                    justChecked={checked_step === step.code}
                    nextStep={nextPending(steps, step.code)}
                  />
                ) : null}
              </StepItem>
            ))}

            {is_finished ? (
              <CompletionItem
                created={created}
                completedAt={onboarding.completed_at}
                onOpen={() => openAt(COMPLETION)}
                open={openCode === COMPLETION}
              />
            ) : null}
          </StepList>
        </Section>

        {/*
          La salida no está terminada mientras no diga qué implica, y eso son dos
          renglones que no entran en la cabecera —alto fijo, una sola línea—. Va
          fuera del acordeón para que siga a la vista en los siete pasos, que es
          lo que la hace persistente.
        */}
        {is_finished ? null : (
          <SkipTour dismissPath={route('onboarding.dismiss')} firstTime={isFirstTime(onboarding)} />
        )}

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

/** El próximo paso pendiente después de uno dado. Vacío cuando no queda ninguno. */
function nextPending(steps: WizardStep[], from: string): string {
  const index = steps.findIndex((step) => step.code === from)
  return steps.slice(index + 1).find((step) => !step.completed)?.code ?? ''
}

// --- Lo que falta, dicho en el idioma de quien mira -------------------------

/**
 * Las dos frases de un motivo: qué falta y dónde se resuelve.
 *
 * El servidor manda el código y los datos; el catálogo arma la oración. Los
 * datos que son a su vez códigos —el estado de acceso de un dominio— se
 * traducen antes de entrar en la frase: mandar la etiqueta desde el servidor lo
 * ataría al idioma que tenía cuando la escribió.
 */
function reasonText(step: WizardStep, part: 'missing' | 'hint'): string {
  if (!step.reason) return ''

  const params = { ...step.reason_params }
  if (typeof params.access_state === 'string') {
    params.access_state = t(`access.${params.access_state}.label` as TranslationKey)
  }

  const key = `stepReason.${step.reason}.${part}`
  if (!hasTranslation(key)) return part === 'missing' ? t('stepReason.UNKNOWN.missing') : ''

  return t(key as TranslationKey, params)
}

// --- Cuánto falta -----------------------------------------------------------

/**
 * Lo primero que hay que poder contestar: en qué paso estoy y cuántos van.
 *
 * Son dos hechos del recorrido entero y por eso viven en la línea de estado de
 * la página. El estado de **cada** paso ya lo dice su fila, así que acá no se
 * repite ninguno.
 */
function progressItems(onboarding: Onboarding, openStep: WizardStep | null) {
  const done = onboarding.completed_steps.length
  const total = onboarding.steps.length

  if (onboarding.is_finished) {
    return [t('tour.progress.allDone', { total: formatNumber(total) })]
  }

  return [
    openStep
      ? t('tour.progress.position', { position: openStep.position, total: formatNumber(total) })
      : null,
    t('tour.progress.done', { done: formatNumber(done) }),
  ]
}

// --- Encabezado y salida ----------------------------------------------------

/** Nadie hizo nada todavía: ni pasos cumplidos, ni objetos creados, ni una omisión previa. */
function isFirstTime(onboarding: Onboarding): boolean {
  return (
    onboarding.completed_steps.length === 0 &&
    !onboarding.is_dismissed &&
    !onboarding.created.project_id &&
    !onboarding.created.client_email
  )
}

/**
 * La línea que dice en qué momento del recorrido está la persona.
 *
 * Al retomar nombra los pasos que ya estaban listos. Es la mitad de «volver no
 * es empezar de nuevo»; la otra mitad son los objetos ya creados, que cada paso
 * muestra nombrados en vez de un formulario vacío (FR-019).
 */
function description(onboarding: Onboarding): string {
  if (onboarding.is_finished) return t('tour.description.finished')

  if (isFirstTime(onboarding)) {
    return t('tour.description.firstTime', { steps: onboarding.steps.length })
  }

  return t('tour.description.resumed', { done: alreadyDone(onboarding) })
}

function alreadyDone(onboarding: Onboarding): string {
  const positions = onboarding.steps
    .filter((step) => onboarding.completed_steps.includes(step.code))
    .map((step) => step.position)

  if (positions.length === 0) return t('tour.done.none')
  if (positions.length === 1) return t('tour.done.single', { step: positions[0] })

  const first = positions[0]
  const last = positions[positions.length - 1]
  const consecutive = last - first + 1 === positions.length

  if (consecutive) return t('tour.done.range', { first, last })
  return t('tour.done.list', { steps: positions.join(', ') })
}

/**
 * Salir del recorrido, siempre a la vista y siempre explicado.
 *
 * No pide confirmación porque no se pierde nada: el progreso queda guardado y
 * ninguna funcionalidad se restringe (FR-018). Un diálogo acá sugeriría lo
 * contrario, que es justo lo que hay que evitar.
 *
 * Por eso mismo la explicación viaja pegada al botón y no en un globo: lo que
 * frena a alguien acá no es no encontrar la salida, es no saber qué pierde al
 * tomarla, y esa respuesta no puede depender de pasar el mouse por encima.
 */
function SkipTour({ dismissPath, firstTime }: { dismissPath: string; firstTime: boolean }) {
  const form = useForm({})

  return (
    <div className="flex flex-col gap-2">
      <Button
        variant="outline"
        className="self-start"
        disabled={form.processing}
        aria-busy={form.processing || undefined}
        onClick={() => form.post(dismissPath)}
      >
        {form.processing
          ? t('tour.skip.leaving')
          : firstTime
            ? t('tour.skip.firstTime')
            : t('tour.skip.later')}
      </Button>
      <p className="text-muted-foreground text-xs text-pretty">{t('tour.skip.note')}</p>
    </div>
  )
}

// --- El paso abierto --------------------------------------------------------

interface PanelProps {
  step: WizardStep
  created: Created
  errors: Record<string, FieldFault>
  /** La dirección vigente, para armar el enlace al paso que destraba a éste. */
  address: string
  keyFileExample: Record<string, string>
  keyFileHighlights: string[]
  /**
   * Si el veredicto que se está mostrando es el de una comprobación recién
   * hecha. Decide si el bloque del resultado se anuncia: abrir un paso pendiente
   * no es que algo haya fallado, y anunciarlo al cargar convierte «todavía no
   * hiciste esto» en una alarma sobre algo que nadie intentó.
   */
  justChecked: boolean
  /** El próximo paso pendiente. Vacío cuando no queda ninguno. */
  nextStep: string
}

/**
 * El paso que se está resolviendo: objetivo, ruta en Google, ejemplo y comprobación.
 *
 * El orden no es negociable: primero **para qué sirve** lo que se está pidiendo,
 * después dónde hacer clic. Quien no sabe qué es Google Cloud necesita entender
 * el objetivo antes que la ruta; al revés, sigue instrucciones sin saber qué
 * está armando y no puede darse cuenta cuando algo sale distinto.
 *
 * Vive adentro del ítem abierto del acordeón, así que ya no escribe su propio
 * título ni su propio estado: los escribe la fila, una sola vez. Ésa era la
 * duplicación de la vista —el mismo paso dibujado en el panel y otra vez en la
 * lista de abajo— y desaparece sin perder ninguno de los dos.
 */
function StepPanel({
  step,
  created,
  errors,
  address,
  keyFileExample,
  keyFileHighlights,
  justChecked,
  nextStep,
}: PanelProps) {
  const form = useForm<Record<string, string | File | null>>(() => initialValues(step, created))
  const firstFieldRef = useRef<HTMLInputElement>(null)
  const resultRef = useRef<HTMLDivElement>(null)
  const hasFileInput = step.inputs.some((field) => field.type === 'file')
  const blocked = step.state === 'BLOCKED'

  const hasFieldError = step.inputs.some((field) => errors[field.name])

  useEffect(() => {
    // El foco vuelve al primer campo con error al recibir la respuesta: sin esto
    // hay que recorrer el formulario entero para encontrar qué salió mal (RT-17).
    if (step.inputs.some((field) => errors[field.name])) firstFieldRef.current?.focus()
  }, [errors, step.inputs])

  useEffect(() => {
    // Cuando la comprobación no pasa, el foco va al resultado: lo que hay que
    // leer es qué falta, y está a la vista de quien apretó el botón (RT-11). El
    // botón queda deshabilitado mientras corre, así que sin esto el foco se
    // pierde en el cuerpo del documento.
    //
    // Salvo que lo enviado tenga un error de campo: ahí no falta un paso previo,
    // hay un dato concreto que corregir y el foco es del campo que lo corrige
    // (RT-08).
    if (!justChecked || step.state === 'PASSED' || hasFieldError) return
    resultRef.current?.focus()
  }, [justChecked, step.state, hasFieldError])

  const check = (event?: FormEvent) => {
    event?.preventDefault()
    form.post(route('onboarding.verify', { step: step.code }), {
      // Sin `preserveScroll` la respuesta manda arriba de todo y el resultado
      // aparece fuera de la vista de quien apretó el botón (RT-11).
      preserveScroll: true,
      // Qué paso queda abierto ya no depende de esto: lo decide el servidor y
      // vuelve en `?step=`. Lo que `preserveState` conserva ahora es lo escrito
      // en el formulario cuando la respuesta trae un error de campo, para no
      // hacer reescribir un dato que sólo había que corregir.
      preserveState: true,
      forceFormData: hasFileInput,
    })
  }

  const googleScreen = GOOGLE_SCREENS[step.code]
  const createdItems = alreadyCreated(step, created)

  return (
    <div className="flex flex-col gap-5">
      {/* El objetivo, antes que la ruta y antes que el progreso. */}
      <p className="text-foreground text-pretty">
        {t(`step.${step.code}.goal` as TranslationKey)}
      </p>

      <dl className="grid gap-2">
        <div className="flex flex-wrap gap-x-2">
          <dt className="text-muted-foreground shrink-0">{t('tour.where')}</dt>
          <dd className="min-w-0 font-mono leading-5 wrap-break-word">
            {t(`step.${step.code}.path` as TranslationKey)}
          </dd>
        </div>
        <div className="flex flex-wrap gap-x-2">
          <dt className="text-muted-foreground shrink-0">{t('tour.bringBack')}</dt>
          <dd className="min-w-0 wrap-break-word">
            <RichText text={t(`step.${step.code}.bringBack` as TranslationKey)} />
          </dd>
        </div>
      </dl>

      {step.has_example ? (
        <div className="bg-muted/40 rounded-md px-3 py-2">
          <p className="text-muted-foreground text-xs">{t('tour.looksLike')}</p>
          <code className="text-sm wrap-anywhere" translate="no">
            {t(`step.${step.code}.example` as TranslationKey)}
          </code>
        </div>
      ) : null}

      <p className="text-muted-foreground text-sm leading-relaxed text-pretty">
        <RichText text={t(`step.${step.code}.note` as TranslationKey)} />
      </p>

      {googleScreen ? (
        <div>
          <GoogleConsoleLink screen={googleScreen} projectId={created.project_id}>
            {t(`step.${step.code}.open` as TranslationKey)}
          </GoogleConsoleLink>
        </div>
      ) : null}

      {step.code === 'SERVICE_ACCOUNT_KEY' ? (
        <>
          <p className="text-muted-foreground text-sm leading-relaxed text-pretty">
            <RichText text={t('tour.keyFile.notAnApiKey')} />
          </p>
          {/*
            El ejemplo va plegado: son quinientos píxeles de referencia que se
            miran **mientras se busca el archivo en Google**, en otra pestaña, y
            desplegados empujaban el campo donde se sube fuera de la primera
            pantalla. Un solo bloque secundario, así que es `Collapsible` y no
            un acordeón.

            Y siempre el ejemplo inventado del servidor, nunca el archivo de la
            persona: es una pieza para comparar contra lo que bajó, no una vista
            previa de lo que subió (RT-06).
          */}
          <KeyFilePreview example={keyFileExample} highlights={keyFileHighlights} />
        </>
      ) : null}

      {step.code === 'AUTHORIZE_PROPERTY' && created.client_email ? (
        <ServiceAccountAddress
          clientEmail={created.client_email}
          heading={t('tour.serviceAccount.heading')}
        />
      ) : null}

      {createdItems.length > 0 ? <CreatedSoFar items={createdItems} /> : null}

      {/*
        Sin encabezado propio: adentro de un paso, un título más sería un cuarto
        nivel, y el contrato no tiene ninguno. La lista se lee como lo que el
        paso acaba de conseguir, que es exactamente donde está.
      */}
      {step.code === 'AUTHORIZE_PROPERTY' && created.properties.length > 0 ? (
        <AccessiblePropertyList properties={created.properties} />
      ) : null}

      {/*
        Un paso bloqueado no ofrece su formulario ni su botón. No le falta nada
        que se pueda hacer acá: le falta un paso previo, y ofrecer un control que
        no puede funcionar —aunque sea deshabilitado— manda a insistir sobre algo
        que no depende de esta persona (RT-07).
      */}
      {blocked ? (
        <BlockedStep
          step={step}
          address={address}
          resultRef={resultRef}
          justChecked={justChecked}
        />
      ) : (
        <form onSubmit={check} className="flex flex-col gap-5" noValidate>
          {step.inputs.map((field, index) => (
            <StepField
              key={field.name}
              field={field}
              value={form.data[field.name] ?? ''}
              error={faultText(errors[field.name])}
              inputRef={index === 0 ? firstFieldRef : undefined}
              onChange={(value) => form.setData(field.name, value)}
            />
          ))}

          <CheckResult
            step={step}
            resultRef={resultRef}
            justChecked={justChecked}
            checking={form.processing}
            onRecheck={() => check()}
          />

          <div className="flex flex-wrap items-center gap-3">
            <Button
              type="submit"
              size="lg"
              disabled={form.processing}
              aria-busy={form.processing || undefined}
            >
              {form.processing
                ? t('tour.checking')
                : step.state === 'PASSED'
                  ? t('tour.recheck')
                  : t('tour.check')}
            </Button>

            {/*
              «Continuar» no existe hasta que la comprobación pasa. Un botón
              deshabilitado obligaría a explicar por qué no se puede apretar, y
              la explicación sería justamente lo que dice el paso más arriba.
            */}
            {step.state === 'PASSED' && nextStep ? (
              <Button asChild variant="outline">
                <Link href={stepHref(address, nextStep)} preserveScroll preserveState>
                  {t('tour.continue')}
                  <ArrowRight aria-hidden />
                </Link>
              </Button>
            ) : null}
          </div>
        </form>
      )}

      {/*
        La salida a la pantalla completa está en todos los pasos, cumplidos o no:
        el recorrido es un atajo y quien prefiera el formulario de siempre no
        pierde el progreso por usarlo (FR-018).
      */}
      <p className="text-sm">
        <Link
          href={step.form_path}
          className="text-muted-foreground hover:text-foreground underline! underline-offset-4"
        >
          {t('tour.preferFullForm')}
        </Link>
      </p>
    </div>
  )
}

/**
 * El archivo de clave por dentro, a un clic.
 *
 * Es lo que decide si alguien que nunca entró a Google Cloud puede avanzar solo
 * —en la misma pantalla de Google hay tres archivos que se parecen y la única
 * forma de distinguirlos es mirando adentro—, pero se mira una vez y ocupa media
 * pantalla. Plegado, el campo donde se sube el archivo queda a la vista.
 */
function KeyFilePreview({
  example,
  highlights,
}: {
  example: Record<string, string>
  highlights: string[]
}) {
  const [open, setOpen] = useState(false)

  return (
    <Collapsible open={open} onOpenChange={setOpen}>
      <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('tour.keyFile.hide') : t('tour.keyFile.show')}
        </Button>
      </CollapsibleTrigger>
      <CollapsibleContent className="pt-3">
        <KeyFileExample example={example} highlights={highlights} />
      </CollapsibleContent>
    </Collapsible>
  )
}

/**
 * Un paso que todavía no se puede comprobar, con el camino al que lo destraba.
 *
 * No se presenta como falla: nada salió mal y no hay nada que corregir acá. Por
 * eso el tono es el neutro y no el de alarma —`BLOCKED` es `neutral`, nunca
 * `critical`— y por eso «Ir al paso que falta» es un enlace de verdad: lleva a
 * una dirección que existe.
 */
function BlockedStep({
  step,
  address,
  resultRef,
  justChecked,
}: {
  step: WizardStep
  address: string
  resultRef: React.RefObject<HTMLDivElement | null>
  justChecked: boolean
}) {
  return (
    <Alert
      ref={resultRef}
      tabIndex={-1}
      role={justChecked ? 'status' : undefined}
      className="focus-visible:ring-ring/50 focus-visible:ring-3 focus-visible:outline-none"
    >
      <Lock />
      <AlertTitle className="text-pretty">
        <RichText text={reasonText(step, 'missing')} />
      </AlertTitle>
      <AlertDescription className="flex flex-col items-start gap-3">
        <span className="text-pretty">
          <RichText text={reasonText(step, 'hint')} />
        </span>
        {/* Enlace y no botón: `AlertDescription` subraya todo `<a>`, así que un
            `Button asChild` sale con borde y subrayado a la vez. */}
        {step.blocked_by ? (
          <Link
            href={stepHref(address, step.blocked_by)}
            preserveScroll
            preserveState
            className="focus-visible:ring-ring inline-flex items-center gap-1 rounded font-medium focus-visible:ring-2 focus-visible:outline-none"
          >
            {t('tour.goToBlocking')}
            <ArrowRight className="size-4" aria-hidden />
          </Link>
        ) : null}
      </AlertDescription>
    </Alert>
  )
}

/**
 * El veredicto de la comprobación, escrito en la pantalla y con su fecha.
 *
 * `role="alert"` sólo cuando falta algo que se resuelve acá. `UNCONFIRMED` es
 * Google que no contestó —no es un error de quien está configurando— y tratarlo
 * como falla lo mandaría a rehacer algo que estaba bien; por eso se anuncia como
 * estado y con el panel neutro de `PROVIDER_UNAVAILABLE`, que es el único motivo
 * de credencial que ese estado puede tener.
 *
 * Y los tres se anuncian sólo si vienen de una comprobación recién hecha. El
 * bloque está en pantalla desde que el paso se abre —diciendo qué falta, que es
 * su trabajo—, así que dejarlo como región viva permanente haría que abrir el
 * recorrido anunciara como novedad algo que nadie intentó todavía.
 */
function CheckResult({
  step,
  resultRef,
  justChecked,
  checking,
  onRecheck,
}: {
  step: WizardStep
  resultRef: React.RefObject<HTMLDivElement | null>
  justChecked: boolean
  checking: boolean
  onRecheck: () => void
}) {
  const focusable = 'focus-visible:ring-ring/50 focus-visible:ring-3 focus-visible:outline-none'

  if (step.state === 'PASSED') {
    return (
      <Alert
        ref={resultRef}
        tabIndex={-1}
        variant="positive"
        role={justChecked ? 'status' : undefined}
        className={focusable}
      >
        <Check />
        <AlertTitle>{t('tour.passed.title')}</AlertTitle>
        {step.completed_at ? (
          <AlertDescription>
            {t('tour.passed.checkedAt')} <DataTimestamp value={step.completed_at} />
          </AlertDescription>
        ) : null}
      </Alert>
    )
  }

  const missing = reasonText(step, 'missing')
  const hint = reasonText(step, 'hint')

  if (step.state === 'UNCONFIRMED') {
    return (
      <div
        ref={resultRef}
        tabIndex={-1}
        className={cn('flex flex-col gap-2 rounded-lg', focusable)}
      >
        <CredentialErrorPanel
          errorCode="PROVIDER_UNAVAILABLE"
          message={missing}
          onRecheck={onRecheck}
          checking={checking}
          announce={justChecked ? 'status' : 'none'}
        />
        {hint ? (
          <p className="text-muted-foreground text-sm leading-relaxed text-pretty">
            <RichText text={hint} />
          </p>
        ) : null}
      </div>
    )
  }

  return (
    <Alert
      ref={resultRef}
      tabIndex={-1}
      variant="attention"
      role={justChecked ? 'alert' : undefined}
      className={focusable}
    >
      <CircleAlert />
      <AlertTitle className="text-pretty">
        <RichText text={missing} />
      </AlertTitle>
      <AlertDescription className="text-pretty">
        <RichText text={hint} />
      </AlertDescription>
    </Alert>
  )
}

// --- Los campos que pide cada paso ------------------------------------------

/**
 * Con qué arranca cada campo al abrir el paso.
 *
 * Se prellena con lo que ya existe. Al retomar, un formulario en blanco es lo
 * que termina en un segundo «ejemplo.com» que se reparte la cuota con el primero
 * (FR-019, SC-009).
 */
function initialValues(step: WizardStep, created: Created): Record<string, string | File | null> {
  const values: Record<string, string | File | null> = {}

  for (const field of step.inputs) {
    if (field.type === 'file') {
      values[field.name] = null
      continue
    }
    values[field.name] = alreadyEntered(field.name, created) || field.default || ''
  }

  return values
}

function alreadyEntered(name: string, created: Created): string {
  switch (name) {
    case 'project_id':
      return created.project_id ?? ''
    case 'hostname':
      return created.hostname ?? ''
    case 'location':
      return created.sitemap_location ?? ''
    default:
      return ''
  }
}

/**
 * El rótulo de una opción.
 *
 * Hoy el único campo de opciones del recorrido es la forma de la propiedad, y sus
 * dos valores ya tienen nombre en el catálogo: es el mismo par que elige el
 * formulario completo de alta de dominio, y decirlo distinto en los dos lugares
 * haría dudar de si son la misma decisión.
 *
 * No hay rama de reserva a propósito. Una que armara `stepField.<campo>.option.…`
 * apuntaría a claves que nadie escribió: el día que aparezca un segundo campo de
 * opciones, lo que corresponde es darle sus textos, no que caiga en un nombre
 * inventado que revienta al dibujar.
 */
function optionLabel(value: string): string {
  return t(`propertyShape.${value}.title` as TranslationKey)
}

/**
 * Un campo, armado desde lo que el paso declara.
 *
 * Los campos salen de `inputs` y no de siete formularios escritos a mano porque
 * quien decide qué dato hace falta para cumplir un paso es su comprobación. Con
 * las dos mitades en archivos distintos, en algún momento el formulario pide algo
 * que la comprobación no mira.
 *
 * Lo que el servidor declara es el dato; cómo se llama, qué ejemplo lleva y qué
 * aclaración necesita sale del catálogo por el nombre del campo.
 */
function StepField({
  field,
  value,
  error,
  inputRef,
  onChange,
}: {
  field: StepInput
  value: string | File | null
  error?: string
  inputRef?: React.RefObject<HTMLInputElement | null>
  onChange: (value: string | File | null) => void
}) {
  const errorId = `error-${field.name}`
  const helpId = `help-${field.name}`
  const label = t(`stepField.${field.name}.label` as TranslationKey)
  const helpKey = `stepField.${field.name}.help`
  const help = hasTranslation(helpKey) ? t(helpKey as TranslationKey) : undefined
  const placeholderKey = `stepField.${field.name}.placeholder`
  const placeholder = hasTranslation(placeholderKey)
    ? t(placeholderKey as TranslationKey)
    : undefined
  const describedBy =
    [help ? helpId : '', error ? errorId : ''].filter(Boolean).join(' ') || undefined
  const text = typeof value === 'string' ? value : ''

  if (field.type === 'choice') {
    return (
      <fieldset className="flex flex-col gap-3">
        <legend className="text-sm font-medium">{label}</legend>
        {/*
          Radios y no un desplegable: las opciones tienen que verse a la vez para
          poder compararse. Un desplegable obliga a recordar la que no está en
          pantalla, que es justo la decisión que se está tomando.
        */}
        {/*
          El recuadro de cada opción sale de `Item`, que es la fila de lista de
          la primitiva. Ninguna página dibuja su propio borde: acá se elige el
          `variant` y lo único que se agrega es en qué se nota la elegida.
        */}
        {(field.options ?? []).map((option) => (
          <Item
            key={option}
            asChild
            variant="outline"
            className={cn(
              'cursor-pointer items-start transition-colors duration-150 motion-reduce:transition-none',
              text === option ? 'border-foreground bg-accent/40' : 'hover:bg-accent/20'
            )}
          >
            <label htmlFor={`${field.name}-${option}`}>
              <input
                type="radio"
                id={`${field.name}-${option}`}
                name={field.name}
                value={option}
                checked={text === option}
                onChange={(event) => onChange(event.target.value)}
                className="accent-foreground mt-0.5 size-4"
              />
              <span className="min-w-0 flex-1 text-pretty">{optionLabel(option)}</span>
            </label>
          </Item>
        ))}
        <FieldError id={errorId} message={error} />
      </fieldset>
    )
  }

  if (field.type === 'file') {
    return (
      <KeyFileField
        name={field.name}
        label={label}
        file={value instanceof File ? value : null}
        error={error}
        help={help}
        inputRef={inputRef}
        onChange={onChange}
      />
    )
  }

  const isUrl = field.type === 'url'

  return (
    <Field>
      <FieldLabel htmlFor={field.name}>{label}</FieldLabel>
      <Input
        id={field.name}
        name={field.name}
        ref={inputRef}
        type={isUrl ? 'url' : 'text'}
        inputMode={isUrl ? 'url' : undefined}
        autoComplete="off"
        spellCheck={false}
        autoCapitalize="none"
        autoCorrect="off"
        placeholder={placeholder}
        value={text}
        onChange={(event) => onChange(event.target.value)}
        {...fieldErrorProps(errorId, error)}
        aria-describedby={describedBy}
      />
      {help ? (
        <p id={helpId} className="text-muted-foreground text-sm text-pretty">
          {help}
        </p>
      ) : null}
      <FieldError id={errorId} message={error} />
    </Field>
  )
}

// --- Lo que ya está creado --------------------------------------------------

interface NamedValue {
  label: string
  value: string
  mono?: boolean
  /** Adónde se va a ver el objeto. Sin esto queda nombrado pero no alcanzable. */
  href?: string
}

/**
 * Los objetos que este paso ya creó, nombrados.
 *
 * Es lo que hace que retomar no se sienta como empezar de nuevo: quien vuelve
 * tiene que **leer** «ejemplo.com» en vez de escribirlo otra vez, porque la
 * segunda escritura es la que termina en dos dominios iguales (FR-019).
 */
function alreadyCreated(step: WizardStep, created: Created): NamedValue[] {
  switch (step.code) {
    case 'GOOGLE_PROJECT':
      return created.project_id
        ? [{ label: t('tour.created.project'), value: created.project_id, mono: true }]
        : []
    case 'SERVICE_ACCOUNT_KEY':
      return created.client_email
        ? [{ label: t('tour.created.clientEmail'), value: created.client_email, mono: true }]
        : []
    case 'ADD_DOMAIN':
      if (!created.hostname) return []
      return [
        { label: t('tour.created.hostname'), value: created.hostname },
        ...(created.property_uri
          ? [
              {
                label: t('tour.created.propertyUri'),
                value: created.property_uri,
                mono: true,
              },
            ]
          : []),
      ]
    case 'ADD_SITEMAP':
      return created.sitemap_location
        ? [{ label: t('tour.created.sitemap'), value: created.sitemap_location, mono: true }]
        : []
    case 'FIRST_BATCH':
      // El lote va con su enlace: una sincronización que se encoló y no se puede
      // seguir es una pantalla que dice «arrancó» y deja a la persona esperando
      // sin dónde mirar (RT-13).
      return created.batch_id
        ? [
            {
              label: t('tour.created.batch'),
              value: `#${created.batch_id.slice(0, 8)}`,
              mono: true,
              href: route('batch.show', { batch_id: created.batch_id }),
            },
          ]
        : []
    default:
      return []
  }
}

function CreatedSoFar({ items }: { items: NamedValue[] }) {
  return (
    <dl className="bg-muted/40 grid gap-2 rounded-lg p-3">
      {items.map((item) => (
        <div key={item.label} className="flex flex-wrap gap-x-2">
          <dt className="text-muted-foreground shrink-0">{item.label}</dt>
          <dd
            className={cn('min-w-0 wrap-anywhere', item.mono && 'font-mono text-xs')}
            translate="no"
          >
            {item.href ? (
              <Link href={item.href} className="underline! underline-offset-4">
                {item.value}
              </Link>
            ) : (
              item.value
            )}
          </dd>
        </div>
      ))}
    </dl>
  )
}

// --- El cierre --------------------------------------------------------------

/**
 * La pantalla final, como un ítem más del acordeón.
 *
 * Es donde más fácil se rompe la única regla que no se puede romper: acá no se
 * promete indexación, ni posicionamiento, ni cuándo va a rastrear Google
 * (RT-05). Lo que se dice es qué hicimos nosotros —encolar un lote— y qué se va
 * a poder ver: el estado que **informó** Google para cada URL, con su fecha.
 *
 * Que sea un ítem y no una tarjeta arriba de todo es lo que evita que la
 * pantalla festeje más de lo que informa: los siete pasos cumplidos quedan
 * plegados en siete líneas y los tres destinos viven adentro de un solo panel.
 */
function CompletionItem({
  created,
  completedAt,
  onOpen,
  open,
}: {
  created: Created
  completedAt: string | null
  onOpen: () => void
  open: boolean
}) {
  const finished = created.batch_state === 'COMPLETED' || created.batch_state === 'PARTIAL'

  return (
    <AccordionItem value={COMPLETION} aria-current={open ? 'step' : undefined}>
      <AccordionTrigger onClick={onOpen} className="items-center gap-3 hover:no-underline">
        <span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">
          <span className="min-w-0 leading-snug text-pretty">
            {finished ? t('completion.title.ran') : t('completion.title.queued')}
          </span>
          <StatusBadge tone="positive" icon={Check}>
            {t('completion.badge')}
          </StatusBadge>
          {created.batch_state ? <BatchStateBadge state={created.batch_state} /> : null}
        </span>
      </AccordionTrigger>

      <AccordionContent className="h-auto pt-2 pb-6 [&_a]:no-underline [&_a]:hover:text-inherit [&_p:not(:last-child)]:mb-0">
        {open ? (
          <div className="flex flex-col gap-4">
            <p className="text-pretty">
              {finished
                ? t('completion.body.ran', { urls: formatNumber(5000) })
                : t('completion.body.queued', { urls: formatNumber(5000) })}
            </p>

            {completedAt ? (
              <p className="text-muted-foreground text-sm">
                {t('completion.finishedAt')} <DataTimestamp value={completedAt} />.
              </p>
            ) : null}

            {/*
              Los destinos se resuelven por nombre, igual que en el resto de la
              pantalla, y no con los `*_path` que también vienen en `created`:
              ésos existen para quien consume la API por su cuenta, que no tiene
              tabla de rutas. Usar los dos mecanismos acá dejaría dos formas de
              escribir el mismo enlace y una sola de acordarse de cambiarlas.
            */}
            <div className="flex flex-wrap items-center gap-3">
              {created.domain_id ? (
                <Button asChild size="lg">
                  <Link href={route('coverage', { domain_id: created.domain_id })}>
                    {t('completion.coverage', {
                      hostname: created.hostname ?? t('completion.yourDomain'),
                    })}
                  </Link>
                </Button>
              ) : null}

              {/*
                El lote, no el listado de sitemaps: lo que se acaba de encolar es
                una corrida concreta y su progreso vive en su propia ficha.
                Mandar al listado del que salió obliga a buscar cuál era (RT-13).
              */}
              {created.batch_id ? (
                <Button asChild variant="outline">
                  <Link href={route('batch.show', { batch_id: created.batch_id })}>
                    {t('completion.batch', { batch: created.batch_id.slice(0, 8) })}
                  </Link>
                </Button>
              ) : null}

              {created.domain_id ? (
                <Button asChild variant="outline">
                  <Link href={route('sitemaps', { domain_id: created.domain_id })}>
                    {t('completion.sitemaps')}
                  </Link>
                </Button>
              ) : null}
            </div>
          </div>
        ) : null}
      </AccordionContent>
    </AccordionItem>
  )
}
