"""Pruebas del laboratorio. LAB_DIR dice dónde está su trabajo: lab o solution.

Cada prueba corre en su propio proceso, con sanitizers: una fuga o un acceso inválido
la hace fallar aunque el resultado parezca correcto.
"""
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")
DATA = os.environ.get("DATA", "../../data")
OUT = HERE / "build" / LAB_DIR
MAP_TESTS = [
    "new_is_empty", "set_and_get", "overwrite", "contains", "remove_present", "remove_absent",
    "unusual_keys", "growth", "copy_is_independent", "self_assignment",
    "move_leaves_empty_and_usable", "random_small", "random_large", "run_map_suite",
    "capacity_doubles_past_one", "overwrite_does_not_grow", "remove_does_not_shrink", "long_chains",
    "lower_load_factor_grows_now", "growth_allocates_only_slots", "growth_copies_no_values",
    "pointers_survive_growth", "value_from_the_same_table", "value_without_default_constructor",
    "no_objects_left_behind", "moves_are_noexcept",
]


def short_report(text):
    """Deja lo útil de un informe: el tipo de error y las líneas de sus archivos."""
    if "ERROR:" not in text and "runtime error" not in text:
        return text.strip()
    useful = [line.strip() for line in text.splitlines()
              if "ERROR:" in line or "SUMMARY:" in line or "runtime error" in line
              or re.search(r"(hash_map|var_table|chain_lengths|map_suite)\.h:\d+", line)]
    return "\n".join(useful[:12])


def run(*command):
    return subprocess.run(
        [str(part) for part in command], cwd=HERE, text=True, encoding="utf-8",
        stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=120,
    )


def build(*targets):
    return run("make", "--no-print-directory", f"LAB_DIR={LAB_DIR}", *[f"build/{LAB_DIR}/{t}" for t in targets])


@unittest.skipUnless((HERE / LAB_DIR).is_dir(), f"no existe la carpeta {LAB_DIR}")
class Builds(unittest.TestCase):
    def test_builds_without_warnings(self):
        for name in ["map_tests", "hash_tests", "calc", "chain_report"]:
            (OUT / name).unlink(missing_ok=True)
        done = build("map_tests", "hash_tests", "calc", "chain_report")
        self.assertEqual(done.returncode, 0, done.stdout)
        # Solo interesan las advertencias del compilador, no las de make por el reloj.
        compiler = [line for line in done.stdout.splitlines() if not line.startswith("make")]
        self.assertNotIn("warning", "\n".join(compiler))


class BuiltFirst(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        done = build("map_tests", "hash_tests", "calc", "chain_report")
        if done.returncode != 0:
            raise AssertionError(done.stdout)


@unittest.skipUnless((HERE / LAB_DIR).is_dir(), f"no existe la carpeta {LAB_DIR}")
class TestMap(BuiltFirst):
    """Pasos 1 y 2."""


for test_name in MAP_TESTS:
    def map_case(self, name=test_name):
        done = run(OUT / "map_tests", name)
        self.assertEqual(done.returncode, 0, short_report(done.stdout))
    setattr(TestMap, f"test_{test_name}", map_case)


@unittest.skipUnless((HERE / LAB_DIR).is_dir(), f"no existe la carpeta {LAB_DIR}")
class TestCalc(BuiltFirst):
    """Paso 3."""

    def run_program(self, text):
        with tempfile.NamedTemporaryFile("w", suffix=".calc", encoding="utf-8", delete=False) as file:
            file.write(text)
        try:
            return run(OUT / "calc", file.name)
        finally:
            os.unlink(file.name)

    def test_example_program(self):
        done = run(OUT / "calc", "lab/tests/program.calc")
        self.assertEqual(done.returncode, 0, short_report(done.stdout))
        self.assertEqual(done.stdout, (HERE / "lab/tests/program.expected").read_text(encoding="utf-8"))

    def test_undefined_variable(self):
        done = self.run_program("x = 1\nprint y\n")
        self.assertEqual(done.returncode, 1, short_report(done.stdout))
        self.assertIn("error en la línea 2: variable 'y' sin definir", done.stdout)

    def test_delete_then_read(self):
        done = self.run_program("x = 1\ndel x\nprint x\n")
        self.assertEqual(done.returncode, 1, short_report(done.stdout))
        self.assertIn("error en la línea 3: variable 'x' sin definir", done.stdout)

    def test_delete_absent(self):
        done = self.run_program("del x\n")
        self.assertEqual(done.returncode, 1, short_report(done.stdout))
        self.assertIn("no se puede borrar 'x'", done.stdout)

    def test_many_variables_force_growth(self):
        lines = [f"v{i} = {i}" for i in range(5000)]
        lines += ["total = " + " + ".join(f"v{i}" for i in range(5000)), "print total", "vars"]
        done = self.run_program("\n".join(lines) + "\n")
        self.assertEqual(done.returncode, 0, short_report(done.stdout))
        self.assertEqual(done.stdout.split(), [str(sum(range(5000))), "5001"])


@unittest.skipUnless((HERE / LAB_DIR).is_dir(), f"no existe la carpeta {LAB_DIR}")
class TestHash(BuiltFirst):
    """Las funciones hash y el paso 4. Los conteos salen de una lista pequeña, en lab/tests."""

    def test_fnv1a(self):
        done = run(OUT / "hash_tests", "fnv1a")
        self.assertEqual(done.returncode, 0, short_report(done.stdout))

    def test_sum_hash(self):
        done = run(OUT / "hash_tests", "sum_hash")
        self.assertEqual(done.returncode, 0, short_report(done.stdout))

    def test_chain_lengths(self):
        done = run(OUT / "hash_tests", "chain_lengths")
        self.assertEqual(done.returncode, 0, short_report(done.stdout))

    def test_report_on_small_list(self):
        done = run(OUT / "chain_report", "lab/tests/words_small.txt")
        self.assertEqual(done.returncode, 0, short_report(done.stdout))
        lines = done.stdout.splitlines()
        self.assertRegex(lines[0], r"palabras 697, distintas en la tabla 697, encontradas 697, casillas 1024")
        rows = {line.split()[0]: line.split() for line in lines[2:]}
        self.assertEqual(rows["suma"][1:3], ["550", "6"])
        self.assertEqual(rows["fnv1a"][1:3], ["525", "4"])

    def test_report_on_real_words(self):
        """Con 28 039 términos reales, la suma ya se nota: es un conteo, no un tiempo."""
        done = build("chain_report_fast")
        self.assertEqual(done.returncode, 0, done.stdout)
        done = run(OUT / "chain_report_fast", f"{DATA}/terminos.tsv")
        self.assertEqual(done.returncode, 0, short_report(done.stdout))
        rows = {line.split()[0]: line.split() for line in done.stdout.splitlines()[2:]}
        self.assertEqual(rows["suma"][2], "100")
        self.assertEqual(rows["fnv1a"][2], "6")


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