<?php

namespace Tests\Feature\Products;

use App\Livewire\Products\Index;
use App\Livewire\Products\Show;
use App\Models\Auth\User;
use App\Models\Clients\Client;
use App\Models\Environments\Environment;
use App\Models\Products\LicenseToken;
use App\Models\Products\Product;
use App\Models\Setups\Setup;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Livewire\Livewire;
use Spatie\Permission\Models\Permission;
use Tests\TestCase;

/**
 * Listado y ficha de productos, desde `Productos.dc.html`.
 *
 * Lo que se fija: que se vea qué tiene publicado cada producto, que los dos estados
 * —operativo y a la venta— se distingan, y que un cajón vacío se vea vacío sin pulsar
 * nada. Las siete pestañas anteriores hacían falsa la última afirmación.
 */
class ProductScreensTest extends TestCase
{
    use RefreshDatabase;

    private function admin(): User
    {
        $user = User::factory()->create();

        $permisos = [
            'admin.products.index', 'admin.products.create', 'admin.products.edit', 'admin.products.show',
            'admin.products.destroy',
            'admin.setups.index', 'admin.scss.index', 'admin.js.index', 'admin.scss-cdn.index',
            'admin.features.index', 'admin.resources.index', 'admin.tutorials.index',
            'admin.license-tokens.index', 'admin.environments.index',
        ];

        foreach ($permisos as $nombre) {
            Permission::firstOrCreate(
                ['name' => $nombre, 'guard_name' => 'web'],
                ['model' => 'products', 'description' => $nombre]
            );
        }

        $user->givePermissionTo($permisos);
        $this->actingAs($user);
        Cache::flush();

        return $user;
    }

    private function producto(string $nombre, array $extra = []): Product
    {
        return Product::create(array_merge([
            'name' => $nombre,
            'slug' => \Illuminate\Support\Str::snake($nombre),
            'type' => 'local',
            'tech' => 'moodle',
            'actived' => true,
            'enabled' => true,
        ], $extra));
    }

    private function crearSetup(Product $producto, string $version): Setup
    {
        return Setup::create([
            'product_id' => $producto->id,
            'version' => $version,
            'yaml' => "clave: valor
",
            'status' => 'active',
        ]);
    }

    /* ===================== El listado ===================== */

    public function test_el_listado_dice_que_tiene_publicado_cada_producto(): void
    {
        // Era la pregunta sin respuesta: la tarjeta mostraba «la última versión» sin
        // decir de qué, así que un producto con un setup de hace dos años y otro con
        // SCSS publicado ayer se veían igual.
        $this->admin();

        $conSetups = $this->producto('Con setups');
        $this->crearSetup($conSetups, '2026010100');
        $this->crearSetup($conSetups, '2026060100');

        $this->producto('Sin nada');

        Cache::flush();
        $publicado = Livewire::test(Index::class)->viewData('publicado');

        $this->assertSame(2, $publicado[$conSetups->id]['setups']['n']);
        $this->assertSame('2026060100', $publicado[$conSetups->id]['setups']['version']);

        $respuesta = $this->get(route('products.index'))->assertOk();
        $respuesta->assertSee('Última publicación');
        $respuesta->assertSee('Nada todavía');
    }

    public function test_la_cabecera_cuenta_los_que_no_tienen_nada_publicado(): void
    {
        // Es el caso de 4 de 5 en la base real, y con siete pestañas vacías no se veía.
        $this->admin();

        $conAlgo = $this->producto('Con algo');
        $this->crearSetup($conAlgo, '2026050100');
        $this->producto('Vacio uno');
        $this->producto('Vacio dos');

        Cache::flush();
        $resumen = Livewire::test(Index::class)->viewData('resumen');

        $this->assertSame(3, $resumen['total']);
        $this->assertSame(1, $resumen['conContenido']);
        $this->assertSame(2, $resumen['sinContenido']);
    }

    public function test_operativo_y_a_la_venta_son_dos_estados_distintos(): void
    {
        // El modelo los lleva separados y la pantalla pintaba un solo «Activo». La
        // diferencia es «lo hemos jubilado» frente a «está roto».
        $this->admin();

        $this->producto('Retirado pero vivo', ['actived' => true, 'enabled' => false]);

        $this->get(route('products.index'))
            ->assertOk()
            ->assertSee('Operativo · retirado de la venta');
    }

    public function test_un_producto_inactivo_se_dice_con_otras_palabras(): void
    {
        $this->admin();

        $this->producto('Apagado del todo', ['actived' => false, 'enabled' => false]);

        $this->get(route('products.index'))
            ->assertOk()
            ->assertSee('Inactivo')
            ->assertDontSee('Operativo · retirado de la venta');
    }

    public function test_se_filtra_por_tipo_y_la_busqueda_lo_respeta(): void
    {
        // Los `orWhere` en su propio paréntesis: sueltos, la búsqueda se saltaría el
        // filtro de tipo (MGR-054).
        $this->admin();

        $this->producto('Tema bonito', ['type' => 'theme', 'slug' => 'theme_bonito']);
        $this->producto('Tema feo bloque', ['type' => 'block', 'slug' => 'block_feo']);

        $nombres = Livewire::test(Index::class)
            ->set('tipo', 'theme')
            ->set('search', 'Tema')
            ->viewData('products')->pluck('name')->all();

        $this->assertSame(['Tema bonito'], $nombres);
    }

    public function test_los_inactivos_van_al_final(): void
    {
        $this->admin();

        $this->producto('Aaa inactivo', ['actived' => false]);
        $this->producto('Zzz activo');

        $nombres = Livewire::test(Index::class)->viewData('products')->pluck('name')->all();

        $this->assertSame(['Zzz activo', 'Aaa inactivo'], $nombres);
    }

    /* ===================== La ficha ===================== */

    public function test_la_ficha_agrupa_el_contenido_en_dos_familias(): void
    {
        // Siete pestañas iguales fuera: lo que configura el Moodle y lo que lee la gente
        // dentro de él son dos trabajos de dos equipos distintos.
        $this->admin();

        $producto = $this->producto('Con familias');
        $this->crearSetup($producto, '2026070100');

        Cache::flush();
        $familias = Livewire::test(Show::class, ['product' => $producto])->viewData('familias');

        $this->assertCount(4, $familias['config']);
        $this->assertCount(3, $familias['contenido']);

        $setups = collect($familias['config'])->firstWhere('clave', 'setups');
        $this->assertTrue($setups['tiene']);
        $this->assertSame('2026070100', $setups['version']);

        // Y los vacíos vienen marcados, no ausentes: saber que no hay SCSS es información.
        $scss = collect($familias['config'])->firstWhere('clave', 'scss');
        $this->assertFalse($scss['tiene']);
        $this->assertSame(0, $scss['n']);
    }

    public function test_un_producto_sin_nada_publicado_lo_dice(): void
    {
        $this->admin();

        $producto = $this->producto('Vacio del todo');

        Cache::flush();

        $this->assertTrue(Livewire::test(Show::class, ['product' => $producto])->viewData('sinPublicar'));

        $this->get(route('products.show', $producto))
            ->assertOk()
            ->assertSee('Nada publicado todavía')
            ->assertSee('Sin versiones');
    }

    public function test_la_ficha_lista_las_licencias_y_los_entornos_que_lo_reciben(): void
    {
        $this->admin();

        $producto = $this->producto('Repartido');
        $cliente = Client::create(['name' => 'Cliente Reparto', 'shortname' => 'reparto', 'actived' => true]);

        $licencia = LicenseToken::create([
            'client_id' => $cliente->id,
            'name' => 'Licencia con el producto',
            'token' => '3IP-REPA-RTO1',
            'active' => true,
            'end_at' => now()->addYear(),
        ]);
        $licencia->products()->attach($producto->id, ['status' => 'active']);

        Environment::create([
            'name' => 'Entorno con el producto',
            'domain' => 'https://reparto.test',
            'env' => 'pro',
            'version' => '5.1.1',
            'client_id' => $cliente->id,
            'license_token_id' => $licencia->id,
            'active' => true,
            'refresh_at' => now()->subHour(),
        ]);

        Cache::flush();
        $componente = Livewire::test(Show::class, ['product' => $producto->fresh()]);

        $this->assertCount(1, $componente->viewData('entornos'));

        $this->get(route('products.show', $producto))
            ->assertOk()
            ->assertSee('Licencia con el producto')
            ->assertSee('Cliente Reparto')
            ->assertSee('reparto.test');
    }

    public function test_la_ficha_dice_cuando_ninguna_licencia_lo_incluye(): void
    {
        // Un producto que no llega a nadie: no es un cero, es que no se está vendiendo.
        $this->admin();

        $producto = $this->producto('Nadie lo tiene');

        $this->get(route('products.show', $producto))
            ->assertOk()
            ->assertSee('Ninguna licencia incluye este producto');
    }

    public function test_un_setup_deprecado_no_cuenta_como_publicado(): void
    {
        // **La API solo sirve los setups `active`** (`findCompatibleVersion()`), así
        // que contar un deprecado haría que la pantalla dijera «publicado» de algo que
        // ningún Moodle recibe. Es la misma clase de fallo que MGR-052 y MGR-055: un
        // número que no significa lo que dice.
        $this->admin();

        $producto = $this->producto('Solo deprecados');
        \App\Models\Setups\Setup::create([
            'product_id' => $producto->id,
            'version' => '2025010100',
            'yaml' => "clave: valor
",
            'status' => 'deprecated',
        ]);

        Cache::flush();

        $this->assertTrue(Livewire::test(Show::class, ['product' => $producto])->viewData('sinPublicar'));

        // Y en cuanto hay uno activo, sí cuenta —y solo cuenta ese—.
        $this->crearSetup($producto, '2026010100');
        Cache::flush();

        $publicado = Livewire::test(Index::class)->viewData('publicado');
        $this->assertSame(1, $publicado[$producto->id]['setups']['n']);
        $this->assertSame('2026010100', $publicado[$producto->id]['setups']['version']);
    }

    /* ===================== Borrar un producto ===================== */

    public function test_no_se_puede_borrar_un_producto_que_alguien_tiene_contratado(): void
    {
        // **Y la razón no es cosmética.** El borrado es lógico, así que las filas del
        // pivote se quedan; la API resuelve por slug y ya no lo encuentra; responde
        // «Plugin not found» con `X001`; y `X001` está en la lista `CODES_NO_LICENCE`
        // del plugin. Resultado: el cliente que lo ha pagado ve la promoción de
        // «contrata este producto».
        $this->admin();

        $producto = $this->producto('En uso');
        $cliente = Client::create(['name' => 'Cliente Uso', 'shortname' => 'uso', 'actived' => true]);
        $licencia = LicenseToken::create([
            'client_id' => $cliente->id,
            'name' => 'Licencia que lo incluye',
            'token' => '3IP-USO-1',
            'active' => true,
        ]);
        $licencia->products()->attach($producto->id, ['status' => 'active']);

        $modal = Livewire::test(\App\Livewire\Products\ProductDeleteModal::class, ['productId' => $producto->id]);

        $this->assertTrue($modal->instance()->estaEnUso());
        $modal->assertSee('no se puede borrar');

        // Y si se llama al método directamente, tampoco: esconder el botón no es
        // protegerlo (MGR-005).
        $modal->call('borrar');
        $this->assertNotNull($producto->fresh());
    }

    public function test_una_licencia_apagada_tambien_bloquea_el_borrado(): void
    {
        // Una licencia apagada se puede volver a encender, y entonces el producto
        // tendría que seguir estando.
        $this->admin();

        $producto = $this->producto('En licencia apagada');
        $cliente = Client::create(['name' => 'Cliente Apagado', 'shortname' => 'apag', 'actived' => true]);
        $licencia = LicenseToken::create([
            'client_id' => $cliente->id,
            'name' => 'Licencia apagada',
            'token' => '3IP-APAG-1',
            'active' => false,
        ]);
        $licencia->products()->attach($producto->id, ['status' => 'active']);

        $modal = Livewire::test(\App\Livewire\Products\ProductDeleteModal::class, ['productId' => $producto->id]);

        $this->assertTrue($modal->instance()->estaEnUso());
    }

    public function test_un_producto_que_no_tiene_nadie_se_borra_en_blando(): void
    {
        $this->admin();

        $producto = $this->producto('Nadie lo tiene');
        $this->crearSetup($producto, '2026010100');

        Cache::flush();

        $modal = Livewire::test(\App\Livewire\Products\ProductDeleteModal::class, ['productId' => $producto->id]);

        $this->assertFalse($modal->instance()->estaEnUso());
        // Dice cuánto contenido se archiva, aunque no impida borrar.
        $this->assertSame(1, $modal->instance()->impacto()['contenido']);

        $modal->call('borrar');

        // Lógico siempre: se puede recuperar.
        $this->assertNull(Product::find($producto->id));
        $this->assertNotNull(Product::withTrashed()->find($producto->id));

        $log = \App\Models\Monitoring\Log::where('code', '16024')->first();
        $this->assertNotNull($log);
        $this->assertStringContainsString('nadie_lo_tiene', $log->msg);
    }

    public function test_el_listado_ofrece_borrar_con_modal_y_no_borra_al_pulsar(): void
    {
        // **El botón no existía**: el `delete()` estaba en el componente y ninguna de las
        // dos pantallas de producto lo llamaba, así que no se podía borrar un producto
        // desde el panel. Tercera vez que aparece el patrón (MGR-039, MGR-045).
        $this->admin();
        $producto = $this->producto('Con boton');

        $this->get(route('products.index'))->assertOk()->assertSee('Borrar…');

        Livewire::test(Index::class)
            ->call('abrirBorrado', $producto->id)
            ->assertDispatched('openModal');

        // Abrir la modal no borra nada.
        $this->assertNotNull($producto->fresh());
    }

    /* ===================== El recuento de entornos (MGR-016) ===================== */

    public function test_el_listado_no_consulta_los_entornos_producto_a_producto(): void
    {
        // **Es el regression guard de MGR-016.** Los entornos de un producto se derivan de
        // sus licencias y no son una relación de Eloquent, así que la tarjeta los contaba
        // con `$product->environmentsQuery()->count()`: **una consulta por tarjeta**, cinco
        // de más con cinco productos y cuarenta con cuarenta.
        //
        // Se cuentan las consultas **contra `environments`** y no las totales: el total
        // incluye la caché de permisos de Spatie, que se calienta en la primera llamada y
        // hace que el número baje entre dos medidas. Eso escondería el N+1 en lugar de
        // enseñarlo.
        //
        // Con el scope, `environments` aparece solo dentro de la consulta de productos
        // —como subconsulta correlacionada—, así que **ninguna consulta debe salir de esa
        // tabla por su cuenta**, con dos productos o con doce.
        $this->admin();
        $cliente = Client::create(['name' => 'Cliente N1', 'shortname' => 'n1', 'actived' => true]);

        for ($i = 1; $i <= 12; $i++) {
            $producto = $this->producto('Producto ' . $i);

            $licencia = LicenseToken::create([
                'client_id' => $cliente->id,
                'name' => 'Licencia ' . $i,
                'token' => '3IP-N1-' . $i,
                'active' => true,
            ]);
            $licencia->products()->attach($producto->id, ['status' => 'active']);
            $this->entorno($cliente, 'entorno-' . $i . '.test', ['license_token_id' => $licencia->id]);
        }

        Cache::flush();

        $deEntornos = [];
        \Illuminate\Support\Facades\DB::listen(function ($consulta) use (&$deEntornos) {
            // La consulta del listado se salta: lleva `environments` dentro, pero como
            // subconsulta correlacionada, que es justo el arreglo.
            if (str_starts_with($consulta->sql, 'select "products"')) {
                return;
            }

            if (str_contains($consulta->sql, '"environments"') || str_contains($consulta->sql, '`environments`')) {
                $deEntornos[] = $consulta->sql;
            }
        });

        $productos = Livewire::test(Index::class)->viewData('products');

        // Y el dato sale igual: cada producto tiene su entorno contado.
        $this->assertSame(12, $productos->count());
        $this->assertSame(1, (int) $productos->first()->environments_count);

        $this->assertSame(
            [],
            $deEntornos,
            'El listado consulta la tabla de entornos por su cuenta: ha vuelto el N+1 de MGR-016'
        );
    }

    public function test_el_scope_cuenta_lo_mismo_que_la_consulta_de_uno_en_uno(): void
    {
        // El scope sustituye a `environmentsQuery()->count()`, así que tiene que dar el
        // mismo número. Si divergen, el listado y la ficha dirían cosas distintas del
        // mismo producto.
        $this->admin();

        $producto = $this->producto('Repartido');
        $cliente = Client::create(['name' => 'Cliente Rep', 'shortname' => 'rep', 'actived' => true]);
        $licencia = LicenseToken::create([
            'client_id' => $cliente->id,
            'name' => 'Licencia',
            'token' => '3IP-REP-1',
            'active' => true,
        ]);
        $licencia->products()->attach($producto->id, ['status' => 'active']);

        $this->entorno($cliente, 'uno.test', ['license_token_id' => $licencia->id]);
        $this->entorno($cliente, 'dos.test', ['license_token_id' => $licencia->id]);

        $delScope = Product::withEnvironmentsCount()->find($producto->id);

        $this->assertSame(2, (int) $delScope->environments_count);
        $this->assertSame(2, $producto->environmentsQuery()->count());
    }

    public function test_un_producto_quitado_de_la_licencia_no_cuenta_sus_entornos(): void
    {
        // **El `whereNull('deleted_at')` del pivote no es opcional.** Un producto quitado
        // de una licencia está borrado en blando ahí, y sin el filtro los entornos de esa
        // licencia seguirían contando —y la modal de borrado bloquearía por un producto
        // que ya no sirve a nadie—.
        $this->admin();

        $producto = $this->producto('Quitado');
        $cliente = Client::create(['name' => 'Cliente Quit', 'shortname' => 'quit', 'actived' => true]);
        $licencia = LicenseToken::create([
            'client_id' => $cliente->id,
            'name' => 'Licencia',
            'token' => '3IP-QUIT-1',
            'active' => true,
        ]);
        $licencia->products()->attach($producto->id, ['status' => 'active']);
        $this->entorno($cliente, 'quitado.test', ['license_token_id' => $licencia->id]);

        $this->assertSame(1, (int) Product::withEnvironmentsCount()->find($producto->id)->environments_count);

        // Se quita el producto de la licencia: borrado en blando del pivote.
        \App\Models\Products\LicenseTokenProduct::where('license_token_id', $licencia->id)
            ->where('product_id', $producto->id)
            ->delete();

        $this->assertSame(0, (int) Product::withEnvironmentsCount()->find($producto->id)->environments_count);
    }

    /** Un entorno de un cliente, colgado de una licencia. */
    private function entorno(Client $cliente, string $dominio, array $extra = []): Environment
    {
        return Environment::create(array_merge([
            'name' => $dominio,
            'domain' => 'https://' . $dominio,
            'env' => 'pro',
            'version' => '5.1.1',
            'client_id' => $cliente->id,
            'active' => true,
            'refresh_at' => now()->subHour(),
        ], $extra));
    }
}
