<?php

declare(strict_types=1);

namespace Tests\Feature\Pwa;

use App\Models\CapitalContribution;
use App\Models\Company;
use App\Models\Expense;
use App\Models\Partner;
use App\Models\PartnerWithdrawal;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum;
use PHPUnit\Framework\Attributes\Test;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

/**
 * PWA-002: un aporte o retiro de socio cuya respuesta se perdió no se registra
 * dos veces al reintentar.
 */
class PartnerTransactionIdempotencyTest extends TestCase
{
    use DatabaseTransactions;

    private const PLANTADO_NO_DISPARO = 'El otro envío no llegó a plantarse: el listener de consultas no vio la búsqueda de la clave en partner_withdrawals.';

    private Company $company;

    private User $admin;

    private Partner $partner;

    /** El movimiento que registra el "otro envío" en las pruebas de carrera. */
    private CapitalContribution|PartnerWithdrawal|null $ganador = null;

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

        Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']);
        $this->company = Company::factory()->create();
        $plan = Plan::factory()->create(['has_pwa_access' => true]);
        Subscription::factory()->active()->create([
            'company_id' => $this->company->id,
            'plan_id' => $plan->id,
            'ends_at' => now()->addYear(),
        ]);
        $this->admin = User::factory()->create(['company_id' => $this->company->id]);
        $this->admin->assignRole('admin');
        // No existe PartnerFactory: 'joined_at' es obligatorio.
        $this->partner = Partner::create([
            'company_id' => $this->company->id,
            'name' => 'Socio de Prueba',
            'joined_at' => now(),
        ]);
        Sanctum::actingAs($this->admin->fresh());
    }

    private function url(): string
    {
        return "/api/pwa/partners/{$this->partner->id}/transaction";
    }

    /** @return array<string, mixed> */
    private function aporte(array $extra = []): array
    {
        return array_merge([
            'transaction_type' => 'contribution',
            'contribution_type' => 'additional',
            'amount' => 100000,
        ], $extra);
    }

    /** @return array<string, mixed> */
    private function retiro(array $extra = []): array
    {
        return array_merge([
            'transaction_type' => 'withdrawal',
            'withdrawal_type' => 'profit_withdrawal',
            'amount' => 100000,
        ], $extra);
    }

    private function gastosDeLaEmpresa(): int
    {
        return Expense::withoutGlobalScopes()->where('company_id', $this->company->id)->count();
    }

    /**
     * Registra un movimiento "del otro envío" justo después de que el endpoint
     * busque la clave en los retiros, es decir cuando las dos búsquedas ya
     * fallaron y todavía no abrió su transacción. Se planta con un listener de
     * consultas y no con un evento `creating` del modelo: este correría dentro
     * del savepoint del endpoint y se desharía con él.
     *
     * Dispara una sola vez: las consultas del propio plantado no lo reactivan.
     */
    private function plantarTrasLaBusquedaDeRetiros(string $clave, \Closure $plantar): void
    {
        $disparado = false;

        DB::listen(function (QueryExecuted $consulta) use (&$disparado, $clave, $plantar) {
            if ($disparado
                || ! str_starts_with(strtolower(ltrim($consulta->sql)), 'select')
                || ! str_contains($consulta->sql, 'partner_withdrawals')
                || ! in_array($clave, $consulta->bindings, true)) {
                return;
            }

            $disparado = true;
            $this->ganador = $plantar();
        });
    }

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

        $id = $this->postJson($this->url(), $this->aporte(['idempotency_key' => $clave]))
            ->assertCreated()
            ->json('transaction.id');

        $this->postJson($this->url(), $this->aporte(['idempotency_key' => $clave]))
            ->assertOk()
            ->assertJsonPath('duplicate', true)
            ->assertJsonPath('transaction.id', $id)
            ->assertJsonPath('transaction.type', 'contribution');

        $this->assertSame(1, CapitalContribution::withoutGlobalScopes()->where('idempotency_key', $clave)->count());
    }

    #[Test]
    public function el_reintento_de_un_retiro_que_vacio_el_saldo_devuelve_el_original_y_un_solo_gasto(): void
    {
        $this->postJson($this->url(), $this->aporte())->assertCreated();
        $clave = (string) Str::uuid();
        $gastosAntes = $this->gastosDeLaEmpresa();

        $id = $this->postJson($this->url(), $this->retiro(['idempotency_key' => $clave]))
            ->assertCreated()
            ->json('transaction.id');

        // El primer intento dejó el saldo en cero: sin la deduplicación antes del
        // chequeo de saldo, el reintento se rechazaría con "supera el saldo".
        $this->postJson($this->url(), $this->retiro(['idempotency_key' => $clave]))
            ->assertOk()
            ->assertJsonPath('duplicate', true)
            ->assertJsonPath('transaction.id', $id)
            ->assertJsonPath('transaction.type', 'withdrawal');

        $this->assertSame(1, PartnerWithdrawal::withoutGlobalScopes()->where('idempotency_key', $clave)->count());
        $this->assertSame($gastosAntes + 1, $this->gastosDeLaEmpresa());
    }

    #[Test]
    public function sin_clave_cada_envio_registra_un_movimiento_como_hasta_ahora(): void
    {
        $this->postJson($this->url(), $this->aporte())->assertCreated();
        $this->postJson($this->url(), $this->aporte())->assertCreated();

        $this->assertSame(2, CapitalContribution::withoutGlobalScopes()->where('partner_id', $this->partner->id)->count());
    }

    #[Test]
    public function una_clave_que_no_es_uuid_se_rechaza(): void
    {
        $this->postJson($this->url(), $this->aporte(['idempotency_key' => 'no-es-uuid']))
            ->assertStatus(422)
            ->assertJsonValidationErrors(['idempotency_key']);
    }

    #[Test]
    public function si_otro_envio_con_la_misma_clave_vacio_el_saldo_se_devuelve_su_retiro_y_no_un_422(): void
    {
        // Dos envíos con la misma clave: los dos fallan la búsqueda. El otro
        // registra su retiro (y vacía el saldo) antes de que este llegue al
        // chequeo de saldo, que lo vería reducido y respondería 422. El teléfono
        // tomaría ese 422 por un rechazo real y soltaría la clave aunque el
        // retiro exista.
        $this->postJson($this->url(), $this->aporte())->assertCreated();
        $clave = (string) Str::uuid();
        $gastosAntes = $this->gastosDeLaEmpresa();
        Log::spy();

        $this->plantarTrasLaBusquedaDeRetiros($clave, fn () => PartnerWithdrawal::create([
            'company_id' => $this->company->id,
            'partner_id' => $this->partner->id,
            'amount' => 100000,
            'type' => 'profit_withdrawal',
            'operation_date' => now()->toDateString(),
            'user_id' => $this->admin->id,
            'approval_status' => 'approved',
            'idempotency_key' => $clave,
        ]));

        $respuesta = $this->postJson($this->url(), $this->retiro(['idempotency_key' => $clave]));

        $this->assertNotNull($this->ganador, self::PLANTADO_NO_DISPARO);
        $respuesta->assertOk()
            ->assertJsonPath('duplicate', true)
            ->assertJsonPath('transaction.id', $this->ganador->id)
            ->assertJsonPath('transaction.type', 'withdrawal');

        $this->assertSame(1, PartnerWithdrawal::withoutGlobalScopes()->where('idempotency_key', $clave)->count());
        $this->assertSame($gastosAntes, $this->gastosDeLaEmpresa(), 'El envío perdedor no debe crear un gasto.');
        $this->assertDuplicadoRegistrado($clave, 'withdrawal', 'saldo');
    }

    #[Test]
    public function en_una_carrera_el_indice_frena_al_segundo_retiro_y_no_queda_un_gasto_huerfano(): void
    {
        // Es la única carrera con un efecto lateral: el retiro perdedor inserta
        // primero su gasto contable y recién después choca con el índice único
        // del retiro. El rollback tiene que llevarse ese gasto.
        $this->postJson($this->url(), $this->aporte(['amount' => 200000]))->assertCreated();
        $clave = (string) Str::uuid();
        $gastosAntes = $this->gastosDeLaEmpresa();
        Log::spy();

        // El ganador retira 100000 de los 200000: al perdedor le quedan 100000,
        // así que pasa el chequeo de saldo, llega a la transacción y choca.
        $this->plantarTrasLaBusquedaDeRetiros($clave, fn () => PartnerWithdrawal::create([
            'company_id' => $this->company->id,
            'partner_id' => $this->partner->id,
            'amount' => 100000,
            'type' => 'profit_withdrawal',
            'operation_date' => now()->toDateString(),
            'user_id' => $this->admin->id,
            'approval_status' => 'approved',
            'idempotency_key' => $clave,
        ]));

        $respuesta = $this->postJson($this->url(), $this->retiro(['idempotency_key' => $clave]));

        $this->assertNotNull($this->ganador, self::PLANTADO_NO_DISPARO);
        $respuesta->assertOk()
            ->assertJsonPath('duplicate', true)
            ->assertJsonPath('transaction.id', $this->ganador->id)
            ->assertJsonPath('transaction.type', 'withdrawal');

        $this->assertSame(1, PartnerWithdrawal::withoutGlobalScopes()->where('idempotency_key', $clave)->count());
        // El ganador plantado no tiene gasto: cualquier gasto de más es del perdedor.
        $this->assertSame($gastosAntes, $this->gastosDeLaEmpresa(), 'El gasto del envío perdedor debe volver atrás con su transacción.');
        $this->assertDuplicadoRegistrado($clave, 'withdrawal', 'carrera');
    }

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

        // El otro envío se registra tras las dos búsquedas fallidas y antes de la
        // transacción de este: el insert de este choca con el índice único.
        $this->plantarTrasLaBusquedaDeRetiros($clave, fn () => CapitalContribution::create([
            'company_id' => $this->company->id,
            'partner_id' => $this->partner->id,
            'amount' => 100000,
            'type' => 'additional',
            'operation_date' => now()->toDateString(),
            'user_id' => $this->admin->id,
            'idempotency_key' => $clave,
        ]));

        $respuesta = $this->postJson($this->url(), $this->aporte(['idempotency_key' => $clave]));

        $this->assertNotNull($this->ganador, self::PLANTADO_NO_DISPARO);
        $respuesta->assertOk()
            ->assertJsonPath('duplicate', true)
            ->assertJsonPath('transaction.id', $this->ganador->id)
            ->assertJsonPath('transaction.type', 'contribution');

        $this->assertSame(1, CapitalContribution::withoutGlobalScopes()->where('idempotency_key', $clave)->count());
        $this->assertDuplicadoRegistrado($clave, 'contribution', 'carrera');
    }

    #[Test]
    public function la_misma_clave_en_otra_empresa_no_choca(): void
    {
        $clave = (string) Str::uuid();
        $otraEmpresa = Company::factory()->create();
        $otroSocio = Partner::create([
            'company_id' => $otraEmpresa->id,
            'name' => 'Socio de Otra Empresa',
            'joined_at' => now(),
        ]);
        $ajeno = CapitalContribution::create([
            'company_id' => $otraEmpresa->id,
            'partner_id' => $otroSocio->id,
            'amount' => 50000,
            'type' => 'additional',
            'operation_date' => now()->toDateString(),
            'user_id' => $this->admin->id,
            'idempotency_key' => $clave,
        ]);

        $id = $this->postJson($this->url(), $this->aporte(['idempotency_key' => $clave]))
            ->assertCreated()
            ->assertJsonMissingPath('duplicate')
            ->json('transaction.id');

        $this->assertNotSame($ajeno->id, $id);
        $this->assertSame(1, CapitalContribution::withoutGlobalScopes()
            ->where('company_id', $this->company->id)->where('idempotency_key', $clave)->count());
    }

    #[Test]
    public function el_duplicado_devuelve_el_socio_del_movimiento_y_no_el_de_la_url(): void
    {
        $clave = (string) Str::uuid();
        $this->postJson($this->url(), $this->aporte(['idempotency_key' => $clave]))->assertCreated();
        $otroSocio = Partner::create([
            'company_id' => $this->company->id,
            'name' => 'Otro Socio',
            'joined_at' => now(),
        ]);

        // Si una clave se reusara con otro socio, la PWA actualiza la tarjeta
        // del socio que trae la respuesta: tiene que ser el dueño del movimiento.
        $this->postJson("/api/pwa/partners/{$otroSocio->id}/transaction", $this->aporte(['idempotency_key' => $clave]))
            ->assertOk()
            ->assertJsonPath('duplicate', true)
            ->assertJsonPath('partner.id', $this->partner->id)
            ->assertJsonPath('partner.balance.contributed', 100000);
    }

    #[Test]
    public function el_reintento_de_un_aporte_deja_registrado_que_se_evito_un_duplicado(): void
    {
        $clave = (string) Str::uuid();
        $this->postJson($this->url(), $this->aporte(['idempotency_key' => $clave]))->assertCreated();
        Log::spy();

        $this->postJson($this->url(), $this->aporte(['idempotency_key' => $clave]))->assertOk();

        $this->assertDuplicadoRegistrado($clave, 'contribution', 'busqueda');
    }

    private function assertDuplicadoRegistrado(string $clave, string $tipo, string $via): void
    {
        Log::shouldHaveReceived('info')
            ->withArgs(fn (string $mensaje, array $contexto = []) => $mensaje === 'PWA Partner: Duplicate prevented'
                && ($contexto['idempotency_key'] ?? null) === $clave
                && ($contexto['transaction_type'] ?? null) === $tipo
                && ($contexto['via'] ?? null) === $via
                && ($contexto['company_id'] ?? null) === $this->company->id
                && ($contexto['user_id'] ?? null) === $this->admin->id
                && isset($contexto['existing_transaction_id']))
            ->once();
    }
}
