"""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 subprocess
import unittest

HERE = pathlib.Path(__file__).parent
LAB_DIR = os.environ.get("LAB_DIR", "solution")
BINARY = HERE / "build" / LAB_DIR / "map_tests"
BASE = [
    "new_is_empty", "insert_and_find", "insert_does_not_overwrite", "subscript_operator", "strings_as_keys",
    "only_needs_less_than", "no_objects_left_behind", "copy_is_independent", "self_assignment",
    "move_leaves_empty_and_usable", "random_against_map_links", "queries_still_work",
]
ROTATIONS = [
    "rotate_right_at_root", "rotate_left_at_root", "rotate_under_left_link", "rotate_under_right_link",
    "rotate_moves_middle_subtree", "rotate_without_middle_subtree", "rotate_leaves_colors_alone",
    "rotate_keeps_nodes_in_place", "rotate_counts_rotations", "rotate_random_nodes",
]
BROKEN = [
    "broken_invalid_color", "broken_red_root_alone", "broken_red_root_with_children", "broken_red_red_left",
    "broken_red_red_right", "broken_red_red_deep", "broken_blacks_at_root", "broken_blacks_missing_child",
    "broken_blacks_inner_left", "broken_blacks_inner_right", "broken_blacks_inner_deep", "broken_order_child",
    "broken_order_deep_left", "broken_order_deep_right", "broken_parent_of_child", "broken_parent_of_root", "broken_size",
]
INSERT = [
    "first_key_is_black", "parent_black_needs_nothing", "uncle_red_recolors", "line_rotates_once",
    "zigzag_rotates_twice", "review_question", "recolor_then_rotate_above", "root_stays_black", "subscript_repairs",
    "subscript_reference_after_rotation", "repeated_key_changes_nothing", "at_most_two_rotations_in_every_order",
    "same_as_notes_tree_in_every_order", "same_as_notes_tree_with_many_keys", "sorted_keys_in_both_directions",
    "pointers_survive_rotations", "iterate_after_rotations", "copy_keeps_colors", "random_inserts_against_map",
    "random_strings",
]
MEASURE = ["time_inserts_builds_a_new_map_each_time", "time_finds_looks_up_every_key", "measure_works_with_the_real_maps"]


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 and "Assertion" 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 "Assertion" in line
              or "sin implementar" in line or re.search(r"(red_black_map\.h|map_tests\.cpp|measure\.h):\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):
    return subprocess.run(
        [str(BINARY), name], cwd=HERE, text=True,
        stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=120,
    )


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


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.assertIn("Assertion", done.stdout, "falló por otra razón, y no un assert\n" + short_report(done.stdout))
            self.assertNotIn("sin implementar", done.stdout)
        setattr(cls, f"test_{name}", case)


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", "bench_counts", "bench_times", "bench_words"]:
            (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 TestBase(LabCase):
    """El mapa de partida: pasa desde el inicio, y debe seguir pasando al final."""


@unittest.skipUnless((HERE / LAB_DIR).is_dir(), f"no existe la carpeta {LAB_DIR}")
class TestRotations(LabCase):
    """Paso 1: rotate_left y rotate_right, las de la semana 11."""


@unittest.skipUnless((HERE / LAB_DIR).is_dir(), f"no existe la carpeta {LAB_DIR}")
class TestCheck(LabCase):
    """Paso 2: check_invariants acepta árboles sanos y detecta los rotos."""


@unittest.skipUnless((HERE / LAB_DIR).is_dir(), f"no existe la carpeta {LAB_DIR}")
class TestInsert(LabCase):
    """Paso 3: la inserción, contra std::map y contra las formas exactas."""


@unittest.skipUnless((HERE / LAB_DIR).is_dir(), f"no existe la carpeta {LAB_DIR}")
class TestMeasure(LabCase):
    """Paso 5: time_inserts y time_finds hacen el trabajo que dicen."""


add_cases(TestBase, BASE)
add_cases(TestRotations, ROTATIONS)
add_cases(TestCheck, ["check_accepts_healthy_trees"])
add_broken(TestCheck, BROKEN)
add_cases(TestInsert, INSERT)
add_cases(TestMeasure, MEASURE)


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