"""Pruebas del laboratorio. LAB_DIR dice dónde están sus archivos: 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 resource
import subprocess
import unittest

HERE = pathlib.Path(__file__).parent
LAB_DIR = os.environ.get("LAB_DIR", "solution")
BINARY = HERE / "build" / LAB_DIR / "lab_tests"
MAZE = HERE / "build" / LAB_DIR / "maze"
UNION_FIND = [
    "new_sets_are_separate", "unite_reports_and_counts", "unite_uses_roots", "rank_rises_only_on_ties",
    "height_stays_logarithmic", "compression_flattens_the_whole_path", "compression_leaves_other_branches",
    "compression_keeps_ranks", "random_against_naive",
]
GENERATOR = ["generator_makes_a_tree", "generator_opens_only_real_walls", "generator_depends_on_seed"]
SEARCH = [
    "maze_graph_has_each_passage", "maze_graph_keeps_passage_order", "bfs_small_case",
    "bfs_stops_when_marking_target", "bfs_source_is_target", "bfs_unreachable", "bfs_random_against_brute_force",
    "dfs_small_case", "dfs_path_skips_dead_ends", "dfs_source_is_target", "dfs_unreachable",
    "dfs_random_paths_are_valid", "perfect_maze_gives_the_same_path", "cycles_separate_the_paths",
]
# Pila de 128 KB: alcanza para el programa, y no para una recursión de 6000 niveles.
SMALL_STACK = 128 * 1024
# Un BFS que marca al sacar se queda sin memoria o sin tiempo en la prueba de los rombos.
LIMIT_SECONDS = 20


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"(union_find\.h|maze\.h|solver\.h|lab_tests\.cpp):\d+", line)]
    return "\n".join(useful[:12])


def build(target):
    done = subprocess.run(
        ["make", "--no-print-directory", f"LAB_DIR={LAB_DIR}", f"build/{LAB_DIR}/{target}"],
        cwd=HERE, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
    )
    return done.returncode, done.stdout


def run_case(name, setup=None, env=None):
    return subprocess.run(
        [str(BINARY), name], cwd=HERE, text=True, encoding="utf-8", errors="replace", preexec_fn=setup, env=env,
        stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=LIMIT_SECONDS,
    )


def small_stack():
    resource.setrlimit(resource.RLIMIT_STACK, (SMALL_STACK, SMALL_STACK))


# ASan reserva mucha memoria virtual, así que el límite de memoria se le pasa en sus opciones.
LIMITED_MEMORY = {**os.environ, "ASAN_OPTIONS": "hard_rss_limit_mb=800"}


def add_cases(cls, names):
    for name in names:
        def case(self, name=name):
            done = run_case(name)
            self.assertEqual(done.returncode, 0, short_report(done.stdout))
        setattr(cls, f"test_{name}", case)


class LabCase(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        code, output = build("lab_tests")
        if code != 0:
            raise AssertionError(output)


@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 target in ["lab_tests", "maze", "compare_mazes"]:
            (HERE / "build" / LAB_DIR / target).unlink(missing_ok=True)
            code, output = build(target)
            self.assertEqual(code, 0, output)
            self.assertNotIn("warning", output)


@unittest.skipUnless((HERE / LAB_DIR).is_dir(), f"no existe la carpeta {LAB_DIR}")
class TestUnionFind(LabCase):
    """Paso 1: UnionFind, contra la versión ingenua."""


@unittest.skipUnless((HERE / LAB_DIR).is_dir(), f"no existe la carpeta {LAB_DIR}")
class TestGenerator(LabCase):
    """Paso 2: el generador de laberintos."""


@unittest.skipUnless((HERE / LAB_DIR).is_dir(), f"no existe la carpeta {LAB_DIR}")
class TestSearch(LabCase):
    """Paso 3: el grafo del laberinto, BFS y DFS."""

    def test_bfs_marks_when_pushing(self):
        try:
            done = run_case("bfs_marks_when_pushing", env=LIMITED_MEMORY)
        except subprocess.TimeoutExpired:
            self.fail("no terminó a tiempo: ¿marcan el nodo al sacarlo de la cola, y no al meterlo?")
        self.assertEqual(done.returncode, 0, short_report(done.stdout))

    def test_dfs_long_corridor(self):
        done = run_case("dfs_long_corridor", small_stack)
        self.assertEqual(done.returncode, 0, short_report(done.stdout))


@unittest.skipUnless((HERE / LAB_DIR).is_dir(), f"no existe la carpeta {LAB_DIR}")
class TestProgram(unittest.TestCase):
    """Paso 4: el programa completo, y su dibujo."""

    @classmethod
    def setUpClass(cls):
        code, output = build("maze")
        if code != 0:
            raise AssertionError(output)

    def run_maze(self, *args):
        return subprocess.run(
            [str(MAZE), *args], cwd=HERE, text=True, encoding="utf-8", errors="replace",
            stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=LIMIT_SECONDS,
        )

    def test_draws_both_searches(self):
        done = self.run_maze("8", "4", "1")
        self.assertEqual(done.returncode, 0, short_report(done.stdout))
        lines = done.stdout.splitlines()
        self.assertTrue(lines[0].startswith("BFS: camino de "), lines[0])
        self.assertEqual(lines[1], "+--" * 8 + "+")
        self.assertEqual(lines[2][1:3], "SS")
        self.assertTrue(lines[10].startswith("DFS: camino de "), lines[10])
        self.assertEqual(lines[8][-3:-1], "EE")

    def test_bad_usage(self):
        self.assertNotEqual(self.run_maze().returncode, 0)
        self.assertNotEqual(self.run_maze("0", "4", "1").returncode, 0)


add_cases(TestUnionFind, UNION_FIND)
add_cases(TestGenerator, GENERATOR)
add_cases(TestSearch, SEARCH)


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