<?php

declare(strict_types=1);

namespace Tests\Feature\Pwa;

use App\Jobs\SyncCreditStatusJob;
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 App\Services\CreditStatusSyncService;
use App\Services\PaymentManager;
use App\Services\PaymentSyncService;
use Carbon\CarbonInterface;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Exceptions;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Illuminate\Testing\TestResponse;
use Mockery\MockInterface;
use PDOException;
use PHPUnit\Framework\Attributes\Test;
use RuntimeException;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

/**
 * POST /api/pwa/sync/payments: un cobro capturado en campo nunca se pierde ni se
 * rechaza en silencio. Lo que no se puede aplicar con seguridad queda en
 * `held_payments` (sin tocar saldos) y el teléfono puede soltarlo. (PWA-001)
 */
class SyncPaymentsHeldTest extends TestCase
{
    use DatabaseTransactions;

    private Company $company;

    private User $collector;

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

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

        $plan = Plan::factory()->create();
        $this->company = Company::factory()->create();

        Subscription::factory()->create([
            'company_id' => $this->company->id,
            'plan_id' => $plan->id,
            'status' => Subscription::STATUS_ACTIVE,
            'is_active' => true,
            'starts_at' => now()->subMonth(),
            'ends_at' => now()->addYear(),
        ]);

        $this->collector = $this->makeUser('collector');
    }

    private function makeUser(string $role, ?Company $company = null): User
    {
        $user = User::factory()->create(['company_id' => ($company ?? $this->company)->id]);
        $user->assignRole($role);

        return $user->fresh();
    }

    private function makeCredit(User $collector, float $total = 60_000.0, ?Company $company = null): Credit
    {
        $company ??= $this->company;
        $client = Client::factory()->create(['company_id' => $company->id]);

        $credit = Credit::factory()->create([
            'company_id' => $company->id,
            'client_id' => $client->id,
            'collector_user_id' => $collector->id,
            'created_by_user_id' => $collector->id,
            'status' => Credit::STATUS_ACTIVE,
            'amount' => $total,
            'installments_count' => 1,
            'periodicity' => 'monthly',
            'start_date' => now()->subDays(5),
            'due_date' => now()->addDays(25),
        ]);

        Installment::factory()->create([
            'company_id' => $company->id,
            'credit_id' => $credit->id,
            'installment_number' => 1,
            'status' => 'pending',
            'amount_paid' => 0,
            'total_amount' => $total,
            'principal_amount' => $total * 0.8,
            'interest_amount' => $total * 0.2,
            'due_date' => now()->addDays(25),
        ]);

        return $credit;
    }

    private function item(Credit $credit, array $overrides = []): array
    {
        return array_merge([
            'idempotency_key' => (string) Str::uuid(),
            'credit_id' => $credit->id,
            'amount' => 10_000.0,
            'payment_method' => 'cash',
            'payment_date' => now()->toDateString(),
            'created_at_local' => now()->toIso8601String(),
        ], $overrides);
    }

    private function simplifiedMode(bool $enabled): void
    {
        $this->actingAs($this->collector);
        CompanyFinancialSettings::forCompany($this->company->id)->update([
            'simplified_input_enabled' => $enabled,
            'simplified_input_multiplier' => 1000,
        ]);
    }

    /** Un pago registrado en ese momento del servidor, con su propia clave (p. ej. cargado a mano en línea). */
    private function paymentAt(Credit $credit, CarbonInterface $when, ?string $paymentDate = null): Payment
    {
        $this->travelTo($when);

        try {
            return app(PaymentSyncService::class)->applyToCredit($credit->fresh(), [
                'amount' => 10_000.0,
                'payment_date' => $paymentDate ?? $when->toDateString(),
                'payment_method' => 'cash',
                'device_id' => null,
                'offline_created_at' => null,
                'latitude' => null,
                'longitude' => null,
            ], $this->collector->id, (string) Str::uuid());
        } finally {
            $this->travelBack();
        }
    }

    private function sync(User $user, array $items): TestResponse
    {
        return $this->actingAs($user, 'sanctum')
            ->postJson('/api/pwa/sync/payments', ['payments' => $items]);
    }

    private function assertHeld(TestResponse $response, int $index, string $reason, array $item): HeldPayment
    {
        $response->assertOk();
        $this->assertSame('held', $response->json("results.{$index}.status"));
        $this->assertSame($reason, $response->json("results.{$index}.reason"));
        $this->assertDatabaseMissing('payments', ['idempotency_key' => $item['idempotency_key']]);

        return HeldPayment::withoutGlobalScopes()
            ->where('idempotency_key', $item['idempotency_key'])
            ->firstOrFail();
    }

    #[Test]
    public function un_item_ilegible_no_tumba_el_lote(): void
    {
        $credit = $this->makeCredit($this->collector);
        $bueno = $this->item($credit);
        $malo = $this->item($credit, ['amount' => 'abc']);
        $sinClave = $this->item($credit, ['idempotency_key' => 'no-es-uuid']);

        $response = $this->sync($this->collector, [$bueno, $malo, $sinClave]);

        $response->assertOk();
        $this->assertSame('success', $response->json('results.0.status'));
        $this->assertHeld($response, 1, HeldPayment::REASON_INVALID, $malo);
        // Sin clave válida no se puede deduplicar: es el único caso que vuelve como error.
        $this->assertSame('error', $response->json('results.2.status'));
        $this->assertSame(['total' => 3, 'success' => 1, 'duplicates' => 0, 'held' => 1, 'errors' => 1], $response->json('summary'));
        // Cada resultado lleva su posición: el teléfono empareja por ahí el que no
        // trae una clave que pueda reconocer.
        $this->assertSame([0, 1, 2], array_column($response->json('results'), 'index'));
    }

    #[Test]
    public function un_lote_de_51_se_rechaza_por_forma(): void
    {
        $credit = $this->makeCredit($this->collector);
        $items = array_map(fn () => $this->item($credit, ['amount' => 1]), range(1, 51));

        $this->sync($this->collector, $items)->assertUnprocessable();
    }

    #[Test]
    public function un_cobro_de_hace_mas_de_7_dias_queda_retenido_sin_tocar_el_saldo(): void
    {
        $credit = $this->makeCredit($this->collector);
        $saldoAntes = (float) $credit->fresh()->remaining_balance;
        $item = $this->item($credit, [
            'payment_date' => now()->subDays(10)->toDateString(),
            'created_at_local' => now()->subDays(10)->toIso8601String(),
        ]);

        $held = $this->assertHeld($this->sync($this->collector, [$item]), 0, HeldPayment::REASON_STALE, $item);

        $this->assertSame($saldoAntes, (float) $credit->fresh()->remaining_balance);
        $this->assertSame(HeldPayment::STATUS_PENDING, $held->status);
        $this->assertSame($credit->id, $held->credit_id);
        $this->assertSame($this->collector->id, $held->captured_by_user_id);
        $this->assertSame('10000.00', $held->amount);
    }

    #[Test]
    public function tambien_se_retiene_si_se_capturo_hace_mas_de_7_dias_aunque_la_fecha_sea_hoy(): void
    {
        $credit = $this->makeCredit($this->collector);
        $item = $this->item($credit, ['created_at_local' => now()->subDays(9)->toIso8601String()]);

        $this->assertHeld($this->sync($this->collector, [$item]), 0, HeldPayment::REASON_STALE, $item);
    }

    #[Test]
    public function una_fecha_futura_queda_retenida(): void
    {
        $credit = $this->makeCredit($this->collector);
        $item = $this->item($credit, ['payment_date' => now()->addDays(2)->toDateString()]);

        $this->assertHeld($this->sync($this->collector, [$item]), 0, HeldPayment::REASON_FUTURE_DATE, $item);
    }

    #[Test]
    public function el_credito_de_otro_cobrador_queda_retenido(): void
    {
        $credit = $this->makeCredit($this->makeUser('collector'));
        $item = $this->item($credit);

        $held = $this->assertHeld($this->sync($this->collector, [$item]), 0, HeldPayment::REASON_CREDIT_UNAVAILABLE, $item);
        $this->assertSame($credit->id, $held->credit_id);
    }

    #[Test]
    public function un_credito_pagado_queda_retenido(): void
    {
        $credit = $this->makeCredit($this->collector);
        $credit->update(['status' => Credit::STATUS_PAID]);
        $item = $this->item($credit);

        $this->assertHeld($this->sync($this->collector, [$item]), 0, HeldPayment::REASON_CREDIT_UNAVAILABLE, $item);
    }

    #[Test]
    public function un_credito_activo_sin_cuotas_pendientes_queda_retenido(): void
    {
        // Estado inconsistente: el crédito sigue activo y con saldo, pero su única
        // cuota figura pagada. El flujo interactivo (PaymentPolicyValidator) lo
        // rechaza; el lote no lo aplica a ciegas: lo retiene para que un admin mire.
        $credit = $this->makeCredit($this->collector);
        $credit->installments()->update(['status' => 'paid']);
        $this->assertSame(Credit::STATUS_ACTIVE, $credit->fresh()->status);
        $item = $this->item($credit);

        $held = $this->assertHeld($this->sync($this->collector, [$item]), 0, HeldPayment::REASON_CREDIT_UNAVAILABLE, $item);

        $this->assertSame('El crédito no tiene cuotas pendientes donde aplicar el pago.', $held->reason_detail);
        $this->assertSame($credit->id, $held->credit_id);
    }

    #[Test]
    public function un_monto_mayor_que_el_saldo_queda_retenido(): void
    {
        $credit = $this->makeCredit($this->collector, 60_000.0);
        $item = $this->item($credit, ['amount' => 90_000.0]);

        $this->assertHeld($this->sync($this->collector, [$item]), 0, HeldPayment::REASON_EXCEEDS_BALANCE, $item);
    }

    #[Test]
    public function el_cobrador_puede_mandarlo_a_revision(): void
    {
        $credit = $this->makeCredit($this->collector);
        $item = $this->item($credit, ['hold' => true]);

        $this->assertHeld($this->sync($this->collector, [$item]), 0, HeldPayment::REASON_MANUAL, $item);
    }

    #[Test]
    public function mandarlo_a_revision_con_un_capturador_que_no_se_reconoce_queda_como_invalido(): void
    {
        // Antes el `hold` manual se miraba primero y el retenido quedaba `manual`
        // a nombre de quien sube: se podía aprobar a nombre de otro cobrador.
        $ajeno = $this->makeUser('collector', Company::factory()->create());
        $credit = $this->makeCredit($this->collector);
        $item = $this->item($credit, ['hold' => true, 'captured_by_user_id' => $ajeno->id]);

        $held = $this->assertHeld($this->sync($this->collector, [$item]), 0, HeldPayment::REASON_INVALID, $item);

        $this->assertSame('El usuario que capturó el cobro no pertenece a la empresa o ya no tiene rol en la app.', $held->reason_detail);
    }

    #[Test]
    public function reenviar_un_retenido_no_crea_otro(): void
    {
        $credit = $this->makeCredit($this->collector);
        $item = $this->item($credit, ['hold' => true]);

        $this->sync($this->collector, [$item])->assertOk();
        $response = $this->sync($this->collector, [$item]);

        $this->assertSame('held', $response->json('results.0.status'));
        $this->assertSame(1, HeldPayment::withoutGlobalScopes()->where('idempotency_key', $item['idempotency_key'])->count());
    }

    #[Test]
    public function la_cola_de_otro_usuario_del_telefono_queda_retenida_a_su_nombre(): void
    {
        $anterior = $this->makeUser('collector');
        $credit = $this->makeCredit($anterior);
        $item = $this->item($credit, ['captured_by_user_id' => $anterior->id]);

        $held = $this->assertHeld($this->sync($this->collector, [$item]), 0, HeldPayment::REASON_FOREIGN_USER, $item);

        $this->assertSame($anterior->id, $held->captured_by_user_id);
        $this->assertSame($this->collector->id, $held->synced_by_user_id);
    }

    #[Test]
    public function un_capturador_de_otra_empresa_queda_retenido_como_invalido_a_nombre_de_quien_sube(): void
    {
        $ajeno = $this->makeUser('collector', Company::factory()->create());
        $credit = $this->makeCredit($this->collector);
        $item = $this->item($credit, ['captured_by_user_id' => $ajeno->id]);

        $held = $this->assertHeld($this->sync($this->collector, [$item]), 0, HeldPayment::REASON_INVALID, $item);

        $this->assertSame($this->collector->id, $held->captured_by_user_id);
    }

    #[Test]
    public function el_pago_aplicado_guarda_la_hora_offline_y_cierra_el_credito_si_se_paga_todo(): void
    {
        $credit = $this->makeCredit($this->collector, 60_000.0);
        $item = $this->item($credit, ['amount' => 60_000.0]);

        $response = $this->sync($this->collector, [$item]);

        $response->assertOk();
        $this->assertSame('success', $response->json('results.0.status'));
        $this->assertTrue($response->json('results.0.credit_is_paid'));
        $this->assertNotNull(
            Payment::withoutGlobalScopes()->where('idempotency_key', $item['idempotency_key'])->value('offline_created_at')
        );
    }

    #[Test]
    public function un_monto_de_menos_de_un_centavo_queda_retenido_como_invalido(): void
    {
        // 0.001 pasaba `gt:0` y se aplicaba como un pago de $0.00.
        $credit = $this->makeCredit($this->collector);
        $item = $this->item($credit, ['amount' => 0.001]);

        $held = $this->assertHeld($this->sync($this->collector, [$item]), 0, HeldPayment::REASON_INVALID, $item);

        $this->assertNull($held->amount);
    }

    #[Test]
    public function el_mismo_monto_el_mismo_dia_en_el_mismo_credito_queda_retenido_como_posible_duplicado(): void
    {
        // Caso típico: la cola no bajaba y el cobrador volvió a cargar el cobro a mano.
        $credit = $this->makeCredit($this->collector);
        $primero = $this->item($credit);
        $paymentId = $this->sync($this->collector, [$primero])->json('results.0.payment_id');
        $segundo = $this->item($credit);

        $held = $this->assertHeld($this->sync($this->collector, [$segundo]), 0, HeldPayment::REASON_POSSIBLE_DUPLICATE, $segundo);

        $hoy = now()->toDateString();
        $this->assertSame("Ya hay un pago de 10000.00 en este crédito el {$hoy} (pago #{$paymentId}).", $held->reason_detail);
    }

    #[Test]
    public function el_mismo_monto_otro_dia_se_aplica(): void
    {
        // Sin ventana de días: un crédito diario paga el mismo monto todos los días.
        $credit = $this->makeCredit($this->collector);
        $this->sync($this->collector, [$this->item($credit, ['payment_date' => now()->subDay()->toDateString()])])
            ->assertJsonPath('results.0.status', 'success');

        $this->sync($this->collector, [$this->item($credit)])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'success');
    }

    #[Test]
    public function otro_monto_el_mismo_dia_se_aplica(): void
    {
        $credit = $this->makeCredit($this->collector);
        $this->sync($this->collector, [$this->item($credit)])->assertJsonPath('results.0.status', 'success');

        $this->sync($this->collector, [$this->item($credit, ['amount' => 10_000.5])])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'success');
    }

    #[Test]
    public function el_credito_de_otra_empresa_queda_retenido_sin_enlazarlo(): void
    {
        $otra = Company::factory()->create();
        $credit = $this->makeCredit($this->makeUser('collector', $otra), 60_000.0, $otra);
        $item = $this->item($credit);

        $held = $this->assertHeld($this->sync($this->collector, [$item]), 0, HeldPayment::REASON_CREDIT_UNAVAILABLE, $item);

        // Queda en la empresa de quien sube; el id ajeno solo sobrevive en `payload`.
        $this->assertSame($this->company->id, $held->company_id);
        $this->assertNull($held->credit_id);
    }

    #[Test]
    public function un_credit_id_con_decimales_no_se_enlaza_al_credito_truncado(): void
    {
        $credit = $this->makeCredit($this->collector);
        $item = $this->item($credit, ['credit_id' => $credit->id + 0.5]);

        $held = $this->assertHeld($this->sync($this->collector, [$item]), 0, HeldPayment::REASON_INVALID, $item);

        $this->assertNull($held->credit_id);
    }

    #[Test]
    public function el_resultado_devuelve_la_clave_tal_como_llego(): void
    {
        // La base compara las claves sin distinguir mayúsculas; el teléfono empareja
        // por la clave que mandó, así que hay que devolverle esa y no la guardada.
        $credit = $this->makeCredit($this->collector);
        $aplicado = $this->item($credit);
        $retenido = $this->item($credit, ['hold' => true]);
        $this->sync($this->collector, [$aplicado, $retenido])->assertOk();

        $response = $this->sync($this->collector, [
            array_merge($aplicado, ['idempotency_key' => strtoupper($aplicado['idempotency_key'])]),
            array_merge($retenido, ['idempotency_key' => strtoupper($retenido['idempotency_key'])]),
        ]);

        $response->assertOk();
        $this->assertSame('duplicate', $response->json('results.0.status'));
        $this->assertSame(strtoupper($aplicado['idempotency_key']), $response->json('results.0.idempotency_key'));
        $this->assertSame('held', $response->json('results.1.status'));
        $this->assertSame(strtoupper($retenido['idempotency_key']), $response->json('results.1.idempotency_key'));
    }

    #[Test]
    public function un_item_que_no_es_un_objeto_vuelve_como_error_con_su_posicion(): void
    {
        $credit = $this->makeCredit($this->collector);

        $response = $this->sync($this->collector, [$this->item($credit), null]);

        $response->assertOk();
        $this->assertSame('success', $response->json('results.0.status'));
        $this->assertSame('error', $response->json('results.1.status'));
        $this->assertSame(1, $response->json('results.1.index'));
        $this->assertSame(1, $response->json('summary.errors'));
    }

    #[Test]
    public function si_falla_el_cierre_automatico_el_pago_ya_aplicado_sigue_siendo_success(): void
    {
        Exceptions::fake();
        // El job encolado que en producción cierra el crédito más tarde no corre acá.
        Bus::fake([SyncCreditStatusJob::class]);
        $this->partialMock(CreditStatusSyncService::class, function (MockInterface $mock): void {
            $mock->shouldReceive('forceSync')->andThrow(new RuntimeException('cierre caído'));
        });
        $credit = $this->makeCredit($this->collector, 60_000.0);
        $item = $this->item($credit, ['amount' => 60_000.0]);

        $response = $this->sync($this->collector, [$item]);

        // El pago ya se confirmó: reportarlo como error haría que el teléfono lo reenvíe.
        $response->assertOk();
        $this->assertSame('success', $response->json('results.0.status'));
        $this->assertDatabaseHas('payments', ['idempotency_key' => $item['idempotency_key']]);
        Exceptions::assertReported(fn (RuntimeException $e) => $e->getMessage() === 'cierre caído');
    }

    #[Test]
    public function un_fallo_interno_vuelve_como_error_sin_mandar_datos_del_cobro_al_log_ni_al_reporte(): void
    {
        Exceptions::fake();
        Log::spy();
        // Un INSERT fallido real: el mensaje de la QueryException trae el SQL con
        // todos los valores enlazados (GPS, device_id) y el PDOException encadenado.
        $pdo = new PDOException('SQLSTATE[22003]: Numeric value out of range: 1264 Out of range value');
        $pdo->errorInfo = ['22003', 1264, 'Out of range value'];
        $fallo = new QueryException(
            'mysql',
            'update `payments` set `latitude` = ?, `device_id` = ? where `id` = ?',
            ['4.6097100', 'tel-juan-01', 1],
            $pdo,
        );
        $this->mock(PaymentManager::class, function (MockInterface $mock) use ($fallo): void {
            $mock->shouldReceive('registerPayment')->andThrow($fallo);
        });
        $credit = $this->makeCredit($this->collector);
        $item = $this->item($credit, ['latitude' => 4.60971, 'device_id' => 'tel-juan-01']);

        $response = $this->sync($this->collector, [$item]);

        $response->assertOk();
        $this->assertSame('error', $response->json('results.0.status'));
        $this->assertSame(0, $response->json('results.0.index'));
        // Lo que llega a Sentry / laravel.log: sin valores del cobro y sin la
        // excepción original encadenada (Sentry también manda las previas).
        // El tipo del closure tiene que ser la clase exacta reportada: assertReported
        // solo evalúa el closure si coincide (con Throwable nunca lo haría).
        Exceptions::assertReported(fn (RuntimeException $e) => ! str_contains($e->getMessage(), '4.6097100')
            && ! str_contains($e->getMessage(), 'tel-juan-01')
            && $e->getPrevious() === null
            && str_contains($e->getMessage(), '22003')
            && str_contains($e->getMessage(), '1264')
            && str_contains($e->getMessage(), 'update `payments` set `latitude` = ?'));
        // Y nada más: si alguien vuelve a poner report($e) al lado, esto falla.
        Exceptions::assertNotReported(QueryException::class);
        Log::shouldHaveReceived('error')->withArgs(
            fn (string $message, array $context) => ($context['exception'] ?? null) === QueryException::class
                && ! str_contains((string) json_encode($context), '4.6097100')
                && ! str_contains((string) json_encode($context), 'tel-juan-01')
        );
    }

    #[Test]
    public function aplicar_pidiendo_que_propague_el_fallo_del_cierre_lo_propaga(): void
    {
        // La aprobación de un retenido llama a applyToCredit() dentro de su propia
        // transacción: si el cierre falla, esa transacción tiene que enterarse.
        Bus::fake([SyncCreditStatusJob::class]);
        $this->partialMock(CreditStatusSyncService::class, function (MockInterface $mock): void {
            $mock->shouldReceive('forceSync')->andThrow(new RuntimeException('cierre caído'));
        });
        $credit = $this->makeCredit($this->collector, 60_000.0);

        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage('cierre caído');

        DB::transaction(fn () => app(PaymentSyncService::class)->applyToCredit($credit, [
            'amount' => 60_000.0,
            'payment_date' => now()->toDateString(),
            'payment_method' => 'cash',
            'device_id' => null,
            'offline_created_at' => null,
            'latitude' => null,
            'longitude' => null,
        ], $this->collector->id, (string) Str::uuid(), rethrowCloseFailure: true));
    }

    #[Test]
    public function un_cobro_de_hace_exactamente_7_dias_todavia_se_aplica(): void
    {
        $credit = $this->makeCredit($this->collector);
        $item = $this->item($credit, ['payment_date' => now()->subDays(7)->toDateString()]);

        $this->sync($this->collector, [$item])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'success');
    }

    #[Test]
    public function un_cobro_de_hace_8_dias_queda_retenido(): void
    {
        $credit = $this->makeCredit($this->collector);
        $item = $this->item($credit, ['payment_date' => now()->subDays(8)->toDateString()]);

        $this->assertHeld($this->sync($this->collector, [$item]), 0, HeldPayment::REASON_STALE, $item);
    }

    #[Test]
    public function un_pago_aplicado_reenviado_con_hold_es_duplicado_y_no_crea_retenido(): void
    {
        $credit = $this->makeCredit($this->collector);
        $item = $this->item($credit);
        $this->sync($this->collector, [$item])->assertJsonPath('results.0.status', 'success');

        $response = $this->sync($this->collector, [array_merge($item, ['hold' => true])]);

        $response->assertOk();
        $this->assertSame('duplicate', $response->json('results.0.status'));
        $this->assertSame(0, HeldPayment::withoutGlobalScopes()->where('idempotency_key', $item['idempotency_key'])->count());
    }

    #[Test]
    public function un_cobro_encolado_en_modo_simplificado_se_aplica_con_el_monto_real(): void
    {
        // Del 2026-02-05 al 2026-03-09 PaymentView encolaba el monto tal como se
        // tecleó ('50' = $50.000) junto con is_simplified_amount: esas filas pueden
        // seguir en algún teléfono y se aplicaban mil veces menores.
        $this->simplifiedMode(true);
        $credit = $this->makeCredit($this->collector, 60_000.0);
        $item = $this->item($credit, ['amount' => 50, 'is_simplified_amount' => true]);

        $this->sync($this->collector, [$item])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'success');

        $this->assertSame('50000.00', Payment::withoutGlobalScopes()->where('idempotency_key', $item['idempotency_key'])->value('amount'));
    }

    #[Test]
    public function un_cobro_simplificado_atrasado_queda_retenido_con_el_monto_real_y_lo_tecleado_a_la_vista(): void
    {
        // Es el caso real: esas filas llevan meses en la cola. La empresa pudo apagar
        // el modo después; el flag dice cómo se escribió el número.
        $this->simplifiedMode(false);
        $credit = $this->makeCredit($this->collector);
        $item = $this->item($credit, [
            'amount' => 50,
            'is_simplified_amount' => true,
            'payment_date' => now()->subDays(200)->toDateString(),
            'created_at_local' => now()->subDays(200)->toIso8601String(),
        ]);

        $held = $this->assertHeld($this->sync($this->collector, [$item]), 0, HeldPayment::REASON_STALE, $item);

        $this->assertSame('50000.00', $held->amount);
        $this->assertStringEndsWith('(capturado en modo simplificado: 50)', (string) $held->reason_detail);
        // El payload guarda el ítem tal como llegó.
        $this->assertSame(50, $held->payload['amount']);
        $this->assertTrue($held->payload['is_simplified_amount']);
    }

    #[Test]
    public function un_cobro_que_paso_dias_en_la_cola_se_retiene_si_despues_se_cargo_a_mano_otro_dia(): void
    {
        // El backlog del día del despliegue: se capturó sin señal el día D, el
        // cobrador vio el saldo igual y lo volvió a cargar en línea el D+1, con otra
        // clave. La copia de la cola sigue dentro de los 7 días y se aplicaría también.
        $credit = $this->makeCredit($this->collector);
        $capturado = now()->subDays(3);
        $item = $this->item($credit, [
            'payment_date' => $capturado->toDateString(),
            'created_at_local' => $capturado->toIso8601String(),
        ]);
        $manual = $this->paymentAt($credit, now()->subDays(2));

        $held = $this->assertHeld($this->sync($this->collector, [$item]), 0, HeldPayment::REASON_POSSIBLE_DUPLICATE, $item);

        $this->assertSame(
            "Tras 3 días en la cola, ya hay un pago de 10000.00 en este crédito del {$manual->payment_date->toDateString()} (pago #{$manual->id}), registrado después de capturar este.",
            $held->reason_detail,
        );
    }

    #[Test]
    public function un_cobro_que_paso_dias_en_la_cola_se_aplica_si_el_pago_parecido_se_registro_antes_de_capturarlo(): void
    {
        // Registrado antes de la captura, no puede ser este mismo cobro cargado otra
        // vez: es la cuota de otro día (un crédito diario paga lo mismo cada día).
        $credit = $this->makeCredit($this->collector);
        $this->paymentAt($credit, now()->subDays(4));
        $capturado = now()->subDays(3);
        $item = $this->item($credit, [
            'payment_date' => $capturado->toDateString(),
            'created_at_local' => $capturado->toIso8601String(),
        ]);

        $this->sync($this->collector, [$item])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'success');
    }

    #[Test]
    public function un_backlog_de_varios_dias_del_mismo_credito_se_aplica_entero(): void
    {
        // Las cuotas diarias que quedaron en la cola suben juntas: la del día D se
        // aplica primero y el servidor la registra AHORA, pero se capturó antes que
        // la del D+1. Vale la hora de captura, no la de llegada al servidor.
        $credit = $this->makeCredit($this->collector);
        $items = array_map(fn (int $dias) => $this->item($credit, [
            'payment_date' => now()->subDays($dias)->toDateString(),
            'created_at_local' => now()->subDays($dias)->toIso8601String(),
        ]), [3, 2]);

        $response = $this->sync($this->collector, $items);

        $response->assertOk();
        $this->assertSame(['success', 'success'], array_column($response->json('results'), 'status'));
    }

    #[Test]
    public function un_cobro_recien_capturado_se_aplica_aunque_haya_un_pago_igual_de_ayer_registrado_despues(): void
    {
        // Con menos de 24 h en la cola rige solo la regla del mismo día. El pago de
        // ayer se cargó tarde, después de capturar este: es otra cuota de un crédito diario.
        $credit = $this->makeCredit($this->collector);
        $item = $this->item($credit, ['created_at_local' => now()->subHour()->toIso8601String()]);
        $this->paymentAt($credit, now()->subMinutes(30), paymentDate: now()->subDay()->toDateString());

        $this->sync($this->collector, [$item])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'success');
    }
}
