"""Pruebas del laboratorio. LAB_DIR dice dónde está la clase: lab o solution.

Cada prueba compila con sanitizers lo que necesita y lo corre: una fuga o un acceso
inválido la hace fallar, aunque el resultado parezca correcto.
"""
import os
import pathlib
import random
import re
import subprocess
import tempfile
import unittest

HERE = pathlib.Path(__file__).parent
LAB_DIR = os.environ.get("LAB_DIR", "solution")
SOURCE = HERE / LAB_DIR
GIVEN = HERE / "lab"
CXX_FLAGS = ["g++", "-Wall", "-Wextra", "-std=c++20", "-g", "-fsanitize=address,undefined"]
C_FLAGS = ["gcc", "-Wall", "-Wextra", "-std=c17", "-g", "-fsanitize=address,undefined"]
# Mínimo de assert en sus pruebas: los 38 del módulo en C que se pueden pasar a la clase,
# más al menos tres de casos borde.
MIN_ASSERTS = 41
# Lo que ya no debe aparecer en la clase.
MANUAL_MEMORY = r"\b(malloc|calloc|realloc|free|strdup|strcpy|strcmp|strlen|new|delete)\b|\bchar\s*\*"


def strip_comments(text):
    return re.sub(r"//[^\n]*", "", text)


def build(command, output):
    done = subprocess.run(command + ["-o", str(output)], cwd=HERE, text=True,
                          stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=120)
    return done.returncode, done.stdout


def run(program, stdin=""):
    return subprocess.run([str(program)], cwd=HERE, input=stdin, text=True,
                          stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=60)


def random_commands(seed, steps):
    """Una secuencia de órdenes con semilla fija, para comparar las dos versiones."""
    generator = random.Random(seed)
    lines = []
    for _ in range(steps):
        name = f"e{generator.randrange(25)}"
        grade = generator.randrange(-2, 23)
        command = generator.choice(["add", "add", "set", "get", "remove", "list"])
        if command in ("add", "set"):
            lines.append(f"{command} {name} {grade}")
        elif command in ("get", "remove"):
            lines.append(f"{command} {name}")
        else:
            lines.append("list")
    return "\n".join(lines) + "\n"


@unittest.skipUnless(SOURCE.exists(), f"no existe {LAB_DIR}/")
class LabCase(unittest.TestCase):
    def setUp(self):
        self.folder = tempfile.TemporaryDirectory()
        self.addCleanup(self.folder.cleanup)
        self.temp = pathlib.Path(self.folder.name)

    def compile_class(self, main_source):
        """Compila main_source con la clase. Falla la prueba si no compila o si da advertencias."""
        program = self.temp / "program"
        code, messages = build(CXX_FLAGS + [f"-I{SOURCE}", str(main_source),
                                            str(SOURCE / "grade_book.cpp")], program)
        self.assertEqual(code, 0, f"no compila:\n{messages}")
        self.assertEqual(messages.strip(), "", "hay advertencias del compilador")
        return program

    def assert_runs(self, program):
        done = run(program)
        self.assertEqual(done.returncode, 0, done.stdout + done.stderr)


class Step1Environment(LabCase):
    def test_hello(self):
        program = self.temp / "hello"
        code, messages = build(CXX_FLAGS + [str(GIVEN / "hello.cpp")], program)
        self.assertEqual((code, messages.strip()), (0, ""))
        self.assertEqual(run(program).stdout, "entorno: ok\ncompilador: ok\nmake: ok\n")


class Step2GivenModule(LabCase):
    def test_c_module_compiles_and_passes(self):
        program = self.temp / "tests_c"
        code, messages = build(C_FLAGS + [str(GIVEN / "c" / "test_grade_book.c"),
                                          str(GIVEN / "c" / "grade_book.c")], program)
        self.assertEqual((code, messages.strip()), (0, ""))
        self.assert_runs(program)


class Step3Port(LabCase):
    def test_public_tests(self):
        self.assert_runs(self.compile_class(GIVEN / "tests" / "public_port.cpp"))

    def test_name_at_checks_its_contract(self):
        source = self.temp / "out_of_range.cpp"
        source.write_text('#include "grade_book.h"\nint main() {\n    GradeBook book;\n'
                          '    return static_cast<int>(book.name_at(0).size());\n}\n')
        done = run(self.compile_class(source))
        self.assertNotEqual(done.returncode, 0, "name_at fuera de rango debe detenerse con assert")

    def test_same_behavior_as_the_c_module(self):
        driver_c = self.temp / "driver_c"
        code, messages = build(C_FLAGS + [str(GIVEN / "c" / "driver.c"),
                                          str(GIVEN / "c" / "grade_book.c")], driver_c)
        self.assertEqual((code, messages.strip()), (0, ""))
        driver = self.compile_class(GIVEN / "tests" / "driver.cpp")
        for seed in (1, 2, 3):
            commands = random_commands(seed, 2000)
            expected = run(driver_c, commands)
            actual = run(driver, commands)
            self.assertEqual(expected.returncode, 0, expected.stderr)
            self.assertEqual(actual.returncode, 0, actual.stderr)
            self.assertEqual(actual.stdout, expected.stdout, f"semilla {seed}")

    def test_no_manual_memory(self):
        for name in ("grade_book.h", "grade_book.cpp"):
            text = strip_comments((SOURCE / name).read_text(encoding="utf-8"))
            found = re.search(MANUAL_MEMORY, text)
            self.assertIsNone(found, f"{name}: {found.group(0) if found else ''}")

    def test_uses_string_and_vector(self):
        text = strip_comments((SOURCE / "grade_book.h").read_text(encoding="utf-8"))
        self.assertIn("std::vector", text)
        self.assertNotIn("placeholder_", text)


class Step4OwnTests(LabCase):
    def test_own_tests_pass(self):
        self.assert_runs(self.compile_class(SOURCE / "test_grade_book.cpp"))

    def test_own_tests_cover_the_c_tests_and_more(self):
        text = strip_comments((SOURCE / "test_grade_book.cpp").read_text(encoding="utf-8"))
        count = len(re.findall(r"\bassert\(", text))
        self.assertGreaterEqual(count, MIN_ASSERTS, f"hay {count} assert")


class Step5Stats(LabCase):
    def test_public_tests(self):
        self.assert_runs(self.compile_class(GIVEN / "tests" / "public_stats.cpp"))

    def test_add_is_not_const(self):
        source = self.temp / "const_add.cpp"
        source.write_text('#include "grade_book.h"\nvoid f(const GradeBook& book) { book.add("a", 1); }\n')
        command = CXX_FLAGS + [f"-I{SOURCE}", "-c", str(source)]
        code, _ = build(command, self.temp / "const_add.o")
        self.assertNotEqual(code, 0, "un GradeBook const no debe aceptar add")


class Step6Comparison(LabCase):
    def test_comparison_is_filled_in(self):
        text = (SOURCE / "comparison.txt").read_text(encoding="utf-8")
        self.assertNotIn("PENDIENTE", text)
        self.assertRegex(text, "read-only|discards qualifiers", "falta la línea del error del compilador")


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