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

Cada prueba compila un main propio con su código, aparte de sus pruebas. Corren con ASan,
así que una fuga también hace fallar la prueba.
"""
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")
SOURCE = HERE / LAB_DIR
FLAGS = ["-Wall", "-Wextra", "-std=c17", "-g", "-fsanitize=address,undefined"]
INCLUDE = f"-I{HERE / 'lab'}"
HEADERS = '#include <stdio.h>\n#include <stdlib.h>\n#include <string.h>\n'


def compile_command(args):
    done = subprocess.run(["gcc", *FLAGS, INCLUDE, *map(str, args)], text=True,
                          stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
    if done.returncode != 0:
        raise AssertionError(f"No compila:\n{done.stdout}")


def run_binary(binary, args=()):
    try:
        done = subprocess.run([str(binary), *args], text=True, encoding="utf-8", errors="replace",
                              stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=30)
    except subprocess.TimeoutExpired:
        raise AssertionError("El programa no terminó en 30 segundos.") from None
    return done.stdout, done.returncode


def build_and_run(body, header, source):
    with tempfile.TemporaryDirectory() as name:
        driver = pathlib.Path(name) / "driver.c"
        driver.write_text(f'{HEADERS}#include "{header}"\nint main(void) {{\n{body}\nreturn 0;\n}}\n',
                          encoding="utf-8")
        binary = pathlib.Path(name) / "driver"
        compile_command([driver, SOURCE / source, "-o", binary])
        return run_binary(binary)


def without_comments_and_text(path):
    text = path.read_text(encoding="utf-8")
    text = re.sub(r"/\*.*?\*/", " ", text, flags=re.S)
    text = re.sub(r"//[^\n]*", " ", text)
    text = re.sub(r'"(\\.|[^"\\])*"', '""', text)
    return re.sub(r"'(\\.|[^'\\])*'", "' '", text)


class LabCase(unittest.TestCase):
    longMessage = False

    def check(self, result, wanted, what):
        out, code = result
        if code != 0:
            self.fail(f"{what}: el programa terminó con error.\n{out}")
        if out.strip() != wanted:
            self.fail(f"{what}\n  se esperaba: {wanted}\n  se obtuvo:   {out.strip()}")

    def assert_no_loops(self, path):
        code = without_comments_and_text(path)
        for word in ("for", "while", "goto"):
            if re.search(rf"\b{word}\b", code):
                self.fail(f"{path.name} no puede usar {word}: aquí todo se resuelve con recursión.")


@unittest.skipUnless((SOURCE / "recursion.c").exists(), f"no está {LAB_DIR}/recursion.c")
class SessionOne(LabCase):
    def recursion(self, body):
        return build_and_run(body, "recursion.h", "recursion.c")

    def test_no_loops(self):
        self.assert_no_loops(SOURCE / "recursion.c")

    def test_array_sum(self):
        body = """
        int a[] = {4, -2, 7, 2000000000, 2000000000};
        printf("%lld %lld %lld %lld\\n", array_sum(NULL, 0), array_sum(a, 1), array_sum(a, 3), array_sum(a, 5));
        """
        self.check(self.recursion(body), "0 4 9 4000000009", "array_sum con 0, 1, 3 y 5 elementos")

    def test_array_max(self):
        body = """
        int a[] = {-4, -2, -7, 3, 3, 1};
        printf("%d %d %d %d\\n", array_max(a, 1), array_max(a, 3), array_max(a, 6), array_max(a + 2, 1));
        """
        self.check(self.recursion(body), "-4 -2 3 -7", "array_max con negativos y con repetidos")

    def test_is_palindrome(self):
        cases = {"": 1, "a": 1, "aa": 1, "ab": 0, "abba": 1, "reconocer": 1, "abca": 0, "abcda": 0, "Ana": 0}
        for text, wanted in cases.items():
            with self.subTest(text=text):
                body = f'printf("%d\\n", is_palindrome("{text}") != 0);'
                self.check(self.recursion(body), str(wanted), f'is_palindrome("{text}")')

    def test_count_char(self):
        body = """
        printf("%zu %zu %zu %zu\\n", count_char("", 'a'), count_char("banana", 'a'),
               count_char("banana", 'x'), count_char("Chosica", 'c'));
        """
        self.check(self.recursion(body), "0 3 0 1", "count_char distingue mayúsculas y revisa la cadena completa")

    def test_reverse_string(self):
        for text, wanted in {"": "", "a": "a", "ab": "ba", "abc": "cba", "Lima": "amiL", "recursion": "noisrucer"}.items():
            with self.subTest(text=text):
                body = f'char s[] = "{text}"; reverse_string(s); printf("[%s]\\n", s);'
                self.check(self.recursion(body), f"[{wanted}]", f'reverse_string("{text}")')

    def test_recursion_is_really_used_on_long_input(self):
        body = """
        size_t n = 5000;
        char *s = malloc(n + 1);
        if (s == NULL) { return 1; }
        memset(s, 'x', n); s[n] = '\\0';
        printf("%d %zu\\n", is_palindrome(s) != 0, count_char(s, 'x'));
        s[0] = 'y'; reverse_string(s);
        printf("%c%c\\n", s[0], s[n - 1]);
        free(s);
        """
        self.check(self.recursion(body), "1 5000\nxy", "cadena de 5000 caracteres")

    def test_their_own_tests_cover_every_function(self):
        path = SOURCE / "recursion_tests.c"
        code = path.read_text(encoding="utf-8")
        for name in ["array_max", "is_palindrome", "count_char", "reverse_string"]:
            with self.subTest(function=name):
                if name + "(" not in code:
                    self.fail(f"recursion_tests.c todavía no prueba {name}.")

    def test_their_own_tests_pass(self):
        with tempfile.TemporaryDirectory() as name:
            binary = pathlib.Path(name) / "tests"
            compile_command([SOURCE / "recursion_tests.c", SOURCE / "recursion.c", "-o", binary])
            out, code = run_binary(binary)
        self.assertEqual(code, 0, f"Sus pruebas fallan:\n{out}")


@unittest.skipUnless((SOURCE / "counted.c").exists(), f"no está {LAB_DIR}/counted.c")
class Counting(LabCase):
    def counted(self, body):
        return build_and_run(body, "counted.h", "counted.c")

    def test_no_loops(self):
        self.assert_no_loops(SOURCE / "counted.c")

    def test_power_fast_values(self):
        body = """
        long c = 0;
        printf("%lld %lld %lld %lld %lld %lld\\n", power_fast(5, 0, &c), power_fast(2, 1, &c), power_fast(2, 10, &c),
               power_fast(3, 13, &c), power_fast(-3, 3, &c), power_fast(2, 62, &c));
        """
        self.check(self.counted(body), "1 2 1024 1594323 -27 4611686018427387904", "valores de power_fast")

    def test_power_fast_calls(self):
        for n, wanted in {0: 1, 1: 2, 10: 5, 62: 7, 1024: 12}.items():
            with self.subTest(n=n):
                body = f'long c = 0; power_fast(1, {n}, &c); printf("%ld\\n", c);'
                self.check(self.counted(body), str(wanted), f"llamadas de power_fast(1, {n})")

    def test_binary_search_finds_each_position(self):
        body = """
        int a[] = {1, 3, 5, 7, 9, 11, 13, 15};
        long c = 0;
        for (size_t n = 1; n <= 8; n++) {
            for (size_t i = 0; i < n; i++) {
                if (binary_search(a, n, a[i], &c) != (long) i) { printf("mal: n=%zu i=%zu\\n", n, i); }
            }
        }
        printf("listo\\n");
        """
        self.check(self.counted(body), "listo", "binary_search con cada posición y cada tamaño")

    def test_binary_search_when_absent(self):
        body = """
        int a[] = {1, 3, 5, 7};
        long c = 0;
        printf("%ld %ld %ld %ld %ld\\n", binary_search(NULL, 0, 1, &c), binary_search(a, 4, 0, &c),
               binary_search(a, 4, 4, &c), binary_search(a, 4, 8, &c), binary_search(a, 1, 3, &c));
        """
        self.check(self.counted(body), "-1 -1 -1 -1 -1", "binary_search de un valor que no está")

    def test_binary_search_with_repeated_values(self):
        body = """
        int a[] = {2, 2, 2, 2, 5};
        long c = 0;
        long i = binary_search(a, 5, 2, &c);
        printf("%d %ld\\n", i >= 0 && i <= 3, binary_search(a, 5, 5, &c));
        """
        self.check(self.counted(body), "1 4", "binary_search con repetidos")

    def test_binary_search_really_halves(self):
        body = """
        size_t n = 1000000;
        int *a = malloc(n * sizeof *a);
        if (a == NULL) { return 1; }
        for (size_t i = 0; i < n; i++) { a[i] = 2 * (int) i; }
        long found = 0, absent = 0;
        long i = binary_search(a, n, 1999998, &found);
        long j = binary_search(a, n, 7, &absent);
        printf("%ld %ld %d %d\\n", i, j, found >= 1 && found <= 22, absent >= 1 && absent <= 22);
        free(a);
        """
        self.check(self.counted(body), "999999 -1 1 1", "binary_search en un millón de elementos, con 22 llamadas o menos")


@unittest.skipUnless((SOURCE / "merge_sort.c").exists(), f"no está {LAB_DIR}/merge_sort.c")
class MergeSort(LabCase):
    def sorted_ok(self, data):
        values = ", ".join(map(str, data))
        wanted = " ".join(map(str, sorted(data)))
        body = f"""
        int a[] = {{{values}}};
        size_t n = sizeof a / sizeof a[0];
        if (!merge_sort(a, n)) {{ printf("devolvió 0\\n"); return 0; }}
        for (size_t i = 0; i < n; i++) {{ printf(i ? " %d" : "%d", a[i]); }}
        printf("\\n");
        """
        return build_and_run(body, "merge_sort.h", "merge_sort.c"), wanted

    def test_small_arrays(self):
        for data in ([1], [2, 1], [1, 2], [3, 1, 2], [5, 5, 5], [9, -1, 4, -1, 0], [31, 18, 25, 9, 22, 7, 14],
                     [8, 7, 6, 5, 4, 3, 2, 1], [1, 2, 3, 4, 5, 6, 7, 8]):
            with self.subTest(data=data):
                result, wanted = self.sorted_ok(data)
                self.check(result, wanted, f"merge_sort de {data}")

    def test_empty_and_one_element(self):
        body = """
        int one[] = {7};
        printf("%d %d %d\\n", merge_sort(NULL, 0) != 0, merge_sort(one, 1) != 0, one[0]);
        """
        self.check(build_and_run(body, "merge_sort.h", "merge_sort.c"), "1 1 7", "merge_sort de 0 y 1 elementos")

    def test_large_array(self):
        body = """
        size_t n = 200000;
        int *a = malloc(n * sizeof *a);
        if (a == NULL) { return 1; }
        unsigned int x = 12345;
        long long total = 0, after = 0;
        for (size_t i = 0; i < n; i++) {
            x = x * 1103515245u + 12345u;
            a[i] = (int) (x % 100000) - 50000;
            total += a[i];
        }
        printf("%d\\n", merge_sort(a, n) != 0);
        int good = 1;
        for (size_t i = 0; i < n; i++) {
            after += a[i];
            if (i > 0 && a[i - 1] > a[i]) { good = 0; }
        }
        printf("%d %d\\n", good, total == after);
        free(a);
        """
        self.check(build_and_run(body, "merge_sort.h", "merge_sort.c"), "1\n1 1", "merge_sort de 200000 elementos")

    def memory_case(self, fail_at):
        """Falla el pedido número fail_at; merge_sort se compila aparte para interceptar malloc."""
        driver = HEADERS + f"""#include "merge_sort.h"
        static int requests = 0;
        void *test_malloc(size_t size) {{
            requests++;
            return requests == {fail_at} ? NULL : malloc(size);
        }}
        void *test_calloc(size_t count, size_t size) {{
            requests++;
            return requests == {fail_at} ? NULL : calloc(count, size);
        }}
        int main(void) {{
            int a[] = {{5, 3, 8, 1, 9, 2, 7, 4, 6, 0}};
            int ok = merge_sort(a, 10);
            int one[] = {{1}};
            requests = 0;
            int small = merge_sort(one, 1) != 0 && merge_sort(NULL, 0) != 0;
            printf("%d %d %d\\n", ok != 0, requests == 0, small);
            return 0;
        }}
        """
        with tempfile.TemporaryDirectory() as name:
            folder = pathlib.Path(name)
            (folder / "driver.c").write_text(driver, encoding="utf-8")
            compile_command(["-c", SOURCE / "merge_sort.c", "-Dmalloc=test_malloc", "-Dcalloc=test_calloc",
                             "-o", folder / "merge_sort.o"])
            compile_command([folder / "driver.c", folder / "merge_sort.o", "-o", folder / "driver"])
            return run_binary(folder / "driver")

    def test_without_memory_returns_zero_and_does_not_leak(self):
        self.check(self.memory_case(1), "0 1 1", "merge_sort sin memoria debe devolver 0, sin fugas")

    def test_a_later_failure_does_not_leak(self):
        # Si reservan más de una vez, un pedido que falla más adelante tampoco deja fugas.
        out, code = self.memory_case(2)
        if code != 0:
            self.fail(f"merge_sort, cuando falla el segundo pedido de memoria: el programa terminó con error.\n{out}")


@unittest.skipUnless((SOURCE / "overflow.c").exists(), f"no está {LAB_DIR}/overflow.c")
class Overflow(LabCase):
    def built(self, folder):
        source = SOURCE / "overflow.c"
        binary = pathlib.Path(folder) / "overflow"
        compile_command([source, "-o", binary])
        return binary

    def test_sum_by_twos_ends_with_odd_numbers(self):
        with tempfile.TemporaryDirectory() as name:
            binary = self.built(name)
            for n, wanted in {0: "0", 10: "30", 11: "36", 1: "1", -3: "0", -4: "0"}.items():
                with self.subTest(n=n):
                    self.check(run_binary(binary, ["twos", str(n)]), wanted, f"overflow twos {n}")

    def test_sum_stays_correct(self):
        with tempfile.TemporaryDirectory() as name:
            binary = self.built(name)
            self.check(run_binary(binary, ["sum", "1000"]), "500500", "overflow sum 1000")


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