<?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 Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use PHPUnit\Framework\Attributes\Test;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

/**
 * Desde PWA-002 la MISMA fila de la cola sale primero por el POST directo y, si
 * su respuesta se pierde, la vuelve a mandar el lote con la misma clave. Lo que
 * queda guardado no puede depender de cuál de los dos caminos la llevó, ni el
 * directo puede rechazar (y el teléfono soltar) lo que el lote habría aceptado.
 *
 * Cada test manda el mismo payload por los dos caminos, cada uno con su clave,
 * y compara las dos filas enteras tal como quedaron en la base.
 */
class DirectoYLoteMismasReglasTest extends TestCase
{
    use DatabaseTransactions;

    /**
     * Lo único que puede diferir entre las dos filas: identidad, momento y
     * `metadata`, donde cada camino deja por cuál entró.
     */
    private const PROPIAS_DE_CADA_ENVIO = ['id', 'idempotency_key', 'created_at', 'updated_at', 'metadata'];

    private Company $company;

    private User $collector;

    private User $admin;

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

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

    private function makeUser(string $role): User
    {
        $user = User::factory()->create(['company_id' => $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(): 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' => $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' => $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;
    }

    /** Lo que ExpenseCreateView arma y manda igual por los dos caminos. */
    private function gasto(array $overrides = []): array
    {
        return array_merge([
            'amount' => 50,
            'category' => 'transportation',
            'notes' => 'Combustible de la ruta',
            'date' => now()->toDateString(),
            'is_simplified_amount' => false,
        ], $overrides);
    }

    /** Lo que NoPaymentModal arma y manda igual por los dos caminos. */
    private function visita(Credit $credit, array $overrides = []): array
    {
        return array_merge([
            'credit_id' => $credit->id,
            'installment_id' => null,
            'visit_type' => 'no_payment',
            'reason' => CollectionVisit::REASON_NO_MONEY,
            'notes' => 'Dice que paga el viernes',
            'promised_date' => now()->addDays(3)->toDateString(),
            'promised_amount' => 20,
            'is_simplified_amount' => false,
            'visit_date' => now()->toDateString(),
            'visit_time' => '10:30',
            'device_id' => 'telefono-de-prueba',
            'latitude' => 4.6097102,
            'longitude' => -74.0817500,
        ], $overrides);
    }

    /**
     * Lo que el motor de la cola agrega al subir la fila (sync.js, COLAS.*.item):
     * la clave y quién la capturó.
     */
    private function filaDelLote(array $datos, string $clave): array
    {
        return [...$datos, 'idempotency_key' => $clave, 'captured_by_user_id' => $this->collector->id];
    }

    /**
     * @return array{directo: array<string, mixed>, lote: array<string, mixed>} las dos filas, crudas de la base
     */
    private function gastoPorLosDosCaminos(array $datos): array
    {
        $directo = (string) Str::uuid();
        $lote = (string) Str::uuid();

        $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/expenses', [...$datos, 'idempotency_key' => $directo])
            ->assertCreated();

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

        $filas = ['directo' => $this->fila('expenses', $directo), 'lote' => $this->fila('expenses', $lote)];
        $this->assertSame($filas['lote'], $filas['directo'], 'el directo y el lote guardaron gastos distintos');

        return $filas;
    }

    /**
     * @return array{directo: array<string, mixed>, lote: array<string, mixed>} las dos filas, crudas de la base
     */
    private function visitaPorLosDosCaminos(array $datos): array
    {
        $directo = (string) Str::uuid();
        $lote = (string) Str::uuid();

        $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/visits', [...$datos, 'idempotency_key' => $directo])
            ->assertCreated();

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

        $filas = ['directo' => $this->fila('collection_visits', $directo), 'lote' => $this->fila('collection_visits', $lote)];
        $this->assertSame($filas['lote'], $filas['directo'], 'el directo y el lote guardaron visitas distintas');

        return $filas;
    }

    /** @return array<string, mixed> */
    private function fila(string $tabla, string $clave): array
    {
        $fila = DB::table($tabla)->where('idempotency_key', $clave)->first();
        $this->assertNotNull($fila, "no se guardó la fila {$clave} en {$tabla}");

        return Arr::except((array) $fila, self::PROPIAS_DE_CADA_ENVIO);
    }

    // ── Gastos ──────────────────────────────────────────────────────────────

    #[Test]
    public function un_gasto_simplificado_vale_lo_mismo_aunque_la_empresa_haya_apagado_el_modo(): void
    {
        // El teléfono tenía los ajustes viejos en caché: mostró el modo
        // simplificado, el cobrador tecleó "50" pensando en $50.000 y la empresa
        // ya lo había apagado. El flag dice cómo se escribió el número.
        $this->simplifiedMode(false);

        $filas = $this->gastoPorLosDosCaminos($this->gasto(['amount' => 50, 'is_simplified_amount' => true]));

        $this->assertSame('50000.00', $filas['directo']['amount']);
    }

    #[Test]
    public function el_tope_del_gasto_se_mira_sobre_el_monto_ya_redondeado_en_los_dos(): void
    {
        // 9999.999994 × 1000 = 9.999.999,994: sin redondear se pasa del tope de
        // 9.999.999,99 y el directo lo rechazaba; el lote lo redondea primero y
        // lo aceptaba.
        $this->simplifiedMode(true);

        $filas = $this->gastoPorLosDosCaminos($this->gasto(['amount' => 9999.999994, 'is_simplified_amount' => true]));

        $this->assertSame('9999999.99', $filas['directo']['amount']);
    }

    #[Test]
    public function sin_flag_ninguno_de_los_dos_toca_el_monto_del_gasto(): void
    {
        $this->simplifiedMode(true);

        $filas = $this->gastoPorLosDosCaminos($this->gasto(['amount' => 50]));

        $this->assertSame('50.00', $filas['directo']['amount']);
    }

    #[Test]
    public function el_flag_escrito_como_texto_se_lee_igual_por_los_dos(): void
    {
        // El lote lo lee con FILTER_VALIDATE_BOOLEAN; la regla `boolean` de
        // Laravel no acepta "true" ni "false" y el directo respondía 422.
        $this->simplifiedMode(true);

        foreach (['true' => '50000.00', 'on' => '50000.00', 'false' => '50.00'] as $flag => $monto) {
            $filas = $this->gastoPorLosDosCaminos($this->gasto(['amount' => 50, 'is_simplified_amount' => $flag]));

            $this->assertSame($monto, $filas['directo']['amount'], "flag \"{$flag}\"");
        }
    }

    #[Test]
    public function un_gasto_con_fecha_de_manana_entra_por_los_dos(): void
    {
        // Un día de margen por la zona horaria del teléfono. Sin él, el directo
        // respondía 422 y el teléfono soltaba la fila como rechazada de verdad.
        $manana = now()->addDay()->toDateString();

        $filas = $this->gastoPorLosDosCaminos($this->gasto(['date' => $manana]));

        $this->assertSame($manana, $filas['directo']['operation_date']);
    }

    #[Test]
    public function un_gasto_con_fecha_de_pasado_manana_lo_rechazan_los_dos(): void
    {
        $datos = $this->gasto(['date' => now()->addDays(2)->toDateString()]);
        $directo = (string) Str::uuid();
        $lote = (string) Str::uuid();

        $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/expenses', [...$datos, 'idempotency_key' => $directo])
            ->assertUnprocessable()
            ->assertJsonValidationErrors('date');

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

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

    #[Test]
    public function el_gasto_directo_deja_dicho_por_donde_entro(): void
    {
        // El lote guarda `source: pwa_sync`; sin esto, el directo quedaba con []
        // y no se sabía por cuál de los dos había entrado.
        $clave = (string) Str::uuid();

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

        $metadata = Expense::withoutGlobalScopes()->where('idempotency_key', $clave)->firstOrFail()->metadata;
        $this->assertSame('pwa_direct', $metadata['source'] ?? null);
    }

    // ── Visitas "No paga" ───────────────────────────────────────────────────

    #[Test]
    public function la_promesa_simplificada_vale_lo_mismo_aunque_la_empresa_haya_apagado_el_modo(): void
    {
        $this->simplifiedMode(false);

        $filas = $this->visitaPorLosDosCaminos(
            $this->visita($this->makeCredit(), ['promised_amount' => 20, 'is_simplified_amount' => true])
        );

        $this->assertSame('20000.00', $filas['directo']['promised_amount']);
    }

    #[Test]
    public function el_flag_de_la_promesa_escrito_como_texto_se_lee_igual_por_los_dos(): void
    {
        $this->simplifiedMode(true);

        $filas = $this->visitaPorLosDosCaminos(
            $this->visita($this->makeCredit(), ['promised_amount' => 20, 'is_simplified_amount' => 'true'])
        );

        $this->assertSame('20000.00', $filas['directo']['promised_amount']);
    }

    #[Test]
    public function una_promesa_de_cero_entra_por_los_dos(): void
    {
        $filas = $this->visitaPorLosDosCaminos($this->visita($this->makeCredit(), ['promised_amount' => 0]));

        $this->assertSame('0.00', $filas['directo']['promised_amount']);
    }

    #[Test]
    public function una_visita_con_fecha_de_manana_entra_por_los_dos(): void
    {
        $manana = now()->addDay()->toDateString();

        $filas = $this->visitaPorLosDosCaminos($this->visita($this->makeCredit(), ['visit_date' => $manana]));

        $this->assertSame($manana, $filas['directo']['visit_date']);
    }

    #[Test]
    public function una_promesa_para_ayer_entra_por_los_dos(): void
    {
        // El teléfono va un día atrás del servidor: para él la promesa es para hoy.
        $ayer = now()->subDay()->toDateString();

        $filas = $this->visitaPorLosDosCaminos($this->visita($this->makeCredit(), ['promised_date' => $ayer]));

        $this->assertSame($ayer, $filas['directo']['promised_date']);
    }

    #[Test]
    public function el_directo_sigue_frenando_las_fechas_de_mas_de_un_dia(): void
    {
        // El directo solo recibe filas recién capturadas: más de un día de
        // diferencia ya no es la zona horaria. El lote no frena fechas porque un
        // ítem de la cola puede ser viejo.
        $credit = $this->makeCredit();

        $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/visits', [
                ...$this->visita($credit, ['visit_date' => now()->addDays(2)->toDateString()]),
                'idempotency_key' => (string) Str::uuid(),
            ])
            ->assertUnprocessable()
            ->assertJsonValidationErrors('visit_date');

        $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/visits', [
                ...$this->visita($credit, ['promised_date' => now()->subDays(2)->toDateString()]),
                'idempotency_key' => (string) Str::uuid(),
            ])
            ->assertUnprocessable()
            ->assertJsonValidationErrors('promised_date');
    }

    #[Test]
    public function una_visita_cuya_respuesta_directa_se_perdio_la_reconoce_el_lote_y_no_crea_otra(): void
    {
        // La visita entró por el directo pero la respuesta no llegó al teléfono:
        // la fila sigue en la cola y el lote la sube con la misma clave.
        $datos = $this->visita($this->makeCredit());
        $clave = (string) Str::uuid();

        $visitaId = $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/visits', [...$datos, 'idempotency_key' => $clave])
            ->assertCreated()
            ->json('visit.id');

        $this->actingAs($this->collector, 'sanctum')
            ->postJson('/api/pwa/sync/visits', ['visits' => [$this->filaDelLote($datos, $clave)]])
            ->assertOk()
            ->assertJsonPath('results.0.status', 'duplicate')
            ->assertJsonPath('results.0.visit_id', $visitaId);

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