<?php

declare(strict_types=1);

namespace Tests\Feature\Pwa;

use App\Models\Client;
use App\Models\Company;
use App\Models\Credit;
use App\Models\Expense;
use App\Models\FinancialOperation;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use App\Services\PlanLimitService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum;
use PHPUnit\Framework\Attributes\Test;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

/**
 * PWA-002: un crédito cuya respuesta se perdió no se crea dos veces al
 * reintentar. Un crédito doble es un desembolso doble.
 */
class CreditIdempotencyTest extends TestCase
{
    use DatabaseTransactions;

    private Company $company;

    private Client $client;

    /** El crédito que crea el "otro envío" en las pruebas de carrera. */
    private ?Credit $ganador = null;

    protected function setUp(): void
    {
        parent::setUp();

        foreach (['admin', 'supervisor', 'collector'] as $role) {
            Role::firstOrCreate(['name' => $role, 'guard_name' => 'web']);
        }

        [$this->company, $admin, $this->client] = $this->empresaConAdmin(maxCreditosActivos: 0);
        Sanctum::actingAs($admin->fresh());
    }

    /**
     * @param  int  $maxCreditosActivos  0 = sin límite
     * @return array{0: Company, 1: User, 2: Client}
     */
    private function empresaConAdmin(int $maxCreditosActivos): array
    {
        $empresa = Company::factory()->create();
        $plan = Plan::factory()->create([
            'has_pwa_access' => true,
            'max_active_credits' => $maxCreditosActivos,
            'max_monthly_volume' => 0,
        ]);
        Subscription::factory()->active()->create([
            'company_id' => $empresa->id,
            'plan_id' => $plan->id,
            'ends_at' => now()->addYear(),
        ]);
        $admin = User::factory()->create(['company_id' => $empresa->id]);
        $admin->assignRole('admin');

        return [$empresa, $admin, Client::factory()->create(['company_id' => $empresa->id])];
    }

    /** @return array<string, mixed> */
    private function credito(Client $cliente, array $extra = []): array
    {
        return array_merge([
            'client_id' => $cliente->id,
            'amount' => 200000,
            'interest_rate' => 5,
            'installments_count' => 4,
            'periodicity' => 'monthly',
            'start_date' => now()->addDay()->toDateString(),
        ], $extra);
    }

    private function creditosConClave(string $clave): int
    {
        return Credit::withoutGlobalScopes()->where('idempotency_key', $clave)->count();
    }

    #[Test]
    public function la_misma_clave_dos_veces_crea_un_credito_un_desembolso_y_una_operacion(): void
    {
        $clave = (string) Str::uuid();

        $primero = $this->postJson('/api/pwa/credits', $this->credito($this->client, ['idempotency_key' => $clave]))
            ->assertCreated()
            ->assertJsonPath('duplicate', false);

        $segundo = $this->postJson('/api/pwa/credits', $this->credito($this->client, ['idempotency_key' => $clave]))
            ->assertOk()
            ->assertJsonPath('duplicate', true)
            ->assertJsonPath('credit.id', $primero->json('credit.id'));

        // El reintento recibe exactamente el mismo crédito que la respuesta perdida.
        $this->assertSame($primero->json('credit'), $segundo->json('credit'));

        // Se cuenta por EMPRESA, no por crédito: un segundo desembolso sobre un
        // segundo crédito también debe hacer fallar esta prueba.
        $this->assertSame(1, $this->creditosConClave($clave));
        $this->assertSame(1, Credit::withoutGlobalScopes()->where('company_id', $this->company->id)->count());
        $this->assertSame(1, Expense::withoutGlobalScopes()
            ->where('company_id', $this->company->id)->where('category', 'disbursement')->count());
        $this->assertSame(1, FinancialOperation::withoutGlobalScopes()
            ->where('company_id', $this->company->id)->where('operation_type', 'credit_disbursement')->count());
    }

    #[Test]
    public function el_reintento_con_el_cupo_lleno_devuelve_el_original(): void
    {
        [, $admin, $cliente] = $this->empresaConAdmin(maxCreditosActivos: 1);
        Sanctum::actingAs($admin->fresh());
        $clave = (string) Str::uuid();

        $id = $this->postJson('/api/pwa/credits', $this->credito($cliente, ['idempotency_key' => $clave]))
            ->assertCreated()
            ->json('credit.id');

        // El cupo lo llenó este mismo crédito: su reintento no puede rechazarse por eso.
        $this->postJson('/api/pwa/credits', $this->credito($cliente, ['idempotency_key' => $clave]))
            ->assertOk()
            ->assertJsonPath('duplicate', true)
            ->assertJsonPath('credit.id', $id);

        // Un crédito nuevo, con otra clave, sí choca con el cupo.
        $this->postJson('/api/pwa/credits', $this->credito($cliente, ['idempotency_key' => (string) Str::uuid()]))
            ->assertStatus(422);
    }

    /**
     * Simula la carrera: entre la búsqueda inicial (que no encontró nada) y el
     * momento en que este envío consigue el lock de la empresa, el otro envío con
     * la misma clave crea su crédito. Después corre la operación real de este envío.
     */
    private function simularGanadorDeLaCarrera(string $clave, Client $cliente): void
    {
        $this->partialMock(PlanLimitService::class, function ($mock) use ($clave, $cliente) {
            $mock->shouldReceive('runGuardedCreate')->once()->andReturnUsing(
                function (Company $empresa, \Closure $operacion) use ($clave, $cliente) {
                    $this->ganador = Credit::factory()->create([
                        'company_id' => $empresa->id,
                        'client_id' => $cliente->id,
                        'idempotency_key' => $clave,
                    ]);

                    return $operacion();
                }
            );
        });
    }

    #[Test]
    public function si_otro_envio_con_la_misma_clave_gana_la_carrera_se_devuelve_su_credito(): void
    {
        $clave = (string) Str::uuid();
        $this->simularGanadorDeLaCarrera($clave, $this->client);

        // El insert real de este envío choca con el índice único y el controlador
        // debe devolver el crédito del otro.
        $this->postJson('/api/pwa/credits', $this->credito($this->client, ['idempotency_key' => $clave]))
            ->assertOk()
            ->assertJsonPath('duplicate', true)
            ->assertJsonPath('credit.id', $this->ganador->id);

        $this->assertSame(1, $this->creditosConClave($clave));
        // El insert del crédito es lo primero que escribe el perdedor: el índice lo
        // frena antes de cuotas, desembolso y operación, así que no hay ningún
        // desembolso de este envío.
        $this->assertSame(0, Expense::withoutGlobalScopes()
            ->where('company_id', $this->company->id)->where('category', 'disbursement')->count());
    }

    #[Test]
    public function si_el_ganador_de_la_carrera_lleno_el_cupo_se_devuelve_su_credito_y_no_un_422(): void
    {
        [$empresa, $admin, $cliente] = $this->empresaConAdmin(maxCreditosActivos: 1);
        Sanctum::actingAs($admin->fresh());
        $clave = (string) Str::uuid();
        $this->simularGanadorDeLaCarrera($clave, $cliente);

        // El ganador llenó el cupo (1 de 1): al perdedor, que esperaba el lock, el
        // límite del plan lo rechaza. Pero el crédito existe y es el suyo: el 422 le
        // haría creer al teléfono que no se creó y soltaría la clave.
        $this->postJson('/api/pwa/credits', $this->credito($cliente, ['idempotency_key' => $clave]))
            ->assertOk()
            ->assertJsonPath('duplicate', true)
            ->assertJsonPath('credit.id', $this->ganador->id);

        $this->assertSame(1, Credit::withoutGlobalScopes()->where('company_id', $empresa->id)->count());
        $this->assertSame(0, Expense::withoutGlobalScopes()
            ->where('company_id', $empresa->id)->where('category', 'disbursement')->count());
    }

    #[Test]
    public function sin_clave_cada_envio_crea_un_credito_como_hasta_ahora(): void
    {
        $this->postJson('/api/pwa/credits', $this->credito($this->client))->assertCreated();
        $this->postJson('/api/pwa/credits', $this->credito($this->client))->assertCreated();

        $this->assertSame(2, Credit::withoutGlobalScopes()->where('client_id', $this->client->id)->count());
    }

    #[Test]
    public function la_misma_clave_en_otra_empresa_no_choca(): void
    {
        $clave = (string) Str::uuid();
        Credit::factory()->create(['company_id' => Company::factory()->create()->id, 'idempotency_key' => $clave]);

        $this->postJson('/api/pwa/credits', $this->credito($this->client, ['idempotency_key' => $clave]))
            ->assertCreated()
            ->assertJsonPath('duplicate', false);

        $this->assertSame(2, $this->creditosConClave($clave));
    }

    #[Test]
    public function una_clave_que_no_es_uuid_se_rechaza(): void
    {
        $this->postJson('/api/pwa/credits', $this->credito($this->client, ['idempotency_key' => 'no-es-uuid']))
            ->assertStatus(422)
            ->assertJsonValidationErrors(['idempotency_key']);
    }
}
