"""Comprueba los programas de la clase y lo que los apuntes dicen de sus errores."""
import pathlib
import subprocess
import tempfile
import unittest

HERE = pathlib.Path(__file__).parent
BUILD = HERE / "build"
FLAGS = ["-Wall", "-Wextra", "-std=c17", "-g", "-fsanitize=address,undefined"]
VALGRIND_FLAGS = ["-Wall", "-Wextra", "-std=c17", "-g", "-O0"]
PROGRAMS = ["reverse_numbers", "range_block", "sizes", "calloc_zero", "strings_owner",
            "realloc_grow", "growth_count", "image_size", "no_memory"]

# Programa de errors/ y lo que debe decir su informe con sanitizers.
SANITIZER_ERRORS = {
    "big_local": "stack-overflow",
    "short_block": "heap-buffer-overflow",
    "leak": "LeakSanitizer: detected memory leaks",
    "use_after_free": "heap-use-after-free",
    "double_free": "attempting double-free",
    "off_by_one": "heap-buffer-overflow",
    "free_middle": "attempting free on address which was not malloc()-ed",
    "local_overflow": "stack-buffer-overflow",
    "stale_pointer": "heap-use-after-free",
    "realloc_lost": "allocation-size-too-big",
}
# Programa de errors/ y lo que debe decir valgrind.
VALGRIND_ERRORS = {
    "leak": "definitely lost: 120 bytes in 3 blocks",
    "use_after_free": "Invalid read of size 4",
    "double_free": "Invalid free()",
    "off_by_one": "Invalid write of size 4",
    "uninit_read": "Conditional jump or move depends on uninitialised value(s)",
    "stale_pointer": "Invalid read of size 4",
    "realloc_lost": "definitely lost: 40 bytes in 1 blocks",
}
# Programa de la cacería y lo que reporta cada herramienta en la versión sin corregir.
HUNT_ERRORS = {
    "longest_word": ("LeakSanitizer", "definitely lost"),
    "seat_list": ("heap-use-after-free", "Invalid read"),
    "route_backup": ("attempting double-free", "Invalid free()"),
    "grade_histogram": ("heap-buffer-overflow", "Invalid read"),
    "passing_groups": (None, "Conditional jump"),
}


def run(command, stdin=None):
    done = subprocess.run(
        command, cwd=HERE, text=True, encoding="utf-8", input=stdin,
        stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60,
    )
    return done.stdout, done.returncode


def with_sanitizers(source, stdin=None):
    with tempfile.TemporaryDirectory() as folder:
        binary = pathlib.Path(folder) / "program"
        built, code = run(["gcc", *FLAGS, str(source), "-o", str(binary)])
        if code != 0:
            raise AssertionError(built)
        return run([str(binary)], stdin)


def with_valgrind(source):
    with tempfile.TemporaryDirectory() as folder:
        binary = pathlib.Path(folder) / "program"
        built, code = run(["gcc", *VALGRIND_FLAGS, str(source), "-o", str(binary)])
        if code != 0:
            raise AssertionError(built)
        return run(["valgrind", "--leak-check=full", str(binary)])


class Programs(unittest.TestCase):
    def test_every_program_compiles_without_warnings(self):
        for name in [*PROGRAMS, "student"]:
            with self.subTest(program=name):
                output, code = run(["gcc", *FLAGS, "-Werror", "-c", f"{name}.c", "-o", "/dev/null"])
                self.assertEqual(code, 0, output)

    def test_examples_end_without_errors(self):
        # Con sanitizers: una fuga o un acceso inválido cambiaría el código de salida.
        for name in ["range_block", "sizes", "calloc_zero", "strings_owner", "student",
                     "realloc_grow", "growth_count", "image_size"]:
            with self.subTest(program=name):
                output, code = run([str(BUILD / name)])
                self.assertEqual(code, 0, output)

    def test_examples_are_clean_in_valgrind(self):
        for name in ["range_block", "calloc_zero", "strings_owner", "student", "realloc_grow"]:
            with self.subTest(program=name):
                output, _ = with_valgrind(HERE / f"{name}.c")
                self.assertIn("ERROR SUMMARY: 0 errors", output)
                self.assertIn("no leaks are possible", output)

    def test_reverse_reads_any_count(self):
        numbers = " ".join(str(i) for i in range(5000))
        output, code = run([str(BUILD / "reverse_numbers")], f"5000 {numbers}\n")
        self.assertEqual(code, 0)
        self.assertEqual(output.split(), [str(i) for i in reversed(range(5000))])

    def test_reverse_rejects_bad_input_without_leaks(self):
        for text in ("x\n", "0\n", "-2\n", "3 1 2\n"):
            with self.subTest(text=text):
                output, code = run([str(BUILD / "reverse_numbers")], text)
                self.assertEqual(code, 1)
                self.assertEqual(output.strip(), "Entrada inválida")

    def test_no_memory_reports_null_without_sanitizers(self):
        output, code = run([str(BUILD / "no_memory")])
        self.assertEqual(code, 1)
        self.assertIn("devolvió NULL", output)


class Errors(unittest.TestCase):
    def test_sanitizers_name_each_error(self):
        for name, marker in SANITIZER_ERRORS.items():
            with self.subTest(program=name):
                output, code = with_sanitizers(HERE / "errors" / f"{name}.c")
                self.assertNotEqual(code, 0)
                self.assertIn(marker, output)

    def test_valgrind_names_each_error(self):
        for name, marker in VALGRIND_ERRORS.items():
            with self.subTest(program=name):
                output, _ = with_valgrind(HERE / "errors" / f"{name}.c")
                self.assertIn(marker, output)

    def test_asan_does_not_see_uninitialized_reads(self):
        output, code = with_sanitizers(HERE / "errors" / "uninit_read.c")
        self.assertEqual(code, 0)
        self.assertNotIn("ERROR", output)

    def test_valgrind_does_not_see_local_overflows(self):
        output, _ = with_valgrind(HERE / "errors" / "local_overflow.c")
        self.assertIn("ERROR SUMMARY: 0 errors", output)

    def test_strdup_is_not_declared_in_c17(self):
        output, code = run(["gcc", *VALGRIND_FLAGS, "-c", "errors/strdup_c17.c", "-o", "/dev/null"])
        self.assertNotEqual(code, 0)
        self.assertIn("strdup", output)


@unittest.skipUnless((HERE / "solution").exists(), "falta la solución")
class LabAsPublished(unittest.TestCase):
    """Solo con la carpeta de la solución, antes de entregar el laboratorio: ustedes van a
    corregir lab/hunt, y estas pruebas ya no tendrían sentido en su copia."""

    def test_hunt_compiles_without_warnings(self):
        for name in HUNT_ERRORS:
            with self.subTest(program=name):
                output, code = run(["gcc", *FLAGS, "-Werror", "-c", f"lab/hunt/{name}.c",
                                    "-o", "/dev/null"])
                self.assertEqual(code, 0, output)

    def test_each_hunt_program_has_the_error_it_should(self):
        for name, (with_asan, with_vg) in HUNT_ERRORS.items():
            source = HERE / "lab" / "hunt" / f"{name}.c"
            with self.subTest(program=name, tool="sanitizers"):
                output, code = with_sanitizers(source)
                if with_asan is None:
                    self.assertEqual(code, 0, output)
                else:
                    self.assertNotEqual(code, 0)
                    self.assertIn(with_asan, output)
            with self.subTest(program=name, tool="valgrind"):
                self.assertIn(with_vg, with_valgrind(source)[0])

    def test_starter_and_solution_compile_without_warnings(self):
        for folder in ("lab", "solution"):
            for name in ("image.c", "str_tools.c", "int_array.c", "image_tests.c", "str_tests.c",
                         "array_tests.c"):
                with self.subTest(file=f"{folder}/{name}"):
                    output, code = run(["gcc", *FLAGS, "-Werror", "-Ilab", "-c", f"{folder}/{name}",
                                        "-o", "/dev/null"])
                    self.assertEqual(code, 0, output)
        for name in ("filters.c", "image_demo.c", "sum_file.c"):
            with self.subTest(file=f"lab/{name}"):
                output, code = run(["gcc", *FLAGS, "-Werror", "-Ilab", "-c", f"lab/{name}",
                                    "-o", "/dev/null"])
                self.assertEqual(code, 0, output)


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