<?php

namespace App\Support;

/**
 * Una revisión sencilla de un archivo SCSS o JS antes de guardarlo.
 *
 * **Qué es y qué no es.** No compila ni interpreta nada: no hay compilador de SASS ni motor
 * de JavaScript en el servidor, y meterlos era una decisión más grande de la que hace falta
 * aquí. Lo que hace es mirar lo que se puede mirar leyendo el texto, que resulta ser justo lo
 * que más veces rompe un archivo de verdad:
 *
 * 1. **Llaves, paréntesis y corchetes sin cerrar.** Es el fallo más común y el más caro: un
 *    SCSS con una llave de menos no compila, así que el cliente no recibe *nada* de ese
 *    producto —no solo la regla mal escrita—.
 * 2. **Comentarios y comillas sin cerrar**, que son la causa habitual del anterior: todo lo
 *    que viene detrás se come y el desbalance aparece a cien líneas de donde está el fallo.
 * 3. **`@import` y `@use` que apuntan a un archivo que no está en esta versión.** Esto no lo
 *    ve ningún analizador de sintaxis, porque la sintaxis es correcta. Y es el fallo propio
 *    de este panel: los archivos se suben de uno en uno y es fácil publicar el que importa
 *    antes que el importado.
 *
 * **Lo que se cuenta como error y lo que como aviso** no es lo mismo: un desbalance es un
 * archivo que no va a funcionar, y eso frena el guardado. Un `@import` que no cuadra puede
 * ser un archivo que va a subirse después, así que se dice y se deja pasar.
 *
 * Para las comprobaciones se quitan antes las cadenas y los comentarios: contar llaves con
 * un `{` dentro de una cadena de texto da un desbalance que no existe, y un aviso falso se
 * deja de leer a la segunda vez.
 */
final class RevisionDeCodigo
{
    public const SCSS = 'scss';

    public const JS = 'js';

    /**
     * Revisa un archivo y devuelve lo que haya encontrado.
     *
     * @param  string  $codigo  el contenido del archivo
     * @param  string  $lenguaje  `scss` o `js`
     * @param  array<int, string>  $hermanos  los nombres de los otros archivos de la misma
     *                                        versión, sin extensión, para resolver los
     *                                        `@import`
     * @return array<int, array{tipo: string, mensaje: string, linea: ?int}>
     */
    public static function revisar(string $codigo, string $lenguaje, array $hermanos = []): array
    {
        $avisos = [];

        $sinRuido = self::sinCadenasNiComentarios($codigo, $lenguaje, $avisos);

        foreach (self::desbalances($sinRuido) as $aviso) {
            $avisos[] = $aviso;
        }

        if ($lenguaje === self::SCSS) {
            // **Con las cadenas puestas**: el nombre del archivo importado vive dentro de
            // unas comillas, así que sobre el texto sin cadenas no queda nada que leer. Se
            // quitan solo los comentarios, para no avisar de un `@import` comentado.
            foreach (self::importacionesQueFaltan(self::sinComentarios($codigo), $hermanos) as $aviso) {
                $avisos[] = $aviso;
            }
        }

        return $avisos;
    }

    /** ¿Hay algo que impida guardar? Solo los errores; los avisos no frenan nada. */
    public static function hayErrores(array $avisos): bool
    {
        foreach ($avisos as $aviso) {
            if ($aviso['tipo'] === 'error') {
                return true;
            }
        }

        return false;
    }

    /**
     * Deja el código sin cadenas ni comentarios, **conservando los saltos de línea**.
     *
     * Lo segundo importa: los números de línea que se le dan al usuario tienen que ser los
     * de su archivo, no los de esta copia. Por eso lo que se quita se sustituye por espacios
     * y no se borra.
     *
     * De paso detecta lo que se queda abierto, que es la causa más habitual del desbalance
     * que se mide después.
     *
     * @param  array<int, array{tipo: string, mensaje: string, linea: ?int}>  $avisos
     */
    private static function sinCadenasNiComentarios(string $codigo, string $lenguaje, array &$avisos): string
    {
        $limpio = '';
        $largo = strlen($codigo);
        $linea = 1;
        $i = 0;

        while ($i < $largo) {
            $caracter = $codigo[$i];
            $siguiente = $i + 1 < $largo ? $codigo[$i + 1] : '';

            if ($caracter === "\n") {
                $linea++;
                $limpio .= "\n";
                $i++;

                continue;
            }

            // Comentario de bloque.
            if ($caracter === '/' && $siguiente === '*') {
                $cierre = strpos($codigo, '*/', $i + 2);
                $lineaDeApertura = $linea;

                if ($cierre === false) {
                    $avisos[] = self::error(
                        'Hay un comentario /* … */ que no se cierra. Todo lo que viene después '
                        . 'se considera comentario, así que el archivo se queda a medias.',
                        $lineaDeApertura
                    );

                    return $limpio . self::soloSaltos(substr($codigo, $i));
                }

                $trozo = substr($codigo, $i, $cierre + 2 - $i);
                $limpio .= self::soloSaltos($trozo);
                $linea += substr_count($trozo, "\n");
                $i = $cierre + 2;

                continue;
            }

            // Comentario de línea: `//` en los dos lenguajes.
            if ($caracter === '/' && $siguiente === '/') {
                $fin = strpos($codigo, "\n", $i);
                $fin = $fin === false ? $largo : $fin;
                $limpio .= str_repeat(' ', $fin - $i);
                $i = $fin;

                continue;
            }

            // Cadenas. Las plantillas de JS con acentos graves también cuentan.
            if ($caracter === '"' || $caracter === "'" || ($lenguaje === self::JS && $caracter === '`')) {
                $lineaDeApertura = $linea;
                $j = $i + 1;
                $cerrada = false;

                while ($j < $largo) {
                    if ($codigo[$j] === '\\') {
                        $j += 2;

                        continue;
                    }

                    if ($codigo[$j] === "\n" && $caracter !== '`') {
                        // Una cadena normal no salta de línea: se ha quedado abierta.
                        break;
                    }

                    if ($codigo[$j] === $caracter) {
                        $cerrada = true;

                        break;
                    }

                    $j++;
                }

                if (! $cerrada) {
                    $avisos[] = self::error(
                        'Hay una comilla (' . $caracter . ') que no se cierra.',
                        $lineaDeApertura
                    );
                }

                $hasta = min($j + 1, $largo);
                $trozo = substr($codigo, $i, $hasta - $i);
                $limpio .= self::soloSaltos($trozo);
                $linea += substr_count($trozo, "\n");
                $i = $hasta;

                continue;
            }

            $limpio .= $caracter;
            $i++;
        }

        return $limpio;
    }

    /**
     * El mismo texto sin comentarios, **con las cadenas intactas**.
     *
     * Lo necesita la comprobación de `@import`: el nombre del archivo va entre comillas, así
     * que sobre el texto sin cadenas no queda nada que leer. Y los comentarios sí hay que
     * quitarlos, o un `@import` comentado saldría como que falta.
     */
    private static function sinComentarios(string $codigo): string
    {
        $codigo = preg_replace_callback(
            '#/\*.*?\*/#s',
            fn (array $m) => self::soloSaltos($m[0]),
            $codigo
        ) ?? $codigo;

        return preg_replace('#//[^\n]*#', '', $codigo) ?? $codigo;
    }

    /** El mismo texto con todo en blanco menos los saltos de línea. */
    private static function soloSaltos(string $texto): string
    {
        return preg_replace('/[^\n]/', ' ', $texto) ?? '';
    }

    /**
     * Llaves, paréntesis y corchetes que no cuadran.
     *
     * Se dice **dónde está el que sobra**, no solo que sobra: con un archivo de doscientas
     * líneas, «falta una llave» sin número no es una ayuda, es un encargo.
     *
     * @return array<int, array{tipo: string, mensaje: string, linea: ?int}>
     */
    private static function desbalances(string $codigo): array
    {
        $parejas = ['}' => '{', ')' => '(', ']' => '['];
        $nombres = ['{' => 'la llave', '(' => 'el paréntesis', '[' => 'el corchete'];

        $pila = [];
        $avisos = [];
        $linea = 1;
        $largo = strlen($codigo);

        for ($i = 0; $i < $largo; $i++) {
            $caracter = $codigo[$i];

            if ($caracter === "\n") {
                $linea++;

                continue;
            }

            if (isset($nombres[$caracter])) {
                $pila[] = ['signo' => $caracter, 'linea' => $linea];

                continue;
            }

            if (isset($parejas[$caracter])) {
                $ultimo = array_pop($pila);

                if ($ultimo === null) {
                    $avisos[] = self::error(
                        'Sobra un «' . $caracter . '»: no hay ningún «' . $parejas[$caracter] . '» que cerrar.',
                        $linea
                    );

                    continue;
                }

                if ($ultimo['signo'] !== $parejas[$caracter]) {
                    $avisos[] = self::error(
                        'Se cierra con «' . $caracter . '» un «' . $ultimo['signo'] . '» de la línea '
                        . $ultimo['linea'] . '.',
                        $linea
                    );
                }
            }
        }

        foreach ($pila as $abierto) {
            $avisos[] = self::error(
                'Falta cerrar ' . $nombres[$abierto['signo']] . ' «' . $abierto['signo']
                . '» que se abre aquí.',
                $abierto['linea']
            );
        }

        return $avisos;
    }

    /**
     * `@import` y `@use` que apuntan a un archivo que no está en esta versión.
     *
     * **Es el fallo propio de este panel** y no lo ve ningún analizador de sintaxis, porque
     * la sintaxis está bien. Los archivos se suben de uno en uno y es fácil publicar el que
     * importa antes que el importado; entonces la compilación del cliente falla entera y
     * aquí no había nada que lo dijera.
     *
     * Va como **aviso y no como error**: puede ser un archivo que se vaya a subir en un
     * momento, y frenar el guardado obligaría a subirlos en un orden concreto.
     *
     * @param  array<int, string>  $hermanos
     * @return array<int, array{tipo: string, mensaje: string, linea: ?int}>
     */
    private static function importacionesQueFaltan(string $codigo, array $hermanos): array
    {
        if (! preg_match_all('/@(import|use|forward)\s+([^;]+);/i', $codigo, $coincidencias, PREG_OFFSET_CAPTURE)) {
            return [];
        }

        // Los nombres de los hermanos, normalizados: sin extensión y sin el guion bajo del
        // parcial, que es opcional al importar («@import "variables"» trae `_variables`).
        $disponibles = [];

        foreach ($hermanos as $nombre) {
            $disponibles[self::normalizar($nombre)] = true;
        }

        $avisos = [];

        foreach ($coincidencias[2] as $indice => [$lista, $posicion]) {
            $linea = substr_count(substr($codigo, 0, $posicion), "\n") + 1;

            foreach (explode(',', $lista) as $referencia) {
                $referencia = trim($referencia);

                // `@use "x" as y` / `@forward "x" show z`: solo interesa lo primero.
                $referencia = preg_split('/\s+/', $referencia)[0] ?? '';
                $referencia = trim($referencia, "\"' \t");

                if ($referencia === '') {
                    continue;
                }

                // Lo que no es un archivo de la versión: una URL, algo de `node_modules`,
                // una ruta a otra carpeta. Aquí no se puede comprobar nada.
                if (str_contains($referencia, '/') || str_contains($referencia, ':')) {
                    continue;
                }

                if (! isset($disponibles[self::normalizar($referencia)])) {
                    $avisos[] = self::aviso(
                        'Se importa «' . $referencia . '» y no hay ningún archivo con ese nombre '
                        . 'en esta versión. Si no se sube antes de publicarla, la compilación del '
                        . 'cliente fallará entera.',
                        $linea
                    );
                }
            }
        }

        return $avisos;
    }

    private static function normalizar(string $nombre): string
    {
        $nombre = strtolower(trim($nombre));
        $nombre = preg_replace('/\.(scss|sass|css)$/', '', $nombre) ?? $nombre;

        return ltrim($nombre, '_');
    }

    /** @return array{tipo: string, mensaje: string, linea: ?int} */
    private static function error(string $mensaje, ?int $linea): array
    {
        return ['tipo' => 'error', 'mensaje' => $mensaje, 'linea' => $linea];
    }

    /** @return array{tipo: string, mensaje: string, linea: ?int} */
    private static function aviso(string $mensaje, ?int $linea): array
    {
        return ['tipo' => 'aviso', 'mensaje' => $mensaje, 'linea' => $linea];
    }
}
