"""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", "in_order_is_sorted",
    "strings_as_keys", "only_needs_less_than", "no_objects_left_behind", "copy_is_independent", "self_assignment",
    "move_leaves_empty_and_usable", "erase_absent", "random_against_map_links", "queries_still_work",
]
ROTATIONS = [
    "update_height_from_children", "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_keeps_in_order", "rotate_leaves_heights_alone", "rotate_keeps_nodes_in_place",
    "rotate_counts_rotations", "rotate_random_nodes",
]
INSERT = [
    "check_accepts_healthy_trees", "case_left_left", "case_right_right", "case_left_right", "case_right_left",
    "one_to_seven", "double_rotation_moves_subtrees", "repair_below_the_root", "sorted_height_is_floor_log2",
    "subscript_balances", "subscript_reference_after_rotation", "repeated_key_changes_nothing",
    "one_repair_per_insert", "fibonacci_needs_no_rotation", "pointers_survive_rotations",
    "iterate_after_rotations", "comparisons_follow_height", "random_inserts_against_map",
]
BROKEN = [
    "broken_deep_order", "broken_parent", "broken_size", "broken_stale_leaf_height",
    "broken_stale_inner_height", "broken_root_height", "broken_real_imbalance",
]
ERASE = [
    "erase_needs_single_rotation", "erase_needs_double_rotation", "erase_heavy_child_with_zero_balance",
    "erase_zero_balance_uneven_grandchildren", "erase_two_children_repairs_from_successor", "erase_until_empty",
    "erase_can_repair_many_levels", "erase_repairs_are_bounded", "random_against_map_with_balance",
    "random_strings_with_balance", "copy_keeps_heights", "exact_shape_after_script",
]


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"(avl_map\.h|map_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):
    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.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_avl"]:
            (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: update_height, rotate_left y rotate_right."""


@unittest.skipUnless((HERE / LAB_DIR).is_dir(), f"no existe la carpeta {LAB_DIR}")
class TestInsert(LabCase):
    """Paso 2: la inserción con balance, y check_invariants con la condición AVL."""


@unittest.skipUnless((HERE / LAB_DIR).is_dir(), f"no existe la carpeta {LAB_DIR}")
class TestErase(LabCase):
    """Paso 3: el borrado con balance, contra std::map."""


add_cases(TestBase, BASE)
add_cases(TestRotations, ROTATIONS)
add_cases(TestInsert, INSERT)
add_broken(TestInsert, BROKEN)
add_cases(TestErase, ERASE)


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