#!/usr/bin/env python3
"""
tests/test_lexer_parser_ast.py - Suite de pruebas para el lexer/parser/AST
formal (items #23-25 del feedback).

Este archivo estaba documentado en 'docs/CHANGELOG.md' (version 41.34:
"tests/test_lexer_parser_ast.py (nuevo, 23 tests)... valida cruzado
contra Interprete.evaluar_expresion del motor de produccion real") pero
nunca habia llegado a este paquete, aunque 'src/lexer_sipi.py' y
'src/ast_sipi.py' (las piezas que este archivo prueba) si existian.
Sin esta suite, un cambio futuro en el lexer/parser podia romper algo
silenciosamente -- exactamente lo que la propia nota de la v41.34 decia
que evitaba.

Cubre: tokenizacion (numeros, texto, identificadores, operadores,
parentesis, alias 'y'/'o'/'no'), parseo con precedencia correcta,
evaluacion (aritmetica, comparacion, logica con cortocircuito real,
llamadas a funcion), y --lo mas importante-- paridad cruzada contra
'Interprete.evaluar_expresion' del motor de produccion real (sipi.py):
las dos implementaciones deben dar EXACTAMENTE el mismo resultado para
el mismo lote de expresiones.

Uso:
    python3 tests/test_lexer_parser_ast.py
    python3 -m pytest tests/test_lexer_parser_ast.py -v
"""
import os
import sys
import unittest

AQUI = os.path.dirname(os.path.abspath(__file__))
CARPETA_SRC = os.path.dirname(AQUI)
sys.path.insert(0, CARPETA_SRC)

from lexer_sipi import tokenizar, TipoToken, ErrorLexico
from ast_sipi import (
    parsear, evaluar, evaluar_texto, ErrorEvaluacion, ErrorSintactico,
    NumeroLiteral, TextoLiteral, BooleanoLiteral, NuloLiteral, Variable,
    Unario, Binario, Llamada,
)

try:
    import sipi as motor_sipi
except ImportError:
    import sipi_protegido as motor_sipi  # noqa: F401 -- carpeta de publicacion sin fuente


def _interprete_de_produccion(variables=None):
    """Instancia minima de Interprete (sin cargar ningun archivo real)
    solo para poder llamar a 'evaluar_expresion' -- el mismo camino que
    usa cualquier programa .sipi en produccion."""
    interprete = motor_sipi.Interprete("_paridad_lexer_ast_.sipi")
    if variables:
        interprete.entorno.variables.update(variables)
    return interprete


class TestLexer(unittest.TestCase):
    def test_numero_entero(self):
        tokens = tokenizar("42")
        self.assertEqual(tokens[0].tipo, TipoToken.NUMERO)
        self.assertEqual(tokens[0].valor, 42)

    def test_numero_decimal(self):
        tokens = tokenizar("3.14")
        self.assertEqual(tokens[0].valor, 3.14)

    def test_texto_simple(self):
        tokens = tokenizar('"hola mundo"')
        self.assertEqual(tokens[0].tipo, TipoToken.TEXTO)
        self.assertEqual(tokens[0].valor, "hola mundo")

    def test_texto_con_escapes(self):
        tokens = tokenizar('"linea1\\nlinea2\\tfin"')
        self.assertEqual(tokens[0].valor, "linea1\nlinea2\tfin")

    def test_texto_sin_cerrar_da_error_con_columna(self):
        with self.assertRaises(ErrorLexico) as ctx:
            tokenizar('"sin cerrar')
        self.assertEqual(ctx.exception.columna, 0)

    def test_identificador(self):
        tokens = tokenizar("mi_variable")
        self.assertEqual(tokens[0].tipo, TipoToken.IDENTIFICADOR)
        self.assertEqual(tokens[0].valor, "mi_variable")

    def test_alias_operadores_logicos_espanol(self):
        tokens = tokenizar("verdadero y falso o no verdadero")
        operadores = [t.valor for t in tokens if t.tipo == TipoToken.OPERADOR]
        self.assertEqual(operadores, ["and", "or", "not"])

    def test_operadores_dos_caracteres(self):
        tokens = tokenizar("a == b != c <= d >= e")
        operadores = [t.valor for t in tokens if t.tipo == TipoToken.OPERADOR]
        self.assertEqual(operadores, ["==", "!=", "<=", ">="])

    def test_parentesis_y_coma(self):
        tokens = tokenizar("f(a, b)")
        tipos = [t.tipo for t in tokens]
        self.assertIn(TipoToken.PARENTESIS_ABRE, tipos)
        self.assertIn(TipoToken.PARENTESIS_CIERRA, tipos)
        self.assertIn(TipoToken.COMA, tipos)

    def test_caracter_desconocido_da_error_con_columna(self):
        with self.assertRaises(ErrorLexico) as ctx:
            tokenizar("2 @ 3")
        self.assertEqual(ctx.exception.columna, 2)

    def test_termina_siempre_en_eof(self):
        tokens = tokenizar("1 + 1")
        self.assertEqual(tokens[-1].tipo, TipoToken.EOF)


class TestParser(unittest.TestCase):
    def test_precedencia_multiplicacion_antes_que_suma(self):
        nodo = parsear("2 + 3 * 4")
        self.assertIsInstance(nodo, Binario)
        self.assertEqual(nodo.operador, "+")
        self.assertIsInstance(nodo.derecha, Binario)
        self.assertEqual(nodo.derecha.operador, "*")

    def test_parentesis_cambian_precedencia(self):
        nodo = parsear("(2 + 3) * 4")
        self.assertEqual(nodo.operador, "*")
        self.assertIsInstance(nodo.izquierda, Binario)
        self.assertEqual(nodo.izquierda.operador, "+")

    def test_unarios_encadenados(self):
        nodo = parsear("- - 5")
        self.assertIsInstance(nodo, Unario)
        self.assertIsInstance(nodo.operando, Unario)

    def test_llamada_a_funcion_con_argumentos(self):
        nodo = parsear("doble(5)")
        self.assertIsInstance(nodo, Llamada)
        self.assertEqual(nodo.nombre, "doble")
        self.assertEqual(len(nodo.argumentos), 1)

    def test_llamada_anidada(self):
        nodo = parsear("suma_uno(doble(3))")
        self.assertIsInstance(nodo, Llamada)
        self.assertIsInstance(nodo.argumentos[0], Llamada)

    def test_error_sintactico_parentesis_sin_cerrar(self):
        with self.assertRaises(ErrorSintactico):
            parsear("(1 + 2")

    def test_error_sintactico_token_sobrante(self):
        with self.assertRaises(ErrorSintactico):
            parsear("1 + 2 3")

    def test_solo_se_puede_llamar_a_un_nombre(self):
        with self.assertRaises(ErrorSintactico):
            parsear("(1 + 2)(3)")


class TestEvaluador(unittest.TestCase):
    def test_aritmetica_basica(self):
        self.assertEqual(evaluar_texto("2 + 3 * 4"), 14)

    def test_division_por_cero(self):
        with self.assertRaises(ErrorEvaluacion):
            evaluar_texto("5 / 0")

    def test_comparaciones(self):
        self.assertTrue(evaluar_texto("5 > 3"))
        self.assertFalse(evaluar_texto("5 < 3"))
        self.assertTrue(evaluar_texto("5 == 5"))

    def test_concatenacion_texto_y_numero(self):
        self.assertEqual(evaluar_texto('"Puntaje: " + 10'), "Puntaje: 10")

    def test_variables(self):
        # Notese que se evita el nombre 'y' a proposito: en SiPi 'y' es
        # el alias en español del operador logico 'and', asi que no es
        # un nombre de variable valido (igual que 'and' no lo seria en
        # Python) -- no es un bug, es la misma ambiguedad que ya evita
        # cualquier lenguaje con palabras reservadas.
        self.assertEqual(evaluar_texto("x + z", variables={"x": 2, "z": 3}), 5)

    def test_variable_no_declarada(self):
        with self.assertRaises(ErrorEvaluacion):
            evaluar_texto("x + 1")

    def test_cortocircuito_and_no_evalua_lado_derecho(self):
        llamadas = []

        def efecto_secundario():
            llamadas.append(1)
            return True

        resultado = evaluar_texto(
            "falso y f()",
            variables={"falso": False},
            funciones={"f": efecto_secundario},
        )
        self.assertFalse(resultado)
        self.assertEqual(llamadas, [])

    def test_cortocircuito_or_no_evalua_lado_derecho(self):
        llamadas = []

        def efecto_secundario():
            llamadas.append(1)
            return True

        resultado = evaluar_texto(
            "verdadero o f()",
            variables={"verdadero": True},
            funciones={"f": efecto_secundario},
        )
        self.assertTrue(resultado)
        self.assertEqual(llamadas, [])

    def test_llamada_a_funcion_inyectada(self):
        resultado = evaluar_texto("doble(21)", funciones={"doble": lambda x: x * 2})
        self.assertEqual(resultado, 42)

    def test_nulo_es_distinguible(self):
        self.assertIsNone(evaluar_texto("nulo"))


class TestParidadConProduccion(unittest.TestCase):
    """El corazon de este archivo, tal como lo describe la v41.34: para
    el mismo lote de expresiones, 'ast_sipi.evaluar_texto' y
    'Interprete.evaluar_expresion' (el motor real de produccion) deben
    coincidir EXACTAMENTE -- si algun dia divergen, es una señal de que
    uno de los dos cambio de comportamiento sin que el otro se entere."""

    EXPRESIONES_SIMPLES = [
        "2 + 3",
        "10 - 4",
        "6 * 7",
        "20 / 4",
        "2 + 3 * 4",
        "(2 + 3) * 4",
        "5 > 3",
        "5 < 3",
        "5 == 5",
        "5 != 4",
    ]

    def test_paridad_expresiones_numericas_simples(self):
        interprete = _interprete_de_produccion()
        for expr in self.EXPRESIONES_SIMPLES:
            with self.subTest(expr=expr):
                esperado = interprete.evaluar_expresion(expr)
                obtenido = evaluar_texto(expr)
                self.assertEqual(obtenido, esperado, f"diverge en: {expr!r}")

    def test_paridad_con_variables(self):
        variables = {"edad": 20, "limite": 18}
        interprete = _interprete_de_produccion(variables)
        for expr in ["edad + 1", "edad >= limite", "edad - limite"]:
            with self.subTest(expr=expr):
                esperado = interprete.evaluar_expresion(expr)
                obtenido = evaluar_texto(expr, variables=variables)
                self.assertEqual(obtenido, esperado, f"diverge en: {expr!r}")

    def test_paridad_division_por_cero_mismo_tipo_de_fallo(self):
        interprete = _interprete_de_produccion()
        with self.assertRaises(Exception):
            interprete.evaluar_expresion("1 / 0")
        with self.assertRaises(ErrorEvaluacion):
            evaluar_texto("1 / 0")

    def test_paridad_concatenacion_texto_numero(self):
        interprete = _interprete_de_produccion()
        expr = '"Total: " + 5'
        esperado = interprete.evaluar_expresion(expr)
        obtenido = evaluar_texto(expr)
        self.assertEqual(obtenido, esperado)


if __name__ == "__main__":
    unittest.main(verbosity=2)
