<?php

namespace App\Services\Setups;

/**
 * Revisión estructural del YAML de un setup: si está **bien escrito**.
 *
 * **El techo de esto, y hay que tenerlo claro antes de leer el resto.** Quien sabe si un
 * setup *va a funcionar* es `local_tresipunt`, que es el que lo aplica en el Moodle del
 * cliente: si una capacidad existe, si un tipo de campo es válido, si el sitio admite un
 * protocolo. **El Manager no conoce ese esquema y no debe inventárselo**: una validación que
 * dijera «funciona» de algo que el plugin rechaza sería peor que no validar nada, porque da
 * confianza falsa.
 *
 * Lo que sí puede comprobar el Manager es la **coherencia del propio fichero**, y ahí están
 * los errores que se cometen de verdad:
 *
 * - Una clave de primer nivel mal escrita —`user_custom_field` en singular—, que hoy se
 *   acepta y **simplemente no hace nada**.
 * - Un `users[].role` que apunta a un rol **que no está declarado en el mismo YAML**.
 * - Dos campos o dos roles con el mismo `shortname`.
 * - Un `archetype` que no es uno de los siete de Moodle.
 * - Campos obligatorios que faltan.
 *
 * Todo eso es demostrable leyendo el fichero, sin saber nada del plugin. Por eso los avisos
 * dicen **«bien escrito»** y nunca «va a funcionar»: es la diferencia entre lo que se sabe y
 * lo que se supone.
 *
 * **Nada de esto bloquea al guardar.** Un setup puede tener un motivo para salirse de lo
 * previsto, y el que escribe sabe más que esta clase. Se avisa y se guarda.
 */
class RevisionDelSetup
{
    /**
     * Las claves que el setup puede declarar en el primer nivel.
     *
     * Una clave fuera de esta lista **no es un error**: puede ser un bloque nuevo del plugin
     * que aquí no se conozca todavía. Se avisa como «no la reconocemos», que es lo que de
     * verdad se sabe, y no como «está mal».
     */
    private const CLAVES = [
        'plugin', 'version',
        'course_custom_fields', 'user_custom_fields',
        'roles', 'users', 'webservice', 'settings',
    ];

    /** Los arquetipos de Moodle. Lista cerrada del núcleo, no nuestra. */
    private const ARQUETIPOS = [
        'manager', 'coursecreator', 'editingteacher', 'teacher',
        'student', 'guest', 'user', 'frontpage',
    ];

    /**
     * Revisa el YAML ya parseado.
     *
     * `$plugin` y `$version` son **el producto y la versión de la fila**, para poder
     * comparar la cabecera con ellos. Son opcionales porque la API también revisa setups y
     * allí no hay formulario del que sacarlos: sin contexto se comprueba que la cabecera
     * exista y tenga forma, y no contra qué.
     *
     * @param  array<string, mixed>  $setup
     * @return array{avisos: array<int, string>, limpio: bool}
     */
    public static function revisar(array $setup, ?string $plugin = null, ?string $version = null): array
    {
        $avisos = array_merge(
            self::clavesDesconocidas($setup),
            self::cabecera($setup, $plugin, $version),
            self::roles($setup),
            self::usuarios($setup),
            self::campos($setup, 'course_custom_fields', 'type'),
            self::campos($setup, 'user_custom_fields', 'datatype'),
            self::servicioWeb($setup),
        );

        return ['avisos' => $avisos, 'limpio' => $avisos === []];
    }

    /** @return array<int, string> */
    private static function clavesDesconocidas(array $setup): array
    {
        $avisos = [];

        foreach (array_keys($setup) as $clave) {
            if (! in_array($clave, self::CLAVES, true)) {
                $avisos[] = 'La clave «' . $clave . '» no la reconocemos: si está mal escrita '
                    . 'se acepta igual y no hace nada. Las conocidas son: '
                    . implode(', ', self::CLAVES) . '.';
            }
        }

        return $avisos;
    }

    /**
     * La cabecera: que esté, que tenga forma y **que diga lo mismo que la fila**.
     *
     * Lo último es MGR-096. `plugin:` y `version:` no deciden nada —lo que decide a qué
     * producto pertenece el setup y qué clientes lo reciben son el `product_id` y la
     * `version` de la fila—, así que pueden contradecirla sin que nada falle. Y quien abra
     * ese fichero meses después leerá la cabecera y creerá otra cosa.
     *
     * @return array<int, string>
     */
    private static function cabecera(array $setup, ?string $plugin = null, ?string $version = null): array
    {
        $avisos = [];

        $suPlugin = isset($setup['plugin']) ? trim((string) $setup['plugin']) : '';

        if ($suPlugin === '') {
            $avisos[] = 'Falta la clave «plugin» de la cabecera.';
        } elseif ($plugin !== null && $plugin !== '' && $suPlugin !== $plugin) {
            $avisos[] = 'La cabecera dice «plugin: ' . $suPlugin . '» y este setup es de «'
                . $plugin . '». No cambia a quién se sirve —eso lo decide el producto '
                . 'elegido—, pero el fichero queda documentando otro producto. Suele ser un '
                . 'setup copiado de otro sitio.';
        }

        if (! isset($setup['version'])) {
            $avisos[] = 'Falta la clave «version» de la cabecera.';
        } elseif (preg_match('/^\d{10}$/', (string) $setup['version']) !== 1) {
            $avisos[] = 'La «version» de la cabecera no tiene el formato YYYYMMDDXX de diez '
                . 'dígitos: dice «' . $setup['version'] . '».';
        } elseif ($version !== null && $version !== '' && (string) $setup['version'] !== $version) {
            $avisos[] = 'La cabecera dice «version: ' . $setup['version'] . '» y este setup se '
                . 'guarda como ' . $version . '. Se sirve el de la fila, así que no cambia lo '
                . 'que recibe nadie; lo que queda mal es el dato que documenta el fichero.';
        }

        return $avisos;
    }

    /**
     * Reescribe las dos líneas de la cabecera para que digan lo que dice la fila.
     *
     * **Es lo que se quiere el 99 % de las veces**, y a mano es justo lo que se olvida. Se
     * trabaja sobre el texto y no sobre el YAML parseado a propósito: volver a serializarlo
     * reordenaría las claves, se llevaría los comentarios y cambiaría el estilo de las
     * comillas — o sea, ensuciaría todo el fichero para arreglar dos líneas.
     */
    public static function conLaCabeceraPuesta(string $yaml, string $plugin, string $version): string
    {
        foreach (['plugin' => $plugin, 'version' => $version] as $clave => $valor) {
            $patron = '/^' . $clave . ':[^\r\n]*$/mi';

            if (preg_match($patron, $yaml) === 1) {
                $yaml = preg_replace($patron, $clave . ': ' . $valor, $yaml, 1);

                continue;
            }

            // Si no estaba, se pone delante: la cabecera va arriba por convención y un
            // `plugin:` al final del fichero se lee como parte del último bloque.
            $yaml = $clave . ': ' . $valor . "\n" . $yaml;
        }

        return $yaml;
    }

    /** @return array<int, string> */
    private static function roles(array $setup): array
    {
        if (! isset($setup['roles']) || ! is_array($setup['roles'])) {
            return [];
        }

        $avisos = [];
        $cortos = [];

        foreach ($setup['roles'] as $clave => $rol) {
            if (! is_array($rol)) {
                $avisos[] = 'El rol «' . $clave . '» no es un bloque de claves y valores.';

                continue;
            }

            foreach (['name', 'shortname', 'archetype'] as $obligatorio) {
                if (! isset($rol[$obligatorio]) || trim((string) $rol[$obligatorio]) === '') {
                    $avisos[] = 'Al rol «' . $clave . '» le falta «' . $obligatorio . '».';
                }
            }

            if (isset($rol['archetype']) && ! in_array($rol['archetype'], self::ARQUETIPOS, true)) {
                $avisos[] = 'El rol «' . $clave . '» declara el arquetipo «' . $rol['archetype']
                    . '», que no es uno de los de Moodle: ' . implode(', ', self::ARQUETIPOS)
                    . '. El arquetipo decide los permisos de partida del rol.';
            }

            if (isset($rol['shortname'])) {
                $corto = (string) $rol['shortname'];

                if (in_array($corto, $cortos, true)) {
                    $avisos[] = 'Hay dos roles con el mismo «shortname»: «' . $corto . '».';
                }

                $cortos[] = $corto;
            }
        }

        return $avisos;
    }

    /**
     * Los usuarios, y la comprobación que más vale de todas: que su rol exista aquí.
     *
     * @return array<int, string>
     */
    private static function usuarios(array $setup): array
    {
        if (! isset($setup['users']) || ! is_array($setup['users'])) {
            return [];
        }

        $avisos = [];

        // Un rol se puede referenciar por su clave en el bloque `roles` o por su
        // `shortname`: se admiten las dos porque en los setups reales aparecen las dos.
        $declarados = [];

        foreach ($setup['roles'] ?? [] as $clave => $rol) {
            $declarados[] = (string) $clave;

            if (is_array($rol) && isset($rol['shortname'])) {
                $declarados[] = (string) $rol['shortname'];
            }
        }

        foreach ($setup['users'] as $indice => $usuario) {
            $donde = 'El usuario ' . (is_array($usuario) && isset($usuario['username'])
                ? '«' . $usuario['username'] . '»'
                : '#' . ($indice + 1));

            if (! is_array($usuario)) {
                $avisos[] = $donde . ' no es un bloque de claves y valores.';

                continue;
            }

            foreach (['username', 'firstname', 'lastname', 'email'] as $obligatorio) {
                if (! isset($usuario[$obligatorio]) || trim((string) $usuario[$obligatorio]) === '') {
                    $avisos[] = $donde . ' no tiene «' . $obligatorio . '».';
                }
            }

            if (! isset($usuario['role']) || trim((string) $usuario['role']) === '') {
                $avisos[] = $donde . ' no tiene rol asignado.';

                continue;
            }

            if (! in_array((string) $usuario['role'], $declarados, true)) {
                $avisos[] = $donde . ' tiene el rol «' . $usuario['role'] . '», que **no está '
                    . 'declarado en este setup**. Si el rol no existe ya en el sitio, la cuenta '
                    . 'se queda sin permisos.';
            }
        }

        return $avisos;
    }

    /**
     * Los campos personalizados, de curso o de usuario.
     *
     * La clave del tipo cambia entre los dos bloques —`type` en los de curso, `datatype` en
     * los de usuario—, y esa asimetría es del plugin: se respeta.
     *
     * @return array<int, string>
     */
    private static function campos(array $setup, string $bloque, string $claveDelTipo): array
    {
        if (! isset($setup[$bloque]) || ! is_array($setup[$bloque])) {
            return [];
        }

        $avisos = [];
        $definicion = $setup[$bloque];

        if (! isset($definicion['category']['name'])) {
            $avisos[] = 'En «' . $bloque . '» falta el nombre de la categoría '
                . '(`category.name`).';
        }

        if (! isset($definicion['fields']) || ! is_array($definicion['fields'])) {
            $avisos[] = 'En «' . $bloque . '» no hay lista de campos (`fields`).';

            return $avisos;
        }

        $cortos = [];

        foreach ($definicion['fields'] as $indice => $campo) {
            $donde = 'En «' . $bloque . '», el campo ' . (is_array($campo) && isset($campo['shortname'])
                ? '«' . $campo['shortname'] . '»'
                : '#' . ($indice + 1));

            if (! is_array($campo)) {
                $avisos[] = $donde . ' no es un bloque de claves y valores.';

                continue;
            }

            foreach (['name', 'shortname', $claveDelTipo] as $obligatorio) {
                if (! isset($campo[$obligatorio]) || trim((string) $campo[$obligatorio]) === '') {
                    $avisos[] = $donde . ' no tiene «' . $obligatorio . '».';
                }
            }

            if (isset($campo['shortname'])) {
                $corto = (string) $campo['shortname'];

                if (in_array($corto, $cortos, true)) {
                    $avisos[] = 'En «' . $bloque . '» hay dos campos con el mismo «shortname»: '
                        . '«' . $corto . '».';
                }

                $cortos[] = $corto;
            }
        }

        return $avisos;
    }

    /** @return array<int, string> */
    private static function servicioWeb(array $setup): array
    {
        if (! isset($setup['webservice']) || ! is_array($setup['webservice'])) {
            return [];
        }

        $avisos = [];
        $ws = $setup['webservice'];

        if (isset($ws['protocols']) && ! is_array($ws['protocols'])) {
            $avisos[] = 'En «webservice», «protocols» tiene que ser una lista, aunque sea de '
                . 'uno: `protocols: ["rest"]`.';
        }

        if (! isset($ws['service']['name'])) {
            $avisos[] = 'En «webservice» falta el nombre del servicio (`service.name`).';
        }

        // La comprobación cruzada del token: la cuenta a la que se emite tiene que estar
        // declarada. Es el mismo caso que el rol de un usuario, y falla igual de callado.
        if (isset($ws['token']['user'])) {
            $usuarios = [];

            foreach ($setup['users'] ?? [] as $usuario) {
                if (is_array($usuario) && isset($usuario['username'])) {
                    $usuarios[] = (string) $usuario['username'];
                }
            }

            if (! in_array((string) $ws['token']['user'], $usuarios, true)) {
                $avisos[] = 'El token de «webservice» se emite a «' . $ws['token']['user']
                    . '», que **no está en la lista de usuarios** de este setup.';
            }
        }

        return $avisos;
    }
}
