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

Cada prueba copia la parte a una carpeta temporal, así que no toca sus archivos. Con
LAB_DIR=solution, los archivos de solution/ se copian encima de los de lab/.
"""
import os
import pathlib
import re
import shutil
import subprocess
import tempfile
import time
import unittest

HERE = pathlib.Path(__file__).parent
LAB_DIR = os.environ.get("LAB_DIR", "solution")
FLAGS = ["-Wall", "-Wextra", "-std=c17", "-g", "-fsanitize=address,undefined"]
# Un make corrido dentro de otro make hereda estas variables y escribe otras cosas.
CLEAN_ENV = {k: v for k, v in os.environ.items() if k not in ("MAKELEVEL", "MAKEFLAGS", "MFLAGS")}
PIXELS = "#include <stdio.h>\n#include \"filters.h\"\n"


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


class Workspace:
    """Una copia temporal de una parte del laboratorio."""

    def __init__(self, part):
        self.temp = tempfile.TemporaryDirectory()
        self.path = pathlib.Path(self.temp.name) / part
        shutil.copytree(HERE / "lab" / part, self.path, ignore=shutil.ignore_patterns("build"))
        if LAB_DIR != "lab":
            shutil.copytree(HERE / LAB_DIR / part, self.path, dirs_exist_ok=True)

    def close(self):
        self.temp.cleanup()

    def compile_only(self, source):
        (self.path / "probe.c").write_text(source, encoding="utf-8")
        return shell(["gcc", *FLAGS, "-c", "probe.c", "-o", "probe.o"], self.path)

    def run_driver(self, source, sources):
        """Compila un main propio con los .c indicados y lo corre. Falla si no compila."""
        (self.path / "driver.c").write_text(source, encoding="utf-8")
        out, code = shell(["gcc", *FLAGS, "driver.c", *sources, "-o", "driver"], self.path)
        if code != 0:
            raise AssertionError(f"No compila:\n{out}")
        return shell(["./driver"], self.path)


class PartTest(unittest.TestCase):
    part = ""

    def setUp(self):
        if not (HERE / LAB_DIR).exists():
            self.skipTest(f"no está {LAB_DIR}/")
        self.work = Workspace(self.part)
        self.addCleanup(self.work.close)

    def read(self, name):
        path = self.work.path / name
        if not path.exists():
            self.fail(f"Falta el archivo {self.part}/{name}")
        return path.read_text(encoding="utf-8")

    def check_driver(self, body, sources, wanted=None):
        out, code = self.work.run_driver(body, sources)
        if code != 0:
            self.fail(f"El programa falló, o dejó una fuga de memoria:\n{out}")
        if wanted is not None and out.strip() != wanted:
            self.fail(f"Se esperaba:\n{wanted}\nSalió:\n{out.strip()}")


class SplitProgram(PartTest):
    part = "split"
    INTERFACE = {
        "sales.o": {"load_sales", "sale_total", "sales_total", "best_sale"},
        "report.o": {"print_table", "print_summary"},
    }

    def make(self, *args):
        if not (self.work.path / "Makefile").exists():
            self.fail("Falta su lab/split/Makefile")
        return shell(["make", *args], self.work.path)

    def build(self):
        out, code = self.make()
        if code != 0:
            self.fail(f"make falló:\n{out}")
        return out

    def recompiled_after_touching(self, name):
        self.build()
        time.sleep(0.02)
        (self.work.path / name).touch()
        out, code = self.make("-n")
        if code != 0:
            self.fail(f"make -n falló:\n{out}")
        # Vale cualquier orden de las opciones: -c -o x.o x.c o -c x.c -o x.o.
        compiled = set()
        for line in out.splitlines():
            tokens = line.split()
            if "-c" in tokens:
                compiled.update(pathlib.Path(token).name for token in tokens)
        return compiled & {"main.c", "sales.c", "report.c"}

    def test_builds_without_warnings_and_with_the_flags(self):
        out = self.build()
        self.assertNotIn("warning:", out, "Hay advertencias al compilar")
        for flag in ("-Wall", "-Wextra", "-std=c17", "-g", "-fsanitize=address,undefined"):
            self.assertIn(flag, out, f"El Makefile no usa {flag}")

    def test_same_output_as_the_single_file(self):
        self.build()
        original, code = shell(["gcc", *FLAGS, "market.c", "-o", "original"], self.work.path)
        self.assertEqual(code, 0, original)
        expected, _ = shell(["./original"], self.work.path)
        out, code = shell(["build/market"], self.work.path)
        self.assertEqual(code, 0, f"build/market falló:\n{out}")
        if out != expected:
            self.fail(f"La salida cambió. Se esperaba:\n{expected}\nSalió:\n{out}")

    def test_no_c_file_is_included(self):
        for name in ("main.c", "sales.c", "report.c", "sales.h", "report.h"):
            with self.subTest(file=name):
                self.assertNotRegex(self.read(name), r'#include\s+"[^"]*\.c"',
                                    f"{name} incluye un .c: cada .c se compila por separado")

    def test_guards(self):
        source = '#include "sales.h"\n#include "report.h"\n#include "sales.h"\n#include "report.h"\n'
        self.read("sales.h")
        self.read("report.h")
        out, code = self.work.compile_only(source)
        self.assertEqual(code, 0, f"Incluir dos veces los encabezados no compila:\n{out}")

    def test_only_the_interface_is_public(self):
        self.build()
        for obj, names in self.INTERFACE.items():
            with self.subTest(object=obj):
                out, code = shell(["nm", f"build/{obj}"], self.work.path)
                self.assertEqual(code, 0, f"No está build/{obj}")
                public = set(re.findall(r"\sT (\w+)$", out, re.M))
                self.assertEqual(public, names,
                                 f"{obj} debe ofrecer solo {sorted(names)}. Ofrece {sorted(public)}")

    def test_sales_does_not_need_report(self):
        self.read("sales.h")
        body = ('#include <stdio.h>\n#include "sales.h"\n'
                "int main(void) {\n    struct sale sales[10];\n"
                '    int n = load_sales("ventas.txt", sales, 10);\n'
                '    printf("%d %d %d\\n", n, sales_total(sales, n), best_sale(sales, n));\n'
                "    return 0;\n}\n")
        self.check_driver(body, ["sales.c"], "5 11860 0")

    def test_touching_a_c_file_recompiles_only_that_file(self):
        self.assertEqual(self.recompiled_after_touching("report.c"), {"report.c"})

    def test_touching_sales_h_recompiles_everything(self):
        self.assertEqual(self.recompiled_after_touching("sales.h"),
                         {"main.c", "sales.c", "report.c"})

    def test_touching_report_h_does_not_recompile_sales(self):
        self.assertEqual(self.recompiled_after_touching("report.h"), {"main.c", "report.c"})

    def test_clean(self):
        self.build()
        self.make("clean")
        self.assertFalse((self.work.path / "build").exists(), "make clean no borró build/")


class FractionModule(PartTest):
    part = "fraction"

    def show(self, code, wanted):
        source = ('#include <stdio.h>\n#include "fraction.h"\n'
                  "static void show(Fraction *f) {\n"
                  '    if (f == NULL) { printf("NULL\\n"); return; }\n'
                  '    printf("%d %d\\n", fraction_numerator(f), fraction_denominator(f));\n'
                  "    fraction_free(f);\n}\n"
                  f"int main(void) {{\n{code}\n    return 0;\n}}\n")
        self.check_driver(source, ["fraction.c"], wanted)

    def test_new_reduces(self):
        self.show("show(fraction_new(6, 8));", "3 4")

    def test_the_sign_goes_in_the_numerator(self):
        self.show("show(fraction_new(2, -4)); show(fraction_new(-2, -4));", "-1 2\n1 2")

    def test_zero(self):
        self.show("show(fraction_new(0, 5)); show(fraction_new(0, -5));", "0 1\n0 1")

    def test_zero_denominator(self):
        self.show("show(fraction_new(1, 0));", "NULL")

    def binary(self, call, wanted):
        self.show("Fraction *a = fraction_new(1, 2);\nFraction *b = fraction_new(-3, 4);\n"
                  f"show({call});\nshow(a);\nshow(b);", wanted + "\n1 2\n-3 4")

    def test_add(self):
        self.binary("fraction_add(a, b)", "-1 4")

    def test_add_reduces(self):
        self.show("Fraction *a = fraction_new(1, 6);\nFraction *b = fraction_new(1, 3);\n"
                  "show(fraction_add(a, b));\nshow(a);\nshow(b);", "1 2\n1 6\n1 3")

    def test_multiply(self):
        self.binary("fraction_multiply(a, b)", "-3 8")

    def test_equal(self):
        self.show('Fraction *a = fraction_new(1, 2);\nFraction *b = fraction_new(3, 6);\n'
                  'Fraction *c = fraction_new(1, 3);\n'
                  'printf("%d %d\\n", fraction_equal(a, b) != 0, fraction_equal(a, c) != 0);\n'
                  'show(a);\nshow(b);\nshow(c);', "1 0\n1 2\n1 2\n1 3")

    def test_print(self):
        self.show('Fraction *a = fraction_new(3, 4);\nFraction *b = fraction_new(4, 2);\n'
                  'Fraction *c = fraction_new(-1, 2);\n'
                  'fraction_print(a); printf(" "); fraction_print(b); printf(" ");\n'
                  'fraction_print(c); printf("\\n");\n'
                  'fraction_free(a);\nfraction_free(b);\nfraction_free(c);', "3/4 2 -1/2")

    def test_free_accepts_null(self):
        self.show("fraction_free(NULL);", "")

    def test_type_stays_opaque(self):
        self.assertNotRegex(self.read("fraction.h"), r"struct\s+fraction\s*\{",
                            "Los campos de la fracción no van en el encabezado")

    def test_your_tests(self):
        source = re.sub(r"//[^\n]*", "", self.read("test_fraction.c"))
        for name in ("fraction_new", "fraction_add", "fraction_multiply", "fraction_equal"):
            with self.subTest(function=name):
                self.assertRegex(source, rf"\b{name}\(", f"test_fraction.c no prueba {name}")
        out, code = shell(["make"], self.work.path)
        self.assertEqual(code, 0, f"make falló:\n{out}")
        out, code = shell(["build/test_fraction"], self.work.path)
        self.assertEqual(code, 0, f"Sus pruebas fallan, o dejan una fuga:\n{out}")


class StackModule(PartTest):
    part = "stack"
    IMPLEMENTATIONS = ("stack_array.c", "stack_blocks.c")

    def both(self, code, wanted):
        source = ('#include <stdio.h>\n#include "stack.h"\n'
                  f"int main(void) {{\n    Stack *s = stack_new();\n    int v = -1;\n"
                  f'    if (s == NULL) {{ printf("stack_new devolvió NULL\\n"); return 1; }}\n'
                  f"{code}\n    stack_free(s);\n    return 0;\n}}\n")
        for name in self.IMPLEMENTATIONS:
            with self.subTest(implementation=name):
                self.check_driver(source, [name], wanted)

    def test_last_in_first_out(self):
        self.both('stack_push(s, 1); stack_push(s, 2);\n'
                  'stack_pop(s, &v); printf("%d ", v);\nstack_pop(s, &v); printf("%d\\n", v);', "2 1")

    def test_pop_on_empty_leaves_the_value_alone(self):
        self.both('printf("%d %d\\n", stack_pop(s, &v), v);', "0 -1")

    def test_peek_does_not_remove(self):
        self.both('stack_push(s, 5);\nint ok = stack_peek(s, &v);\n'
                  'printf("%d %d %d\\n", ok, v, stack_size(s));', "1 5 1")

    def test_peek_on_empty(self):
        self.both('printf("%d %d\\n", stack_peek(s, &v), v);', "0 -1")

    def test_cross_every_small_limit_in_both_directions(self):
        code = ("for (int n = 0; n <= 40; n++) {\n"
                "    for (int i = 0; i < n; i++) { if (!stack_push(s, i)) return 1; }\n"
                "    if (stack_size(s) != n) return 2;\n"
                "    for (int i = n - 1; i >= 0; i--) {\n"
                "        if (!stack_peek(s, &v) || v != i) return 3;\n"
                "        if (!stack_pop(s, &v) || v != i) return 4;\n"
                "    }\n"
                "    if (stack_size(s) != 0) return 5;\n"
                "}\n"
                'printf("ok\\n");')
        self.both(code, "ok")

    def test_many_values_and_free_with_values_inside(self):
        self.both("for (int i = 0; i < 20000; i++) { if (!stack_push(s, i)) return 1; }\n"
                  'printf("%d\\n", stack_size(s));', "20000")

    def test_each_file_uses_its_representation(self):
        array = re.sub(r"//[^\n]*", "", self.read("stack_array.c"))
        blocks = re.sub(r"//[^\n]*", "", self.read("stack_blocks.c"))
        self.assertIn("realloc", array, "stack_array.c debe crecer con realloc")
        self.assertNotIn("realloc", blocks, "stack_blocks.c no usa realloc")
        self.assertRegex(blocks, r"->\s*next", "stack_blocks.c debe enlazar sus bloques con next")

    def test_the_given_suite_passes_with_one_object(self):
        out, code = shell(["make"], self.work.path)
        self.assertEqual(code, 0, f"make falló:\n{out}")
        self.assertEqual(len(re.findall(r"-c test_stack\.c", out)), 1,
                         "test_stack.c debe compilarse una sola vez")
        for program in ("test_array", "test_blocks"):
            with self.subTest(program=program):
                out, code = shell([f"build/{program}"], self.work.path)
                self.assertEqual(code, 0, f"{program} falló:\n{out}")

    def test_header_and_suite_stay_opaque(self):
        self.assertNotRegex(self.read("stack.h"), r"struct\s+(stack|block)\s*\{",
                            "Los campos de la pila no van en el encabezado")


class OpaqueImage(PartTest):
    part = "image"
    SOURCES = ["image.c", "filters.c"]

    def main(self, code):
        return f"{PIXELS}int main(void) {{\n{code}\n    return 0;\n}}\n"

    def filtered(self, call, wanted):
        source = self.main(
            "const int values[] = {0, 10, 255, 30, 40, 100};\n"
            "Image *img = image_new(3, 2, 255, 0);\n"
            'if (img == NULL) { printf("NULL\\n"); return 1; }\n'
            "for (int y = 0; y < 2; y++) {\n"
            "    for (int x = 0; x < 3; x++) { image_set(img, x, y, values[y * 3 + x]); }\n"
            "}\n"
            f"{call};\n"
            "for (int y = 0; y < 2; y++) {\n"
            '    for (int x = 0; x < 3; x++) { printf("%d ", image_get(img, x, y)); }\n'
            "}\n"
            'printf("\\n");\n'
            "image_free(img);")
        self.check_driver(source, self.SOURCES, wanted)

    def test_a_pointer_is_allowed(self):
        out, code = self.work.compile_only('#include "image.h"\nImage *keep(Image *img) { return img; }\n')
        self.assertEqual(code, 0, f"image.h debe declarar el tipo Image:\n{out}")

    def assert_rejected(self, line):
        self.test_a_pointer_is_allowed()
        source = f'#include "image.h"\nint probe(Image *img) {{\n    (void) img;\n    {line}\n    return 0;\n}}\n'
        _, code = self.work.compile_only(source)
        self.assertNotEqual(code, 0, f"Esta línea no debería compilar fuera de image.c: {line}")

    def test_cannot_declare_an_image(self):
        self.assert_rejected("Image local; (void) local;")

    def test_cannot_read_a_field(self):
        self.assert_rejected("return img->width;")

    def test_accessors(self):
        self.check_driver(self.main(
            "Image *img = image_new(3, 2, 15, 7);\n"
            'if (img == NULL) { printf("NULL\\n"); return 1; }\n'
            "image_set(img, 2, 1, 9);\n"
            'printf("%d %d %d %d %d\\n", image_width(img), image_height(img), image_max_value(img),\n'
            "       image_get(img, 2, 1), image_get(img, 0, 1));\n"
            "image_free(img);\nimage_free(NULL);"), self.SOURCES, "3 2 15 9 7")

    def test_new_rejects_bad_data(self):
        self.check_driver(self.main(
            'printf("%d %d %d %d\\n", image_new(0, 2, 255, 0) == NULL, image_new(2, 0, 255, 0) == NULL,\n'
            "       image_new(2, 2, 300, 0) == NULL, image_new(2, 2, 15, 16) == NULL);"),
            self.SOURCES, "1 1 1 1")

    def test_load_and_save_round_trip(self):
        self.check_driver(self.main(
            'Image *img = image_load("images/tiny.pgm");\n'
            'if (img == NULL) { printf("NULL\\n"); return 1; }\n'
            'if (!image_save(img, "copy.pgm")) { printf("no guarda\\n"); return 1; }\n'
            'Image *copy = image_load("copy.pgm");\n'
            'if (copy == NULL) { printf("NULL\\n"); return 1; }\n'
            'printf("%d %d %d\\n", image_width(copy), image_height(copy), image_get(copy, 2, 1));\n'
            "image_free(img);\nimage_free(copy);"), self.SOURCES, "3 2 50")

    def test_load_rejects_bad_files(self):
        bad = {"formato": "P5\n1 1\n255\n7\n", "pocos": "P2\n2 2\n255\n1 2 3\n",
               "grande": "P2\n1 1\n255\n256\n", "letra": "P2\n1 1\n255\nx\n"}
        for name, content in bad.items():
            with self.subTest(case=name):
                (self.work.path / "bad.pgm").write_text(content, encoding="utf-8")
                self.check_driver(self.main(
                    'Image *img = image_load("bad.pgm");\n'
                    'printf("%d\\n", img == NULL);\nimage_free(img);'), self.SOURCES, "1")

    def test_invert(self):
        self.filtered("invert(img)", "255 245 0 225 215 155")

    def test_threshold_with_the_exact_level(self):
        self.filtered("threshold(img, 100)", "0 0 255 0 0 255")

    def test_flip(self):
        self.filtered("flip_horizontal(img)", "255 10 0 100 40 30")

    def test_stats(self):
        self.filtered('int min, max; double mean;\nimage_stats(img, &min, &max, &mean);\n'
                      'printf("%d %d %.2f\\n", min, max, mean)',
                      "0 255 72.50\n0 10 255 30 40 100")

    def test_demo_runs(self):
        out, code = shell(["make"], self.work.path)
        self.assertEqual(code, 0, f"make falló:\n{out}")
        self.assertNotIn("warning:", out, "Hay advertencias al compilar")
        out, code = shell(["build/filter_demo"], self.work.path)
        self.assertEqual(code, 0, f"filter_demo falló:\n{out}")
        self.assertIn("Tamaño: 32 x 16", out)

    def test_the_given_tests_pass(self):
        out, code = shell(["make", "test_image"], self.work.path)
        self.assertEqual(code, 0, f"Las pruebas todavía no compilan:\n{out}")
        out, code = shell(["build/test_image"], self.work.path)
        self.assertEqual(code, 0, f"test_image falló:\n{out}")


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