"""
El catálogo es un contrato entre el servidor y la pantalla, y se comprueba.

El producto dejó de tener oraciones escritas en el código. El servidor manda un
**código** —de error, de aviso, de estado, de motivo de un paso— y la pantalla
compone la frase con el catálogo del idioma de quien está mirando. Eso resuelve
el problema de fondo y crea uno nuevo, silencioso: **un código sin texto**.
`t()` revienta ante una clave que falta, así que el defecto no llega como una
oración rara sino como una pantalla en blanco, y aparece recién cuando alguien
llega al estado que emite ese código —una credencial revocada, un sitemap que
devuelve 404—, que puede ser meses después del despliegue.

Lo que se defiende acá es que eso no pueda pasar:

- los dos catálogos declaran **exactamente** las mismas claves;
- cada código que el servidor puede emitir tiene su texto en los dos;
- las dos redacciones de una misma clave piden **los mismos datos**;
- ninguna pantalla escribe una oración suelta fuera del catálogo.

El último es el que evita que todo lo anterior se vuelva decorativo: alcanza con
un `<p>Guardado</p>` en un `.tsx` para que una pantalla vuelva a existir en un
solo idioma.
"""

import json
import re
from pathlib import Path

import pytest
from django.conf import settings

from apps.accounts.models import Language
from apps.core.errors import ErrorCode
from apps.credentials.models import CredentialError, CredentialStatus
from apps.credentials.validation import KeyFileError
from apps.domains.models import AccessState, PropertyType
from apps.jobs.models import BatchKind, BatchOrigin, BatchState
from apps.onboarding import content
from apps.sitemaps.models import CoverageState, SitemapKind, SitemapSource

PROJECT_ROOT = Path(settings.BASE_DIR)
LOCALES = PROJECT_ROOT / 'frontend' / 'locales'
FRONTEND = PROJECT_ROOT / 'frontend'


def catalog(locale: str) -> dict[str, str]:
    return json.loads((LOCALES / f'{locale}.json').read_text(encoding='utf-8'))


@pytest.fixture(scope='module')
def catalogs() -> dict[str, dict[str, str]]:
    return {locale: catalog(locale) for locale in ('en', 'es-ar')}


# --- Los catálogos entre sí -------------------------------------------------


def test_the_two_catalogs_declare_exactly_the_same_keys(catalogs):
    """
    Una clave que está en uno y no en el otro es una pantalla que revienta en un
    idioma y anda en el otro, y sólo para quien tenga elegido el que falta.
    """
    english, spanish = set(catalogs['en']), set(catalogs['es-ar'])

    assert not english - spanish, f'Sin traducir al español: {sorted(english - spanish)}'
    assert not spanish - english, f'Sin original en inglés: {sorted(spanish - english)}'


def test_no_line_is_left_empty(catalogs):
    """
    Salvo las que declaran la ausencia de un dato.

    `step.<CODE>.example` y `.open` van vacías a propósito en los pasos que no
    tienen ejemplo ni pantalla de Google que abrir: la pantalla pregunta por
    `has_example` antes de dibujarlas. Cualquier otra clave vacía es una
    traducción que quedó por hacer, y en pantalla se ve como un hueco.
    """
    allowed_empty = re.compile(r'^step\.[A-Z_]+\.(example|open)$')

    for locale, entries in catalogs.items():
        empty = [key for key, line in entries.items() if not line and not allowed_empty.match(key)]
        assert not empty, f'Claves vacías en «{locale}»: {sorted(empty)}'


def test_both_versions_of_a_line_ask_for_the_same_data(catalogs):
    """
    Un `{hostname}` que está en una redacción y no en la otra es un dato que se
    pierde al traducir —la frase queda diciendo menos— o un hueco que se dibuja
    con la llave a la vista, que se lee como un error de la aplicación.
    """
    placeholders = re.compile(r'\{(\w+)\}')
    mismatched = {}

    for key, english in catalogs['en'].items():
        spanish = catalogs['es-ar'][key]
        wanted, translated = set(placeholders.findall(english)), set(placeholders.findall(spanish))
        if wanted != translated:
            mismatched[key] = (sorted(wanted), sorted(translated))

    assert not mismatched, f'Las dos redacciones piden datos distintos: {mismatched}'


def test_a_plural_key_declares_both_of_its_forms(catalogs):
    """
    `Intl.PluralRules` elige la forma y `t()` cae en `.other` cuando la que eligió
    no está. Declarar sólo una deja la oración en singular contando cuarenta
    cosas —o al revés— sin que nada falle.
    """
    for locale, entries in catalogs.items():
        singulars = {key[: -len('.one')] for key in entries if key.endswith('.one')}
        plurals = {key[: -len('.other')] for key in entries if key.endswith('.other')}

        assert singulars == plurals, (
            f'En «{locale}» hay formas plurales sin su pareja: {sorted(singulars ^ plurals)}'
        )


# --- Cada código que el servidor emite --------------------------------------


def codes_of(enum_or_class) -> list[str]:
    """Los valores de un enumerado o de una clase de constantes."""
    if hasattr(enum_or_class, 'values'):
        return list(enum_or_class.values)
    return [value for name, value in vars(enum_or_class).items() if not name.startswith('_')]


#: Qué clave le corresponde a cada familia de códigos del servidor.
#:
#: La tabla es la lista de todo lo que el servidor puede nombrar sin escribir la
#: oración. Agregar un código a un enumerado y no agregarlo acá deja el código
#: sin comprobar, así que la tabla también es la que dice qué falta traducir.
FAMILIES = [
    ('serverError.{code}.title', codes_of(ErrorCode)),
    ('serverError.{code}.explanation', codes_of(ErrorCode)),
    ('errors.{code}', codes_of(KeyFileError)),
    ('credentialError.{code}.title', codes_of(CredentialError)),
    ('credentialError.{code}.explanation', codes_of(CredentialError)),
    ('credentialStatus.{code}', codes_of(CredentialStatus)),
    ('access.{code}.label', codes_of(AccessState)),
    ('access.{code}.help', codes_of(AccessState)),
    ('propertyShape.{code}.title', codes_of(PropertyType)),
    ('coverage.{code}.label', codes_of(CoverageState)),
    ('coverage.{code}.help', codes_of(CoverageState)),
    ('batch.{code}.label', codes_of(BatchState)),
    ('batch.{code}.help', codes_of(BatchState)),
    ('batch.kind.{code}', codes_of(BatchKind)),
    ('batch.origin.{code}', codes_of(BatchOrigin)),
    ('batch.unit.{code}', codes_of(BatchKind)),
    ('sitemapKind.{code}', codes_of(SitemapKind)),
    ('sitemapSource.{code}', codes_of(SitemapSource)),
    ('step.{code}.title', list(content.STEP_CODES)),
    ('step.{code}.goal', list(content.STEP_CODES)),
    ('step.{code}.path', list(content.STEP_CODES)),
    ('step.{code}.bringBack', list(content.STEP_CODES)),
    ('step.{code}.note', list(content.STEP_CODES)),
]


@pytest.mark.parametrize(('template', 'codes'), FAMILIES, ids=[f[0] for f in FAMILIES])
def test_every_code_the_server_emits_has_its_text(template, codes, catalogs):
    for locale, entries in catalogs.items():
        missing = [template.format(code=code) for code in codes]
        missing = [key for key in missing if key not in entries]
        assert not missing, f'Sin texto en «{locale}»: {missing}'


def test_every_reason_a_step_can_return_has_both_of_its_sentences(catalogs):
    """
    FR-017 comprobado desde el catálogo: qué falta **y** dónde se resuelve.

    Los códigos se leen del módulo que los emite y no de una lista escrita acá:
    una lista a mano se desactualiza en silencio, que es exactamente el fallo
    que este test existe para atrapar.
    """
    source = (PROJECT_ROOT / 'apps' / 'onboarding' / 'steps.py').read_text(encoding='utf-8')
    reasons = set(re.findall(r"reason=\(?\s*'([A-Z_]+)'", source))
    reasons |= set(re.findall(r"else\s+'([A-Z_]+)'", source))

    assert len(reasons) > 20, 'No se encontraron los motivos de los pasos: cambió cómo se escriben.'

    for locale, entries in catalogs.items():
        for reason in sorted(reasons):
            assert f'stepReason.{reason}.missing' in entries, (
                f'«{reason}» no dice qué falta en «{locale}».'
            )
            assert f'stepReason.{reason}.hint' in entries, (
                f'«{reason}» no dice dónde resolverlo en «{locale}».'
            )


def test_every_account_notice_has_its_three_pieces(catalogs):
    """
    Un aviso permanente es título, motivo y acción. Sin la acción describe un
    problema y deja a la persona buscando la pantalla donde se arregla.
    """
    source = (PROJECT_ROOT / 'apps' / 'accounts' / 'services.py').read_text(encoding='utf-8')
    notices = set(re.findall(r"code='([A-Z_]+)'", source))

    assert notices, 'No se encontró ningún aviso de cuenta: cambió cómo se escriben.'

    for locale, entries in catalogs.items():
        for code in sorted(notices):
            for piece in ('title', 'message', 'action'):
                key = f'notices.{code}.{piece}'
                # Los avisos que cuentan cosas declaran sus dos formas plurales
                # en vez de la clave pelada, y `t()` los pide por la base.
                assert key in entries or f'{key}.other' in entries, (
                    f'El aviso «{code}» no tiene «{piece}» en «{locale}».'
                )


def test_every_notification_has_its_three_pieces(catalogs):
    """
    Lo mismo para los avisos que quedan guardados, que además se leen meses
    después: el texto se arma al mirarlos, con el catálogo del idioma de ese día.
    """
    source = (PROJECT_ROOT / 'apps' / 'notifications' / 'services.py').read_text(encoding='utf-8')
    keys = set(re.findall(r"text_key='([A-Z_]+)'", source))

    assert keys, 'No se encontró ninguna notificación: cambió cómo se escriben.'

    for locale, entries in catalogs.items():
        for key in sorted(keys):
            for piece in ('title', 'body', 'action'):
                entry = f'notification.{key}.{piece}'
                # Mismo criterio que los avisos de cuenta: la que cuenta cosas
                # declara sus dos formas plurales en vez de la clave pelada, y
                # `t()` la pide por la base. Exigir la pelada obligaría a que un
                # aviso de una sola URL diga «1 URLs».
                assert entry in entries or f'{entry}.other' in entries, (
                    f'La notificación «{key}» no tiene «{piece}» en «{locale}».'
                )


# --- Ninguna oración suelta en una pantalla ---------------------------------

#: Texto visible dentro de un elemento: `>Guardado<`.
#:
#: Pide **dos letras seguidas** para no marcar los símbolos que sí van escritos
#: —«·», «—», «%»— y un espacio o una letra minúscula para no marcar una sigla
#: pegada a una llave.
VISIBLE_TEXT = re.compile(r'>\s*([A-Za-zÁÉÍÓÚÑáéíóúñ][^<>{}\n]*[a-záéíóúñ][^<>{}\n]*?)\s*<')

#: Atributos que la persona lee, escritos a mano.
VISIBLE_ATTRIBUTE = re.compile(
    r'\b(title|placeholder|aria-label|alt|emptyLabel|heading|label|description)='
    r'"([^"{}\n]*[a-záéíóúñ][^"{}\n]*)"'
)

#: Lo que no es una oración del producto aunque lo parezca.
#:
#: Son los identificadores de Google que se muestran tal cual —un `sc-domain:`
#: traducido deja de existir—, las direcciones y los nombres de archivo. Se
#: listan por forma y no por archivo: una excepción por archivo se convierte en
#: el lugar donde se escribe la próxima oración suelta.
NOT_A_SENTENCE = re.compile(
    r'^(https?://|sc-domain:|/|\w+\.(xml|json|com|test)$|[\w-]+@|#|&\w+;|[a-z-]+:[a-z-]+)'
)

#: Código que quedó dentro del recorte porque `>` y `<` también son operadores.
#:
#: Los genéricos de TypeScript y las llaves de cierre de una expresión JSX caen
#: entre un `>` y un `<` sin ser texto. Se descartan por su forma —una firma, un
#: tipo, una llamada— y no por el archivo donde aparecen.
LOOKS_LIKE_CODE = re.compile(r'(=>|\(\)|\?|;|&&|\|\||\breturn\b|\bconst\b|\.\w+\(|\)|:\s*[A-Z]\w*)')


def visible_strings(source: str) -> list[str]:
    """Las oraciones que quedaron escritas en un archivo de pantalla."""
    found = [match.group(1) for match in VISIBLE_TEXT.finditer(source)]
    found += [match.group(2) for match in VISIBLE_ATTRIBUTE.finditer(source)]

    return [
        text
        for text in found
        # Una palabra sola casi nunca es una oración del producto: es un nombre
        # propio, una unidad o una sigla. Lo que hay que atrapar son las frases.
        if len(text.split()) > 1
        and not NOT_A_SENTENCE.match(text)
        and not LOOKS_LIKE_CODE.search(text)
    ]


def screen_files() -> list[Path]:
    """Todo lo que dibuja algo: las pantallas, sus piezas y el armazón."""
    return sorted(
        path
        for folder in ('pages', 'components', 'layouts')
        for path in (FRONTEND / folder).rglob('*.tsx')
        # `components/ui` es la copia de shadcn: es de afuera y se actualiza
        # desde el registro, así que no se le pide que use nuestro catálogo.
        if 'ui' not in path.relative_to(FRONTEND).parts
    )


@pytest.mark.parametrize('path', screen_files(), ids=lambda path: path.name)
def test_no_screen_writes_a_sentence_of_its_own(path):
    """
    Una oración suelta en un `.tsx` es una oración que sólo existe en un idioma.

    No falla por «se ve mal en el otro idioma»: falla porque esa frase **no se
    puede traducir**. No está en el catálogo, así que no hay dónde escribirla, y
    el defecto se descubre mirando la pantalla con el idioma cambiado, que es
    justo lo que nadie hace.
    """
    loose = visible_strings(path.read_text(encoding='utf-8'))

    assert not loose, f'{path.relative_to(PROJECT_ROOT)} escribe texto fuera del catálogo: {loose}'


# --- Lo que la pantalla pide, contra lo que el catálogo tiene ---------------

#: Letras que sólo existen en el español del producto.
#:
#: Es la red que atrapa lo que el recorte por `>…<` no ve: una oración partida en
#: tres renglones, un atributo de una palabra, un mensaje de error de programación.
#: Vale porque el idioma fuente es el inglés: cualquier «á» en un archivo de la
#: interfaz es una frase que se escribió en el idioma equivocado, esté donde esté.
SPANISH_LETTERS = re.compile(r'[áéíóúÁÉÍÓÚñÑ¿¡«»]')


def code_lines(source: str) -> list[tuple[int, str]]:
    """Las líneas que son código, sin los comentarios —que sí van en español—."""
    lines, inside_block = [], False

    for number, line in enumerate(source.splitlines(), 1):
        stripped = line.strip()

        if inside_block:
            inside_block = '*/' not in stripped
            continue
        if stripped.startswith(('/*', '{/*')):
            inside_block = '*/' not in stripped
            continue
        if stripped.startswith(('//', '*')):
            continue

        lines.append((number, line))

    return lines


def frontend_files() -> list[Path]:
    """Todo el TypeScript del producto, incluido lo que no dibuja."""
    return sorted(
        path for path in FRONTEND.rglob('*.ts*') if 'ui' not in path.relative_to(FRONTEND).parts
    )


#: Una clave escrita entera: `t('profile.title')`.
LITERAL_KEY = re.compile(r"\bt\(\s*'([^'`\n]+)'")

#: Una clave armada con un dato: ``t(`step.${step.code}.goal`)``.
#:
#: Se comprueban distinto y las dos hacen falta. La literal se compara contra el
#: catálogo tal cual; la armada no se puede resolver sin ejecutar la pantalla, así
#: que lo que se exige es que **alguna** clave del catálogo tenga su forma. Es lo
#: que atrapa que un prefijo entero deje de existir —`step.*`, `stepReason.*`—,
#: que es como se rompió el recorrido guiado al migrarlo.
BUILT_KEY = re.compile(r'\bt\(\s*`([^`\n]+)`')


def catalog_keys(entries: dict[str, str]) -> set[str]:
    """Las claves que `t()` acepta: las declaradas más las bases de los plurales."""
    bases = {key.rsplit('.', 1)[0] for key in entries if key.endswith(('.one', '.other'))}
    return set(entries) | bases


@pytest.mark.parametrize('path', frontend_files(), ids=lambda path: path.name)
def test_every_key_a_screen_asks_for_is_in_the_catalog(path, catalogs):
    """
    La red que faltaba: `t()` corre en el navegador, no en el servidor.

    Una vista de Django puede contestar 200 con la pantalla rota, porque lo que
    manda son props y la frase se arma después. Una clave que no existe revienta
    recién al dibujar, y el test de pantallas sólo cubre las piezas que el arnés
    monta. Esto lo comprueba entero, archivo por archivo, sin navegador.
    """
    known = catalog_keys(catalogs['en'])
    source = '\n'.join(line for _number, line in code_lines(path.read_text(encoding='utf-8')))

    literal = {match.group(1) for match in LITERAL_KEY.finditer(source)}
    assert not literal - known, (
        f'{path.relative_to(PROJECT_ROOT)} pide claves que no están en el catálogo: '
        f'{sorted(literal - known)}'
    )

    for built in {match.group(1) for match in BUILT_KEY.finditer(source)}:
        # `${…}` es el hueco donde entra el código; el resto tiene que coincidir.
        shape = re.compile('^' + re.sub(r'\\\$\\\{[^}]*\\\}', r'[\\w.-]+', re.escape(built)) + '$')
        assert any(shape.match(key) for key in known), (
            f'{path.relative_to(PROJECT_ROOT)} arma claves con la forma «{built}» y el catálogo '
            f'no tiene ninguna así.'
        )


@pytest.mark.parametrize('path', frontend_files(), ids=lambda path: path.name)
def test_nothing_in_the_interface_is_written_in_spanish(path):
    """
    El idioma fuente es el inglés y el español es una traducción más.

    Los comentarios y los docstrings **sí** van en español: explican por qué algo
    es así, y quien los lee es quien trabaja en el repositorio. Lo que no puede
    quedar en español es nada de lo que se ejecuta: ni una frase de pantalla, ni
    un mensaje de error de programación, ni un rótulo de un atributo.
    """
    offenders = [
        (number, line.strip())
        for number, line in code_lines(path.read_text(encoding='utf-8'))
        if SPANISH_LETTERS.search(line)
    ]

    assert not offenders, (
        f'{path.relative_to(PROJECT_ROOT)} escribe en español fuera de un comentario: {offenders}'
    )


def test_the_locales_the_server_offers_are_the_ones_that_exist():
    """
    Un idioma elegible sin catálogo es una cuenta que no puede abrir ninguna
    pantalla, y la única forma de salir es cambiarlo desde la base.
    """
    for value in Language.values:
        slug = value.lower().replace('_', '-')
        assert (LOCALES / f'{slug}.json').exists(), f'«{value}» no tiene catálogo.'
