"""Pruebas del laboratorio. LAB_DIR dice dónde está el código: 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")
BUILD = HERE / "build" / LAB_DIR
FIXTURE = HERE / "lab" / "tests" / "fixture.txt"
SAMPLES = HERE / "build" / "samples"
# Un tamaño mal leído de la cabecera hace crecer la salida sin fin: se corta antes de agotar la memoria.
LIMIT_SECONDS = 30
SAMPLE_NAMES = ["empty", "one_byte", "single_symbol", "all_bytes", "random", "special"]
HEAP = [
    "new_is_empty", "guide_example", "pop_example", "pop_chooses_smaller_child", "pops_in_order",
    "one_element", "other_comparator", "comparator_with_capture", "only_uses_comparator",
    "pairs_for_dijkstra", "no_copies_inside", "copy_is_independent", "build_example",
    "build_small_sizes", "build_comparisons", "push_comparisons", "pop_comparisons",
    "random_against_priority_queue", "random_builds_and_strings",
]
BROKEN = ["broken_root", "broken_right_child", "broken_last_leaf"]
TOP = ["top_k_cases", "top_k_random", "top_k_comparisons", "top_k_keeps_few_objects"]
TREE = [
    "count_frequencies", "tree_guide_example", "tree_ties_follow_creation_order",
    "tree_weights_above_32_bits", "tree_empty", "tree_one_symbol", "tree_random_is_optimal",
    "codes_longer_than_a_word", "tree_write_guide_example", "tree_write_and_read_all_symbols",
    "tree_read_one_leaf",
]
COMPRESS = [
    "exact_bytes", "exact_size_in_header", "empty_input", "one_byte_input", "one_symbol_input",
    "two_symbols", "padding_is_not_data", "all_byte_values", "special_bytes", "random_round_trips",
    "reference_file", "rejects_bad_header", "rejects_truncated_file", "rejects_endless_tree",
    "rejects_too_many_leaves",
]


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"(min_heap\.h|top_k\.h|huffman\.h|_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(binary, name):
    return subprocess.run(
        [str(BUILD / binary), name], cwd=HERE, text=True, errors="replace",
        stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=LIMIT_SECONDS,
    )


def add_cases(cls, binary, names):
    for name in names:
        def case(self, name=name):
            done = run_case(binary, 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("heap_tests", name)
            self.assertIn("sano", done.stdout, "check_invariants falló en un heap 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):
    TARGETS = []

    @classmethod
    def setUpClass(cls):
        for target in cls.TARGETS:
            code, output = build(target)
            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 ["heap_tests", "huffman_tests", "top_words", "compress", "decompress"]:
            (BUILD / target).unlink(missing_ok=True)
            code, output = build(target)
            self.assertEqual(code, 0, output)
            # Solo las advertencias del compilador: make también avisa de otras cosas, como un reloj desfasado.
            self.assertNotRegex(output, r"\.(cpp|h):\d+:\d+: warning")


@unittest.skipUnless((HERE / LAB_DIR).is_dir(), f"no existe la carpeta {LAB_DIR}")
class TestHeap(LabCase):
    """Sesión 1, paso 1: MinHeap con comparador, contra std::priority_queue."""
    TARGETS = ["heap_tests"]


@unittest.skipUnless((HERE / LAB_DIR).is_dir(), f"no existe la carpeta {LAB_DIR}")
class TestTop(LabCase):
    """Sesión 1, paso 3: top_k y el programa top_words, sobre un texto pequeño."""
    TARGETS = ["heap_tests", "top_words"]

    def run_program(self, *args):
        return subprocess.run(
            [str(BUILD / "top_words"), *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_top_three_with_ties_in_byte_order(self):
        self.assertEqual(self.lines(str(FIXTURE), "3"), ["4 árbol", "3 el", "3 y"])

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

    def test_k_zero_prints_nothing(self):
        self.assertEqual(self.lines(str(FIXTURE), "0"), [])

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


@unittest.skipUnless((HERE / LAB_DIR).is_dir(), f"no existe la carpeta {LAB_DIR}")
class TestTree(LabCase):
    """Sesión 2, pasos 4 y 5: frecuencias, árbol, códigos, escribir y leer el árbol."""
    TARGETS = ["huffman_tests"]


@unittest.skipUnless((HERE / LAB_DIR).is_dir(), f"no existe la carpeta {LAB_DIR}")
class TestCompress(LabCase):
    """Sesión 2, pasos 5 y 6: comprimir, descomprimir y comparar archivos con cmp."""
    TARGETS = ["huffman_tests", "compress", "decompress"]

    def setUp(self):
        self.folder = tempfile.TemporaryDirectory()
        self.addCleanup(self.folder.cleanup)
        self.work = pathlib.Path(self.folder.name)

    def run_tool(self, tool, source, target):
        return subprocess.run(
            [str(BUILD / tool), str(source), str(target)], cwd=HERE, text=True, errors="replace",
            stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=LIMIT_SECONDS,
        )

    def assert_round_trip(self, source):
        packed = self.work / "packed"
        back = self.work / "back"
        done = self.run_tool("compress", source, packed)
        self.assertEqual(done.returncode, 0, short_report(done.stdout))
        done = self.run_tool("decompress", packed, back)
        self.assertEqual(done.returncode, 0, short_report(done.stdout))
        compared = subprocess.run(["cmp", str(source), str(back)], text=True, stdout=subprocess.PIPE,
                                  stderr=subprocess.STDOUT)
        self.assertEqual(compared.returncode, 0, compared.stdout)
        return packed

    def test_round_trip_fixture(self):
        self.assert_round_trip(FIXTURE)

    def test_round_trip_real_text(self):
        source = HERE / DATA / "tradiciones.txt"
        packed = self.assert_round_trip(source)
        self.assertLess(packed.stat().st_size, source.stat().st_size * 0.7)

    def test_empty_file_size(self):
        self.assertEqual(self.assert_round_trip(SAMPLES / "empty.bin").stat().st_size, 12)

    def test_single_symbol_size(self):
        self.assertEqual(self.assert_round_trip(SAMPLES / "single_symbol.bin").stat().st_size, 14)

    def test_garbage_fails_without_output(self):
        target = self.work / "out"
        done = self.run_tool("decompress", FIXTURE, target)
        self.assertEqual(done.returncode, 1, short_report(done.stdout))
        self.assertNotIn("ERROR:", done.stdout)
        self.assertFalse(target.exists())

    def test_truncated_file_fails_without_output(self):
        packed = self.assert_round_trip(FIXTURE)
        cut = self.work / "cut"
        cut.write_bytes(packed.read_bytes()[:-1])
        target = self.work / "out"
        done = self.run_tool("decompress", cut, target)
        self.assertEqual(done.returncode, 1, short_report(done.stdout))
        self.assertNotIn("ERROR:", done.stdout)
        self.assertFalse(target.exists())

    def test_missing_file(self):
        done = self.run_tool("compress", self.work / "no_existe", self.work / "out")
        self.assertEqual(done.returncode, 1)


for sample in SAMPLE_NAMES:
    def sample_case(self, sample=sample):
        self.assert_round_trip(SAMPLES / f"{sample}.bin")
    setattr(TestCompress, f"test_round_trip_{sample}", sample_case)

add_cases(TestHeap, "heap_tests", HEAP)
add_broken(TestHeap, BROKEN)
add_cases(TestTop, "heap_tests", TOP)
add_cases(TestTree, "huffman_tests", TREE)
add_cases(TestCompress, "huffman_tests", COMPRESS)


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