"""Vuelve a correr los comandos de los apuntes y de las diapositivas, y comprueba que el
código citado existe de verdad en esta carpeta.

Si una salida depende de la máquina, solo se compara su forma: se cambian los números por N.
Ninguna prueba compara tiempos, salvo una razón enorme y muy estable en test_keep_works."""
import pathlib
import re
import subprocess
import unittest

HERE = pathlib.Path(__file__).parent
ROOT = HERE.parent.parent
NOTES = ROOT / "notes" / "week-03.md"
SLIDES = ROOT / "slides" / "week-03.tex"
# Antes de un bloque cuyos números dependen de la máquina.
MACHINE = "<!-- depende de la máquina -->"
# Antes de un bloque que omite líneas de la salida, marcadas con '...'.
CUT = "<!-- salida recortada -->"
NUMBER = re.compile(r"-?\d+(\.\d+)?(e[+-]?\d+)?")


def normalize(text):
    # Según el idioma del entorno, g++ usa comillas tipográficas o rectas.
    return text.replace("‘", "'").replace("’", "'")


def run_shell(command):
    done = subprocess.run(
        ["bash", "-c", command], cwd=HERE, text=True, encoding="utf-8",
        stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=120,
    )
    return normalize(done.stdout)


def clean_lines(text):
    return [line.rstrip() for line in text.splitlines()]


def shape(line):
    """La línea sin sus números y sin el ancho de los espacios."""
    return " ".join(NUMBER.sub("N", line).split())


def split_commands(lines):
    """Parte un bloque de terminal en pares (comando, líneas de salida)."""
    pairs = []
    for line in lines:
        if line.startswith("$ "):
            pairs.append((line[2:], []))
        elif pairs:
            pairs[-1][1].append(normalize(line.rstrip()))
    return pairs


def note_blocks(language):
    """Devuelve (líneas, marca) de cada bloque de los apuntes."""
    blocks, current, mark, previous = [], None, "", ""
    indent = 0
    for raw in NOTES.read_text(encoding="utf-8").splitlines():
        line = re.sub(r"^> ?", "", raw)
        if current is None:
            if line.strip() == "```" + language:
                current, mark = [], previous.strip()
                indent = len(line) - len(line.lstrip())
            elif line.strip():
                previous = line
        elif line.strip() == "```":
            blocks.append((current, mark))
            current = None
        else:
            current.append(line[indent:])
    return blocks


def slide_blocks(environments):
    text = SLIDES.read_text(encoding="utf-8")
    pattern = r"\\begin\{(?:%s)\}[^\n]*\n(.*?)\\end\{(?:%s)\}" % (environments, environments)
    return [match.group(1).splitlines() for match in re.finditer(pattern, text, re.S)]


def known_code_lines():
    lines = set()
    sources = list(HERE.glob("*.cpp")) + list(HERE.glob("*.h"))
    sources += list((HERE / "errors").glob("*.cpp"))
    for source in sources:
        lines.update(line.strip() for line in source.read_text(encoding="utf-8").splitlines())
    return lines


class Common(unittest.TestCase):
    def assert_in_order(self, command, expected, actual):
        """Las líneas esperadas aparecen en la salida real, en orden. '...' marca un recorte."""
        position = 0
        for line in expected:
            if not line.strip() or line.strip() == "...":
                continue
            with self.subTest(command=command, line=line):
                found = [i for i in range(position, len(actual)) if actual[i] == line]
                self.assertTrue(found, f"no aparece en la salida real: {line}")
                if found:
                    position = found[0] + 1


class NotesAreTrue(Common):
    @unittest.skipUnless(NOTES.exists(), "faltan los apuntes")
    def test_terminal_blocks_in_notes(self):
        checked = 0
        for lines, mark in note_blocks("text"):
            if not lines or not lines[0].startswith("$ "):
                continue
            for command, expected in split_commands(lines):
                actual = clean_lines(run_shell(command))
                checked += 1
                if mark == MACHINE:
                    with self.subTest(command=command):
                        self.assertEqual([shape(line) for line in actual],
                                         [shape(line) for line in expected])
                elif mark == CUT:
                    self.assert_in_order(command, expected, actual)
                else:
                    with self.subTest(command=command):
                        self.assertEqual(actual, expected)
        self.assertGreater(checked, 8)

    @unittest.skipUnless(NOTES.exists(), "faltan los apuntes")
    def test_code_blocks_in_notes_exist(self):
        known = known_code_lines()
        for lines, _ in note_blocks("cpp"):
            for line in lines:
                if line.strip() and line.strip() != "...":
                    with self.subTest(line=line):
                        self.assertIn(line.strip(), known)

    @unittest.skipUnless(NOTES.exists(), "faltan los apuntes")
    def test_links_exist(self):
        text = NOTES.read_text(encoding="utf-8")
        for target in re.findall(r"\]\(([^)#\s]+)[^)]*\)", text):
            if not target.startswith("http"):
                with self.subTest(target=target):
                    self.assertTrue((NOTES.parent / target).exists())

    def test_files_named_in_notes_exist(self):
        if not NOTES.exists():
            self.skipTest("faltan los apuntes")
        text = NOTES.read_text(encoding="utf-8")
        for name in set(re.findall(r"`((?:errors/)?[a-z_0-9]+\.(?:cpp|h|py))`", text)):
            found = (HERE / name).exists() or (HERE / "lab" / name).exists()
            with self.subTest(name=name):
                self.assertTrue(found)


class Claims(unittest.TestCase):
    """Afirmaciones de los apuntes, comprobadas con cuentas."""

    def table_rows(self, title):
        output = run_shell("build/call_tree")
        lines = output.split(title + "\n", 1)[1].split("\n\n", 1)[0].splitlines()[1:]
        return [[int(cell) for cell in line.split()] for line in lines]

    def test_two_halves_makes_2n_minus_1_calls(self):
        for n, calls, _, _ in self.table_rows("T(n) = 2 T(n/2) + 1"):
            self.assertEqual(calls, 2 * n - 1)

    def test_two_halves_other_sizes(self):
        for n, calls, _, _ in self.table_rows("T(n) = 2 T(n/2) + 1, n que no es potencia de 2"):
            self.assertTrue(n <= calls <= 2 * n - 1, n)

    def test_two_halves_linear_is_n_log_n(self):
        for n, _, work, depth in self.table_rows("T(n) = 2 T(n/2) + n"):
            self.assertEqual(work, n * (depth + 1))

    def test_two_minus_one_is_exponential(self):
        for n, calls, _, depth in self.table_rows("T(n) = 2 T(n-1) + 1"):
            self.assertEqual(calls, 2 ** (n + 1) - 1)
            self.assertEqual(depth, n)

    def test_nested_halving_stays_between_bounds(self):
        import math
        output = run_shell("build/count_loops")
        row = [line for line in output.splitlines() if line.startswith("triangulo_divide")][0]
        counts = [int(cell) for cell in row.split()[1:5]]
        for n, count in zip([1000, 2000, 4000, 8000], counts):
            self.assertLessEqual(count, 2 * n * math.log2(n))
            self.assertGreaterEqual(count, n * math.log2(n) / 4)

    def test_keep_works(self):
        """Sin keep el ciclo desaparece y con keep no. La diferencia es de miles de veces."""
        run_shell("g++ -Wall -Wextra -std=c++20 -O2 errors/no_keep.cpp -o build/no_keep")
        values = {"sin": [], "con": []}
        for line in run_shell("build/no_keep").splitlines():
            match = re.search(r"(sin|con) keep:\s+(\d+) ns", line)
            values[match.group(1)].append(int(match.group(2)))
        for without, with_keep in zip(values["sin"], values["con"]):
            self.assertGreater(with_keep, 100 * (without + 100))


class SlidesAreTrue(Common):
    @unittest.skipUnless(SLIDES.exists(), "faltan las diapositivas")
    def test_program_output_in_slides(self):
        for block in slide_blocks("salida"):
            for command, expected in split_commands(block):
                self.assert_in_order(command, expected, clean_lines(run_shell(command)))

    @unittest.skipUnless(SLIDES.exists(), "faltan las diapositivas")
    def test_measurements_in_slides_have_the_right_shape(self):
        for block in slide_blocks("medicion"):
            for command, expected in split_commands(block):
                actual = [shape(line) for line in clean_lines(run_shell(command))]
                self.assert_in_order(command, [shape(line) for line in expected], actual)

    @unittest.skipUnless(SLIDES.exists(), "faltan las diapositivas")
    def test_code_blocks_in_slides_exist(self):
        known = known_code_lines()
        for block in slide_blocks("codigo|codigochico|codigomini"):
            for line in block:
                if line.strip() and line.strip() != "...":
                    with self.subTest(line=line):
                        self.assertIn(line.strip(), known)


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