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

HERE = pathlib.Path(__file__).parent
BUILD = HERE / "build"
BASE = ["-Wall", "-Wextra", "-std=c17"]
FLAGS = [*BASE, "-g", "-fsanitize=address,undefined"]
PLAIN = ["factorial", "factorial_trace", "frame_addresses", "array_examples", "convert", "digits", "deep_sum"]


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


class Compiled:
    """Un programa compilado aparte, con sus opciones, en una carpeta temporal."""

    def __init__(self, source, flags):
        self.folder = tempfile.TemporaryDirectory()
        self.binary = str(pathlib.Path(self.folder.name) / "program")
        self.messages, self.code = run(["gcc", *flags, source, "-o", self.binary])

    def __enter__(self):
        return self

    def __exit__(self, *_):
        self.folder.cleanup()


class Programs(unittest.TestCase):
    def test_programs_compile_without_warnings(self):
        for name in PLAIN:
            with self.subTest(program=name):
                with Compiled(f"{name}.c", [*FLAGS, "-Werror"]) as program:
                    self.assertEqual(program.code, 0, program.messages)

    def test_examples_end_without_reports(self):
        for name in ["factorial", "factorial_trace", "array_examples", "convert", "digits"]:
            with self.subTest(program=name):
                out, code = run([str(BUILD / name)])
                self.assertEqual(code, 0, out)
                self.assertNotIn("runtime error", out)

    def test_each_call_has_its_own_n(self):
        with Compiled("frame_addresses.c", [*BASE, "-g", "-O0"]) as program:
            out, _ = run([program.binary])
        addresses = re.findall(r"está en (0x[0-9a-f]+)", out)
        self.assertEqual(len(set(addresses)), 4)
        values = [int(a, 16) for a in addresses]
        self.assertEqual(values, sorted(values, reverse=True), "la pila debería crecer hacia abajo")
        self.assertIn("al volver, n sigue siendo 3", out)


class StackLimit(unittest.TestCase):
    def report(self, binary, *args):
        # En x86_64 emulado, a veces el proceso muere sin dejar el informe: se reintenta.
        out = ""
        for _ in range(5):
            out, code = run([binary, *args])
            self.assertNotEqual(code, 0)
            if "stack-overflow" in out:
                return out
        return out

    def test_gcc_warns_without_a_base_case(self):
        with Compiled("errors/no_base_case.c", BASE) as program:
            self.assertIn("-Winfinite-recursion", program.messages)

    def test_gcc_does_not_warn_about_an_unreached_base_case(self):
        with Compiled("errors/skipped_base.c", BASE) as program:
            self.assertEqual(program.messages, "")

    def test_asan_names_the_overflow(self):
        for source in ["errors/no_base_case.c", "errors/skipped_base.c"]:
            with self.subTest(source=source):
                with Compiled(source, [*FLAGS, "-Wno-infinite-recursion"]) as program:
                    out = self.report(program.binary)
                self.assertIn("AddressSanitizer: stack-overflow", out)

    def test_a_correct_base_case_does_not_save_a_deep_recursion(self):
        with Compiled("deep_sum.c", [*BASE, "-g", "-O0"]) as program:
            self.assertEqual(run([program.binary, "recursive", "1000"])[0].strip(), "500500")
            self.assertNotEqual(run([program.binary, "recursive", "10000000"])[1], 0)
            self.assertEqual(run([program.binary, "loop", "10000000"])[0].strip(), "50000005000000")

    def test_o2_can_remove_the_recursion(self):
        # No es una garantía del lenguaje: es lo que hace este gcc, y lo que dicen los apuntes.
        with Compiled("deep_sum.c", [*BASE, "-O2"]) as program:
            self.assertEqual(run([program.binary, "recursive", "10000000"])[0].strip(), "50000005000000")

    def test_o2_turns_a_missing_base_case_into_a_hang(self):
        with Compiled("errors/no_base_case.c", [*BASE, "-O2", "-Wno-infinite-recursion"]) as program:
            done, code = run(["timeout", "2", program.binary])
        self.assertEqual(code, 124, done)


class WrongButQuiet(unittest.TestCase):
    def test_zero_base_case_breaks_only_some_inputs(self):
        with Compiled("errors/min_base_zero.c", FLAGS) as program:
            out, code = run([program.binary])
        self.assertEqual(code, 0)
        self.assertEqual(out.split("\n")[:3], ["{5}: 0", "{4, 2, 7}: 0", "{4, -2, 7}: -2"])

    def test_power_of_1024_does_not_fit(self):
        with Compiled("errors/power_too_big.c", FLAGS) as program:
            out, _ = run([program.binary])
        self.assertIn("signed integer overflow: 2 * 4611686018427387904", out)
        self.assertIn("2^62 = 4611686018427387904", out)

    def test_two_calls_cost_more_than_the_slow_version(self):
        with Compiled("errors/power_twice.c", BASE) as program:
            out, _ = run([program.binary])
        self.assertIn("power_twice(1, 1024) = 1, llamadas: 4095", out)


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