"""
Los pasos del recorrido y la comprobación efectiva de cada uno.

Un paso se da por cumplido cuando su requisito se lee del estado real del
sistema: la credencial existe y Google la aceptó, el dominio quedó operativo, el
sitemap se pudo descargar, el lote se encoló. Nunca por haber visitado la
pantalla ni por haber apretado «Siguiente». Marcar por visita deja a la persona
terminando el recorrido con una configuración que no funciona, y enterándose
recién cuando no llega ningún dato: justo lo que este producto viene a evitar
(FR-017).

Cada paso tiene dos operaciones, y la distinción es la que sostiene el principio
II:

- **mirar** consulta sólo la base de datos. Es lo que contesta `GET /onboarding`,
  y por eso no puede hablar con Google: abrir el recorrido no gasta cuota.
- **hacer** es lo que ejecuta el botón «Comprobar». Puede crear objetos y puede
  preguntarle a Google, siempre a través de los servicios que ya lo hacen bien
  —`upload_credential`, `verify_credential`, `create_domain`, `check_access`,
  `register_sitemap`, `sync_domain`—, que son los que reservan cupo antes de cada
  llamada. Después de actuar se vuelve a mirar: el veredicto sale del estado
  guardado y no de lo que la acción creyó haber logrado.

El reparto de los fallos de Google entre los pasos es la otra decisión de fondo:
cada motivo lo reclama el paso que lo resuelve. `API_NOT_ENABLED` es del paso 2,
`INVALID_KEY` del 3 y `NO_PROPERTIES` del 4. Una clave impecable sin propiedades
autorizadas deja el paso 3 cumplido y el 4 pendiente; decir «la clave no sirve»
mandaría a esa persona a bajar otra que tampoco va a servir.
"""

from collections.abc import Callable
from dataclasses import dataclass, field

from django.urls import reverse
from django.utils import timezone

from apps.core.errors import CredentialNotReady, DomainNotOperational, ValidationFailed
from apps.credentials.models import Credential, CredentialError, CredentialStatus, GoogleProject
from apps.credentials.models import Module as CredentialModule
from apps.credentials.services import (
    shared_active_credential,
    shared_resource_account,
    upload_credential,
    verify_credential,
)
from apps.credentials.validation import validate_project_id
from apps.domains.models import Domain, PropertyType
from apps.domains.services import (
    check_access,
    create_company_domain,
    ensure_company_can_connect_another,
    normalize_hostname,
    property_uri_for,
    shared_domains,
)
from apps.jobs.models import Batch, BatchOrigin, BatchState
from apps.onboarding.content import STEP_CODES
from apps.sitemaps.models import Sitemap, SitemapSource
from apps.sitemaps.reader import SitemapUnreadable, read
from apps.sitemaps.services import register_sitemap, sync_domain

GOOGLE_PROJECT = 'GOOGLE_PROJECT'
ENABLE_API = 'ENABLE_API'
#: La segunda API del producto. Va aparte de `ENABLE_API` porque se habilita en
#: otra pantalla de Google Cloud y se autoriza aparte, y porque la instalación
#: sirve entera sin ella: quien sólo quiere mirar la cobertura no la necesita.
ENABLE_INDEXING_API = 'ENABLE_INDEXING_API'
SERVICE_ACCOUNT_KEY = 'SERVICE_ACCOUNT_KEY'
AUTHORIZE_PROPERTY = 'AUTHORIZE_PROPERTY'
ADD_DOMAIN = 'ADD_DOMAIN'
ADD_SITEMAP = 'ADD_SITEMAP'
FIRST_BATCH = 'FIRST_BATCH'


class CheckState:
    """
    Resultado de comprobar un paso. Son cuatro y cada uno se muestra distinto.

    `BLOCKED` y `UNCONFIRMED` existen para no mentir en los dos casos donde «no
    cumplido» sería una respuesta falsa. A un paso bloqueado no le falta nada que
    la persona pueda hacer ahí: le falta un dato que sólo aparece más adelante
    —si la API está encendida sólo se sabe preguntándole a Google, y para
    preguntar hace falta la clave del paso 3—. Y un paso sin confirmar es uno
    donde Google no contestó: tratarlo como incumplido haría leer un corte de red
    como un error de quien está configurando.
    """

    PASSED = 'PASSED'
    MISSING = 'MISSING'
    BLOCKED = 'BLOCKED'
    UNCONFIRMED = 'UNCONFIRMED'


@dataclass(frozen=True)
class StepCheck:
    """
    Lo que la pantalla necesita para dibujar un paso, ya resuelto.

    `reason` es **el código** de lo que falta. La pantalla saca de él dos frases
    —qué falta y dónde se resuelve—, que juntas son el requisito de FR-017: una
    sola deja a la persona sabiendo que algo anda mal y buscando la pantalla
    donde se arregla. Van como código y no escritas porque el recorrido se lee
    en el idioma de quien lo hace, y `params` lleva los datos que la frase
    necesita —qué proyecto, qué dominio, qué dijo Google—.

    `evidence` es lo ya creado, nombrado. Es lo que permite que retomar muestre
    «Dominio ya dado de alta: ejemplo.com» en lugar de un formulario vacío, que
    es exactamente lo que hace que alguien dé de alta el mismo sitio dos veces.
    """

    code: str
    state: str
    reason: str = ''
    params: dict = field(default_factory=dict)
    blocked_by: str = ''
    evidence: dict = field(default_factory=dict)

    @property
    def ok(self) -> bool:
        return self.state == CheckState.PASSED


# --- Lo ya creado -----------------------------------------------------------


@dataclass
class Context:
    """
    Los objetos que el recorrido fue creando, resueltos una sola vez.

    Busca primero por el identificador guardado en el progreso y recién después
    por la cuenta. Ese orden es lo que hace que retomar reencuentre el mismo
    dominio en lugar de crear un segundo (FR-019): sin él, volver al día
    siguiente con el formulario en blanco termina en dos «ejemplo.com» que se
    reparten la cuota entre sí.
    """

    account: object
    data: dict
    _cache: dict = field(default_factory=dict, repr=False)

    def _resolve(self, key: str, lookup: Callable):
        if key not in self._cache:
            self._cache[key] = lookup()
        return self._cache[key]

    def remember(self, **values) -> None:
        """Anota en el progreso lo que se acaba de crear, para reencontrarlo después."""
        self.data.update({key: value for key, value in values.items() if value is not None})
        self._cache.clear()

    def project(self) -> GoogleProject | None:
        def lookup():
            shared = GoogleProject.objects.filter(is_active=True)
            return _saved_or_first(shared, self.data.get('google_project_id'), 'created_at')

        return self._resolve('project', lookup)

    def credential(self) -> Credential | None:
        """
        La credencial vigente del módulo, no la que anotó el recorrido.

        El identificador guardado se ignora a propósito: reemplazar la clave crea
        una fila nueva y desactiva la anterior, y un recorrido que siguiera
        mirando la vieja informaría para siempre sobre una credencial muerta.
        """
        return self._resolve(
            'credential',
            lambda: shared_active_credential(CredentialModule.SEARCH_CONSOLE),
        )

    def domain(self) -> Domain | None:
        def lookup():
            # Sólo los activos: el recorrido comprueba si la cuenta tiene su
            # sitio conectado **hoy**, y uno dado de baja daría el paso por hecho
            # sobre algo que ya no se trabaja.
            shared = shared_domains()
            return _saved_or_first(shared, self.data.get('domain_id'), 'created_at')

        return self._resolve('domain', lookup)

    def sitemap(self) -> Sitemap | None:
        def lookup():
            domain = self.domain()
            if domain is None:
                return None
            owned = Sitemap.objects.filter(domain=domain, source=SitemapSource.DECLARED)
            return _saved_or_first(owned, self.data.get('sitemap_id'), 'created_at')

        return self._resolve('sitemap', lookup)

    def batch(self) -> Batch | None:
        def lookup():
            domain = self.domain()
            if domain is None:
                return None
            owned = Batch.objects.filter(domain=domain)
            return _saved_or_first(owned, self.data.get('batch_id'), '-created_at')

        return self._resolve('batch', lookup)


def _saved_or_first(queryset, identifier, order):
    if identifier:
        found = queryset.filter(id=identifier).first()
        if found is not None:
            return found
    return queryset.order_by(order).first()


# --- Paso 1: el proyecto de Google Cloud ------------------------------------


def _inspect_project(ctx: Context) -> StepCheck:
    project = ctx.project()

    if project is None:
        return StepCheck(GOOGLE_PROJECT, CheckState.MISSING, reason='PROJECT_NOT_DECLARED')

    return StepCheck(GOOGLE_PROJECT, CheckState.PASSED, evidence={'project_id': project.project_id})


def _act_project(ctx: Context, data: dict, **_) -> None:
    declared = (data.get('project_id') or '').strip()
    if not declared:
        return

    # `get_or_create` y no `create`: volver a comprobar el paso con el mismo
    # identificador tiene que reencontrar el proyecto, no declarar otro. Es la
    # misma operación que hace el alta de credencial, así que subir la clave
    # después tampoco duplica nada (FR-019).
    project, _created = GoogleProject.objects.get_or_create(
        account=ctx.account, project_id=validate_project_id(declared)
    )
    ctx.remember(google_project_id=str(project.id), project_id=project.project_id)


# --- Paso 2: la API habilitada ----------------------------------------------


def _inspect_api(ctx: Context) -> StepCheck:
    credential = ctx.credential()

    if credential is None:
        return StepCheck(
            ENABLE_API,
            CheckState.BLOCKED,
            blocked_by=SERVICE_ACCOUNT_KEY,
            reason='API_NEEDS_KEY',
        )

    if credential.last_checked_at is None:
        return StepCheck(ENABLE_API, CheckState.MISSING, reason='API_NOT_CHECKED')

    if credential.last_error_code == CredentialError.API_NOT_ENABLED:
        project = ctx.project()
        # Con el proyecto declarado la frase lo nombra; sin él dice «tu
        # proyecto». Son dos redacciones y por eso dos códigos: el nombre no cae
        # en el mismo lugar de la oración en los dos idiomas.
        return StepCheck(
            ENABLE_API,
            CheckState.MISSING,
            reason='API_NOT_ENABLED_NAMED' if project else 'API_NOT_ENABLED',
            params={'project_id': project.project_id} if project else {},
        )

    if credential.last_error_code == CredentialError.PROVIDER_UNAVAILABLE:
        return _no_response(ENABLE_API)

    return StepCheck(
        ENABLE_API, CheckState.PASSED, evidence={'checked_at': _date(credential.last_checked_at)}
    )


# --- Paso 3: la Indexing API -----------------------------------------------


def _inspect_indexing_api(ctx: Context) -> StepCheck:
    """
    Si la Indexing API está habilitada y la cuenta de servicio autorizada.

    **No se comprueba preguntándole a Google, y no es una omisión.** La única
    forma de saberlo es publicar una dirección, y publicar gasta del techo real
    de la cuota de indexación: comprobar un paso del recorrido no puede consumir
    una de las publicaciones del día.

    Lo que sí hay es la evidencia que el producto ya guarda. Si alguna vez salió
    un pedido, la respuesta de Google contesta la pregunta sin costo, y contesta
    mejor que cualquier sonda nuestra: es lo que Google dijo. Mientras no haya
    salido ninguno, el estado honesto es «sin confirmar» y no «cumplido».
    """
    from apps.indexing.services import ConnectionState, connection_state

    credential = ctx.credential()

    if credential is None:
        return StepCheck(
            ENABLE_INDEXING_API,
            CheckState.BLOCKED,
            blocked_by=SERVICE_ACCOUNT_KEY,
            reason='INDEXING_NEEDS_KEY',
        )

    connection = connection_state(credential.account)

    if connection.state == ConnectionState.WORKING:
        return StepCheck(
            ENABLE_INDEXING_API,
            CheckState.PASSED,
            evidence={'sent_at': _date(connection.checked_at), 'url': connection.url},
        )

    if connection.state == ConnectionState.API_NOT_ENABLED:
        return StepCheck(
            ENABLE_INDEXING_API, CheckState.MISSING, reason='INDEXING_API_NOT_ENABLED'
        )

    if connection.state == ConnectionState.NOT_AUTHORIZED:
        return StepCheck(
            ENABLE_INDEXING_API, CheckState.MISSING, reason='INDEXING_NOT_AUTHORIZED'
        )

    # Nunca se pidió nada, o Google contestó algo que no habla de este paso.
    return StepCheck(ENABLE_INDEXING_API, CheckState.UNCONFIRMED, reason='INDEXING_NOT_TRIED')


# --- Paso 4: la clave de la cuenta de servicio ------------------------------

#: Motivos que sí hablan de la clave. Los demás fallos que Google puede devolver
#: son de otros pasos, y adjudicárselos a éste mandaría a bajar una clave nueva
#: por un problema que la clave nueva no arregla.
_BROKEN_KEY = frozenset({CredentialError.INVALID_KEY, CredentialError.PROJECT_MISMATCH})


def _inspect_key(ctx: Context) -> StepCheck:
    credential = ctx.credential()

    if credential is None:
        return StepCheck(SERVICE_ACCOUNT_KEY, CheckState.MISSING, reason='KEY_NOT_UPLOADED')

    if credential.status == CredentialStatus.REVOKED or not credential.is_active:
        return StepCheck(
            SERVICE_ACCOUNT_KEY,
            CheckState.MISSING,
            reason='KEY_REVOKED',
            evidence={'client_email': credential.client_email},
        )

    if credential.last_checked_at is None:
        return StepCheck(
            SERVICE_ACCOUNT_KEY,
            CheckState.MISSING,
            reason='KEY_NOT_CHECKED',
            evidence={'client_email': credential.client_email},
        )

    if credential.last_error_code in _BROKEN_KEY:
        # Cuando Google dio un motivo, es el motivo lo que se muestra: viene en
        # el idioma en que Google lo dijo y no se traduce.
        detail = credential.last_error_detail
        return StepCheck(
            SERVICE_ACCOUNT_KEY,
            CheckState.MISSING,
            reason='KEY_REJECTED_DETAIL' if detail else 'KEY_REJECTED',
            params={'detail': detail} if detail else {},
            evidence={'client_email': credential.client_email},
        )

    if credential.last_error_code == CredentialError.PROVIDER_UNAVAILABLE:
        return _no_response(SERVICE_ACCOUNT_KEY)

    # Llegar acá con `NO_PROPERTIES` o `API_NOT_ENABLED` es lo esperado, y el
    # paso se cumple igual: en los dos casos Google leyó la clave y contestó. Lo
    # que falta se arregla en el paso 2 o en el 4, no bajando otra clave.
    return StepCheck(
        SERVICE_ACCOUNT_KEY,
        CheckState.PASSED,
        evidence={
            'client_email': credential.client_email,
            'key_fingerprint': credential.key_fingerprint,
            'project_id': credential.google_project.project_id,
        },
    )


def _act_key(ctx: Context, data: dict, *, client=None, **_) -> None:
    material = data.get('key_file')

    if material:
        # El identificador declarado en el paso 1 va como valor por defecto: así
        # el archivo se compara contra el proyecto que la persona ya dijo, y una
        # clave de otro proyecto se rechaza nombrando los dos.
        project = ctx.project()
        upload_credential(
            shared_resource_account(ctx.account),
            project_id=data.get('project_id') or (project.project_id if project else ''),
            key_file=material,
        )
        ctx.remember(key_uploaded_at=timezone.now().isoformat())

    _ask_about_credential(ctx, client=client)


def _ask_about_credential(ctx: Context, *, client=None) -> None:
    """
    Una sola consulta a Google contesta los pasos 2, 3 y 4.

    `verify_credential` reserva cupo, llama, clasifica el fallo y guarda el
    veredicto en la credencial. Los tres pasos lo leen de ahí en vez de preguntar
    cada uno por su cuenta: tres consultas para saber lo mismo gastarían el
    triple del límite diario de comprobaciones.
    """
    credential = ctx.credential()
    if credential is None:
        return

    verify_credential(credential, client=client)
    # Las propiedades accesibles no se copian al progreso: ya quedan guardadas en
    # la credencial, y una segunda copia acá se desactualizaría en cuanto alguien
    # comprobara la credencial desde la pantalla de configuración.
    ctx.remember(credential_id=str(credential.id), client_email=credential.client_email)


# --- Paso 4: la cuenta de servicio autorizada en la propiedad ---------------


def _inspect_authorization(ctx: Context) -> StepCheck:
    credential = ctx.credential()

    if credential is None or credential.status == CredentialStatus.REVOKED:
        return StepCheck(
            AUTHORIZE_PROPERTY,
            CheckState.BLOCKED,
            blocked_by=SERVICE_ACCOUNT_KEY,
            reason='AUTH_NEEDS_KEY',
        )

    if credential.last_checked_at is None:
        return StepCheck(
            AUTHORIZE_PROPERTY,
            CheckState.MISSING,
            reason='AUTH_NOT_CHECKED',
            evidence={'client_email': credential.client_email},
        )

    if credential.last_error_code == CredentialError.PROVIDER_UNAVAILABLE:
        return _no_response(AUTHORIZE_PROPERTY)

    if credential.last_error_code == CredentialError.API_NOT_ENABLED:
        return StepCheck(
            AUTHORIZE_PROPERTY,
            CheckState.BLOCKED,
            blocked_by=ENABLE_API,
            reason='AUTH_API_OFF',
            evidence={'client_email': credential.client_email},
        )

    if not credential.is_usable:
        property_uri = ctx.data.get('property_uri')
        return StepCheck(
            AUTHORIZE_PROPERTY,
            CheckState.MISSING,
            reason='AUTH_NOT_GRANTED_NAMED' if property_uri else 'AUTH_NOT_GRANTED',
            params={
                'client_email': credential.client_email,
                **({'property_uri': property_uri} if property_uri else {}),
            },
            evidence={'client_email': credential.client_email},
        )

    return StepCheck(
        AUTHORIZE_PROPERTY,
        CheckState.PASSED,
        evidence={
            'client_email': credential.client_email,
            'properties': accessible_properties(ctx),
        },
    )


def accessible_properties(ctx: Context) -> list:
    """
    Las propiedades que la última comprobación pudo leer.

    Salen de la credencial y no de una consulta nueva: listar sitios gasta cupo, y
    el paso siguiente sólo necesita saber cuáles hay para poder ofrecerlas.
    """
    credential = ctx.credential()
    return list(credential.accessible_properties) if credential else []


def _act_authorization(ctx: Context, data: dict, *, client=None, **_) -> None:
    _ask_about_credential(ctx, client=client)


# --- Paso 5: el dominio dado de alta acá ------------------------------------


def _inspect_domain(ctx: Context) -> StepCheck:
    domain = ctx.domain()

    if domain is None:
        credential = ctx.credential()
        if credential is None or not credential.is_usable:
            # El alta de dominios exige credencial comprobada. Decir «todavía no
            # diste de alta tu sitio» mandaría a escribir el dominio para que no
            # pase nada y el mensaje siga igual, que es el callejón sin salida
            # que este recorrido viene a sacar.
            return StepCheck(
                ADD_DOMAIN,
                CheckState.BLOCKED,
                blocked_by=SERVICE_ACCOUNT_KEY if credential is None else AUTHORIZE_PROPERTY,
                reason='DOMAIN_NEEDS_CONNECTION',
            )

        return StepCheck(ADD_DOMAIN, CheckState.MISSING, reason='DOMAIN_NOT_ADDED')

    if not domain.is_operational:
        # El estado del acceso viaja como **código** y no como su etiqueta: la
        # pantalla ya sabe traducirlo, y mandar la etiqueta la ataría al idioma
        # que tenía el servidor.
        return StepCheck(
            ADD_DOMAIN,
            CheckState.MISSING,
            reason=(
                'DOMAIN_NOT_OPERATIONAL_DETAIL' if domain.access_error else 'DOMAIN_NOT_OPERATIONAL'
            ),
            params={
                'hostname': domain.hostname,
                'access_state': domain.access_state,
                'property_uri': domain.property_uri,
                **({'detail': domain.access_error} if domain.access_error else {}),
            },
            evidence=_visible_domain(domain),
        )

    return StepCheck(ADD_DOMAIN, CheckState.PASSED, evidence=_visible_domain(domain))


def _act_domain(ctx: Context, data: dict, *, client=None, **_) -> None:
    hostname = (data.get('hostname') or '').strip()
    domain = _reuse_or_create_domain(ctx, hostname, data.get('property_type')) or ctx.domain()

    if domain is None:
        return

    ctx.remember(
        domain_id=str(domain.id),
        hostname=domain.hostname,
        property_uri=domain.property_uri,
    )
    check_access(domain, client=client)


def _reuse_or_create_domain(ctx: Context, hostname: str, property_type) -> Domain | None:
    """
    Da de alta el sitio, o devuelve el que ya estaba con esa misma propiedad.

    La búsqueda previa por `property_uri` es lo que hace idempotente al paso: sin
    ella, volver a comprobarlo con el mismo dominio chocaría contra la
    restricción de unicidad y le mostraría un error a alguien que no hizo nada
    mal (FR-019).
    """
    if not hostname:
        return None

    kind = property_type or PropertyType.DOMAIN
    if kind not in PropertyType.values:
        raise ValidationFailed(
            'Elegí si la propiedad es de dominio o de prefijo de URL.', field='property_type'
        )

    property_uri = property_uri_for(normalize_hostname(hostname), kind)

    # La búsqueda mira **sólo los activos**. Escrita sin ese filtro devolvía un
    # sitio dado de baja como si estuviera bueno: el recorrido lo daba por
    # conectado, lo recordaba en su contexto y seguía al paso siguiente sobre
    # algo que la cuenta había dado de baja.
    existing = shared_domains().filter(property_uri=property_uri).first()
    if existing is not None:
        return existing

    # El recorrido no puede ser la puerta trasera por la que entra el segundo
    # sitio: escribir otro hostname acá dejaba dos activos y ninguna pantalla
    # desde la cual notarlo. La regla vive en el servicio porque la comparte con
    # la conexión, que es la otra puerta de la interfaz.
    ensure_company_can_connect_another()

    # Delega el alta en `create_company_domain()` en vez de crear la fila acá: ahí vive
    # la rama que reactiva un sitio dado de baja, que es la única forma de no
    # chocar contra `one_property_per_account`.
    return create_company_domain(ctx.account, hostname=hostname, property_type=kind)


def _visible_domain(domain: Domain) -> dict:
    return {
        'domain_id': str(domain.id),
        'hostname': domain.hostname,
        'property_uri': domain.property_uri,
        'access_state': domain.access_state,
    }


# --- Paso 6: el sitemap registrado ------------------------------------------


def _inspect_sitemap(ctx: Context) -> StepCheck:
    domain = ctx.domain()
    if domain is None:
        return StepCheck(
            ADD_SITEMAP,
            CheckState.BLOCKED,
            blocked_by=ADD_DOMAIN,
            reason='SITEMAP_NEEDS_DOMAIN',
        )

    sitemap = ctx.sitemap()
    if sitemap is None:
        return StepCheck(
            ADD_SITEMAP,
            CheckState.MISSING,
            reason='SITEMAP_NOT_REGISTERED',
            params={'hostname': domain.hostname},
        )

    if sitemap.last_error:
        return StepCheck(
            ADD_SITEMAP,
            CheckState.MISSING,
            # La dirección va en los datos y no sólo en la evidencia: quien
            # registró un índice y tres hijos necesita saber cuál de los cuatro
            # es el que no se pudo leer para ir a mirarlo.
            reason='SITEMAP_UNREADABLE',
            params={
                'location': sitemap.location,
                'detail': sitemap.last_error,
                'hostname': domain.hostname,
            },
            evidence=_visible_sitemap(sitemap),
        )

    return StepCheck(ADD_SITEMAP, CheckState.PASSED, evidence=_visible_sitemap(sitemap))


def _act_sitemap(ctx: Context, data: dict, *, http=None, **_) -> None:
    domain = ctx.domain()
    if domain is None:
        return

    location = (data.get('location') or '').strip()

    if location:
        if not location.startswith(('http://', 'https://')):
            raise ValidationFailed(
                'La dirección del sitemap tiene que ser completa, empezando por https://.',
                field='location',
            )
        # `register_sitemap` es un get_or_create sobre dominio y ubicación: dar
        # de alta dos veces el mismo devuelve la misma fila (FR-019).
        sitemap = register_sitemap(domain, location)
        ctx.remember(sitemap_id=str(sitemap.id), sitemap_location=sitemap.location)
    else:
        sitemap = ctx.sitemap()

    if sitemap is not None:
        _read_sitemap(sitemap, http=http)


def _read_sitemap(sitemap: Sitemap, *, http=None) -> None:
    """
    Descarga el sitemap para comprobar que existe de verdad.

    Registrar la dirección no prueba nada: el error más común de este paso es una
    dirección que devuelve 404 o una página de error, y darlo por cumplido
    dejaría la sincronización del paso siguiente fallando sin que nadie entienda
    por qué.

    No se guarda la huella del contenido. Quién decide qué reenviarle a Google es
    la sincronización, y adelantarle esa marca desde acá le haría creer que este
    sitemap ya viajó.
    """
    try:
        content = read(sitemap.location, client=http)
    except SitemapUnreadable as exc:
        # El código va junto al mensaje porque la pantalla ramifica por código:
        # «revisá la dirección» y «revisá el archivo» se resuelven en lugares
        # distintos, y deducir cuál es leyendo la oración se rompe al primer
        # cambio de redacción.
        sitemap.last_error = str(exc)
        sitemap.last_error_code = exc.code
        sitemap.save(update_fields=['last_error', 'last_error_code', 'updated_at'])
        return

    sitemap.url_count = content.count
    sitemap.last_read_at = timezone.now()
    sitemap.last_error = ''
    sitemap.last_error_code = ''
    sitemap.save(
        update_fields=[
            'url_count',
            'last_read_at',
            'last_error',
            'last_error_code',
            'updated_at',
        ]
    )


def _visible_sitemap(sitemap: Sitemap) -> dict:
    return {
        'sitemap_id': str(sitemap.id),
        'location': sitemap.location,
        'url_count': sitemap.url_count,
        'last_read_at': _date(sitemap.last_read_at),
        'error_code': sitemap.last_error_code,
    }


# --- Paso 7: la primera sincronización --------------------------------------


def _inspect_batch(ctx: Context) -> StepCheck:
    domain = ctx.domain()
    if domain is None or not domain.is_operational:
        return StepCheck(
            FIRST_BATCH,
            CheckState.BLOCKED,
            blocked_by=ADD_DOMAIN,
            reason='BATCH_NEEDS_DOMAIN',
        )

    batch = ctx.batch()
    if batch is None:
        return StepCheck(FIRST_BATCH, CheckState.MISSING, reason='BATCH_NOT_RUN')

    if batch.state == BatchState.FAILED:
        errors = batch.summary.get('errors') or []
        return StepCheck(
            FIRST_BATCH,
            CheckState.MISSING,
            reason='BATCH_FAILED_DETAIL' if errors else 'BATCH_FAILED',
            params={'detail': errors[0]} if errors else {},
            evidence=_visible_batch(batch),
        )

    return StepCheck(FIRST_BATCH, CheckState.PASSED, evidence=_visible_batch(batch))


def _act_batch(ctx: Context, data: dict, *, client=None, http=None, **_) -> None:
    domain = ctx.domain()
    if domain is None:
        return

    batch = ctx.batch()
    if batch is not None and batch.state != BatchState.FAILED:
        # Ya hay una corrida: volver a comprobar informa sobre ella y no lanza
        # otra. Relanzar en cada visita gastaría cuota del sitio por mirar una
        # pantalla (FR-019).
        return

    new_batch = sync_domain(domain, origin=BatchOrigin.MANUAL, client=client, http=http)
    ctx.remember(batch_id=str(new_batch.id))


def _visible_batch(batch: Batch) -> dict:
    return {
        'batch_id': str(batch.id),
        'state': batch.state,
        'summary': batch.summary,
        'finished_at': _date(batch.finished_at),
    }


# --- Registro ---------------------------------------------------------------


@dataclass(frozen=True)
class StepSpec:
    """
    Un paso: qué mira, qué hace y qué le pide a la persona.

    `inputs` es parte del contrato con la pantalla. Vive acá y no escrito dentro
    del formulario porque quien decide qué dato hace falta para cumplir un paso
    es su comprobación, y tener las dos mitades en archivos distintos garantiza
    que en algún momento el formulario pida algo que la comprobación no mira.

    Lo que declara es **qué** dato pide —su nombre, su tipo, si es obligatorio,
    qué opciones tiene—, nunca cómo se dice. La etiqueta, el ejemplo y la
    aclaración salen del catálogo del cliente por el nombre del campo: el
    formulario se lee en el idioma de quien lo completa, y el servidor no sabe
    cuál es.
    """

    code: str
    inspect: Callable[[Context], StepCheck]
    act: Callable[..., None] | None = None
    inputs: tuple[dict, ...] = ()
    #: Pantalla equivalente del modo directo, para «prefiero hacerlo desde el
    #: formulario completo»: el recorrido es un atajo, no una llave (FR-018).
    form_path: Callable[[Context], str] = lambda _ctx: '/settings'


SPECS: dict[str, StepSpec] = {
    GOOGLE_PROJECT: StepSpec(
        GOOGLE_PROJECT,
        _inspect_project,
        _act_project,
        inputs=({'name': 'project_id', 'type': 'text', 'required': True},),
    ),
    ENABLE_API: StepSpec(ENABLE_API, _inspect_api, _act_authorization),
    # Sin `act`: no hay nada que este paso pueda hacer desde acá. Habilitar una
    # API y autorizar una cuenta de servicio ocurren en pantallas de Google, y
    # la comprobación se apoya en la evidencia que dejan los pedidos reales.
    ENABLE_INDEXING_API: StepSpec(ENABLE_INDEXING_API, _inspect_indexing_api),
    SERVICE_ACCOUNT_KEY: StepSpec(
        SERVICE_ACCOUNT_KEY,
        _inspect_key,
        _act_key,
        inputs=(
            {'name': 'key_file', 'type': 'file', 'required': True},
            {'name': 'project_id', 'type': 'text', 'required': False},
        ),
    ),
    AUTHORIZE_PROPERTY: StepSpec(AUTHORIZE_PROPERTY, _inspect_authorization, _act_authorization),
    ADD_DOMAIN: StepSpec(
        ADD_DOMAIN,
        _inspect_domain,
        _act_domain,
        inputs=(
            {'name': 'hostname', 'type': 'text', 'required': True},
            {
                'name': 'property_type',
                'type': 'choice',
                'required': True,
                'default': PropertyType.DOMAIN,
                # Los valores y nada más: cómo se llama cada forma de propiedad
                # ya lo dice el catálogo, y mandarlo desde acá ataría el
                # formulario al idioma que tenía el servidor.
                'options': [value for value, _label in PropertyType.choices],
            },
        ),
        form_path=lambda _ctx: '/domains/new',
    ),
    ADD_SITEMAP: StepSpec(
        ADD_SITEMAP,
        _inspect_sitemap,
        _act_sitemap,
        inputs=({'name': 'location', 'type': 'url', 'required': True},),
        form_path=lambda ctx: _domain_path(ctx, 'sitemaps'),
    ),
    FIRST_BATCH: StepSpec(
        FIRST_BATCH,
        _inspect_batch,
        _act_batch,
        form_path=lambda ctx: _domain_path(ctx, 'coverage'),
    ),
}


def _domain_path(ctx: Context, route: str) -> str:
    """
    La pantalla equivalente del modo directo, resuelta por nombre.

    Concatenar sufijos daría el mismo resultado hoy y quedaría vieja en silencio
    el día que una dirección cambie: el enlace seguiría existiendo y llevaría a
    un 404, justo en el paso donde alguien buscaba una salida del recorrido.
    """
    domain = ctx.domain()
    if domain is None:
        # Sin sitio conectado no hay ninguna de estas pantallas: todas cuelgan de
        # su identificador. Lo que falta es un paso antes, y ese paso se resuelve
        # en la conexión. Antes acá iba el listado de dominios, que ya no tiene
        # dirección.
        return reverse('settings')
    return reverse(route, kwargs={'domain_id': domain.id})


def exists(code: str) -> bool:
    return code in SPECS


def inspect(code: str, ctx: Context) -> StepCheck:
    """Mira el estado guardado. No habla con Google ni escribe nada."""
    return SPECS[code].inspect(ctx)


def inspect_all(ctx: Context) -> dict[str, StepCheck]:
    return {code: inspect(code, ctx) for code in STEP_CODES}


def act(code: str, ctx: Context, data: dict, *, client=None, http=None) -> None:
    """
    Ejecuta lo que el paso sabe hacer, y se queda callado si todavía no puede.

    Los dos errores que se atajan no son fallos del pedido sino pasos previos sin
    resolver: sin credencial comprobada no se da de alta un dominio, y sin acceso
    confirmado no se sincroniza. Dejarlos salir devolvería un 409 donde la
    respuesta útil es la comprobación del paso diciendo qué falta y dónde.
    """
    spec = SPECS[code]
    if spec.act is None:
        return

    try:
        spec.act(ctx, data, client=client, http=http)
    except (CredentialNotReady, DomainNotOperational):
        return


def form_path(code: str, ctx: Context) -> str:
    return SPECS[code].form_path(ctx)


def inputs(code: str) -> list[dict]:
    return [dict(input_field) for input_field in SPECS[code].inputs]


def _no_response(code: str) -> StepCheck:
    return StepCheck(code, CheckState.UNCONFIRMED, reason='NO_RESPONSE')


def _date(value) -> str | None:
    return value.isoformat() if value else None
