"""Vuelve a correr los comandos que aparecen en los apuntes y en las diapositivas, comprueba que el
código citado existe de verdad en esta carpeta y revisa las afirmaciones de la semana."""
import os
import pathlib
import re
import subprocess
import sys
import unittest

HERE = pathlib.Path(__file__).parent
ROOT = HERE.parent.parent
NOTES = ROOT / "notes" / "week-09.md"
SLIDES = ROOT / "slides" / "week-09.tex"
DATA = os.environ.get("DATA", "../../data")
# Antes de un bloque cuya salida cambia con el sistema. Se corre, pero no se compara.
SYSTEM_DEPENDENT = "<!-- depende del sistema -->"
# Antes de una medición larga, copiada de una corrida. No se corre aquí: solo se comprueba que el
# programa exista. Sus conclusiones se revisaron con tres corridas, a mano.
MEASUREMENT = "<!-- medición -->"


def normalize(text):
    return text.replace("‘", "'").replace("’", "'")


def run_shell(command):
    # Los apuntes muestran la ruta por defecto; aquí manda la variable DATA.
    command = command.replace("../../data", DATA)
    done = subprocess.run(
        ["bash", "-c", command], cwd=HERE, text=True, encoding="utf-8",
        stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=180,
    )
    return normalize(done.stdout)


def compiler_lines(output):
    """Quita los avisos de make, como el de relojes desfasados en una carpeta compartida."""
    return "\n".join(line for line in output.splitlines() if not line.startswith("make:"))


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


def split_commands(lines):
    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, "", ""
    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()
            elif line.strip():
                previous = line
        elif line.strip() == "```":
            blocks.append((current, mark))
            current = None
        else:
            current.append(line)
    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")) + list(HERE.glob("*.py"))
    sources += list((HERE / "errors").glob("*.cpp")) + list((HERE / "lab").glob("*.h"))
    sources += list((HERE / "lab" / "tests").glob("*.*"))
    for source in sources:
        lines.update(line.strip() for line in source.read_text(encoding="utf-8").splitlines())
    return lines


class NotesAreTrue(unittest.TestCase):
    def test_terminal_blocks_in_notes(self):
        checked = 0
        measured = 0
        for lines, mark in note_blocks("text"):
            if not lines or not lines[0].startswith("$ "):
                continue
            for command, expected in split_commands(lines):
                # Estas salidas vienen de la solución, que no se reparte.
                if "build/solution/" in command and not (HERE / "solution").is_dir():
                    continue
                if mark == MEASUREMENT:
                    measured += 1
                    program = command.split()[0]
                    if program.startswith("./"):
                        self.assertTrue((HERE / program).exists(), program)
                    else:
                        target = command.split()[1]
                        makefile = (HERE / "Makefile").read_text(encoding="utf-8")
                        self.assertIn(f"\n{target}:", makefile)
                    continue
                actual = clean_lines(run_shell(command))
                checked += 1
                if mark != SYSTEM_DEPENDENT:
                    with self.subTest(command=command):
                        self.assertEqual(actual, expected)
        self.assertGreater(checked, 8)
        self.assertGreater(measured, 1)

    def test_code_blocks_in_notes_exist(self):
        known = known_code_lines()
        for language in ("cpp", "python"):
            for lines, _ in note_blocks(language):
                for line in lines:
                    if line.strip() and line.strip() != "...":
                        with self.subTest(line=line):
                            self.assertIn(line.strip(), known)

    def test_length_of_notes(self):
        count = len(NOTES.read_text(encoding="utf-8").splitlines())
        self.assertTrue(1100 <= count <= 1400, count)


# Los programas de errors/ que usan las diapositivas los compila la prueba de los apuntes, que corre antes.
class SlidesAreTrue(unittest.TestCase):
    def test_program_output_in_slides(self):
        for block in slide_blocks("terminal|salida"):
            for command, expected in split_commands(block):
                actual = clean_lines(run_shell(command))
                position = 0
                for line in expected:
                    if line.strip() and line.strip() != "...":
                        with self.subTest(command=command, line=line):
                            self.assertIn(line, actual[position:])
                            position = actual.index(line, position) + 1

    def test_measurements_in_slides_come_from_notes(self):
        notes = NOTES.read_text(encoding="utf-8").splitlines()
        blocks = slide_blocks("medicion")
        self.assertGreater(len(blocks), 1)
        for block in blocks:
            for line in block:
                with self.subTest(line=line):
                    self.assertIn(line, notes)

    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)


class WeekClaims(unittest.TestCase):
    def test_review_question_1_and_2(self):
        """Las preguntas de repaso 1 y 2 de la guía, resueltas por el programa."""
        out = run_shell("./build/probe_trace 7 i10 i17 i24 i3 f31")
        self.assertIn("tabla: [0:_] [1:_] [2:_] [3:10] [4:17] [5:24] [6:3]", out)
        self.assertIn("buscar 31 (inicio 3): 3 4 5 6 0 -> no está, 5 casillas", out)
        out = run_shell("./build/probe_trace 7 i10 i17 i24 i3 e17 f24 f3")
        self.assertIn("buscar 24 (inicio 3): 3 4 -> no está", out)
        self.assertIn("buscar 3 (inicio 3): 3 4 -> no está", out)
        out = run_shell("./build/probe_trace 7 i10 i17 i24 i3 t17 f24 f3")
        self.assertIn("buscar 24 (inicio 3): 3 4 5 -> está", out)
        self.assertIn("buscar 3 (inicio 3): 3 4 5 6 -> está", out)

    def test_demos(self):
        self.assertEqual(run_shell("./build/test_demos").strip(), "ok")

    def test_cpython_probe_order_from_its_comment(self):
        # dictobject.c: 0 -> 1 -> 6 -> 7 -> 4 -> 5 -> 2 -> 3 -> 0
        self.assertIn(": 0 1 6 7 4 5 2 3 0", run_shell("./build/probe_order 8 0 9"))

    def test_python_dict(self):
        out = subprocess.run(
            [sys.executable, "dict_demo.py"], cwd=HERE, text=True, stdout=subprocess.PIPE, check=True,
        ).stdout
        self.assertIn("reemplazar puno: ['lima', 'cusco', 'puno']", out)
        self.assertIn("volver a agregar cusco:       ['lima', 'puno', 'tacna', 'cusco']", out)
        rows = [line.split() for line in out.splitlines() if re.match(r"\s+\d+\s+\d+\s+\d+", line)]
        self.assertGreater(len(rows), 5)
        for row in rows:
            self.assertEqual(row[1], row[4], row)

    def test_error_programs(self):
        run_shell("mkdir -p build")
        out = run_shell("g++ -Wall -Wextra -std=c++20 errors/stop_at_tombstone.cpp -o build/stop_at_tombstone"
                        " && ./build/stop_at_tombstone")
        self.assertIn("contains(22) = 0", out)
        out = run_shell("g++ -Wall -Wextra -std=c++20 errors/duplicate_at_tombstone.cpp"
                        " -o build/duplicate_at_tombstone && ./build/duplicate_at_tombstone")
        self.assertIn("copias de 15 en la tabla: 2", out)

    def test_starter_compiles_and_fails(self):
        """El punto de partida compila sin advertencias y sus pruebas fallan."""
        done = subprocess.run(
            ["make", "--no-print-directory", "LAB_DIR=lab", "build/lab/map_tests", "build/lab/spell_tests"],
            cwd=HERE, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
        )
        self.assertEqual(done.returncode, 0, done.stdout)
        self.assertNotIn("warning:", compiler_lines(done.stdout))
        done = subprocess.run([str(HERE / "build/lab/map_tests"), "set_and_get"], cwd=HERE,
                              stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
        self.assertNotEqual(done.returncode, 0)
        done = subprocess.run([str(HERE / "build/lab/spell_tests"), "candidates_are_the_three_edits",
                               str(HERE / "lab/tests/fixtures")], cwd=HERE,
                              stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
        self.assertNotEqual(done.returncode, 0)


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