<?php

declare(strict_types=1);

namespace Tests\Feature\Pwa;

use App\Models\Client;
use App\Models\Company;
use App\Models\CompanyFinancialSettings;
use App\Models\Credit;
use App\Models\HeldPayment;
use App\Models\Installment;
use App\Models\Payment;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Str;
use Illuminate\Testing\TestResponse;
use PHPUnit\Framework\Attributes\Test;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

/**
 * POST /api/pwa/payments, el envío directo de la pantalla de cobro, no puede ser
 * más débil que el lote (/api/pwa/sync/payments), que es su red y reenvía con la
 * misma clave:
 *   - una clave que el lote ya retuvo no se aplica por el directo;
 *   - un cobro igual a otro del mismo día en el mismo crédito no se aplica en
 *     silencio: el servidor pregunta (409) y solo lo registra con
 *     `confirm_duplicate`, que el lote también respeta.
 */
class DirectPaymentGuardsTest extends TestCase
{
    use DatabaseTransactions;

    private Company $company;

    private User $collector;

    private Credit $credit;

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

        foreach (['admin', 'supervisor', 'collector'] as $role) {
            Role::firstOrCreate(['name' => $role, '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');
        $this->collector = $this->collector->fresh();

        $client = Client::factory()->create(['company_id' => $this->company->id]);
        $this->credit = Credit::factory()->create([
            'company_id' => $this->company->id,
            'client_id' => $client->id,
            'collector_user_id' => $this->collector->id,
            'created_by_user_id' => $this->collector->id,
            'status' => Credit::STATUS_ACTIVE,
            'amount' => 60_000,
            'installments_count' => 1,
            'periodicity' => 'monthly',
            'start_date' => now()->subDays(5),
            'due_date' => now()->addDays(25),
        ]);
        Installment::factory()->create([
            'company_id' => $this->company->id,
            'credit_id' => $this->credit->id,
            'installment_number' => 1,
            'status' => 'pending',
            'amount_paid' => 0,
            'total_amount' => 60_000,
            'principal_amount' => 48_000,
            'interest_amount' => 12_000,
            'due_date' => now()->addDays(25),
        ]);
    }

    /** @return array<string, mixed> */
    private function cobro(array $extra = []): array
    {
        return array_merge([
            'credit_id' => $this->credit->id,
            'amount' => 10_000,
            'payment_method' => 'cash',
            'payment_date' => now()->toDateString(),
            'idempotency_key' => (string) Str::uuid(),
        ], $extra);
    }

    private function directo(array $cobro): TestResponse
    {
        return $this->actingAs($this->collector, 'sanctum')->postJson('/api/pwa/payments', $cobro);
    }

    /** Un ítem del lote con los mismos datos y la misma clave que el envío directo. */
    private function lote(array $cobro, array $extra = []): TestResponse
    {
        $item = array_merge([
            'idempotency_key' => $cobro['idempotency_key'],
            'credit_id' => $cobro['credit_id'],
            'amount' => $cobro['amount'],
            'payment_method' => $cobro['payment_method'],
            'payment_date' => $cobro['payment_date'],
            'created_at_local' => now()->toIso8601String(),
            'captured_by_user_id' => $this->collector->id,
        ], $extra);

        return $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/payments', ['payments' => [$item]]);
    }

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

    private function pagosDelCredito(): int
    {
        return Payment::withoutGlobalScopes()->where('credit_id', $this->credit->id)->collections()->count();
    }

    // ── Una clave que el lote retuvo ────────────────────────────────────────

    #[Test]
    public function una_clave_que_el_lote_retuvo_no_se_aplica_por_el_envio_directo(): void
    {
        // El directo se demoró, el teléfono se rindió a los 10 s y el motor subió
        // la fila: el lote la retuvo. Después llega el POST directo atrasado.
        $cobro = $this->cobro();
        $this->lote($cobro, ['hold' => true])->assertOk()->assertJsonPath('results.0.status', 'held');
        $retenido = HeldPayment::withoutGlobalScopes()->where('idempotency_key', $cobro['idempotency_key'])->firstOrFail();
        $saldo = (float) $this->credit->fresh()->remaining_balance;

        $this->directo($cobro)
            ->assertOk()
            ->assertJsonPath('held', true)
            ->assertJsonPath('held_payment.id', $retenido->id)
            ->assertJsonPath('held_payment.reason', HeldPayment::REASON_MANUAL)
            ->assertJsonPath('held_payment.status', HeldPayment::STATUS_PENDING)
            // Sin `payment`: el teléfono no lo confunde con un cobro aplicado.
            ->assertJsonMissingPath('payment');

        $this->assertSame(0, $this->pagosConClave($cobro['idempotency_key']));
        $this->assertSame($saldo, (float) $this->credit->fresh()->remaining_balance);
    }

    #[Test]
    public function una_clave_retenida_y_rechazada_tampoco_se_aplica(): void
    {
        // Un admin ya decidió no aplicarlo: el directo atrasado no puede revertir esa decisión.
        $cobro = $this->cobro();
        $this->lote($cobro, ['hold' => true])->assertOk();
        HeldPayment::withoutGlobalScopes()
            ->where('idempotency_key', $cobro['idempotency_key'])
            ->update(['status' => HeldPayment::STATUS_REJECTED, 'resolved_at' => now()]);

        $this->directo($cobro)
            ->assertOk()
            ->assertJsonPath('held', true)
            ->assertJsonPath('held_payment.status', HeldPayment::STATUS_REJECTED);

        $this->assertSame(0, $this->pagosConClave($cobro['idempotency_key']));
    }

    #[Test]
    public function la_misma_clave_retenida_en_otra_empresa_no_cuenta(): void
    {
        $cobro = $this->cobro();
        $otra = Company::factory()->create();
        $otroUsuario = User::factory()->create(['company_id' => $otra->id]);
        HeldPayment::withoutGlobalScopes()->create([
            'company_id' => $otra->id,
            'idempotency_key' => $cobro['idempotency_key'],
            'captured_by_user_id' => $otroUsuario->id,
            'synced_by_user_id' => $otroUsuario->id,
            'amount' => 10_000,
            'reason' => HeldPayment::REASON_MANUAL,
            'payload' => [],
            'status' => HeldPayment::STATUS_PENDING,
        ]);

        $this->directo($cobro)->assertCreated();

        $this->assertSame(1, $this->pagosConClave($cobro['idempotency_key']));
    }

    // ── Un cobro igual el mismo día ─────────────────────────────────────────

    #[Test]
    public function un_cobro_igual_al_de_hoy_en_el_mismo_credito_pide_confirmacion(): void
    {
        $primero = $this->directo($this->cobro())->assertCreated();
        $antes = $this->pagosDelCredito();
        $segundo = $this->cobro();

        $respuesta = $this->directo($segundo)
            ->assertStatus(409)
            ->assertJsonPath('possible_duplicate', true)
            ->assertJsonPath('existing_payment.id', $primero->json('payment.id'))
            ->assertJsonPath('existing_payment.payment_date', now()->toDateString())
            ->assertJsonMissingPath('payment');

        $this->assertEquals(10_000, $respuesta->json('existing_payment.amount'));
        $this->assertNotEmpty($respuesta->json('existing_payment.captured_at'));
        // El mensaje sirve también a un teléfono con el bundle anterior, que lo muestra como error.
        $this->assertStringContainsString('pago #'.$primero->json('payment.id'), (string) $respuesta->json('message'));
        $this->assertSame(0, $this->pagosConClave($segundo['idempotency_key']));
        $this->assertSame($antes, $this->pagosDelCredito());
    }

    #[Test]
    public function confirmado_como_otro_cobro_se_registra(): void
    {
        $this->directo($this->cobro())->assertCreated();
        $segundo = $this->cobro();
        $this->directo($segundo)->assertStatus(409);

        $this->directo(array_merge($segundo, ['confirm_duplicate' => true]))->assertCreated();

        $this->assertSame(1, $this->pagosConClave($segundo['idempotency_key']));
        $this->assertSame(2, $this->pagosDelCredito());
    }

    #[Test]
    public function el_reintento_de_un_cobro_ya_registrado_sigue_siendo_duplicado_y_no_pregunta(): void
    {
        // La misma clave es el mismo cobro: la respuesta se perdió y el teléfono lo reenvía.
        $cobro = $this->cobro();
        $primero = $this->directo($cobro)->assertCreated();

        $this->directo($cobro)
            ->assertOk()
            ->assertJsonPath('duplicate', true)
            ->assertJsonPath('payment.id', $primero->json('payment.id'));
    }

    #[Test]
    public function otro_monto_el_mismo_dia_no_pregunta(): void
    {
        $this->directo($this->cobro())->assertCreated();

        $this->directo($this->cobro(['amount' => 12_000]))->assertCreated();
    }

    #[Test]
    public function el_mismo_monto_otro_dia_no_pregunta(): void
    {
        // Un crédito diario paga el mismo monto todos los días.
        $this->directo($this->cobro(['payment_date' => now()->subDay()->toDateString()]))->assertCreated();

        $this->directo($this->cobro())->assertCreated();
    }

    #[Test]
    public function un_pago_anulado_no_cuenta_como_el_de_hoy(): void
    {
        $primero = $this->directo($this->cobro())->assertCreated();
        // Anulado por el camino real (reversa con auditoría), no tocando la columna:
        // las cuotas tienen que quedar coherentes para que el siguiente se pueda aplicar.
        $admin = User::factory()->create(['company_id' => $this->company->id]);
        $admin->assignRole('admin');
        $this->actingAs($admin->fresh(), 'sanctum')
            ->postJson("/api/pwa/payments/{$primero->json('payment.id')}/void", ['reason' => 'Cargado por error'])
            ->assertOk();

        $this->directo($this->cobro())->assertCreated();
    }

    #[Test]
    public function confirm_duplicate_tiene_que_ser_booleano(): void
    {
        $this->directo($this->cobro(['confirm_duplicate' => 'quizas']))
            ->assertStatus(422)
            ->assertJsonValidationErrors(['confirm_duplicate']);
    }

    #[Test]
    public function registrado_con_confirmacion_el_reintento_sin_la_marca_es_duplicate_y_no_409(): void
    {
        // La búsqueda por clave va antes del 409: si no, el reintento de B (que ya
        // existe) chocaría con P y el teléfono creería que no quedó.
        $this->directo($this->cobro())->assertCreated();
        $b = $this->cobro();
        $registrado = $this->directo(array_merge($b, ['confirm_duplicate' => true]))->assertCreated();

        $this->directo($b)
            ->assertOk()
            ->assertJsonPath('duplicate', true)
            ->assertJsonPath('payment.id', $registrado->json('payment.id'));
    }

    #[Test]
    public function en_modo_simplificado_compara_el_monto_ya_convertido(): void
    {
        CompanyFinancialSettings::forCompany($this->company->id)->update([
            'simplified_input_enabled' => true,
            'simplified_input_multiplier' => 1000,
        ]);

        // '10' tecleado en modo simplificado son $10.000: igual al de hoy.
        $this->directo($this->cobro())->assertCreated();

        $this->directo($this->cobro(['amount' => 10, 'is_simplified_amount' => true]))
            ->assertStatus(409)
            ->assertJsonPath('possible_duplicate', true);
    }

    #[Test]
    public function el_lote_respeta_la_confirmacion_del_cobrador(): void
    {
        // El reenvío confirmado perdió la respuesta y lo subió el motor: el
        // cobrador ya dijo que es otro cobro, así que el lote no lo retiene.
        $this->travelTo(now()->subMinutes(20));
        $this->directo($this->cobro())->assertCreated();
        $this->travelBack();
        $confirmado = $this->cobro();

        $this->lote($confirmado, ['confirm_duplicate' => true, 'created_at_local' => now()->subMinutes(10)->toIso8601String()])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'success');

        $this->assertSame(1, $this->pagosConClave($confirmado['idempotency_key']));
    }

    #[Test]
    public function la_confirmacion_no_cubre_un_cobro_igual_registrado_despues_de_capturar_el_item(): void
    {
        // C se confirmó contra P y quedó en la cola; entretanto D se confirmó y se
        // aplicó. Cuando C sube, D no es el que el cobrador vio al confirmar: sin
        // este límite quedarían tres pagos donde iban dos.
        $this->travelTo(now()->subMinutes(30));
        $this->directo($this->cobro())->assertCreated(); // P
        $this->travelBack();
        $this->directo(array_merge($this->cobro(), ['confirm_duplicate' => true]))->assertCreated(); // D, ahora

        $this->lote($this->cobro(), ['confirm_duplicate' => true, 'created_at_local' => now()->subMinutes(10)->toIso8601String()]) // C
            ->assertOk()
            ->assertJsonPath('results.0.status', 'held')
            ->assertJsonPath('results.0.reason', HeldPayment::REASON_POSSIBLE_DUPLICATE);
    }

    #[Test]
    public function la_confirmacion_no_salta_las_demas_reglas_del_lote(): void
    {
        $this->directo($this->cobro())->assertCreated();
        $otro = User::factory()->create(['company_id' => $this->company->id]);
        $otro->assignRole('collector');
        $confirmado = ['confirm_duplicate' => true];

        // Más que el saldo.
        $this->lote($this->cobro(['amount' => 1_000_000]), $confirmado)
            ->assertJsonPath('results.0.reason', HeldPayment::REASON_EXCEEDS_BALANCE);
        // Más de 7 días de atraso.
        $viejo = now()->subDays(HeldPayment::STALE_AFTER_DAYS + 1);
        $this->lote($this->cobro(['payment_date' => $viejo->toDateString()]), $confirmado + ['created_at_local' => $viejo->toIso8601String()])
            ->assertJsonPath('results.0.reason', HeldPayment::REASON_STALE);
        // Lo capturó otro usuario del teléfono.
        $this->lote($this->cobro(), $confirmado + ['captured_by_user_id' => $otro->id])
            ->assertJsonPath('results.0.reason', HeldPayment::REASON_FOREIGN_USER);
    }

    #[Test]
    public function la_confirmacion_no_salta_el_posible_duplicado_de_un_item_que_paso_dias_en_la_cola(): void
    {
        // Capturado hace 3 días; después alguien cargó un pago igual a mano. Eso
        // no es el pago del mismo día que el cobrador vio al confirmar.
        $capturado = now()->subDays(3);
        $this->directo($this->cobro(['payment_date' => now()->subDay()->toDateString()]))->assertCreated();

        $this->lote($this->cobro(['payment_date' => $capturado->toDateString()]), [
            'confirm_duplicate' => true,
            'created_at_local' => $capturado->toIso8601String(),
        ])
            ->assertJsonPath('results.0.status', 'held')
            ->assertJsonPath('results.0.reason', HeldPayment::REASON_POSSIBLE_DUPLICATE);
    }

    #[Test]
    public function sin_la_confirmacion_el_lote_sigue_reteniendo_el_posible_duplicado(): void
    {
        $this->directo($this->cobro())->assertCreated();
        $segundo = $this->cobro();

        $this->lote($segundo, ['confirm_duplicate' => false])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'held')
            ->assertJsonPath('results.0.reason', HeldPayment::REASON_POSSIBLE_DUPLICATE);
    }

    #[Test]
    public function el_lote_solo_acepta_la_confirmacion_como_booleano_verdadero(): void
    {
        // Lo que llega en el lote no pasa por un FormRequest: un "true" de texto no confirma nada.
        $this->directo($this->cobro())->assertCreated();

        $this->lote($this->cobro(), ['confirm_duplicate' => 'true'])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'held')
            ->assertJsonPath('results.0.reason', HeldPayment::REASON_POSSIBLE_DUPLICATE);
    }
}
