<?php

declare(strict_types=1);

namespace Tests\Feature\Pwa;

use App\Models\Company;
use App\Models\Expense;
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\Str;
use Laravel\Sanctum\Sanctum;
use PHPUnit\Framework\Attributes\Test;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

/**
 * PWA-002: el gasto con conexión lleva clave de idempotencia. Si la respuesta se
 * pierde y el teléfono lo reintenta (o lo sube el lote), no se crea otro.
 */
class ExpenseIdempotencyTest extends TestCase
{
    use DatabaseTransactions;

    private Company $company;

    private User $collector;

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

        Role::firstOrCreate(['name' => 'collector', 'guard_name' => 'web']);
        $plan = Plan::factory()->create(['has_pwa_access' => true]);
        $this->company = Company::factory()->create();
        Subscription::factory()->active()->create([
            'company_id' => $this->company->id,
            'plan_id' => $plan->id,
            'ends_at' => now()->addYear(),
        ]);
        $this->collector = User::factory()->create(['company_id' => $this->company->id]);
        $this->collector->assignRole('collector');
        Sanctum::actingAs($this->collector->fresh());
    }

    /** @return array<string, mixed> */
    private function gasto(array $extra = []): array
    {
        return array_merge([
            'amount' => 25000,
            'category' => 'transportation',
            'notes' => 'Combustible visita',
        ], $extra);
    }

    private function gastosDeLaEmpresa(?string $clave = null): int
    {
        return Expense::withoutGlobalScopes()
            ->where('company_id', $this->company->id)
            ->when($clave !== null, fn ($q) => $q->where('idempotency_key', $clave))
            ->count();
    }

    /** Un gasto insertado directo, sin hooks: lo que dejó "el otro" envío con la misma clave. */
    private function insertarGasto(int $companyId, int $userId, string $clave): int
    {
        return DB::table('expenses')->insertGetId([
            'company_id' => $companyId,
            'user_id' => $userId,
            'amount' => '25000.00',
            'category' => 'transportation',
            'expense_type' => 'operational',
            'affects_cash' => true,
            'affects_profit' => true,
            'reduces_capital' => false,
            'is_locked' => false,
            'requires_approval' => true,
            'approval_status' => 'pending',
            'operation_date' => now()->toDateString(),
            'idempotency_key' => $clave,
            'created_at' => now(),
            'updated_at' => now(),
        ]);
    }

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

        $primero = $this->postJson('/api/pwa/expenses', $this->gasto(['idempotency_key' => $clave]))
            ->assertCreated();

        $this->postJson('/api/pwa/expenses', $this->gasto(['idempotency_key' => $clave]))
            ->assertOk()
            ->assertJsonPath('duplicate', true)
            ->assertJsonPath('expense.id', $primero->json('expense.id'));

        $this->assertSame(1, $this->gastosDeLaEmpresa($clave));
    }

    #[Test]
    public function sin_clave_cada_envio_crea_un_gasto_como_hasta_ahora(): void
    {
        $antes = $this->gastosDeLaEmpresa();

        $this->postJson('/api/pwa/expenses', $this->gasto())->assertCreated();
        $this->postJson('/api/pwa/expenses', $this->gasto())->assertCreated();

        $this->assertSame($antes + 2, $this->gastosDeLaEmpresa());
    }

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

        $this->postJson('/api/pwa/expenses', $this->gasto(['idempotency_key' => $clave]))
            ->assertCreated();

        $this->assertSame(1, $this->gastosDeLaEmpresa($clave));
    }

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

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

        // El otro envío con la misma clave gana entre la búsqueda y el insert: se
        // simula metiendo su gasto justo después de que la búsqueda por clave del
        // endpoint vuelve vacía y antes de que este abra su transacción de creación.
        DB::listen(function (QueryExecuted $consulta) use ($clave, &$original): void {
            if ($original !== null
                || ! str_starts_with(strtolower(ltrim($consulta->sql)), 'select')
                || ! in_array($clave, $consulta->bindings, true)) {
                return;
            }

            $original = $this->insertarGasto($this->company->id, $this->collector->id, $clave);
        });

        $this->postJson('/api/pwa/expenses', $this->gasto(['idempotency_key' => $clave]))
            ->assertOk()
            ->assertJsonPath('duplicate', true)
            ->assertJsonPath('expense.id', $original);

        $this->assertSame(1, $this->gastosDeLaEmpresa($clave));
    }

    /**
     * Un ítem del lote (`POST /api/pwa/sync/expenses`), con la misma clave que el
     * envío directo: el contrato entre los dos caminos es que comparten clave.
     *
     * @return array<string, mixed>
     */
    private function itemDelLote(string $clave): array
    {
        return [
            'idempotency_key' => $clave,
            'amount' => 25000,
            'category' => 'transportation',
            'notes' => 'Combustible visita',
            'date' => now()->toDateString(),
            'is_simplified_amount' => false,
        ];
    }

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

        $directo = $this->postJson('/api/pwa/expenses', $this->gasto(['idempotency_key' => $clave]))
            ->assertCreated();

        $this->postJson('/api/pwa/sync/expenses', ['expenses' => [$this->itemDelLote($clave)]])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'ok')
            ->assertJsonPath('results.0.expense_id', $directo->json('expense.id'));

        $this->assertSame(1, $this->gastosDeLaEmpresa($clave));
    }

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

        $lote = $this->postJson('/api/pwa/sync/expenses', ['expenses' => [$this->itemDelLote($clave)]])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'ok');

        $this->postJson('/api/pwa/expenses', $this->gasto(['idempotency_key' => $clave]))
            ->assertOk()
            ->assertJsonPath('duplicate', true)
            ->assertJsonPath('expense.id', $lote->json('results.0.expense_id'));

        $this->assertSame(1, $this->gastosDeLaEmpresa($clave));
    }
}
