<?php

declare(strict_types=1);

namespace Tests\Feature\Pwa;

use App\Models\Client;
use App\Models\CollectionVisit;
use App\Models\Company;
use App\Models\CompanyFinancialSettings;
use App\Models\Credit;
use App\Models\Expense;
use App\Models\Installment;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use App\Services\FinanceAutoLogger;
use App\Services\Sync\CapturerResolver;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use PHPUnit\Framework\Attributes\Test;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

/**
 * Colas de visitas y gastos: un ítem malo no tumba el lote, los montos escritos
 * en modo simplificado se convierten (PWA-010) y cada ítem se atribuye a quien
 * lo capturó, no a quien lo sube. Cada resultado trae `index`, su posición en
 * el lote recibido.
 */
class SyncVisitsExpensesOfflineTest extends TestCase
{
    use DatabaseTransactions;

    private Company $company;

    private User $collector;

    private User $admin;

    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');
        $this->admin = $this->makeUser('admin');
    }

    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 simplifiedMode(bool $enabled): void
    {
        $this->actingAs($this->admin);
        CompanyFinancialSettings::forCompany($this->company->id)->update([
            'simplified_input_enabled' => $enabled,
            'simplified_input_multiplier' => 1000,
        ]);
    }

    private function makeCredit(User $collector): Credit
    {
        $client = Client::factory()->create(['company_id' => $this->company->id]);

        $credit = Credit::factory()->create([
            'company_id' => $this->company->id,
            'client_id' => $client->id,
            'collector_user_id' => $collector->id,
            'created_by_user_id' => $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' => $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 $credit;
    }

    private function expense(array $overrides = []): array
    {
        return array_merge([
            'idempotency_key' => (string) Str::uuid(),
            'amount' => 50,
            'category' => 'transportation',
            'notes' => null,
            'date' => now()->toDateString(),
        ], $overrides);
    }

    private function visit(Credit $credit, array $overrides = []): array
    {
        return array_merge([
            'idempotency_key' => (string) Str::uuid(),
            'credit_id' => $credit->id,
            'visit_type' => 'no_payment',
            'reason' => CollectionVisit::REASON_NO_MONEY,
            'visit_date' => now()->toDateString(),
            'promised_amount' => null,
        ], $overrides);
    }

    private function storedExpense(string $key): Expense
    {
        return Expense::withoutGlobalScopes()->where('idempotency_key', $key)->firstOrFail();
    }

    /**
     * Simula que otro request "gana" la carrera: la fila con la misma clave
     * aparece DESPUÉS del SELECT de duplicados y ANTES del INSERT del ítem. Se
     * engancha en CapturerResolver porque el lote lo llama justo entre los dos.
     */
    private function competingInsertBeforeCreate(callable $insert): void
    {
        $this->app->instance(CapturerResolver::class, new class($insert) extends CapturerResolver
        {
            /** @var callable|null */
            private $insert;

            public function __construct(callable $insert)
            {
                $this->insert = $insert;
            }

            public function resolve(mixed $capturedById, User $syncer): ?User
            {
                if ($this->insert !== null) {
                    ($this->insert)();
                    $this->insert = null;
                }

                return parent::resolve($capturedById, $syncer);
            }
        });
    }

    #[Test]
    public function un_gasto_capturado_en_modo_simplificado_se_convierte(): void
    {
        $this->simplifiedMode(true);
        $item = $this->expense(['amount' => 50, 'is_simplified_amount' => true]);

        $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/expenses', ['expenses' => [$item]])
            ->assertOk()
            ->assertJsonPath('results.0.index', 0)
            ->assertJsonPath('results.0.status', 'ok');

        $this->assertDatabaseHas('expenses', ['idempotency_key' => $item['idempotency_key'], 'amount' => 50_000]);
    }

    #[Test]
    public function se_convierte_aunque_el_modo_este_apagado_al_subir(): void
    {
        // Se capturó en modo simplificado y la empresa lo apagó antes de que subiera:
        // el flag describe cómo se escribió el número, no cómo está la empresa hoy.
        $this->simplifiedMode(false);
        $item = $this->expense(['amount' => 50, 'is_simplified_amount' => true]);

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

        $this->assertDatabaseHas('expenses', ['idempotency_key' => $item['idempotency_key'], 'amount' => 50_000]);
    }

    #[Test]
    public function sin_flag_el_monto_no_se_toca(): void
    {
        $this->simplifiedMode(true);
        $item = $this->expense(['amount' => 50]);

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

        $this->assertDatabaseHas('expenses', ['idempotency_key' => $item['idempotency_key'], 'amount' => 50]);
    }

    #[Test]
    public function la_promesa_de_una_visita_en_modo_simplificado_se_convierte(): void
    {
        $this->simplifiedMode(true);
        $credit = $this->makeCredit($this->collector);
        $item = $this->visit($credit, ['promised_amount' => 200, 'is_simplified_amount' => true]);

        $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/visits', ['visits' => [$item]])
            ->assertOk()
            ->assertJsonPath('results.0.index', 0)
            ->assertJsonPath('results.0.status', 'success');

        $this->assertDatabaseHas('collection_visits', ['idempotency_key' => $item['idempotency_key'], 'promised_amount' => 200_000]);
    }

    #[Test]
    public function el_gasto_de_otro_usuario_se_le_atribuye_y_queda_pendiente_aunque_suba_un_admin(): void
    {
        $item = $this->expense(['captured_by_user_id' => $this->collector->id]);

        $this->actingAs($this->admin, 'sanctum')
            ->postJson('/api/pwa/sync/expenses', ['expenses' => [$item]])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'ok');

        $this->assertDatabaseHas('expenses', [
            'idempotency_key' => $item['idempotency_key'],
            'user_id' => $this->collector->id,
            'requires_approval' => 1,
            'approval_status' => 'pending',
            'approved_by_user_id' => null,
        ]);
    }

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

        $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/expenses', ['expenses' => [$item]])
            ->assertOk()
            ->assertJsonPath('results.0.index', 0)
            ->assertJsonPath('results.0.idempotency_key', $item['idempotency_key'])
            ->assertJsonPath('results.0.status', 'error');

        $this->assertDatabaseMissing('expenses', ['idempotency_key' => $item['idempotency_key']]);
    }

    #[Test]
    public function un_gasto_sin_clave_valida_falla_solo_y_se_ubica_por_su_posicion(): void
    {
        $bueno = $this->expense();
        $sinClave = $this->expense(['idempotency_key' => 'no-es-uuid']);

        $response = $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/expenses', ['expenses' => [$bueno, $sinClave]]);

        $response->assertOk()
            ->assertJsonPath('processed', 1)
            ->assertJsonPath('failed', 1)
            ->assertJsonPath('results.0.index', 0)
            ->assertJsonPath('results.0.status', 'ok')
            ->assertJsonPath('results.1.index', 1)
            ->assertJsonPath('results.1.idempotency_key', 'no-es-uuid')
            ->assertJsonPath('results.1.status', 'error');

        $this->assertDatabaseHas('expenses', ['idempotency_key' => $bueno['idempotency_key']]);
    }

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

        // Quien sube es otro cobrador, sin acceso a ese crédito: igual entra, porque la capturó $anterior.
        $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/visits', ['visits' => [$item]])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'success');

        $this->assertDatabaseHas('collection_visits', [
            'idempotency_key' => $item['idempotency_key'],
            'collector_user_id' => $anterior->id,
        ]);
    }

    #[Test]
    public function una_visita_mala_no_tumba_el_lote(): void
    {
        $credit = $this->makeCredit($this->collector);
        $buena = $this->visit($credit);
        $mala = $this->visit($credit, ['visit_type' => 'inventado']);
        $sinClave = $this->visit($credit, ['idempotency_key' => null]);

        $response = $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/visits', ['visits' => [$buena, $mala, $sinClave]]);

        $response->assertOk()
            ->assertJsonPath('summary.success', 1)
            ->assertJsonPath('summary.errors', 2)
            ->assertJsonPath('results.0.index', 0)
            ->assertJsonPath('results.1.index', 1)
            ->assertJsonPath('results.1.idempotency_key', $mala['idempotency_key'])
            ->assertJsonPath('results.2.index', 2)
            ->assertJsonPath('results.2.idempotency_key', null);
        $this->assertSame('success', $response->json('results.0.status'));
        $this->assertSame('error', $response->json('results.1.status'));
        $this->assertSame('error', $response->json('results.2.status'));
        $this->assertDatabaseHas('collection_visits', ['idempotency_key' => $buena['idempotency_key']]);
        $this->assertDatabaseMissing('collection_visits', ['idempotency_key' => $mala['idempotency_key']]);
    }

    #[Test]
    public function un_motivo_desconocido_es_un_error_de_validacion_y_no_un_fallo_interno(): void
    {
        // La columna `reason` es un enum: sin la regla, el INSERT fallaba en la base
        // y el cobrador veía "Error interno" en vez de qué estaba mal.
        $credit = $this->makeCredit($this->collector);
        $item = $this->visit($credit, ['reason' => 'sin_dinero']);

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

        $response->assertOk()->assertJsonPath('results.0.status', 'error');
        $this->assertNotSame('Error interno al procesar la visita.', $response->json('results.0.error'));
        $this->assertDatabaseMissing('collection_visits', ['idempotency_key' => $item['idempotency_key']]);
    }

    #[Test]
    public function el_acceso_se_mira_contra_el_capturador_aunque_suba_un_admin_con_acceso(): void
    {
        // El crédito es de $this->collector; lo "capturó" otro cobrador sin acceso.
        // Que el admin que sube sí lo vea no cuenta.
        $sinAcceso = $this->makeUser('collector');
        $credit = $this->makeCredit($this->collector);
        $item = $this->visit($credit, ['captured_by_user_id' => $sinAcceso->id]);

        $this->actingAs($this->admin, 'sanctum')
            ->postJson('/api/pwa/sync/visits', ['visits' => [$item]])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'error')
            ->assertJsonPath('results.0.error', 'El crédito ya no está activo o fue reasignado.');

        $this->assertDatabaseMissing('collection_visits', ['idempotency_key' => $item['idempotency_key']]);
    }

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

        $lote = [
            // 10.000 pasa el tope; convertido (×1000) ya no: el tope se mira DESPUÉS.
            $this->visit($credit, ['promised_amount' => 10_000, 'is_simplified_amount' => true]),
            // Convertido desborda la columna decimal(12,2).
            $this->visit($credit, ['promised_amount' => 10_000_000, 'is_simplified_amount' => true]),
            $this->visit($credit, ['visit_time' => 'abc']),
            $this->visit($credit, ['latitude' => 91]),
            $this->visit($credit, ['longitude' => -181]),
            $this->visit($credit, ['device_id' => str_repeat('x', 256)]),
        ];

        $response = $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/visits', ['visits' => $lote]);

        $response->assertOk()->assertJsonPath('summary.errors', count($lote));

        foreach ($lote as $i => $item) {
            $this->assertSame('error', $response->json("results.{$i}.status"), "ítem {$i}");
            $this->assertNotSame('Error interno al procesar la visita.', $response->json("results.{$i}.error"), "ítem {$i}");
            $this->assertDatabaseMissing('collection_visits', ['idempotency_key' => $item['idempotency_key']]);
        }

        // Nombres en español: el teléfono muestra este texto como last_error.
        $this->assertStringContainsString('monto prometido', $response->json('results.0.error'));
        $this->assertStringContainsString('hora de la visita', $response->json('results.2.error'));
    }

    #[Test]
    public function la_hora_que_manda_el_telefono_pasa(): void
    {
        // NoPaymentModal manda `new Date().toTimeString().slice(0, 5)` → "HH:MM".
        $credit = $this->makeCredit($this->collector);
        $item = $this->visit($credit, ['visit_time' => '07:05', 'latitude' => -33.4489, 'longitude' => -70.6693]);

        $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/visits', ['visits' => [$item]])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'success');
    }

    #[Test]
    public function una_cuota_de_otro_credito_es_un_error_del_item(): void
    {
        $credit = $this->makeCredit($this->collector);
        $otro = $this->makeCredit($this->collector);
        $cuotaAjena = Installment::withoutGlobalScopes()->where('credit_id', $otro->id)->value('id');
        $item = $this->visit($credit, ['installment_id' => $cuotaAjena]);

        $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/visits', ['visits' => [$item]])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'error')
            ->assertJsonPath('results.0.error', 'La cuota no pertenece a este crédito.');

        $this->assertDatabaseMissing('collection_visits', ['idempotency_key' => $item['idempotency_key']]);
    }

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

        Log::spy();

        $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/visits', ['visits' => [$item]])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'success');

        $visitId = CollectionVisit::withoutGlobalScopes()->where('idempotency_key', $item['idempotency_key'])->value('id');

        Log::shouldHaveReceived('info')->withArgs(fn ($message, $context = []) => $message === 'PWA Sync: visita atribuida a otro usuario'
            && $context === [
                'visit_id' => $visitId,
                'captured_by_user_id' => $anterior->id,
                'synced_by_user_id' => $this->collector->id,
            ])->once();
    }

    #[Test]
    public function el_gasto_que_capturo_un_admin_y_sube_un_cobrador_queda_pendiente_y_guarda_quien_lo_subio(): void
    {
        // Con applyApprovalPolicyForCreator($capturer) quedaría aprobado por ser admin;
        // pero nadie aprobó nada: quien sube es el cobrador.
        $item = $this->expense(['captured_by_user_id' => $this->admin->id]);

        $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/expenses', ['expenses' => [$item]])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'ok');

        $this->assertDatabaseHas('expenses', [
            'idempotency_key' => $item['idempotency_key'],
            'user_id' => $this->admin->id,
            'requires_approval' => 1,
            'approval_status' => 'pending',
            'approved_by_user_id' => null,
        ]);

        $metadata = $this->storedExpense($item['idempotency_key'])->metadata;
        $this->assertSame($this->collector->id, $metadata['synced_by_user_id'] ?? null);
        $this->assertSame('pwa_sync', $metadata['source'] ?? null);
    }

    #[Test]
    public function el_gasto_propio_de_un_admin_queda_aprobado(): void
    {
        $item = $this->expense(['captured_by_user_id' => $this->admin->id]);

        $this->actingAs($this->admin, 'sanctum')
            ->postJson('/api/pwa/sync/expenses', ['expenses' => [$item]])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'ok');

        $this->assertDatabaseHas('expenses', [
            'idempotency_key' => $item['idempotency_key'],
            'user_id' => $this->admin->id,
            'requires_approval' => 0,
            'approval_status' => 'approved',
            'approved_by_user_id' => $this->admin->id,
        ]);
    }

    #[Test]
    public function el_flag_como_texto_se_interpreta_como_booleano(): void
    {
        $this->simplifiedMode(true);
        $uno = $this->expense(['amount' => 50, 'is_simplified_amount' => '1']);
        $falso = $this->expense(['amount' => 50, 'is_simplified_amount' => 'false']);

        $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/expenses', ['expenses' => [$uno, $falso]])
            ->assertOk()
            ->assertJsonPath('processed', 2);

        $this->assertDatabaseHas('expenses', ['idempotency_key' => $uno['idempotency_key'], 'amount' => 50_000]);
        $this->assertDatabaseHas('expenses', ['idempotency_key' => $falso['idempotency_key'], 'amount' => 50]);
    }

    #[Test]
    public function la_fecha_del_gasto_tolera_un_dia_adelante_y_no_mas(): void
    {
        // Un día de margen por la zona horaria del teléfono.
        $manana = $this->expense(['date' => now()->addDay()->toDateString()]);
        $pasado = $this->expense(['date' => now()->addDays(2)->toDateString()]);

        $response = $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/expenses', ['expenses' => [$manana, $pasado]]);

        $response->assertOk()
            ->assertJsonPath('results.0.status', 'ok')
            ->assertJsonPath('results.1.status', 'error');
        $this->assertStringContainsString('fecha', $response->json('results.1.message'));
        $this->assertDatabaseMissing('expenses', ['idempotency_key' => $pasado['idempotency_key']]);
    }

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

        CollectionVisit::creating(function (CollectionVisit $visit) use ($rota): void {
            if ($visit->idempotency_key === $rota['idempotency_key']) {
                throw new \Error('falla simulada');
            }
        });

        $response = $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/visits', ['visits' => [$rota, $buena]]);

        $response->assertOk()
            ->assertJsonPath('results.0.status', 'error')
            ->assertJsonPath('results.0.error', 'Error interno al procesar la visita.')
            ->assertJsonPath('results.1.status', 'success');
        $this->assertDatabaseHas('collection_visits', ['idempotency_key' => $buena['idempotency_key']]);
    }

    #[Test]
    public function un_error_de_php_en_un_gasto_no_tumba_el_lote(): void
    {
        $roto = $this->expense();
        $bueno = $this->expense();

        Expense::creating(function (Expense $expense) use ($roto): void {
            if ($expense->idempotency_key === $roto['idempotency_key']) {
                throw new \Error('falla simulada');
            }
        });

        $response = $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/expenses', ['expenses' => [$roto, $bueno]]);

        $response->assertOk()
            ->assertJsonPath('processed', 1)
            ->assertJsonPath('failed', 1)
            ->assertJsonPath('results.0.status', 'error')
            ->assertJsonPath('results.1.status', 'ok');
        $this->assertDatabaseHas('expenses', ['idempotency_key' => $bueno['idempotency_key']]);
    }

    #[Test]
    public function una_visita_que_otro_request_guardo_en_la_carrera_es_duplicada(): void
    {
        $credit = $this->makeCredit($this->collector);
        $item = $this->visit($credit);
        $ganadora = null;

        $this->competingInsertBeforeCreate(function () use ($item, $credit, &$ganadora): void {
            $ganadora = CollectionVisit::withoutGlobalScopes()->create([
                'company_id' => $this->company->id,
                'credit_id' => $credit->id,
                'collector_user_id' => $this->collector->id,
                'visit_type' => 'no_payment',
                'visit_date' => now()->toDateString(),
                'idempotency_key' => $item['idempotency_key'],
            ]);
        });

        $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/visits', ['visits' => [$item]])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'duplicate')
            ->assertJsonPath('results.0.visit_id', $ganadora->id)
            ->assertJsonPath('summary.duplicates', 1);
    }

    #[Test]
    public function un_gasto_que_otro_request_guardo_en_la_carrera_es_ok(): void
    {
        $item = $this->expense();
        $ganador = null;

        $this->competingInsertBeforeCreate(function () use ($item, &$ganador): void {
            $ganador = app(FinanceAutoLogger::class)->logExpense([
                'company_id' => $this->company->id,
                'user_id' => $this->collector->id,
                'amount' => 50,
                'category' => 'transportation',
                'operation_date' => now()->toDateString(),
                'idempotency_key' => $item['idempotency_key'],
            ]);
        });

        $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/expenses', ['expenses' => [$item]])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'ok')
            ->assertJsonPath('results.0.expense_id', $ganador->id)
            ->assertJsonPath('failed', 0);

        $this->assertSame(1, Expense::withoutGlobalScopes()->where('idempotency_key', $item['idempotency_key'])->count());
    }
}
