#!/usr/bin/env python3
"""
tests/test_suite.py - Suite de regresion de SiPi.

Corre programas .sipi reales contra el interprete de verdad (no mocks:
importa sipi.py y usa la clase Interprete tal cual la usa el editor y
la CLI) y compara la salida exacta con lo esperado. Cubre: lexer/parser
basico, variables, condicionales, bucles, funciones (con recursion),
manejo de errores, listas/diccionarios, y el cache de bytecode (.sipic).

Uso:
    python3 tests/test_suite.py
    python3 -m pytest tests/test_suite.py -v

Referenciado por 'sipi test' (sipi_cli.py) y por el workflow de CI
(.github/workflows/ci.yml) en cada Pull Request.
"""
import os
import sys
import io
import contextlib
import tempfile
import unittest

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

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


def correr(codigo_sipi):
    """Escribe el codigo a un archivo temporal .sipi, lo ejecuta con el
    interprete real y devuelve la salida de 'decir' como texto."""
    with tempfile.TemporaryDirectory(prefix="sipi_test_") as carpeta:
        ruta = os.path.join(carpeta, "programa.sipi")
        with open(ruta, "w", encoding="utf-8") as f:
            f.write(codigo_sipi)
        salida = io.StringIO()
        interprete = motor_sipi.Interprete(ruta)
        with contextlib.redirect_stdout(salida):
            interprete.ejecutar()
        return salida.getvalue()


def correr_esperando_error(codigo_sipi):
    """Igual que correr(), pero para casos donde se espera que el
    interprete levante SiPiError -- devuelve el mensaje de la excepcion."""
    with tempfile.TemporaryDirectory(prefix="sipi_test_") as carpeta:
        ruta = os.path.join(carpeta, "programa.sipi")
        with open(ruta, "w", encoding="utf-8") as f:
            f.write(codigo_sipi)
        interprete = motor_sipi.Interprete(ruta)
        with contextlib.redirect_stdout(io.StringIO()):
            interprete.ejecutar()


class TestBasico(unittest.TestCase):
    def test_decir_texto_simple(self):
        salida = correr('programa "t"\ndecir "hola"\n')
        self.assertEqual(salida.strip(), "hola")

    def test_interpolacion_de_variables(self):
        salida = correr('programa "t"\nvariable nombre = "Mateo"\ndecir "Hola {nombre}"\n')
        self.assertEqual(salida.strip(), "Hola Mateo")

    def test_numeros_y_aritmetica(self):
        salida = correr('programa "t"\nvariable x = 2 + 3 * 4\ndecir x\n')
        self.assertEqual(salida.strip(), "14")

    def test_comentarios_se_ignoran(self):
        salida = correr('programa "t"\n// esto es un comentario\ndecir "ok"\n')
        self.assertEqual(salida.strip(), "ok")


class TestVariables(unittest.TestCase):
    def test_sumar_resta(self):
        salida = correr('programa "t"\nvariable puntaje = 10\nsumar puntaje 5\nrestar puntaje 2\ndecir puntaje\n')
        self.assertEqual(salida.strip(), "13")

    def test_const_no_editable_logicamente(self):
        salida = correr('programa "t"\nconst MIVALOR = 3\ndecir MIVALOR\n')
        self.assertEqual(salida.strip(), "3")


class TestCondicionales(unittest.TestCase):
    def test_si_sino_rama_verdadera(self):
        salida = correr('programa "t"\nsi 5 > 3\n    decir "mayor"\nsino\n    decir "menor"\nfin\n')
        self.assertEqual(salida.strip(), "mayor")

    def test_si_sino_rama_falsa(self):
        salida = correr('programa "t"\nsi 1 > 3\n    decir "mayor"\nsino\n    decir "menor"\nfin\n')
        self.assertEqual(salida.strip(), "menor")

    def test_seleccionar_caso(self):
        codigo = (
            'programa "t"\nvariable v = 2\n'
            'seleccionar v\n    caso 1\n        decir "uno"\n'
            '    caso 2\n        decir "dos"\n    otro\n        decir "otro"\nfin\n'
        )
        self.assertEqual(correr(codigo).strip(), "dos")


class TestBucles(unittest.TestCase):
    def test_repetir_n_veces(self):
        salida = correr('programa "t"\nrepetir 3 veces\n    decir "hola"\nfin\n')
        self.assertEqual(salida.count("hola"), 3)

    def test_mientras(self):
        codigo = 'programa "t"\nvariable i = 0\nmientras i < 3\n    decir i\n    sumar i 1\nfin\n'
        self.assertEqual(correr(codigo).split(), ["0", "1", "2"])

    def test_para_cada(self):
        codigo = 'programa "t"\nvariable lista = [1, 2, 3]\npara_cada item en lista\n    decir item\nfin\n'
        self.assertEqual(correr(codigo).split(), ["1", "2", "3"])

    def test_romper_corta_el_bucle(self):
        codigo = 'programa "t"\nvariable i = 0\nmientras i < 10\n    si i == 3\n        romper\n    fin\n    decir i\n    sumar i 1\nfin\n'
        self.assertEqual(correr(codigo).split(), ["0", "1", "2"])

    def test_continuar_salta_iteracion(self):
        codigo = 'programa "t"\npara_cada n en [1, 2, 3, 4]\n    si n == 2\n        continuar\n    fin\n    decir n\nfin\n'
        self.assertEqual(correr(codigo).split(), ["1", "3", "4"])


class TestFunciones(unittest.TestCase):
    def test_funcion_simple_con_retorno(self):
        codigo = (
            'programa "t"\nfuncion suma(a: entero, b: entero) -> entero\n'
            '    devolver a + b\nfin\n'
            'llamar_valor suma(2, 3) -> resultado\ndecir resultado\n'
        )
        self.assertEqual(correr(codigo).strip(), "5")

    def test_recursion_real(self):
        codigo = (
            'programa "t"\nfuncion factorial(n: entero) -> entero\n'
            '    si n <= 1\n        devolver 1\n    fin\n'
            '    llamar_valor factorial(n - 1) -> resto\n'
            '    devolver n * resto\nfin\n'
            'llamar_valor factorial(5) -> resultado\ndecir resultado\n'
        )
        self.assertEqual(correr(codigo).strip(), "120")

    def test_ambito_local_no_se_filtra(self):
        codigo = (
            'programa "t"\nvariable x = 100\n'
            'funcion cambiar()\n    variable x = 1\n    devolver x\nfin\n'
            'llamar_valor cambiar() -> ignorar\ndecir x\n'
        )
        self.assertEqual(correr(codigo).strip(), "100")


class TestManejoDeErrores(unittest.TestCase):
    def test_intentar_capturar_recupera(self):
        codigo = (
            'programa "t"\nintentar\n    lanzar_error "boom"\ncapturar\n    decir "recuperado"\nfin\n'
        )
        self.assertEqual(correr(codigo).strip(), "recuperado")

    def test_romper_fuera_de_bucle_lanza_sipierror(self):
        with self.assertRaises(motor_sipi.SiPiError):
            correr_esperando_error('programa "t"\nromper\n')

    def test_continuar_fuera_de_bucle_lanza_sipierror(self):
        with self.assertRaises(motor_sipi.SiPiError):
            correr_esperando_error('programa "t"\ncontinuar\n')

    def test_sumar_con_sintaxis_invalida_da_error_amigable(self):
        # Regresion: 'sumar x, 5' (con coma) rompia con un AttributeError
        # crudo de Python ('NoneType' object has no attribute 'group')
        # en vez de un SiPiError explicando la sintaxis correcta.
        codigo = 'programa "t"\nvariable x = 10\nsumar x, 5\n'
        with self.assertRaises(motor_sipi.SiPiError) as contexto:
            correr_esperando_error(codigo)
        self.assertNotIn("NoneType", str(contexto.exception))
        self.assertIn("sumar", str(contexto.exception))

    def test_restar_con_sintaxis_invalida_da_error_amigable(self):
        codigo = 'programa "t"\nvariable x = 10\nrestar x, 5\n'
        with self.assertRaises(motor_sipi.SiPiError) as contexto:
            correr_esperando_error(codigo)
        self.assertNotIn("NoneType", str(contexto.exception))
        self.assertIn("restar", str(contexto.exception))


class TestListasYDiccionarios(unittest.TestCase):
    def test_lista_literal_y_len(self):
        codigo = 'programa "t"\nvariable l = [10, 20, 30]\nlongitud l -> n\ndecir n\n'
        self.assertEqual(correr(codigo).strip(), "3")

    def test_diccionario_acceso(self):
        codigo = 'programa "t"\nvariable d = {"clave": "valor"}\ndecir d["clave"]\n'
        self.assertEqual(correr(codigo).strip(), "valor")


class TestBloquesConFinYConIndentacion(unittest.TestCase):
    def test_bloque_sin_fin_por_indentacion(self):
        # 'fin' es opcional si la indentacion es consistente.
        codigo = 'programa "t"\nsi 5 > 3\n    decir "ok"\n'
        self.assertEqual(correr(codigo).strip(), "ok")

    def test_fin_e_indentacion_mezclados_en_mismo_archivo(self):
        codigo = (
            'programa "t"\n'
            'si 1 == 1\n    decir "primero"\nfin\n'
            'si 2 == 2\n    decir "segundo"\n'
        )
        self.assertEqual(correr(codigo).split("\n")[0:2], ["primero", "segundo"])


class TestCacheBytecode(unittest.TestCase):
    def test_cache_no_cambia_el_resultado(self):
        """Corre el mismo programa dos veces (la segunda deberia poder
        usar el cache .sipic si el archivo no cambio) y confirma que
        el resultado es identico en ambas corridas."""
        codigo = 'programa "t"\ndecir "cache ok"\n'
        with tempfile.TemporaryDirectory(prefix="sipi_test_cache_") as carpeta:
            ruta = os.path.join(carpeta, "programa.sipi")
            with open(ruta, "w", encoding="utf-8") as f:
                f.write(codigo)

            salidas = []
            for _ in range(2):
                salida = io.StringIO()
                interprete = motor_sipi.Interprete(ruta)
                with contextlib.redirect_stdout(salida):
                    interprete.ejecutar()
                salidas.append(salida.getvalue().strip())
            self.assertEqual(salidas[0], salidas[1])
            self.assertEqual(salidas[0], "cache ok")


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