<?php

namespace Tests\Feature\ApiLogs;

use App\Models\ApiRequestLog;
use App\Services\Api\Motivo;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Tests\TestCase;

/**
 * Que el `case` de SQL clasifique el motivo exactamente igual que `Motivo::de()`.
 *
 * **Hermano de `SeveridadEnSqlTest`, y por el mismo motivo.** La misma decisión está
 * escrita dos veces: en PHP, que es la que corre al registrar cada petición, y en SQL, que
 * es la que rellenó las filas que ya existían al crear la columna. Se bajó a SQL porque el
 * relleno fila a fila dejó colgada la migración de `severity` en pre —el coste no depende
 * de cuántos rechazos haya sino de **cuántas filas hay que mirar**—, y `api_request_logs`
 * es la tabla que más crece del sistema.
 *
 * Dos copias de una regla se separan siempre, y aquí el síntoma sería el peor posible: las
 * filas viejas con un motivo y las nuevas con otro, en la misma pantalla y sin nada que lo
 * indique. El reparto por motivos dejaría de significar algo y nadie sabría por qué.
 */
class MotivoEnSqlTest extends TestCase
{
    use RefreshDatabase;

    /**
     * Los casos que hay que clasificar.
     *
     * **Los mensajes son los de verdad**, copiados de `ProductToken` y de las acciones:
     * mientras el `403` del middleware sea plano, el texto es lo único que separa seis
     * situaciones, así que un test con mensajes inventados no probaría nada.
     *
     * @return list<array{int, int|null, string|null}>
     */
    private function casos(): array
    {
        $casos = [];

        $mensajes = [
            null,
            '',
            'Token no proporcionado',
            'Token no válido',
            'Cliente inactivo',
            'Token inactivo',
            'Token aún no válido. Fecha de inicio: 2026-10-01 00:00:00',
            'Token expirado. Fecha de expiración: 2026-01-01 00:00:00',
            'Entorno inactivo',
            'El host proporcionado no está vinculado a este token',
            'Environment not found for this host',
            'Environment not found for this host. Host searched: https://x.test',
            'Parámetro host no proporcionado',
            'Plugin parameter is required',
            'Licence does not include this plugin',
            'Plugin not found',
            'Invalid version format',
            'Límite de peticiones de la licencia excedido (60/minuto). Reintenta en 30 segundo(s).',
            'El campo data.plugins es obligatorio para la acción sync.',
            'Un mensaje que no dice nada de nada',
        ];

        foreach ([2, 3, 4, 5] as $familia) {
            foreach ([0, 1, 2, 3, 4] as $ultimo) {
                $codigo = $familia * 1000 + $ultimo;

                foreach ([400, 401, 403, 404, 422, 429, 500] as $status) {
                    foreach ($mensajes as $mensaje) {
                        $casos[] = [$status, $codigo, $mensaje];
                    }
                }
            }
        }

        // Sin código de familia: el efectivo pasa a ser el HTTP. Es el caso del `403` del
        // middleware y del `401` sin token, que son la mitad de lo que se quiere separar.
        foreach ([200, 201, 204, 304, 400, 401, 403, 404, 422, 429, 500, 502, 503] as $status) {
            foreach ($mensajes as $mensaje) {
                $casos[] = [$status, null, $mensaje];
                $casos[] = [$status, $status, $mensaje];
            }
        }

        return $casos;
    }

    public function test_el_sql_clasifica_igual_que_el_php_en_todos_los_casos(): void
    {
        $casos = $this->casos();

        foreach ($casos as $indice => [$status, $codigo, $mensaje]) {
            ApiRequestLog::create([
                'request_uuid' => (string) Str::uuid(),
                'method' => 'POST',
                'path' => '/api/v1',
                'auth_type' => 'token',
                'http_status' => $status,
                'error_code' => $codigo,
                'error' => $mensaje,
                'duration_ms' => 10,
                'started_at' => now(),
                'ended_at' => now(),
                'action' => 'licence',
                // El id explícito ata cada fila a su caso sin depender del orden.
                'id' => $indice + 1,
            ]);
        }

        $delSql = DB::table('api_request_logs')
            ->selectRaw('id, ' . Motivo::comoCase() . ' as calculado')
            ->pluck('calculado', 'id');

        $discrepancias = [];

        foreach ($casos as $indice => [$status, $codigo, $mensaje]) {
            $esperado = Motivo::de($status, $codigo, $mensaje);
            $obtenido = $delSql[$indice + 1];

            if ($esperado !== $obtenido) {
                $discrepancias[] = sprintf(
                    'http=%d code=%s error=%s → php dice «%s» y sql dice «%s»',
                    $status,
                    $codigo === null ? 'null' : $codigo,
                    $mensaje === null ? 'null' : "«$mensaje»",
                    $esperado,
                    $obtenido
                );
            }
        }

        $this->assertSame([], $discrepancias, sprintf(
            "El case de SQL y Motivo::de() no dicen lo mismo en %d de %d casos:\n%s",
            count($discrepancias),
            count($casos),
            implode("\n", array_slice($discrepancias, 0, 15))
        ));
    }

    public function test_todos_los_motivos_del_catalogo_tienen_etiqueta_y_detalle(): void
    {
        // La lista y el catálogo son dos declaraciones de lo mismo: si alguien añade un
        // motivo y no lo describe, la pantalla lo pintaría como «Sin clasificar» y nadie
        // sabría que el motivo existe.
        foreach (Motivo::TODOS as $motivo) {
            $this->assertArrayHasKey($motivo, Motivo::CATALOGO,
                "el motivo `{$motivo}` no está descrito en el catálogo");

            $this->assertNotSame('', Motivo::CATALOGO[$motivo]['etiqueta'] ?? '');
            $this->assertNotSame('', Motivo::CATALOGO[$motivo]['detalle'] ?? '');

            // **Y no vuelve el dueño.** La primera versión traía un campo `dueno` por
            // motivo —Comercial, nosotros, el plugin— y se quitó porque ese reparto no está
            // decidido: una pantalla que lo afirma convierte una suposición en una
            // asignación de trabajo.
            $this->assertArrayNotHasKey('dueno', Motivo::CATALOGO[$motivo],
                "el motivo `{$motivo}` vuelve a declarar un dueño, y eso no está decidido");
        }

        $this->assertSame(
            [],
            array_diff(array_keys(Motivo::CATALOGO), Motivo::TODOS),
            'hay motivos descritos que no están en la lista: el filtro de la pantalla no los ofrecería'
        );
    }

    public function test_el_motivo_cabe_en_la_columna(): void
    {
        // La columna es `string(40)`. Un motivo más largo se guardaría truncado en MySQL
        // sin avisar, y entonces el `group by` de la pantalla partiría el mismo motivo en
        // dos filas distintas.
        foreach (Motivo::TODOS as $motivo) {
            $this->assertLessThanOrEqual(40, strlen($motivo),
                "el motivo `{$motivo}` no cabe en la columna");
        }
    }
}
