<?php

declare(strict_types=1);

namespace App\Services;

use Carbon\Carbon;
use Exception;
use InvalidArgumentException;

/**
 * Servicio para cálculo de fechas de vencimiento de cuotas.
 *
 * IMPORTANTE: Este servicio usa ISO-8601 para días de la semana:
 * - 1 = Lunes (Monday)
 * - 2 = Martes (Tuesday)
 * - 3 = Miércoles (Wednesday)
 * - 4 = Jueves (Thursday)
 * - 5 = Viernes (Friday)
 * - 6 = Sábado (Saturday)
 * - 7 = Domingo (Sunday)
 *
 * Para monthly/biweekly, day1/day2 representan días del mes (1-31).
 */
class DueDateService
{
    /**
     * Calcula la primera fecha de vencimiento.
     *
     * @param  Carbon  $start  Fecha de inicio del crédito
     * @param  string  $periodicity  Periodicidad: daily, weekly, monthly, biweekly
     * @param  int|null  $day1  Para weekly: día de semana ISO (1-7). Para monthly/biweekly: día del mes (1-31)
     * @param  int|null  $day2  Para biweekly: segundo día del mes (1-31)
     */
    public function firstDueDate(
        Carbon $start,
        string $periodicity,
        ?int $day1,
        ?int $day2 = null
    ): Carbon {
        return match ($periodicity) {
            'daily' => $start->copy()->addDay()->startOfDay(),
            'weekly' => $this->resolveWeeklyFirst($start, $day1),
            'monthly' => $this->resolveMonthly($start, $day1),
            'biweekly' => $this->resolveBiweeklyFirst($start, $day1, $day2),
            default => throw new Exception("Periodicidad '{$periodicity}' inválida."),
        };
    }

    /**
     * Calcula la siguiente fecha de vencimiento.
     *
     * @param  Carbon  $lastDate  Última fecha de vencimiento
     * @param  string  $periodicity  Periodicidad: daily, weekly, monthly, biweekly
     * @param  int|null  $day1  Para weekly: día de semana ISO (1-7). Para monthly/biweekly: día del mes (1-31)
     * @param  int|null  $day2  Para biweekly: segundo día del mes (1-31)
     */
    public function nextDueDate(
        Carbon $lastDate,
        string $periodicity,
        ?int $day1,
        ?int $day2 = null
    ): Carbon {
        return match ($periodicity) {
            'daily' => $lastDate->copy()->addDay()->startOfDay(),
            'weekly' => $this->resolveWeeklyNext($lastDate, $day1),
            'monthly' => $this->resolveMonthlyNext($lastDate, $day1),
            'biweekly' => $this->resolveBiweeklyNext($lastDate, $day1, $day2),
            default => throw new Exception("Periodicidad '{$periodicity}' inválida."),
        };
    }

    /*=========================================================
    | WEEKLY - Usa dayOfWeekIso (ISO-8601: 1=Mon, 7=Sun)
    =========================================================*/

    /**
     * Resuelve la primera fecha semanal basada en día de semana ISO-8601.
     *
     * @param  Carbon  $start  Fecha de inicio
     * @param  int|null  $dayOfWeekIso  Día de la semana ISO (1=Lun, 7=Dom)
     */
    private function resolveWeeklyFirst(Carbon $start, ?int $dayOfWeekIso): Carbon
    {
        // Si no hay día especificado, usar una semana después
        if ($dayOfWeekIso === null || $dayOfWeekIso < 1 || $dayOfWeekIso > 7) {
            return $start->copy()->addWeek()->startOfDay();
        }

        $date = $start->copy()->startOfDay();

        // Avanzar hasta encontrar el día de semana correcto
        // Si hoy es el día indicado, avanzar una semana completa
        // para que la primera cuota no sea el mismo día del desembolso
        while ($date->dayOfWeekIso !== $dayOfWeekIso || $date->isSameDay($start)) {
            $date->addDay();
        }

        return $date;
    }

    /**
     * Resuelve la siguiente fecha semanal (exactamente 7 días después).
     *
     * Mantiene el weekday correcto al sumar exactamente una semana.
     */
    private function resolveWeeklyNext(Carbon $lastDate, ?int $dayOfWeekIso): Carbon
    {
        $next = $lastDate->copy()->addWeek()->startOfDay();

        // Verificación de integridad: el día de semana debe coincidir
        if ($dayOfWeekIso !== null && $next->dayOfWeekIso !== $dayOfWeekIso) {
            // Log warning: esto indica un problema en datos previos
            report(new InvalidArgumentException(sprintf(
                'DueDateService: Inconsistencia de weekday. Esperado ISO %d, obtenido %d. Fecha: %s',
                $dayOfWeekIso,
                $next->dayOfWeekIso,
                $next->toDateString()
            )));
        }

        return $next;
    }

    /*=========================================================
    | MONTHLY - Día del mes (1-31)
    =========================================================*/

    /**
     * Resuelve la primera fecha mensual.
     *
     * @param  Carbon  $start  Fecha de inicio
     * @param  int|null  $dayOfMonth  Día del mes (1-31)
     */
    private function resolveMonthly(Carbon $start, ?int $dayOfMonth): Carbon
    {
        $day = $this->normalizeMonthDay($dayOfMonth);
        $candidate = $start->copy();

        // Intentar en el mes actual
        $candidate = $this->setDaySafely($candidate, $day);

        // Si la fecha candidata ya pasó o es igual a start, ir al siguiente mes
        if ($candidate->lte($start)) {
            $candidate = $this->setDaySafely($start->copy()->addMonthNoOverflow(), $day);
        }

        return $candidate->startOfDay();
    }

    /**
     * Resuelve la siguiente fecha mensual.
     */
    private function resolveMonthlyNext(Carbon $date, ?int $dayOfMonth): Carbon
    {
        $day = $this->normalizeMonthDay($dayOfMonth);

        return $this->setDaySafely($date->copy()->addMonthNoOverflow(), $day)->startOfDay();
    }

    /*=========================================================
    | BIWEEKLY - Dos días del mes (quincenal)
    =========================================================*/

    /**
     * Resuelve la primera fecha quincenal.
     */
    private function resolveBiweeklyFirst(Carbon $start, ?int $d1, ?int $d2): Carbon
    {
        $day1 = $this->normalizeMonthDay($d1);
        $day2 = $this->normalizeMonthDay($d2 ?? $day1);

        // Asegurar que day1 < day2 para lógica consistente
        if ($day1 > $day2) {
            [$day1, $day2] = [$day2, $day1];
        }

        $date1 = $this->setDaySafely($start->copy(), $day1);
        $date2 = $this->setDaySafely($start->copy(), $day2);

        // Buscar la primera fecha futura
        if ($date1->gt($start)) {
            return $date1->startOfDay();
        }

        if ($date2->gt($start)) {
            return $date2->startOfDay();
        }

        // Ambas fechas pasaron, ir al día1 del próximo mes
        return $this->setDaySafely($start->copy()->addMonthNoOverflow(), $day1)->startOfDay();
    }

    /**
     * Resuelve la siguiente fecha quincenal.
     */
    private function resolveBiweeklyNext(Carbon $current, ?int $d1, ?int $d2): Carbon
    {
        $day1 = $this->normalizeMonthDay($d1);
        $day2 = $this->normalizeMonthDay($d2 ?? $day1);

        // Asegurar que day1 < day2
        if ($day1 > $day2) {
            [$day1, $day2] = [$day2, $day1];
        }

        $currentDay = $current->day;

        // Si estamos en day1, siguiente es day2 del mismo mes
        if ($currentDay === $day1 && $day1 !== $day2) {
            return $this->setDaySafely($current->copy(), $day2)->startOfDay();
        }

        // Si estamos en day2 (o day1 == day2), siguiente es day1 del próximo mes
        return $this->setDaySafely($current->copy()->addMonthNoOverflow(), $day1)->startOfDay();
    }

    /*=========================================================
    | HELPERS
    =========================================================*/

    /**
     * Normaliza el día del mes a un valor válido (1-28).
     * Usa 28 como máximo para evitar problemas con febrero.
     */
    private function normalizeMonthDay(?int $day): int
    {
        if ($day === null || $day < 1) {
            return 1;
        }

        // Limitar a 28 para evitar problemas con meses cortos
        return min(28, $day);
    }

    /**
     * Establece el día del mes de forma segura.
     * Si el día no existe en el mes, usa el último día del mes.
     */
    private function setDaySafely(Carbon $date, int $day): Carbon
    {
        $maxDay = $date->daysInMonth;

        return $date->day(min($day, $maxDay));
    }

    /*=========================================================
    | VALIDACIÓN DE WEEKDAY (para uso externo)
    =========================================================*/

    /**
     * Valida que una fecha tenga el día de semana esperado (ISO-8601).
     *
     * @param  Carbon  $date  Fecha a validar
     * @param  int  $expectedDayOfWeekIso  Día esperado (1=Lun, 7=Dom)
     */
    public function validateWeekday(Carbon $date, int $expectedDayOfWeekIso): bool
    {
        return $date->dayOfWeekIso === $expectedDayOfWeekIso;
    }

    /**
     * Obtiene el nombre del día de semana en español.
     */
    public function getWeekdayNameIso(int $dayOfWeekIso): string
    {
        return match ($dayOfWeekIso) {
            1 => 'Lunes',
            2 => 'Martes',
            3 => 'Miércoles',
            4 => 'Jueves',
            5 => 'Viernes',
            6 => 'Sábado',
            7 => 'Domingo',
            default => 'Desconocido',
        };
    }
}
