<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api\Pwa;

use App\Http\Controllers\Api\Pwa\Traits\RoleAwareQueries;
use App\Http\Controllers\Controller;
use App\Http\Requests\Pwa\SyncPaymentsRequest;
use App\Models\Client;
use App\Models\CollectionVisit;
use App\Models\CollectorCreditOrder;
use App\Models\CompanyFinancialSettings;
use App\Models\Credit;
use App\Models\Expense;
use App\Models\Installment;
use App\Models\Payment;
use App\Models\User;
use App\Services\CollectorVisibilityResolver;
use App\Services\FinanceAutoLogger;
use App\Services\PaymentManager;
use App\Services\PaymentSyncService;
use App\Services\Sync\CapturerResolver;
use App\Support\SimplifiedAmount;
use Carbon\Carbon;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;

class SyncController extends Controller
{
    use RoleAwareQueries;

    /** Número máximo de créditos a sincronizar para supervisor/admin.
     *  150 es suficiente para un equipo de cobradores activos y evita saturar
     *  el dispositivo con payloads excesivos en conexiones lentas. */
    private const ADMIN_SYNC_LIMIT = 150;

    /**
     * Categorías de gasto permitidas desde la PWA.
     * Espejo de ExpenseController::ALLOWED_CATEGORIES — excluye categorías de capital
     * y del sistema que no deben registrarse manualmente desde el dispositivo.
     */
    private const EXPENSE_ALLOWED_CATEGORIES = [
        'salary_wages', 'rent', 'utilities', 'transportation', 'marketing',
        'office_supplies', 'professional_services', 'commission', 'bank_fees',
        'other_operational', 'bad_debt_provision', 'refund', 'adjustment',
        // bad_debt_writeoff excluido: requiere credit_id (el hook del modelo lanza si falta).
    ];

    /**
     * Nombres en español para los mensajes de validación por ítem de las colas:
     * el teléfono guarda el mensaje como `last_error` y se lo muestra al cobrador.
     */
    private const VISIT_ATTRIBUTES = [
        'credit_id' => 'crédito',
        'visit_type' => 'tipo de visita',
        'visit_date' => 'fecha de la visita',
        'installment_id' => 'cuota',
        'reason' => 'motivo',
        'notes' => 'notas',
        'promised_date' => 'fecha prometida',
        'promised_amount' => 'monto prometido',
        'visit_time' => 'hora de la visita',
        'latitude' => 'latitud',
        'longitude' => 'longitud',
        'device_id' => 'dispositivo',
    ];

    private const EXPENSE_ATTRIBUTES = [
        'amount' => 'monto',
        'category' => 'categoría',
        'notes' => 'notas',
        'date' => 'fecha',
    ];

    public function __construct(
        private readonly PaymentManager $paymentManager,
        private readonly FinanceAutoLogger $financeLogger,
        private readonly CollectorVisibilityResolver $visibilityResolver,
        private readonly PaymentSyncService $paymentSyncService,
        private readonly CapturerResolver $capturerResolver,
    ) {}

    /**
     * POST /api/pwa/sync/payments
     *
     * Sincroniza múltiples pagos offline en batch.
     * Cada pago tiene su idempotency_key para prevenir duplicados.
     */
    public function syncPayments(SyncPaymentsRequest $request): JsonResponse
    {
        // Mismo guard que POST /api/pwa/payments: solo collector y admin pueden registrar pagos.
        // Supervisors NO pueden registrar pagos, ni por sync batch ni por el endpoint directo.
        if (! $this->canRegisterPayments($request)) {
            return response()->json([
                'message' => 'No tienes permiso para registrar pagos.',
            ], 403);
        }

        $batch = $this->paymentSyncService->processBatch(
            payments: $request->payments,
            user: $request->user(),
            role: $this->getUserPwaRole($request),
        );

        return response()->json([
            'results' => $batch['results'],
            'summary' => $batch['summary'],
            'synced_at' => now()->toIso8601String(),
        ]);
    }

    /**
     * GET /api/pwa/sync/data
     *
     * Descarga datos completos para trabajo offline.
     *
     * Scope por rol:
     *  - Collector     → solo créditos asignados a él (su cartera)
     *  - Supervisor    → todos los créditos activos de la empresa
     *  - Admin         → todos los créditos activos de la empresa (límite ADMIN_SYNC_LIMIT)
     */
    public function downloadData(Request $request): JsonResponse
    {
        $user = $request->user();
        $daysToSync = (int) $request->get('days', 7);
        $role = $this->getUserPwaRole($request);

        // Construir query base de créditos — siempre scoped a la empresa y
        // excluyendo créditos padre (con hijos derivados = no operacionales).
        $creditsQuery = Credit::query()
            ->where('company_id', $user->company_id)
            ->whereIn('status', Credit::ACTIVE_STATUSES)
            ->whereDoesntHave('children')
            ->with(['client', 'installments', 'latestPayment']);

        if ($role === 'collector') {
            // Collectors: solo su cartera asignada
            $creditsQuery->where('collector_user_id', $user->id);
        } elseif ($role === 'supervisor') {
            // Supervisores: créditos de sus collectors asignados (via CollectorVisibilityResolver).
            // Un supervisor con sees_all=true ve toda la empresa; uno sin asignaciones no recibe datos.
            $visibility = $this->visibilityResolver->resolve($user);

            if ($visibility['mode'] === CollectorVisibilityResolver::MODE_ASSIGNED) {
                $creditsQuery->whereIn('collector_user_id', $visibility['collector_ids']);
            } elseif ($visibility['mode'] === CollectorVisibilityResolver::MODE_UNASSIGNED) {
                // Sin asignaciones: devolver cartera vacía con meta explicativo
                return response()->json([
                    'synced_at' => now()->toIso8601String(),
                    'data_version' => now()->timestamp,
                    'collector' => ['id' => $user->id, 'name' => $user->name, 'company_id' => $user->company_id],
                    'clients' => [],
                    'credits' => [],
                    'installments' => [],
                    'recent_payments' => [],
                    'meta' => ['clients_count' => 0, 'credits_count' => 0, 'installments_count' => 0, 'payments_count' => 0, 'days_synced' => $daysToSync, 'unassigned' => true],
                ]);
            }
            // MODE_ALL: sin filtro adicional de collector_user_id
            $creditsQuery->orderBy('updated_at', 'desc')->limit(self::ADMIN_SYNC_LIMIT);
        } else {
            // Admin: todos los créditos activos de la empresa.
            // Se aplica un límite de seguridad para no saturar el dispositivo.
            $creditsQuery->orderBy('updated_at', 'desc')->limit(self::ADMIN_SYNC_LIMIT);
        }

        $credits = $creditsQuery->get();

        // Extraer IDs de clientes
        $clientIds = $credits->pluck('client_id')->unique();

        // Obtener clientes
        $clients = Client::query()
            ->whereIn('id', $clientIds)
            ->with('addresses')
            ->get()
            ->map(fn ($client) => [
                'id' => $client->id,
                'name' => $client->name,
                'identification' => $client->identification,
                'phone' => $client->phone,
                'email' => $client->email,
                'address' => $client->formatted_address,
            ]);

        // Mapa credit_id → sort_order del orden manual del cobrador.
        // DEBE construirse ANTES del map() de créditos: las arrow functions de PHP
        // capturan variables del scope léxico al momento de la llamada, por lo que
        // si $sortOrderMap se define después del map(), la clausura captura null y
        // todos los créditos recibirían sort_order = null → orden incorrecto offline.
        $sortOrderMap = [];
        if ($role === 'collector') {
            $sortOrderMap = CollectorCreditOrder::query()
                ->where('company_id', $user->company_id)
                ->where('user_id', $user->id)
                ->pluck('sort_order', 'credit_id')
                ->toArray();
        }

        // Mapa credit_id → client_name construido desde la colección ya cargada
        // para evitar N+1 al enriquecer los registros de pago.
        $creditClientMap = $credits->mapWithKeys(
            fn ($credit) => [$credit->id => $credit->client?->name ?? '']
        );

        // Formatear créditos
        $creditsData = $credits->map(fn ($credit) => [
            'id' => $credit->id,
            'code' => $credit->code,
            'company_credit_number' => $credit->company_credit_number,
            'client_id' => $credit->client_id,
            'status' => $credit->status,
            'status_label' => $credit->getStatusLabel(),
            'amount' => round((float) $credit->amount, 2),
            'interest_rate' => round((float) $credit->interest_rate, 2),
            'remaining_balance' => round((float) $credit->remaining_balance, 2),
            'total_receivable' => round((float) $credit->total_receivable, 2),
            'total_paid' => round((float) $credit->total_paid, 2),
            'installments_count' => $credit->installments_count,
            'periodicity' => $credit->periodicity,
            'frequency_display' => $this->getFrequencyDisplay($credit),
            'start_date' => $credit->start_date?->toDateString(),
            'due_day_1' => $credit->due_day_1,
            'due_day_2' => $credit->due_day_2,
            'last_payment_date' => $credit->latestPayment?->payment_date?->toDateString(),
            'last_payment_amount' => $credit->latestPayment
                ? round((float) $credit->latestPayment->amount, 2)
                : null,
            // Posición en la ruta manual del cobrador (null para supervisor/admin).
            // $sortOrderMap se construye ANTES de este map() — ver comentario arriba.
            'sort_order' => isset($sortOrderMap[$credit->id])
                ? (int) $sortOrderMap[$credit->id]
                : null,
        ]);

        // Obtener cuotas de los créditos
        $installments = Installment::query()
            ->whereIn('credit_id', $credits->pluck('id'))
            ->orderBy('credit_id')
            ->orderBy('installment_number')
            ->get()
            ->map(fn ($inst) => [
                'id' => $inst->id,
                'credit_id' => $inst->credit_id,
                'number' => $inst->installment_number,
                'due_date' => $inst->due_date->toDateString(),
                'status' => $inst->status,
                'status_label' => $inst->getStatusLabel(),
                'principal_amount' => round((float) $inst->principal_amount, 2),
                'interest_amount' => round((float) $inst->interest_amount, 2),
                'total_amount' => round((float) $inst->total_amount, 2),
                'amount_paid' => round((float) $inst->amount_paid, 2),
                // Se llamaba `balance_due`, igual que la columna de la BD — pero
                // llevaba otra cosa: lo que FALTA por cobrar de la cuota, no el
                // saldo de capital del crédito. Dos significados bajo un mismo
                // nombre según de qué lado del cable se mirara.
                'remaining_amount' => round((float) $inst->remaining_amount, 2),
            ]);

        // Historial completo de pagos para todos los créditos sincronizados.
        // Scope anterior era demasiado restrictivo (solo pagos del user en 7 días),
        // lo que causaba que el historial de pagos offline estuviera truncado.
        // Ahora: todos los pagos no anulados de los créditos en la cartera,
        // ordenados por fecha desc, con límite de seguridad por dispositivo.
        $recentPayments = Payment::query()
            ->whereIn('credit_id', $credits->pluck('id'))
            // `collections()` excluye anulados Y asientos de reversión, para que un
            // pago anulado no aparezca como monto negativo en el historial offline
            // (la caché IndexedDB se llena desde aquí).
            ->collections()
            ->with(['registeredBy:id,name'])
            ->orderBy('payment_date', 'desc')
            ->limit(500)
            ->get()
            ->map(fn ($payment) => [
                'id' => $payment->id,
                'credit_id' => $payment->credit_id,
                'client_name' => $creditClientMap[$payment->credit_id] ?? '',
                'amount' => round((float) $payment->amount, 2),
                'payment_method' => $payment->payment_method,
                'payment_method_label' => match ($payment->payment_method) {
                    'cash' => 'Efectivo',
                    'transfer' => 'Transferencia',
                    'mobile' => 'Pago móvil',
                    default => 'No especificado',
                },
                'payment_date' => $payment->payment_date->toDateString(),
                'registered_by' => $payment->registeredBy?->name ?? 'Sistema',
                'registered_at' => $payment->created_at->toIso8601String(),
                'idempotency_key' => $payment->idempotency_key,
            ]);

        return response()->json([
            'synced_at' => now()->toIso8601String(),
            'data_version' => now()->timestamp,
            'collector' => [
                'id' => $user->id,
                'name' => $user->name,
                'company_id' => $user->company_id,
            ],
            'clients' => $clients->values(),
            'credits' => $creditsData->values(),
            'installments' => $installments->values(),
            'recent_payments' => $recentPayments->values(),
            'meta' => [
                'clients_count' => $clients->count(),
                'credits_count' => $creditsData->count(),
                'installments_count' => $installments->count(),
                'payments_count' => $recentPayments->count(),
                'days_synced' => $daysToSync,
            ],
        ]);
    }

    /**
     * GET /api/pwa/sync/check
     *
     * Verifica si hay cambios desde la última sincronización.
     */
    public function checkUpdates(Request $request): JsonResponse
    {
        $user = $request->user();
        $lastSync = $request->get('last_sync');

        if (! $lastSync) {
            return response()->json([
                'has_updates' => true,
                'reason' => 'No previous sync timestamp provided.',
            ]);
        }

        $lastSyncTime = Carbon::parse($lastSync);

        $role = $this->getUserPwaRole($request);

        // Construir el filtro de créditos según el rol del usuario.
        // Antes: hardcodeado a collector_user_id = user.id (roto para supervisor/admin).
        // Ahora: role-aware con CollectorVisibilityResolver para supervisores.
        $creditScope = function ($q) use ($user, $role) {
            $q->where('company_id', $user->company_id)
                ->whereDoesntHave('children');

            if ($role === 'collector') {
                $q->where('collector_user_id', $user->id);
            } elseif ($role === 'supervisor') {
                $visibility = $this->visibilityResolver->resolve($user);
                if ($visibility['mode'] === CollectorVisibilityResolver::MODE_ASSIGNED) {
                    $q->whereIn('collector_user_id', $visibility['collector_ids']);
                }
                // MODE_ALL y MODE_UNASSIGNED: sin filtro adicional o vacío respectivamente
            }
            // Admin: sin filtro extra → ve toda la empresa
        };

        // Verificar si hay créditos modificados desde última sync
        $hasUpdates = Credit::query()
            ->where('company_id', $user->company_id)
            ->whereDoesntHave('children')
            ->tap(function ($q) use ($user, $role) {
                if ($role === 'collector') {
                    $q->where('collector_user_id', $user->id);
                } elseif ($role === 'supervisor') {
                    $visibility = $this->visibilityResolver->resolve($user);
                    if ($visibility['mode'] === CollectorVisibilityResolver::MODE_ASSIGNED) {
                        $q->whereIn('collector_user_id', $visibility['collector_ids']);
                    }
                }
            })
            ->where('updated_at', '>', $lastSyncTime)
            ->exists();

        // Verificar si hay cuotas modificadas
        if (! $hasUpdates) {
            $hasUpdates = Installment::query()
                ->whereHas('credit', $creditScope)
                ->where('updated_at', '>', $lastSyncTime)
                ->exists();
        }

        // Verificar si el orden manual de ruta cambió desde la última sync.
        // Solo relevante para collectors — supervisores/admins no tienen sort_order propio.
        if (! $hasUpdates && $role === 'collector') {
            $hasUpdates = CollectorCreditOrder::query()
                ->where('company_id', $user->company_id)
                ->where('user_id', $user->id)
                ->where('updated_at', '>', $lastSyncTime)
                ->exists();
        }

        return response()->json([
            'has_updates' => $hasUpdates,
            'checked_at' => now()->toIso8601String(),
            'last_sync' => $lastSyncTime->toIso8601String(),
        ]);
    }

    /**
     * POST /api/pwa/sync/visits
     *
     * Visitas de cobranza offline en batch. Cada ítem se valida por separado (uno
     * malo ya no tumba el lote), la promesa escrita en modo simplificado se
     * convierte y la visita se atribuye a quien la capturó (PWA-001, PWA-010).
     *
     * Cada resultado trae `index` (su posición en el lote recibido) para que el
     * teléfono empareje un ítem cuya clave no puede reconocer.
     */
    public function syncVisits(Request $request): JsonResponse
    {
        $request->validate([
            'visits' => ['required', 'array', 'max:100'],
            'visits.*' => ['array'],
        ]);

        $user = $request->user();
        $settings = CompanyFinancialSettings::forCompany($user->company_id);
        $visitsData = array_values($request->visits);
        $results = [];
        $successCount = 0;
        $errorCount = 0;
        $duplicateCount = 0;

        /** @var array<int, Collection<int, int>> $accessByCapturer */
        $accessByCapturer = [];

        foreach ($visitsData as $position => $visitData) {
            $idempotencyKey = $visitData['idempotency_key'] ?? null;

            try {
                if (! is_string($idempotencyKey) || ! Str::isUuid($idempotencyKey)) {
                    $results[] = ['index' => $position, 'idempotency_key' => $idempotencyKey, 'status' => 'error', 'error' => 'Clave de idempotencia inválida.'];
                    $errorCount++;

                    continue;
                }

                $existing = CollectionVisit::query()
                    ->where('company_id', $user->company_id)
                    ->where('idempotency_key', $idempotencyKey)
                    ->first();

                if ($existing) {
                    $results[] = [
                        'index' => $position,
                        'idempotency_key' => $idempotencyKey,
                        'status' => 'duplicate',
                        'visit_id' => $existing->id,
                        'message' => 'Visita ya registrada anteriormente.',
                    ];
                    $duplicateCount++;

                    continue;
                }

                $capturer = $this->capturerResolver->resolve($visitData['captured_by_user_id'] ?? null, $user);
                if (! $capturer) {
                    $results[] = ['index' => $position, 'idempotency_key' => $idempotencyKey, 'status' => 'error', 'error' => 'El usuario que registró la visita no pertenece a la empresa.'];
                    $errorCount++;

                    continue;
                }

                $visitData = SimplifiedAmount::toReal($visitData, 'promised_amount', $settings);

                // Las mismas reglas que StoreCollectionVisitRequest para lo que la base
                // rechaza (enum, decimal, time, varchar): si no, el INSERT falla y el
                // teléfono recibe "Error interno", que el cobrador no puede corregir.
                // Sin before/after "hoy" en las fechas: un ítem de la cola puede ser viejo.
                $validator = Validator::make($visitData, [
                    'credit_id' => ['required', 'integer'],
                    'visit_type' => ['required', 'string', Rule::in(CollectionVisit::VISIT_TYPES)],
                    'visit_date' => ['required', 'date'],
                    'installment_id' => ['nullable', 'integer'],
                    'reason' => ['nullable', 'string', Rule::in(CollectionVisit::REASONS)],
                    'notes' => ['nullable', 'string', 'max:1000'],
                    'promised_date' => ['nullable', 'date'],
                    // Ya convertido: el tope se mira sobre el monto real.
                    'promised_amount' => ['nullable', 'numeric', 'min:0', 'max:9999999.99'],
                    'visit_time' => ['nullable', 'date_format:H:i'],
                    'latitude' => ['nullable', 'numeric', 'between:-90,90'],
                    'longitude' => ['nullable', 'numeric', 'between:-180,180'],
                    'device_id' => ['nullable', 'string', 'max:255'],
                ], [], self::VISIT_ATTRIBUTES);

                if ($validator->fails()) {
                    $results[] = ['index' => $position, 'idempotency_key' => $idempotencyKey, 'status' => 'error', 'error' => $validator->errors()->first()];
                    $errorCount++;

                    continue;
                }

                $creditId = (int) $visitData['credit_id'];

                // El acceso se mira contra quien CAPTURÓ la visita, no contra quien la sube.
                $accessByCapturer[$capturer->id] ??= $this->accessibleCreditIds($capturer);
                if (! $accessByCapturer[$capturer->id]->has($creditId)) {
                    $results[] = ['index' => $position, 'idempotency_key' => $idempotencyKey, 'status' => 'error', 'error' => 'El crédito ya no está activo o fue reasignado.'];
                    $errorCount++;

                    continue;
                }

                // Igual que CollectionVisitController::store(): la cuota tiene que ser
                // de ese crédito, no de otro (la FK solo pide que exista).
                $installmentId = $visitData['installment_id'] ?? null;
                if ($installmentId !== null
                    && Installment::query()->whereKey($installmentId)->where('credit_id', $creditId)->doesntExist()) {
                    $results[] = ['index' => $position, 'idempotency_key' => $idempotencyKey, 'status' => 'error', 'error' => 'La cuota no pertenece a este crédito.'];
                    $errorCount++;

                    continue;
                }

                try {
                    $visit = CollectionVisit::create([
                        'company_id' => $user->company_id,
                        'credit_id' => $creditId,
                        'installment_id' => $installmentId,
                        'collector_user_id' => $capturer->id,
                        'visit_type' => $visitData['visit_type'],
                        'reason' => $visitData['reason'] ?? null,
                        'notes' => $visitData['notes'] ?? null,
                        'promised_date' => $visitData['promised_date'] ?? null,
                        'promised_amount' => $visitData['promised_amount'] ?? null,
                        'visit_date' => $visitData['visit_date'],
                        'visit_time' => $visitData['visit_time'] ?? null,
                        'latitude' => $visitData['latitude'] ?? null,
                        'longitude' => $visitData['longitude'] ?? null,
                        'device_id' => $visitData['device_id'] ?? null,
                        'idempotency_key' => $idempotencyKey,
                    ]);
                } catch (UniqueConstraintViolationException $e) {
                    // Carrera de idempotencia: otro request guardó esta visita entre
                    // el SELECT de duplicados y el INSERT. Es un duplicado, no un error.
                    $existing = CollectionVisit::query()
                        ->where('company_id', $user->company_id)
                        ->where('idempotency_key', $idempotencyKey)
                        ->first();

                    if (! $existing) {
                        throw $e;
                    }

                    $results[] = [
                        'index' => $position,
                        'idempotency_key' => $idempotencyKey,
                        'status' => 'duplicate',
                        'visit_id' => $existing->id,
                        'message' => 'Visita ya registrada anteriormente.',
                    ];
                    $duplicateCount++;

                    continue;
                }

                if ($capturer->id !== $user->id) {
                    // Rastro de quién subió lo que se atribuyó a otro (solo ids).
                    Log::info('PWA Sync: visita atribuida a otro usuario', [
                        'visit_id' => $visit->id,
                        'captured_by_user_id' => $capturer->id,
                        'synced_by_user_id' => $user->id,
                    ]);
                }

                $results[] = [
                    'index' => $position,
                    'idempotency_key' => $idempotencyKey,
                    'status' => 'success',
                    'visit_id' => $visit->id,
                ];
                $successCount++;

            } catch (\Throwable $e) {
                // Sin getMessage(): el de un INSERT fallido trae el SQL con las
                // notas, la promesa y el GPS.
                Log::error('PWA Sync: Visit error', [
                    'idempotency_key' => $idempotencyKey,
                    'exception' => get_class($e),
                    'code' => $e->getCode(),
                    'user_id' => $user->id,
                ]);

                $results[] = [
                    'index' => $position,
                    'idempotency_key' => $idempotencyKey,
                    'status' => 'error',
                    'error' => 'Error interno al procesar la visita.',
                ];
                $errorCount++;
            }
        }

        return response()->json([
            'results' => $results,
            'summary' => [
                'total' => count($visitsData),
                'success' => $successCount,
                'duplicates' => $duplicateCount,
                'errors' => $errorCount,
            ],
            'synced_at' => now()->toIso8601String(),
        ]);
    }

    /**
     * POST /api/pwa/sync/expenses
     *
     * Gastos offline en batch. Mismo patrón que visitas: validación por ítem,
     * conversión del modo simplificado y atribución al capturador. Un gasto
     * capturado por OTRO usuario del teléfono queda siempre pendiente de aprobación.
     *
     * Por ítem (`index` = posición en el lote recibido):
     *   success / duplicate → { index, idempotency_key, status: "ok",    expense_id }
     *   error               → { index, idempotency_key, status: "error", message }
     */
    public function syncExpenses(Request $request): JsonResponse
    {
        $request->validate([
            'expenses' => ['present', 'array', 'max:100'],
            'expenses.*' => ['array'],
        ]);

        $user = $request->user();
        $companyId = $user->company_id;
        $settings = CompanyFinancialSettings::forCompany($companyId);

        $expensesData = array_values($request->expenses);
        $results = [];
        $processedCount = 0;
        $failedCount = 0;

        foreach ($expensesData as $position => $expenseData) {
            $idempotencyKey = $expenseData['idempotency_key'] ?? null;

            try {
                if (! is_string($idempotencyKey) || ! Str::isUuid($idempotencyKey)) {
                    $results[] = ['index' => $position, 'idempotency_key' => $idempotencyKey, 'status' => 'error', 'message' => 'Clave de idempotencia inválida.'];
                    $failedCount++;

                    continue;
                }

                $existing = Expense::withoutGlobalScopes()
                    ->where('company_id', $companyId)
                    ->where('idempotency_key', $idempotencyKey)
                    ->first();

                if ($existing) {
                    $results[] = ['index' => $position, 'idempotency_key' => $idempotencyKey, 'status' => 'ok', 'expense_id' => $existing->id];
                    $processedCount++;

                    continue;
                }

                $capturer = $this->capturerResolver->resolve($expenseData['captured_by_user_id'] ?? null, $user);
                if (! $capturer) {
                    $results[] = ['index' => $position, 'idempotency_key' => $idempotencyKey, 'status' => 'error', 'message' => 'El usuario que registró el gasto no pertenece a la empresa.'];
                    $failedCount++;

                    continue;
                }

                $expenseData = SimplifiedAmount::toReal($expenseData, 'amount', $settings);

                // `date`: como ExpenseController::store() (no futura), con un día de
                // margen por la zona horaria del teléfono.
                $validator = Validator::make($expenseData, [
                    'amount' => ['required', 'numeric', 'min:0.01', 'max:9999999.99'],
                    'category' => ['required', 'string', Rule::in(self::EXPENSE_ALLOWED_CATEGORIES)],
                    'notes' => ['nullable', 'string', 'max:1000'],
                    'date' => ['nullable', 'date', 'before_or_equal:'.Carbon::tomorrow()->toDateString()],
                ], [], self::EXPENSE_ATTRIBUTES);

                if ($validator->fails()) {
                    $results[] = ['index' => $position, 'idempotency_key' => $idempotencyKey, 'status' => 'error', 'message' => $validator->errors()->first()];
                    $failedCount++;

                    continue;
                }

                $opDate = ! empty($expenseData['date'])
                    ? Carbon::parse($expenseData['date'])->toDateString()
                    : Carbon::today()->toDateString();

                try {
                    // Cada gasto en su propia transacción: un fallo no cancela el resto del batch.
                    $expense = DB::transaction(function () use ($expenseData, $user, $capturer, $companyId, $idempotencyKey, $opDate) {
                        $expense = $this->financeLogger->logExpense([
                            'company_id' => $companyId,
                            'user_id' => $capturer->id,
                            'amount' => (float) $expenseData['amount'],
                            'category' => $expenseData['category'],
                            'operation_date' => $opDate,
                            'notes' => $expenseData['notes'] ?? null,
                            'idempotency_key' => $idempotencyKey,
                            // Quién lo SUBIÓ; `user_id` es quién lo capturó.
                            'metadata' => ['synced_by_user_id' => $user->id, 'source' => 'pwa_sync'],
                        ]);

                        if ($capturer->id === $user->id) {
                            // Mismo flujo que ExpenseController::store(): admin → aprobado; resto → pendiente.
                            $expense->applyApprovalPolicyForCreator($user);
                        } else {
                            $expense->markPendingApproval();
                        }
                        $expense->save();

                        return $expense;
                    });
                } catch (UniqueConstraintViolationException $e) {
                    // Carrera de idempotencia (índice único company_id + clave): otro
                    // request guardó este gasto entre el SELECT y el INSERT.
                    $existing = Expense::withoutGlobalScopes()
                        ->where('company_id', $companyId)
                        ->where('idempotency_key', $idempotencyKey)
                        ->first();

                    if (! $existing) {
                        throw $e;
                    }

                    $results[] = ['index' => $position, 'idempotency_key' => $idempotencyKey, 'status' => 'ok', 'expense_id' => $existing->id];
                    $processedCount++;

                    continue;
                }

                $results[] = ['index' => $position, 'idempotency_key' => $idempotencyKey, 'status' => 'ok', 'expense_id' => $expense->id];
                $processedCount++;

                Log::info('PWA Sync: Expense synced', [
                    'expense_id' => $expense->id,
                    'idempotency_key' => $idempotencyKey,
                    'amount' => $expenseData['amount'],
                    'user_id' => $user->id,
                    'captured_by_user_id' => $capturer->id,
                ]);

            } catch (\Throwable $e) {
                // Sin getMessage(): el de un INSERT fallido trae el SQL con el
                // monto y las notas del gasto.
                Log::error('PWA Sync: Expense error', [
                    'idempotency_key' => $idempotencyKey,
                    'exception' => get_class($e),
                    'code' => $e->getCode(),
                    'user_id' => $user->id,
                ]);

                $results[] = [
                    'index' => $position,
                    'idempotency_key' => $idempotencyKey,
                    'status' => 'error',
                    'message' => 'Error interno al procesar el gasto.',
                ];
                $failedCount++;
            }
        }

        return response()->json([
            'processed' => $processedCount,
            'failed' => $failedCount,
            'results' => $results,
            'synced_at' => now()->toIso8601String(),
        ]);
    }

    /**
     * Créditos activos sobre los que un usuario puede registrar visitas.
     *
     * @return Collection<int, int> ids como claves (para has())
     */
    private function accessibleCreditIds(User $user): Collection
    {
        $query = Credit::query()
            ->where('company_id', $user->company_id)
            ->whereIn('status', Credit::ACTIVE_STATUSES);

        if ($this->pwaRoleFor($user) === 'collector') {
            $query->where('collector_user_id', $user->id);
        }

        return $query->pluck('id')->flip();
    }
}
