"""Pruebas del laboratorio. LAB_DIR dice dónde está el código: lab o solution.

Las funciones se llaman directamente: cada prueba compila el archivo del laboratorio
junto con un main propio, sin correr el main de ustedes.
"""
import math
import os
import pathlib
import re
import subprocess
import tempfile
import unittest

HERE = pathlib.Path(__file__).parent
LAB_DIR = os.environ.get("LAB_DIR", "solution")
FLAGS = ["-Wall", "-Wextra", "-std=c17", "-g", "-fsanitize=address,undefined"]
INT_MAX = 2147483647
INT_MIN = -2147483648
# Esconde de los mensajes de falla las líneas de Python, que no se enseña aquí.
__unittest = True


def source(name):
    return (HERE / LAB_DIR / f"{name}.c").read_text(encoding="utf-8")


def run(program):
    done = subprocess.run(
        [str(HERE / "build" / LAB_DIR / program)], text=True,
        stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=20,
    )
    return done.stdout.strip(), done.returncode


def call_with_driver(name, driver_main, stdin):
    """Compila lab/<name>.c con otro main y devuelve lo que escribe."""
    with tempfile.TemporaryDirectory() as folder:
        driver = pathlib.Path(folder) / "driver.c"
        driver.write_text(
            f'#define main lab_main\n#include "{HERE / LAB_DIR / name}.c"\n#undef main\n{driver_main}\n',
            encoding="utf-8",
        )
        binary = pathlib.Path(folder) / "driver"
        built = subprocess.run(
            ["gcc", *FLAGS, str(driver), "-o", str(binary)],
            text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
        )
        if built.returncode != 0:
            raise AssertionError(f"No compila con las funciones pedidas:\n{built.stdout}")
        done = subprocess.run(
            [str(binary)], input=stdin, text=True,
            stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=30,
        )
    if "runtime error" in done.stdout or "AddressSanitizer" in done.stdout:
        raise AssertionError(f"El sanitizer encontró un problema:\n{done.stdout}")
    return done.stdout.splitlines()


def int_main(function, arguments=1):
    """Un main que lee enteros de a grupos y escribe lo que devuelve la función."""
    names = [f"v{i}" for i in range(arguments)]
    formats = " ".join(["%d"] * arguments)
    pointers = ", ".join(f"&{name}" for name in names)
    return (
        f"int main(void) {{\n    int {', '.join(names)};\n"
        f'    while (scanf("{formats}", {pointers}) == {arguments}) {{\n'
        f'        printf("%d\\n", {function}({", ".join(names)}));\n    }}\n    return 0;\n}}'
    )


def is_prime(n):
    return n > 1 and all(n % i for i in range(2, math.isqrt(n) + 1))


def collatz(n):
    steps = 0
    while n != 1:
        n = n // 2 if n % 2 == 0 else 3 * n + 1
        steps += 1
    return steps


def no_warnings(test, name):
    done = subprocess.run(
        ["gcc", "-Wall", "-Wextra", "-std=c17", "-Werror", "-fsyntax-only",
         str(HERE / LAB_DIR / f"{name}.c")],
        text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
    )
    test.assertEqual(done.returncode, 0, done.stdout)


@unittest.skipUnless((HERE / LAB_DIR).is_dir(), f"no existe {LAB_DIR}/")
class DiagnosticTests(unittest.TestCase):
    """Sesión 1. Si una falla, el mensaje dice qué conviene repasar."""

    def check(self, function, inputs, expected, review, arguments=1):
        stdin = " ".join(str(value) for group in inputs for value in group)
        lines = call_with_driver("diagnostic", int_main(function, arguments), stdin)
        shown = ", ".join(str(group[0]) if arguments == 1 else str(group) for group in inputs)
        self.assertEqual([int(line) for line in lines], expected,
                         f"Datos: {shown}. Conviene repasar: {review}.")

    def test_no_warnings(self):
        no_warnings(self, "diagnostic")

    def test_average_of_two(self):
        driver = (
            'int main(void) {\n    int a, b;\n    while (scanf("%d %d", &a, &b) == 2) {\n'
            '        printf("%.2f\\n", average_of_two(a, b));\n    }\n    return 0;\n}'
        )
        pairs = [(7, 2), (2, 7), (4, 2), (0, 0), (-3, 0), (1, 2), (-7, 2), (100, 101)]
        stdin = " ".join(f"{a} {b}" for a, b in pairs)
        self.assertEqual(call_with_driver("diagnostic", driver, stdin),
                         [f"{(a + b) / 2:.2f}" for a, b in pairs],
                         f"Datos: {', '.join(map(str, pairs))}. Conviene repasar: división y conversión entre tipos, semana 2.")

    def test_clamp(self):
        triples = [(25, 0, 20), (-1, 0, 20), (0, 0, 20), (20, 0, 20), (11, 0, 20),
                   (5, 5, 5), (-10, -8, -2), (-5, -8, -2), (0, -8, -2)]
        self.check("clamp", triples, [min(max(v, lo), hi) for v, lo, hi in triples],
                   "decisiones con if y else if, semana 4", arguments=3)

    def test_sum_to(self):
        values = [1, 2, 4, 10, 100, 1000, 0, -1, -50]
        self.check("sum_to", [(n,) for n in values], [sum(range(1, n + 1)) for n in values],
                   "ciclos, y salirse por uno, semana 5")

    def test_digit_value(self):
        driver = (
            'int main(void) {\n    int code;\n    while (scanf("%d", &code) == 1) {\n'
            '        printf("%d\\n", digit_value((char) code));\n    }\n    return 0;\n}'
        )
        codes = list(range(32, 127))
        stdin = " ".join(map(str, codes))
        expected = [str(code - 48) if 48 <= code <= 57 else "-1" for code in codes]
        self.assertEqual(call_with_driver("diagnostic", driver, stdin), expected,
                         "Datos: los caracteres de código 32 a 126. "
                         "Conviene repasar: char es un número pequeño, ASCII, semanas 2 y 3.")

    def test_dice_ways(self):
        values = list(range(-1, 15))
        expected = [sum(1 for a in range(1, 7) for b in range(1, 7) if a + b == t) for t in values]
        self.check("dice_ways", [(t,) for t in values], expected,
                   "ciclos anidados, semana 6")


@unittest.skipUnless((HERE / LAB_DIR).is_dir(), f"no existe {LAB_DIR}/")
class NumbersTests(unittest.TestCase):
    """Sesión 2: digit_sum, is_prime y gcd."""

    def values(self, function, numbers, arguments=1):
        stdin = " ".join(str(n) for group in numbers for n in group)
        return [int(line) for line in call_with_driver("numbers", int_main(function, arguments), stdin)]

    def test_no_warnings(self):
        no_warnings(self, "numbers")

    def test_digit_sum_normal(self):
        numbers = [5, 9, 10, 23, 99, 100, 407, 2026, 987654321]
        self.assertEqual(self.values("digit_sum", [(n,) for n in numbers]),
                         [sum(map(int, str(n))) for n in numbers])

    def test_digit_sum_edges(self):
        numbers = [0, -1, -9, -407, INT_MAX, INT_MIN]
        self.assertEqual(self.values("digit_sum", [(n,) for n in numbers]),
                         [sum(map(int, str(abs(n)))) for n in numbers])

    def test_is_prime_first_numbers(self):
        numbers = list(range(-20, 500))
        self.assertEqual(self.values("is_prime", [(n,) for n in numbers]),
                         [int(is_prime(n)) for n in numbers])

    def test_is_prime_edges(self):
        numbers = [INT_MIN, -7, -2, -1, 0, 1, 2, 3, 4, 9, 25, 49, 121, 169, 7919, 1000003,
                   46337 * 46337, 2147483629, INT_MAX]
        self.assertEqual(self.values("is_prime", [(n,) for n in numbers]),
                         [int(is_prime(n)) for n in numbers])

    def test_gcd_normal(self):
        pairs = [(48, 18), (18, 48), (13, 7), (7, 7), (100, 75), (1, 1000), (1071, 462), (INT_MAX, 1)]
        self.assertEqual(self.values("gcd", pairs, 2), [math.gcd(a, b) for a, b in pairs])

    def test_gcd_with_zero(self):
        pairs = [(5, 0), (0, 5), (0, 0), (-5, 0), (0, -5), (INT_MAX, 0)]
        self.assertEqual(self.values("gcd", pairs, 2), [math.gcd(a, b) for a, b in pairs])

    def test_gcd_with_negatives(self):
        pairs = [(12, -18), (-12, 18), (-12, -18), (-18, 12), (-1, 1), (-INT_MAX, INT_MAX), (-7, 3)]
        self.assertEqual(self.values("gcd", pairs, 2), [math.gcd(a, b) for a, b in pairs])

    def test_their_tests_exist_and_pass(self):
        text = source("numbers")
        self.assertNotIn("NDEBUG", text)
        for function, minimum in [("digit_sum", 5), ("is_prime", 7), ("gcd", 6)]:
            with self.subTest(function=function):
                found = re.findall(rf"^\s*assert\({function}\(", text, re.M)
                self.assertGreaterEqual(len(found), minimum, f"faltan pruebas de {function}")
        self.assertEqual(run("numbers"), ("Todas las pruebas pasaron", 0))


@unittest.skipUnless((HERE / LAB_DIR).is_dir(), f"no existe {LAB_DIR}/")
class CollatzTests(unittest.TestCase):
    """Sesión 2: collatz_steps y longest_collatz."""

    def values(self, function, numbers):
        stdin = " ".join(map(str, numbers))
        return [int(line) for line in call_with_driver("collatz", int_main(function), stdin)]

    def test_no_warnings(self):
        no_warnings(self, "collatz")

    def test_steps_small(self):
        numbers = [1, 2, 3, 4, 5, 6, 7, 16, 27, 97]
        self.assertEqual(self.values("collatz_steps", numbers), [collatz(n) for n in numbers])

    def test_steps_below_ten_thousand(self):
        numbers = list(range(1, 10000))
        self.assertEqual(self.values("collatz_steps", numbers), [collatz(n) for n in numbers])

    def test_steps_outside_the_domain(self):
        self.assertEqual(self.values("collatz_steps", [0, -1, -6]), [-1, -1, -1])

    def test_longest(self):
        for limit in [2, 3, 4, 7, 8, 10, 28, 100, 1000, 10000]:
            with self.subTest(limit=limit):
                best = max(range(1, limit), key=lambda n: (collatz(n), -n))
                self.assertEqual(self.values("longest_collatz", [limit]), [best])

    def test_longest_with_no_numbers(self):
        self.assertEqual(self.values("longest_collatz", [1, 0, -5]), [0, 0, 0])

    def test_main_writes_the_answer(self):
        best = max(range(1, 10000), key=lambda n: (collatz(n), -n))
        expected = f"Menor que 10000, el de más pasos es {best}, con {collatz(best)} pasos"
        self.assertEqual(run("collatz"), (expected, 0))


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