"""Pruebas del laboratorio. LAB_DIR dice dónde está el código: lab o solution.

Son de varios niveles: programas de Mini completos, errores esperados, y un main en C que mira
los árboles por la interfaz. Todo corre con ASan y UBSan: una fuga o un aviso hacen fallar la prueba.
"""
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")
SOURCE = HERE / LAB_DIR
LAB = HERE / "lab"
FLAGS = ["-Wall", "-Wextra", "-std=c17", "-g", "-fsanitize=address,undefined"]
MODULES = ["env.c", "expr.c", "lexer.c", "stmt.c", "parser.c", "interp.c"]
REPORTS = ("AddressSanitizer", "LeakSanitizer", "runtime error")
FOLDER = tempfile.TemporaryDirectory()
BUILT = {}


def source(name):
    """El archivo de la carpeta que se prueba. Si no está ahí, es uno de los ya hechos de lab/."""
    path = SOURCE / name
    return path if path.exists() else LAB / name


def compile_program(name, sources):
    """Compila una sola vez cada programa, y devuelve su ruta."""
    if name not in BUILT:
        binary = pathlib.Path(FOLDER.name) / name
        done = subprocess.run(
            ["gcc", *FLAGS, f"-I{LAB}", *map(str, sources), "-o", str(binary)],
            text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
        )
        BUILT[name] = (binary, done.returncode, done.stdout)
    binary, code, messages = BUILT[name]
    if code != 0:
        raise AssertionError(f"No compila:\n{messages}")
    if messages:
        raise AssertionError(f"Compila con advertencias, y no debe:\n{messages}")
    return binary


def mini():
    return compile_program("mini", [source(m) for m in MODULES] + [LAB / "mini.c"])


def run_binary(binary, *args):
    try:
        done = subprocess.run(
            [str(binary), *map(str, args)], text=True, encoding="utf-8", errors="replace",
            stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=60,
        )
    except subprocess.TimeoutExpired:
        raise AssertionError("El programa no terminó en 60 segundos.") from None
    return done.stdout, done.stderr, done.returncode


def run_mini(program):
    """Corre el texto de un programa de Mini. Devuelve (salida, errores, código)."""
    path = pathlib.Path(FOLDER.name) / "program.mini"
    path.write_text(program, encoding="utf-8")
    return run_binary(mini(), path)


class MiniCase(unittest.TestCase):
    longMessage = False

    @classmethod
    def setUpClass(cls):
        if not SOURCE.exists():
            raise unittest.SkipTest(f"no está {LAB_DIR}")

    def no_reports(self, program, out, err):
        for word in REPORTS:
            if word in out or word in err:
                lines = err.splitlines()[:14]
                self.fail(f"Hay un informe de {word} con este programa:\n{program}\n" + "\n".join(lines))

    def prints(self, program, wanted):
        out, err, code = run_mini(program)
        self.no_reports(program, out, err)
        if code != 0:
            self.fail(f"Terminó con error, y no debía:\n{program}\n{err}")
        got = out.split()
        if got != wanted.split():
            self.fail(f"Este programa:\n{program}\n  debe escribir: {wanted}\n  escribió:       {' '.join(got)}")

    def run_error(self, program, words, printed=""):
        out, err, code = run_mini(program)
        self.no_reports(program, out, err)
        if code != 1:
            self.fail(f"Debe terminar con un error de ejecución:\n{program}\n  escribió: {out}")
        if "error de ejecución" not in err:
            self.fail(f"Debe ser un error de ejecución:\n{program}\n  se obtuvo: {err}")
        for word in words:
            if word not in err:
                self.fail(f"El mensaje debe decir '{word}':\n{program}\n  se obtuvo: {err}")
        if out.split() != printed.split():
            self.fail(f"Antes del error debe escribir: {printed}\n{program}\n  escribió: {out}")

    def syntax_error(self, program, line=None):
        out, err, code = run_mini(program)
        self.no_reports(program, out, err)
        if code != 1 or "error de sintaxis" not in err:
            self.fail(f"Debe dar un error de sintaxis:\n{program}\n  se obtuvo: {out}{err}")
        if "falta " in err:
            self.fail(f"Aún falta escribir una parte: {err}")
        if out:
            self.fail(f"Con un error de sintaxis no se corre nada:\n{program}\n  escribió: {out}")
        if line is not None and f":{line}:" not in err:
            self.fail(f"El error debe estar en la línea {line}:\n{program}\n  se obtuvo: {err}")


class LetAndPrint(MiniCase):
    def test_print(self):
        self.prints("print 1 + 2 * 3", "7")
        self.prints("print (1 + 2) * 3", "9")
        self.prints("print -2 - -3", "1")
        self.prints("print 0.5", "0.5")
        self.prints("print 1 / 3", "0.333333")

    def test_several_prints_in_order(self):
        self.prints("print 1\nprint 2\nprint 3\n", "1 2 3")

    def test_let(self):
        self.prints("let x = 4\nprint x * x", "16")

    def test_let_changes_the_value(self):
        self.prints("let x = 1\nlet x = x + 1\nprint x", "2")

    def test_no_separator_is_needed(self):
        self.prints("let a = 1 let b = a + 1 print a + b", "3")

    def test_comments_and_blank_lines(self):
        self.prints("# hola\n\nlet a = 2 # dos\n\n# fin\nprint a # listo\n", "2")

    def test_comparisons_print_one_or_zero(self):
        self.prints("print 1 < 2\nprint 2 < 1\nprint 2 <= 2\nprint 3 > 4\nprint 4 >= 4\nprint 1 == 1\nprint 1 != 1",
                    "1 0 1 0 1 1 0")

    def test_empty_program(self):
        self.prints("", "")
        self.prints("# solo un comentario\n", "")


class IfExpression(MiniCase):
    def test_both_branches(self):
        self.prints("print if 1 < 2 then 10 else 20", "10")
        self.prints("print if 2 < 1 then 10 else 20", "20")

    def test_any_value_except_zero_is_true(self):
        self.prints("print if 0.5 then 1 else 2\nprint if -3 then 1 else 2\nprint if 0 then 1 else 2", "1 1 2")

    def test_only_the_chosen_branch_runs(self):
        self.prints("print if 1 then 2 else 1 / 0", "2")
        self.prints("print if 0 then 1 / 0 else 3", "3")
        self.prints("print if 1 then 2 else missing", "2")

    def test_nested(self):
        self.prints("let x = 5\nprint if x < 3 then 1 else if x < 6 then 2 else 3", "2")
        self.prints("print if if 1 then 0 else 1 then 10 else 20", "20")

    def test_inside_parentheses(self):
        self.prints("print 1 + (if 1 then 2 else 3) * 4", "9")

    def test_else_reaches_as_far_as_it_can(self):
        self.prints("print if 1 then 2 else 3 + 4\nprint if 0 then 2 else 3 + 4", "2 7")

    def test_condition_errors(self):
        self.run_error("print if missing then 1 else 2", ["missing"])


class Calls(MiniCase):
    def test_required_example(self):
        self.prints("def fact(n) = if n < 2 then 1 else n * fact(n - 1)\nlet x = 5\nprint fact(x)     # 120\n", "120")

    def test_no_parameters(self):
        self.prints("def seven() = 7\nprint seven() + 1", "8")

    def test_several_parameters(self):
        self.prints("def sub(a, b) = a - b\nprint sub(10, 3)", "7")

    def test_more_arguments_than_any_first_capacity(self):
        names = [f"p{i}" for i in range(20)]
        args = ", ".join(str(i + 1) for i in range(20))
        self.prints(f"def total({', '.join(names)}) = {' + '.join(names)}\nprint total({args})", "210")

    def test_calls_inside_arguments(self):
        self.prints("def inc(n) = n + 1\ndef add(a, b) = a + b\nprint add(inc(1), inc(inc(1)))", "5")

    def test_if_as_argument(self):
        self.prints("def id(n) = n\nprint id(if 1 then 2 else 3)", "2")

    def test_arguments_use_the_frame_of_the_caller(self):
        self.prints("def sub(a, b) = a - b\nlet a = 10\nlet b = 3\nprint sub(b, a)", "-7")
        self.prints("def sub(a, b) = a - b\ndef swap(a, b) = sub(b, a)\nprint swap(1, 5)", "4")

    def test_parameter_covers_a_global(self):
        self.prints("let x = 10\ndef double(x) = x * 2\nprint double(3)\nprint x", "6 10")

    def test_body_reads_globals_when_it_runs(self):
        self.prints("let r = 2\ndef scale(n) = n * r\nprint scale(5)\nlet r = 3\nprint scale(5)", "10 15")

    def test_body_does_not_see_the_locals_of_its_caller(self):
        self.run_error("def show() = n\ndef outer(n) = show()\nprint outer(7)", ["variable desconocida", "n"])

    def test_after_a_call_the_parameter_is_gone(self):
        self.run_error("def id(n) = n\nprint id(1)\nprint n", ["variable desconocida", "n"], "1")

    def test_functions_and_variables_are_separate(self):
        self.prints("let f = 1\ndef f(x) = x + 1\nprint f(f)", "2")

    def test_def_takes_effect_when_it_runs(self):
        self.run_error("print 1\nprint twice(4)\ndef twice(n) = n * 2", ["función desconocida", "twice"], "1")

    def test_body_may_name_a_function_defined_later(self):
        self.prints("def even(n) = if n == 0 then 1 else odd(n - 1)\n"
                    "def odd(n) = if n == 0 then 0 else even(n - 1)\n"
                    "print even(10)\nprint even(7)", "1 0")

    def test_a_new_def_replaces_the_old_one(self):
        self.prints("def f() = 1\nprint f()\ndef f() = 2\nprint f()", "1 2")


class Programs(MiniCase):
    """Los programas de lab/programs, enteros."""

    def run_file(self, name, wanted):
        self.prints((LAB / "programs" / name).read_text(encoding="utf-8"), wanted)

    def test_factorial(self):
        self.run_file("fact.mini", "120")

    def test_large_factorial_goes_through_percent_g(self):
        self.run_file("big.mini", "720 3.6288e+06 6.22702e+09")

    def test_fibonacci(self):
        self.run_file("fib.mini", "1 55 6765")

    def test_mutual_recursion(self):
        self.run_file("even_odd.mini", "1 0")

    def test_a_thousand_mini_frames(self):
        self.run_file("deep.mini", "500500")

    def test_many_calls_leave_no_leaks(self):
        self.prints("def fib(n) = if n < 2 then n else fib(n - 1) + fib(n - 2)\nprint fib(22)", "17711")


class RunErrors(MiniCase):
    def test_unknown_variable(self):
        self.run_error("print 1\nprint x\nprint 2", ["variable desconocida", "x"], "1")

    def test_unknown_function(self):
        self.run_error("print f(1)", ["función desconocida", "f"])

    def test_too_few_arguments(self):
        self.run_error("def add(a, b) = a + b\nprint add(1)", ["argumentos", "add"])

    def test_too_many_arguments(self):
        self.run_error("def add(a, b) = a + b\nprint add(1, 2, 3)", ["argumentos", "add"])

    def test_arguments_for_no_parameters(self):
        self.run_error("def f() = 1\nprint f(2)", ["argumentos", "f"])

    def test_division_by_zero(self):
        self.run_error("print 1 / 0", ["división entre cero"])
        self.run_error("let x = 0\nlet y = 1 / x", ["división entre cero"])

    def test_error_in_an_argument_frees_the_values(self):
        self.run_error("def f(a, b, c) = a\nprint f(1, 2 / 0, 3)", ["división entre cero"])
        self.run_error("def f(a, b, c) = a\nprint f(1, 2, missing)", ["variable desconocida"])

    def test_error_deep_inside_frees_every_frame(self):
        self.run_error("def down(n, m) = if n == 0 then 1 / 0 else 1 + down(n - 1, m)\nprint down(50, 1)",
                       ["división entre cero"])
        self.run_error("def down(n, m) = if n == 0 then nope(1, 2) else n + down(n - 1, m)\nprint down(50, 1)",
                       ["función desconocida", "nope"])

    def test_error_in_a_nested_argument(self):
        self.run_error("def id(n) = n\ndef two(a, b) = a + b\nprint two(id(1), id(two(id(2), id(missing))))",
                       ["variable desconocida", "missing"])


class SyntaxErrors(MiniCase):
    def test_statements(self):
        for program in ("let", "let x", "let x =", "let 3 = 1", "let if = 1", "print", "x = 1", "1 + 2"):
            with self.subTest(program=program):
                self.syntax_error(program)

    def test_if(self):
        for program in ("print if", "print if 1", "print if 1 then", "print if 1 then 2", "print if 1 then 2 else",
                        "print 1 + if 1 then 2 else 3", "print if 1 else 2 then 3"):
            with self.subTest(program=program):
                self.syntax_error(program)

    def test_if_frees_what_it_read(self):
        for program in ("print if a + 1 then", "print if a + 1 then b + 2", "print if a + 1 then b + 2 else *",
                        "print if a + 1 + then 2 else 3", "print if a then b + * else 3"):
            with self.subTest(program=program):
                self.syntax_error(program)

    def test_call(self):
        for program in ("print f(", "print f(1", "print f(1,", "print f(1,)", "print f(,1)", "print f(1 2)",
                        "print f(a + 1, b * 2, c - 3", "print f(a + 1, b * 2, g(c, d), e + ) + 1",
                        "print f(1, 2, 3, 4, 5, 6, 7, 8, 9, *)"):
            with self.subTest(program=program):
                self.syntax_error(program)

    def test_def(self):
        for program in ("def", "def 1() = 2", "def f", "def f(", "def f(a", "def f(a,", "def f(a,) = 1", "def f(1) = 2",
                        "def f(a, b)", "def f(a, b) 1", "def f(a, b) =", "def f(a, b) = a +", "def if() = 1",
                        "def f(a, b, c, d, e, g, h) = *"):
            with self.subTest(program=program):
                self.syntax_error(program)

    def test_comparisons_do_not_chain(self):
        self.syntax_error("print 1 < 2 < 3")

    def test_an_error_after_good_statements_frees_them(self):
        self.syntax_error("def f(a) = a + 1\nlet x = f(2)\nprint x\nprint (", line=4)

    def test_position_of_the_error(self):
        self.syntax_error("let x = 1\n\nprint x +\n", line=4)
        self.syntax_error("let x = 1\nlet = 2\n", line=2)


API_MAIN = r"""
#include <stdio.h>
#include <string.h>
#include "interp.h"
#include "parser.h"

static int shows(const char *source) {
    ParseError error;
    Stmt *program = parse_program(source, &error);
    if (program == NULL) {
        printf("%d:%s\n", error.line, error.message[0] != '\0' ? "error" : "vacio");
        return 0;
    }
    for (const Stmt *s = program; s != NULL; s = s->next) {
        switch (s->kind) {
        case STMT_LET:
            printf("let %s ", s->as.let.name);
            expr_print(s->as.let.value, stdout);
            break;
        case STMT_PRINT:
            printf("print ");
            expr_print(s->as.print.value, stdout);
            break;
        case STMT_DEF:
            printf("def %s %d", s->as.def.name, s->as.def.param_count);
            for (int i = 0; i < s->as.def.param_count; i++) {
                printf(" %s", s->as.def.params[i]);
            }
            printf(" ");
            expr_print(s->as.def.body, stdout);
            break;
        }
        printf("\n");
    }
    stmt_free(program);
    return 1;
}

static void runs_on(const char *source) {
    ParseError syntax;
    RunError error;
    Stmt *program = parse_program(source, &syntax);
    FILE *out = tmpfile();
    if (out == NULL) {
        printf("sin tmpfile\n");
        stmt_free(program);
        return;
    }
    int ok = run_program(program, out, &error);
    rewind(out);
    int c;
    printf("%d [", ok);
    while ((c = fgetc(out)) != EOF) {
        putchar(c == '\n' ? ' ' : c);
    }
    printf("] %s\n", ok ? (error.message[0] == '\0' ? "sin-mensaje" : "con-mensaje") : "error");
    fclose(out);
    stmt_free(program);
}

int main(void) {
    shows("");
    shows("let\n");
    shows("let a = 1\nprint a + 2\ndef f(x, y) = g(x, 1) * (if y then 2 else 3)\ndef h() = 0\n");
    runs_on("print 1 print 2");
    runs_on("");
    runs_on("print 1 print 1 / 0 print 2");
    // Un programa se puede correr dos veces: run_program no se lo apropia.
    ParseError syntax;
    RunError error;
    Stmt *program = parse_program("def f() = 1 print f()", &syntax);
    FILE *out = tmpfile();
    if (out != NULL) {
        int first = run_program(program, out, &error);
        int second = run_program(program, out, &error);
        printf("%d %d\n", first, second);
        fclose(out);
    }
    stmt_free(program);
    return 0;
}
"""


class Interface(MiniCase):
    """Lo mismo, pero por la interfaz de parser.h e interp.h."""

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        path = pathlib.Path(FOLDER.name) / "api_main.c"
        path.write_text(API_MAIN, encoding="utf-8")
        cls.main_path = path

    def test_trees_and_results(self):
        binary = compile_program("api", [self.main_path] + [source(m) for m in MODULES])
        out, err, code = run_binary(binary)
        self.no_reports("(main de prueba)", out, err)
        self.assertEqual(code, 0, err)
        wanted = [
            "0:vacio",
            "2:error",
            "let a 1",
            "print a + 2",
            "def f 2 x y g(x, 1) * (if y then 2 else 3)",
            "def h 0 0",
            "1 [1 2 ] sin-mensaje",
            "1 [] sin-mensaje",
            "0 [1 ] error",
            "1 1",
        ]
        got = out.splitlines()
        if got != wanted:
            self.fail("La interfaz no da lo esperado.\n  se esperaba:\n    " + "\n    ".join(wanted)
                      + "\n  se obtuvo:\n    " + "\n    ".join(got))

    def test_no_writable_globals(self):
        for name in ("parser.c", "interp.c"):
            with tempfile.TemporaryDirectory() as folder:
                obj = pathlib.Path(folder) / "file.o"
                subprocess.run(["gcc", "-std=c17", "-fno-pic", f"-I{LAB}", "-c", str(source(name)), "-o", str(obj)], check=True)
                done = subprocess.run(["nm", str(obj)], text=True, stdout=subprocess.PIPE, check=True)
            found = [line.split()[-1] for line in done.stdout.splitlines() if re.search(r" [bBdD] ", line)]
            with self.subTest(file=name):
                self.assertEqual(found, [], f"{name} no debe tener variables globales: {found}")


class OwnPrograms(MiniCase):
    """Sus programas de mine/: cada x.mini con su x.expected, que dice qué debe escribir."""

    def programs(self):
        folder = SOURCE / "mine"
        return sorted(path for path in folder.glob("*.mini") if path.stem != "example")

    def test_there_are_at_least_two(self):
        self.assertGreaterEqual(len(self.programs()), 2,
                                "Falta la parte 5: agreguen a lab/mine al menos dos programas con su .expected.")

    def test_every_program_gives_what_it_expects(self):
        for path in sorted((SOURCE / "mine").glob("*.mini")):
            with self.subTest(program=path.name):
                expected = path.with_suffix(".expected")
                self.assertTrue(expected.exists(), f"Falta {expected.name}.")
                self.prints(path.read_text(encoding="utf-8"), expected.read_text(encoding="utf-8"))


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