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

HERE = pathlib.Path(__file__).parent
ROOT = HERE.parent.parent
NOTES = ROOT / "notes" / "week-06.md"
SLIDES = ROOT / "slides" / "week-06.tex"
# Antes de un bloque cuya salida cambia con el sistema. No se compara.
SYSTEM_DEPENDENT = "<!-- depende del sistema -->"
# Un make corrido dentro de otro make hereda estas variables y escribe otras cosas.
CLEAN_ENV = {k: v for k, v in os.environ.items() if k not in ("MAKELEVEL", "MAKEFLAGS", "MFLAGS")}


def normalize(text):
    # Según el idioma del entorno, gcc usa comillas tipográficas o rectas.
    text = text.replace("‘", "'").replace("’", "'").replace("`", "'")
    # El nombre del objeto temporal de gcc cambia en cada corrida.
    text = re.sub(r"/tmp/cc\w+\.o", "/tmp/TEMP.o", text)
    # Cambian con la arquitectura: el lugar dentro del código y la dirección que da nm.
    text = re.sub(r"\(\.text\+0x[0-9a-f]+\)", "(.text+ADDR)", text)
    return re.sub(r"^[0-9a-f]{16} ", "ADDR ", text, flags=re.M)


def run_shell(command, cwd):
    # Entre un archivo tocado y el anterior make debe notarse la diferencia de hora.
    if command.startswith(("touch ", "cp ")):
        time.sleep(0.02)
    done = subprocess.run(
        ["bash", "-c", command], cwd=cwd, env=CLEAN_ENV, text=True, encoding="utf-8",
        stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60,
    )
    return normalize(done.stdout)


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


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].extend(clean_lines(normalize(line)))
    return pairs


def run_block(lines):
    """Corre un bloque como en una terminal: un cd vale para las líneas que siguen."""
    cwd = HERE
    for command, expected in split_commands(lines):
        if command.startswith("cd "):
            cwd = cwd / command[3:].strip()
            yield command, expected, []
        else:
            yield command, expected, clean_lines(run_shell(command, cwd))


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 public_sources(patterns):
    for pattern in patterns:
        for source in HERE.rglob(pattern):
            parts = source.relative_to(HERE).parts
            if "solution" not in parts and "build" not in parts:
                yield source


def known_lines(patterns):
    lines = set()
    for source in public_sources(patterns):
        lines.update(line.strip() for line in source.read_text(encoding="utf-8").splitlines())
    return lines


def prepare():
    """Deja los ejemplos compilados y stale/ en su primera versión."""
    run_shell("cp stale/rectangle_v1.h stale/rectangle.h", HERE)
    for folder in ("receipt", "tally"):
        run_shell("make", HERE / folder)


class NotesAreTrue(unittest.TestCase):
    def setUp(self):
        prepare()
        self.addCleanup(prepare)

    @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, actual in run_block(lines):
                checked += 1
                if mark != SYSTEM_DEPENDENT:
                    with self.subTest(command=command):
                        self.assertEqual(actual, expected)
        self.assertGreater(checked, 20)

    @unittest.skipUnless(NOTES.exists(), "faltan los apuntes")
    def test_code_blocks_in_notes_exist(self):
        known = known_lines(["*.c", "*.h"])
        for lines, _ in note_blocks("c"):
            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_makefile_blocks_in_notes_exist(self):
        known = known_lines(["Makefile"])
        for lines, _ in note_blocks("make"):
            for line in lines:
                if line.strip() and line.strip() != "...":
                    with self.subTest(line=line):
                        self.assertIn(line.strip(), known)


class SlidesAreTrue(unittest.TestCase):
    def setUp(self):
        prepare()
        self.addCleanup(prepare)

    @unittest.skipUnless(SLIDES.exists(), "faltan las diapositivas")
    def test_terminal_blocks_in_slides(self):
        for block in slide_blocks("terminal|salida|salidalarga"):
            for command, expected, actual in run_block(block):
                position = 0
                # Una diapositiva puede omitir líneas, pero no cambiarlas ni reordenarlas.
                for line in expected:
                    line = line.strip()
                    if not line:
                        continue
                    with self.subTest(command=command, line=line):
                        found = [i for i in range(position, len(actual)) if actual[i].strip() == line]
                        self.assertTrue(found, f"no aparece en la salida real: {line}")
                        if found:
                            position = found[0] + 1

    @unittest.skipUnless(SLIDES.exists(), "faltan las diapositivas")
    def test_code_blocks_in_slides_exist(self):
        known = known_lines(["*.c", "*.h", "Makefile"])
        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()
