<?php

declare(strict_types=1);

namespace App\Models;

use App\Traits\MultiTenantScope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;

/**
 * Cobro de campo que el lote offline no aplicó: llegó tarde, apunta a un crédito
 * que cambió, supera el saldo, lo capturó otro usuario del mismo teléfono o el
 * cobrador lo mandó a revisión. Un admin lo aprueba (se registra el pago) o lo
 * rechaza. Nunca se borra: es el rastro de plata que el cliente sí entregó.
 *
 * @property int $id
 * @property int $company_id
 * @property string $idempotency_key
 * @property int|null $credit_id
 * @property int|null $captured_by_user_id
 * @property int|null $synced_by_user_id
 * @property string|null $amount
 * @property string|null $payment_method
 * @property Carbon|null $payment_date
 * @property Carbon|null $offline_created_at
 * @property string|null $device_id
 * @property string|null $latitude
 * @property string|null $longitude
 * @property string $reason
 * @property string|null $reason_detail
 * @property array<string, mixed> $payload
 * @property string $status
 * @property int|null $resolved_by_user_id
 * @property Carbon|null $resolved_at
 * @property string|null $resolution_notes
 * @property int|null $payment_id
 * @property-read Company $company
 * @property-read Credit|null $credit
 * @property-read User|null $capturedBy Null si se borró el usuario (nullOnDelete).
 * @property-read User|null $syncedBy
 * @property-read User|null $resolvedBy
 * @property-read Payment|null $payment
 */
class HeldPayment extends Model
{
    use MultiTenantScope;

    public const STATUS_PENDING = 'pending';

    public const STATUS_APPROVED = 'approved';

    public const STATUS_REJECTED = 'rejected';

    public const REASON_MANUAL = 'manual';

    public const REASON_FOREIGN_USER = 'foreign_user';

    public const REASON_INVALID = 'invalid';

    public const REASON_FUTURE_DATE = 'future_date';

    public const REASON_STALE = 'stale';

    public const REASON_CREDIT_UNAVAILABLE = 'credit_unavailable';

    public const REASON_EXCEEDS_BALANCE = 'exceeds_balance';

    public const REASON_POSSIBLE_DUPLICATE = 'possible_duplicate';

    /** Todos los motivos, en el orden en que se ofrecen como filtro. */
    public const REASONS = [
        self::REASON_MANUAL,
        self::REASON_FOREIGN_USER,
        self::REASON_INVALID,
        self::REASON_FUTURE_DATE,
        self::REASON_STALE,
        self::REASON_CREDIT_UNAVAILABLE,
        self::REASON_EXCEEDS_BALANCE,
        self::REASON_POSSIBLE_DUPLICATE,
    ];

    /**
     * Se retiene un cobro cuya fecha de cobro o de captura es anterior a hoy
     * menos estos días, a las 00:00 (por calendario: con 7, el cobro de hace
     * exactamente 7 días todavía se aplica y el de hace 8 no). No es la misma
     * cuenta que StorePaymentRequest, que mide 7 días hacia atrás desde ahora.
     */
    public const STALE_AFTER_DAYS = 7;

    /** Ventana, en días a cada lado de la fecha de cobro, para buscar posibles duplicados. */
    public const DUPLICATE_WINDOW_DAYS = 7;

    /**
     * Un ítem que llega con más de estas horas en la cola también se retiene si
     * hay un pago igual de otro día de la ventana, cargado después de capturarlo
     * (ver PaymentSyncService::backlogDuplicate()).
     */
    public const BACKLOG_AFTER_HOURS = 24;

    protected $fillable = [
        'company_id',
        'idempotency_key',
        'credit_id',
        'captured_by_user_id',
        'synced_by_user_id',
        'amount',
        'payment_method',
        'payment_date',
        'offline_created_at',
        'device_id',
        'latitude',
        'longitude',
        'reason',
        'reason_detail',
        'payload',
        'status',
        'resolved_by_user_id',
        'resolved_at',
        'resolution_notes',
        'payment_id',
    ];

    protected $casts = [
        'amount' => 'decimal:2',
        'payment_date' => 'date',
        'offline_created_at' => 'datetime',
        'resolved_at' => 'datetime',
        'payload' => 'array',
        'latitude' => 'decimal:7',
        'longitude' => 'decimal:7',
    ];

    public static function reasonLabel(string $reason): string
    {
        return match ($reason) {
            self::REASON_MANUAL => 'Enviado a revisión por el cobrador',
            self::REASON_FOREIGN_USER => 'Capturado por otro usuario del teléfono',
            // No siempre son datos ilegibles: también un capturador que no es de la
            // empresa, con todo lo demás completo. El detalle va en reason_detail.
            self::REASON_INVALID => 'No se puede aplicar tal como llegó',
            self::REASON_FUTURE_DATE => 'Fecha de cobro futura',
            self::REASON_STALE => 'Llegó con más de '.self::STALE_AFTER_DAYS.' días de atraso',
            self::REASON_CREDIT_UNAVAILABLE => 'El crédito ya no admite el pago',
            self::REASON_EXCEEDS_BALANCE => 'Supera el saldo del crédito',
            self::REASON_POSSIBLE_DUPLICATE => 'Posible duplicado de un pago ya registrado',
            default => $reason,
        };
    }

    public static function statusLabel(string $status): string
    {
        return match ($status) {
            self::STATUS_PENDING => 'Pendiente',
            self::STATUS_APPROVED => 'Aprobado',
            self::STATUS_REJECTED => 'Rechazado',
            default => $status,
        };
    }

    public static function paymentMethodLabel(string $method): string
    {
        return match ($method) {
            'cash' => 'Efectivo',
            'transfer' => 'Transferencia',
            'mobile' => 'Pago móvil',
            default => $method,
        };
    }

    /**
     * El capturador que dijo el teléfono, con el mismo criterio estricto que
     * CapturerResolver. Null si no lo mandó o si no es un id válido.
     */
    public function reportedCapturerId(): ?int
    {
        return self::strictId($this->payloadValue('captured_by_user_id'));
    }

    /**
     * El crédito que dijo el teléfono. credit_id solo se enlaza si el crédito es
     * de la empresa; el id que llegó sobrevive aquí.
     */
    public function reportedCreditId(): ?int
    {
        return self::strictId($this->payloadValue('credit_id'));
    }

    /**
     * Si quedó a nombre de quien el teléfono dijo que cobró. Cuando el lote no
     * reconoce al capturador, hold() lo guarda a nombre de quien SUBE: ese nombre
     * no es el del cobrador. Sin dato del teléfono (ítems viejos), vale el guardado.
     * Un valor que no es un id (texto, bool) no confirma nada.
     */
    public function capturerIsConfirmed(): bool
    {
        $raw = $this->payloadValue('captured_by_user_id');
        if ($raw === null || $raw === '') {
            return true;
        }

        $reported = $this->reportedCapturerId();

        return $reported !== null
            && $this->captured_by_user_id !== null
            && $reported === (int) $this->captured_by_user_id;
    }

    private function payloadValue(string $key): mixed
    {
        return $this->payload[$key] ?? null;
    }

    /** Entero ≥ 1. filter_var() castea un bool a 0/1 antes de validar: se descarta aparte. */
    private static function strictId(mixed $value): ?int
    {
        if ($value === null || $value === '' || is_bool($value)) {
            return null;
        }

        $id = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);

        return $id === false ? null : $id;
    }

    /** @return BelongsTo<Company, $this> */
    public function company(): BelongsTo
    {
        return $this->belongsTo(Company::class);
    }

    /** @return BelongsTo<Credit, $this> */
    public function credit(): BelongsTo
    {
        return $this->belongsTo(Credit::class);
    }

    /** @return BelongsTo<User, $this> */
    public function capturedBy(): BelongsTo
    {
        return $this->belongsTo(User::class, 'captured_by_user_id');
    }

    /** @return BelongsTo<User, $this> */
    public function syncedBy(): BelongsTo
    {
        return $this->belongsTo(User::class, 'synced_by_user_id');
    }

    /** @return BelongsTo<User, $this> */
    public function resolvedBy(): BelongsTo
    {
        return $this->belongsTo(User::class, 'resolved_by_user_id');
    }

    /** @return BelongsTo<Payment, $this> */
    public function payment(): BelongsTo
    {
        return $this->belongsTo(Payment::class);
    }

    /**
     * @param  Builder<HeldPayment>  $query
     * @return Builder<HeldPayment>
     */
    public function scopePending(Builder $query): Builder
    {
        return $query->where('status', self::STATUS_PENDING);
    }

    /**
     * Pagos vigentes del mismo crédito, por el mismo monto, cerca de la fecha de
     * cobro. Es la pista para el caso típico: el cobrador vio que la cola no
     * bajaba y volvió a cargar el pago a mano, con otra clave.
     *
     * @return Collection<int, Payment>
     */
    public function possibleDuplicates(): Collection
    {
        if ($this->credit_id === null || $this->amount === null || $this->payment_date === null) {
            return new Collection;
        }

        return Payment::query()
            ->where('company_id', $this->company_id)
            ->where('credit_id', $this->credit_id)
            ->collections()
            ->where('amount', $this->amount)
            ->whereBetween('payment_date', [
                $this->payment_date->copy()->subDays(self::DUPLICATE_WINDOW_DAYS)->toDateString(),
                $this->payment_date->copy()->addDays(self::DUPLICATE_WINDOW_DAYS)->toDateString(),
            ])
            ->when($this->payment_id !== null, fn ($q) => $q->where('id', '!=', $this->payment_id))
            ->orderBy('payment_date')
            ->get();
    }
}
