"""Corre los programas de la semana y comprueba lo que los apuntes dicen de ellos.
Los programas de errors/ se compilan aquí: deben fallar de la manera anunciada."""
import os
import pathlib
import shutil
import subprocess
import tempfile
import unittest

HERE = pathlib.Path(__file__).parent
CXXFLAGS = ["-Wall", "-Wextra", "-std=c++20", "-g", "-fsanitize=address,undefined"]
BUILD = HERE / "build"
SOLUTION = HERE / "solution"
LAB_TESTS = {
    "Step1Basics": "paso 1",
    "Step3Copy": "paso 3",
    "Step4Move": "paso 4",
    "Step5Template": "paso 5",
    "Step6Brackets": "paso 6",
}


def run(args, env=None):
    return subprocess.run(
        args, cwd=HERE, text=True, encoding="utf-8", errors="replace", env=env,
        stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=300,
    )


def compile_error(source, *others):
    """Compila un programa de errors/ y devuelve (código, salida, ruta del ejecutable)."""
    target = BUILD / ("check_" + pathlib.Path(source).stem)
    done = run(["g++", *CXXFLAGS, source, *others, "-o", str(target)])
    return done.returncode, done.stdout, target


class Programs(unittest.TestCase):
    def output(self, program):
        done = run([str(BUILD / program)])
        self.assertEqual(done.returncode, 0, done.stdout)
        return done.stdout

    def test_destructors_run_in_reverse_order(self):
        text = self.output("scope_trace")
        self.assertLess(text.index("destruye y"), text.index("destruye x"))
        self.assertLess(text.index("destruye guard"), text.index("resultado 5"))

    def test_copy_is_deep_and_counted(self):
        text = self.output("copy_demo")
        self.assertIn("copia: a.at(0)=10 b.at(0)=10 | new[]=2 delete[]=0", text)
        self.assertIn("cambio b: a.at(0)=10 b.at(0)=99 | new[]=2 delete[]=0", text)
        self.assertIn("b = c: a.at(0)=10 b.at(0)=7 | new[]=4 delete[]=1", text)
        self.assertIn("b = b: a.at(0)=10 b.at(0)=7 | new[]=5 delete[]=2", text)

    def test_move_does_not_reserve(self):
        text = self.output("move_demo")
        self.assertIn("solo std::move(a): new[]=1 delete[]=0", text)
        self.assertIn("same.size()=1000 a.size()=1000 a.at(0)=42", text)
        self.assertIn("constructor de movimiento: new[]=1 delete[]=0", text)
        self.assertIn("a.size()=0 b.size()=1000 b.at(0)=42", text)
        self.assertIn("asignación de movimiento: new[]=2 delete[]=1", text)
        self.assertIn("std::move de un const: new[]=5 delete[]=1", text)

    def test_returning_a_local_is_elided(self):
        text = self.output("special_members")
        tail = text[text.index("Traced d = make_traced();"):text.index("fin de main")]
        self.assertNotIn("movimiento", tail)
        self.assertNotIn("copia", tail)

    def test_destructor_turns_off_implicit_move(self):
        text = self.output("implicit_moves")
        first = text[:text.index("con destructor declarado")]
        second = text[text.index("con destructor declarado"):]
        self.assertIn("constructor de movimiento", first)
        self.assertIn("constructor de copia", second)

    def test_vector_growth_depends_on_noexcept(self):
        text = self.output("vector_growth")
        self.assertIn("SafeMove después de crecer: copias=0 movimientos=1000", text)
        self.assertIn("RiskyMove después de crecer: copias=1000 movimientos=0", text)

    def test_unit_tests(self):
        for program in ["int_buffer_test", "buffer_test", "sorted_ints_test", "vector_stack_test"]:
            with self.subTest(program=program):
                self.assertNotIn("Assertion", self.output(program))

    def test_postfix(self):
        self.assertEqual(self.output("postfix").split(), ["14", "4"])


class ErrorPrograms(unittest.TestCase):
    def test_silent_compile_then_double_free(self):
        for source in ["shallow_copy", "shallow_assign", "shallow_move"]:
            with self.subTest(source=source):
                code, text, target = compile_error(f"errors/{source}.cpp")
                self.assertEqual((code, text), (0, ""), "debe compilar sin avisos")
                done = run([str(target)])
                self.assertNotEqual(done.returncode, 0)
                self.assertIn("attempting double-free", done.stdout)

    def test_new_delete_mismatch(self):
        code, text, target = compile_error("errors/new_delete_mismatch.cpp")
        self.assertEqual(code, 0)
        self.assertIn("-Wmismatched-new-delete", text)
        self.assertIn("alloc-dealloc-mismatch", run([str(target)]).stdout)

    def test_naive_self_assignment_loses_the_data_silently(self):
        code, text, target = compile_error("errors/self_assign.cpp")
        self.assertEqual((code, text), (0, ""))
        done = run([str(target)])
        self.assertEqual(done.returncode, 0, "ASan no lo reporta")
        lines = done.stdout.splitlines()
        self.assertEqual(lines[0], "antes: 10")
        self.assertNotEqual(lines[1], "después: 10")

    def test_use_after_move_is_caught_by_assert(self):
        code, text, target = compile_error("errors/use_after_move.cpp", "int_buffer.cpp")
        self.assertEqual((code, text), (0, ""))
        done = run([str(target)])
        self.assertNotEqual(done.returncode, 0)
        self.assertIn("Assertion `index < size_' failed", done.stdout)

    def test_broken_invariant_is_caught(self):
        code, _, target = compile_error("errors/broken_sorted.cpp")
        self.assertEqual(code, 0)
        self.assertIn("Assertion `items_[i - 1] <= items_[i]' failed", run([str(target)]).stdout)

    def test_programs_that_do_not_compile(self):
        expected = {
            "move_deletes_copy": "use of deleted function",
            "ambiguous_assign": "ambiguous overload",
            "no_default": "no matching function for call to",
            "no_operator": "no match for",
            "template_wrong_type": "cannot convert",
        }
        for source, message in expected.items():
            with self.subTest(source=source):
                code, text, _ = compile_error(f"errors/{source}.cpp")
                self.assertNotEqual(code, 0)
                self.assertIn(message, text)

    def test_template_in_cpp_does_not_link(self):
        code, text, _ = compile_error("errors/split_template/main.cpp", "errors/split_template/box.cpp")
        self.assertNotEqual(code, 0)
        self.assertIn("undefined reference to `Box<int>::Box(int)'", text)

    def test_void_pointer_accepts_the_wrong_comparison(self):
        target = BUILD / "check_void_wrong_compare"
        done = run(["gcc", "-Wall", "-Wextra", "-std=c17", "-g", "-fsanitize=address,undefined",
                    "errors/void_wrong_compare.c", "-o", str(target)])
        self.assertEqual((done.returncode, done.stdout), (0, ""))
        self.assertEqual(run([str(target)]).stdout.strip(), "1.50")


class NoWarnings(unittest.TestCase):
    def test_everything_compiles_cleanly(self):
        sources = sorted(HERE.glob("*.cpp")) + sorted((HERE / "lab").glob("*.cpp"))
        sources += sorted((HERE / "lab" / "tests").glob("*.cpp"))
        if SOLUTION.exists():
            sources += sorted(SOLUTION.glob("*.cpp"))
        for source in sources:
            with self.subTest(source=source.name):
                extra = ["-Isolution"] if source.parent == SOLUTION else ["-Ilab"]
                done = run(["g++", "-Wall", "-Wextra", "-std=c++20", "-fsyntax-only", *extra, "-Ilab",
                            str(source.relative_to(HERE))])
                self.assertEqual((done.returncode, done.stdout), (0, ""))


@unittest.skipUnless(SOLUTION.exists(), "falta la solución")
class LabStages(unittest.TestCase):
    """El laboratorio funciona como dice la guía, paso a paso. Se arma cada etapa quitando
    partes de la solución, en una carpeta temporal dentro de build/."""

    @staticmethod
    def stage(name, keep_copy, keep_move):
        base = pathlib.Path(tempfile.mkdtemp(prefix=name, dir=BUILD))
        shutil.copytree(SOLUTION, base, dirs_exist_ok=True)
        header = (base / "int_stack.h").read_text(encoding="utf-8")
        source = (base / "int_stack.cpp").read_text(encoding="utf-8")
        if not keep_copy:
            header = header.replace("    IntStack(const IntStack& other);\n", "")
            header = header.replace("    IntStack& operator=(const IntStack& other);\n", "")
            source = source.split("IntStack::IntStack(const IntStack& other)")[0] + \
                "void IntStack::swap" + source.split("void IntStack::swap")[1]
        if not keep_move:
            header = header.replace("    IntStack(IntStack&& other) noexcept;\n", "")
            header = header.replace("    IntStack& operator=(IntStack&& other) noexcept;\n", "")
            start = source.find("IntStack::IntStack(IntStack&& other)")
            if start >= 0:
                source = source[:start] + "void IntStack::swap" + source.split("void IntStack::swap")[1]
        (base / "int_stack.h").write_text(header, encoding="utf-8")
        (base / "int_stack.cpp").write_text(source, encoding="utf-8")
        return base.relative_to(HERE)

    def lab_tests(self, lab_dir, test_class):
        env = dict(os.environ, LAB_DIR=str(lab_dir))
        return run(["python3", "-m", "unittest", f"test_lab.{test_class}"], env=env)

    def test_after_step_1_the_copy_demo_double_frees(self):
        lab_dir = self.stage("step1", keep_copy=False, keep_move=False)
        done = run(["make", "--no-print-directory", f"LAB_DIR={lab_dir}", "copy-demo"])
        self.assertNotEqual(done.returncode, 0)
        self.assertIn("attempting double-free", done.stdout)
        self.assertEqual(self.lab_tests(lab_dir, "Step1Basics").returncode, 0)
        self.assertNotEqual(self.lab_tests(lab_dir, "Step3Copy").returncode, 0)

    def test_after_step_3_moving_still_reserves(self):
        lab_dir = self.stage("step3", keep_copy=True, keep_move=False)
        self.assertEqual(self.lab_tests(lab_dir, "Step3Copy").returncode, 0)
        done = self.lab_tests(lab_dir, "Step4Move")
        self.assertNotEqual(done.returncode, 0)

    def test_the_starter_fails_every_step(self):
        run(["make", "--no-print-directory", "LAB_DIR=lab", "lab"])
        for test_class, step in LAB_TESTS.items():
            with self.subTest(step=step):
                self.assertNotEqual(self.lab_tests("lab", test_class).returncode, 0)

    def test_the_starter_fails_almost_every_test(self):
        env = dict(os.environ, LAB_DIR="lab")
        done = run(["python3", "-m", "unittest", "test_lab"], env=env)
        self.assertNotEqual(done.returncode, 0)
        self.assertIn("failures=28", done.stdout)


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