"""Pruebas del laboratorio. LAB_DIR dice dónde está el mapa: 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 / "map_tests"
WORD_FREQ = HERE / "build" / LAB_DIR / "word_freq"
FIXTURE = HERE / "lab" / "tests" / "fixture.txt"
BASICS = [
    "new_is_empty", "insert_and_find", "insert_does_not_overwrite", "find_lets_edit_value",
    "subscript_operator", "in_order_is_sorted", "shape_follows_insertion_order",
    "sorted_keys_make_a_chain", "comparisons_follow_height", "strings_as_keys",
    "no_objects_left_behind", "copy_is_independent", "self_assignment",
    "move_leaves_empty_and_usable",
]
BROKEN = ["broken_deep_order", "broken_repeated_key", "broken_parent", "broken_size"]
ERASE = [
    "erase_absent", "erase_leaf", "erase_one_child", "erase_root_with_one_child",
    "erase_two_children_uses_successor", "erase_successor_is_right_child",
    "erase_successor_with_right_child", "erase_releases_objects", "only_needs_less_than",
    "random_against_map", "random_strings", "copy_after_random_operations",
]
QUERIES = [
    "min_max", "floor_ceiling_cases", "floor_ceiling_random", "range_is_half_open", "range_random",
    "range_does_not_walk_everything", "iterator_in_order", "iterator_edits_value", "const_iterator",
    "iterator_of_empty_map", "iterator_survives_insert", "iterator_random", "iterate_chain_comparisons",
]
# Pila de 128 KB: alcanza para el programa, y no para una recursión de 6000 niveles.
SMALL_STACK = 128 * 1024


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"(bst_map\.h|map_tests\.cpp|word_freq\.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):
    return subprocess.run(
        [str(BINARY), name], cwd=HERE, text=True, preexec_fn=setup,
        stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=120,
    )


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


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)
    return cls


class LabCase(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        code, output = build("map_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 ["map_tests", "word_freq"]:
            (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 TestBasics(LabCase):
    """Sesión 1, paso 1: insert, find, recorrido en orden, destructor, check_invariants."""

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


def add_broken(cls, names):
    for name in names:
        def case(self, name=name):
            done = run_case(name)
            self.assertIn("sano", done.stdout, "check_invariants falló en un árbol sano\n" + short_report(done.stdout))
            self.assertNotEqual(done.returncode, 0, "check_invariants no notó el daño")
            self.assertNotIn("sin implementar", done.stdout)
        setattr(cls, f"test_{name}", case)


@unittest.skipUnless((HERE / LAB_DIR).is_dir(), f"no existe la carpeta {LAB_DIR}")
class TestErase(LabCase):
    """Sesión 1, paso 2: erase con sus tres casos, contra std::map."""


@unittest.skipUnless((HERE / LAB_DIR).is_dir(), f"no existe la carpeta {LAB_DIR}")
class TestQueries(LabCase):
    """Sesión 2, paso 3: floor, ceiling, range e iterador."""


@unittest.skipUnless((HERE / LAB_DIR).is_dir(), f"no existe la carpeta {LAB_DIR}")
class TestWordFreq(unittest.TestCase):
    """Sesión 2, paso 4: el programa de frecuencias, sobre un texto pequeño."""

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

    def run_program(self, *args):
        return subprocess.run(
            [str(WORD_FREQ), *args], cwd=HERE, text=True, encoding="utf-8",
            stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=60,
        )

    def lines(self, *args):
        done = self.run_program(*args)
        self.assertEqual(done.returncode, 0, short_report(done.stderr))
        return done.stdout.splitlines()

    def test_all_words_in_byte_order(self):
        lines = self.lines(str(FIXTURE))
        self.assertEqual(lines[:-1], [
            "ana 2", "de 1", "después 1", "el 3", "la 1", "llegó 2", "no 1", "quién 1", "una 1", "y 3",
            "zorra 2", "zorras 1", "árbol 4", "ñandú 2",
        ])

    def test_range_is_half_open(self):
        lines = self.lines(str(FIXTURE), "el", "no")
        self.assertEqual(lines[:-1], ["el 3", "la 1", "llegó 2"])

    def test_range_after_z(self):
        lines = self.lines(str(FIXTURE), "z", "ñ")
        self.assertEqual(lines[:-1], ["zorra 2", "zorras 1", "árbol 4"])

    def test_summary_line(self):
        lines = self.lines(str(FIXTURE))
        self.assertTrue(lines[-1].startswith("palabras: 25, distintas: 14, altura: "), lines[-1])

    def test_bad_usage(self):
        self.assertNotEqual(self.run_program().returncode, 0)
        self.assertNotEqual(self.run_program("/no/existe.txt").returncode, 0)


add_cases(TestBasics, BASICS)
add_broken(TestBasics, BROKEN)
add_cases(TestErase, ERASE)
add_cases(TestQueries, QUERIES)


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