<?php

declare(strict_types=1);

namespace App\Service\Leave;

use App\Entity\Absence;
use App\Entity\ContractSuspension;
use App\Entity\Employee;
use App\Enum\LeaveRuleCode;
use App\Repository\AbsenceRepository;
use App\Repository\EmployeeContractPeriodRepository;
use App\Repository\EmployeeLeaveBalanceRepository;
use App\Service\PublicHolidayServiceInterface;
use DateTimeImmutable;
use Throwable;

final readonly class LeaveBalanceComputationService
{
    private const int FORFAIT_TARGET_WORKED_DAYS            = 218;
    private const float STANDARD_ANNUAL_DAYS                = 25.0;
    private const float STANDARD_ANNUAL_SATURDAYS           = 5.0;
    private const float STANDARD_ACCRUAL_PER_MONTH          = self::STANDARD_ANNUAL_DAYS / 12.0;
    private const float STANDARD_SATURDAY_ACCRUAL_PER_MONTH = self::STANDARD_ANNUAL_SATURDAYS / 12.0;
    private const float FOUR_HOUR_ANNUAL_DAYS               = 10.0;
    private const float FOUR_HOUR_ACCRUAL_PER_MONTH         = 0.83;
    private const float LONG_MALADIE_MONTHLY_ACCRUAL        = 2.0;

    public function __construct(
        private AbsenceRepository $absenceRepository,
        private EmployeeContractPeriodRepository $periodRepository,
        private EmployeeLeaveBalanceRepository $leaveBalanceRepository,
        private PublicHolidayServiceInterface $publicHolidayService,
        private SuspensionDaysCalculator $suspensionDaysCalculator,
        private LongMaladieService $longMaladieService,
    ) {}

    /**
     * @return array{float, float}
     */
    public function computeDynamicClosingForYear(Employee $employee, LeaveRuleCode $ruleCode, int $targetYear): array
    {
        $firstYear = $this->resolveFirstComputationYear($employee, $ruleCode, $targetYear);
        if ($targetYear < $firstYear) {
            return [0.0, 0.0];
        }

        $previousRemainingDays      = 0.0;
        $previousRemainingSaturdays = 0.0;

        for ($year = $firstYear; $year <= $targetYear; ++$year) {
            [$from, $to] = $this->resolvePeriodBounds($ruleCode, $year);

            // Bootstrap anchor: a manually-entered opening balance (production data
            // bootstrap) is the source of truth for the carry of that year — exactly
            // like EmployeeLeaveSummaryProvider::computeYearSummary for the live view.
            // Without it, the closing would be recomputed from the contract start with no
            // historical absences, inflating the carry by one full year of accrual for
            // every exercise predating the software (cas Aurore : 88 au lieu de 31).
            $openingBalance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);

            $carryDays      = 0.0;
            $carrySaturdays = 0.0;
            if (null !== $openingBalance) {
                $carryDays      = $openingBalance->getOpeningDays();
                $carrySaturdays = LeaveRuleCode::CDI_CDD_NON_FORFAIT === $ruleCode ? $openingBalance->getOpeningSaturdays() : 0.0;
            } elseif ($year > $firstYear) {
                [$previousFrom, $previousTo] = $this->resolvePeriodBounds($ruleCode, $year - 1);
                $hasSettlementOnPreviousYear = $this->periodRepository->hasPaidLeaveSettledClosureBetween($employee, $previousFrom, $previousTo);
                if (!$hasSettlementOnPreviousYear) {
                    $carryDays      = $previousRemainingDays;
                    $carrySaturdays = LeaveRuleCode::CDI_CDD_NON_FORFAIT === $ruleCode ? $previousRemainingSaturdays : 0.0;
                }
            }

            $effectiveFrom = $this->resolveEffectivePeriodStart($employee, $from, $to);
            // A shifted start (new hire / settled closure) zeroes the dynamic carry, but
            // an explicit bootstrap opening balance must be preserved (it already reflects
            // the real situation at the bootstrap date).
            if ($effectiveFrom > $from && null === $openingBalance) {
                $carryDays      = 0.0;
                $carrySaturdays = 0.0;
            }

            $fractionedDays = $this->resolveFractionedDays($employee, $ruleCode, $year);

            if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
                // Business days for forfait must use the RAW holiday list (excluded holidays
                // like "Lundi de Pentecôte" / journée de solidarité still count as non-working
                // days for the 218-day legal target).
                $totalBusinessDays = $this->countBusinessDaysInRange($from, $to, $this->buildRawPublicHolidayMap($from, $to));
                $baseAcquiredDays  = (float) max(0, $totalBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS);
                $acquiredDays      = $carryDays + $baseAcquiredDays + $fractionedDays;
                $absences          = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
                [$takenDays]       = $this->computeTakenAbsences($absences, $effectiveFrom, $to, false, false);
                if (null !== $openingBalance) {
                    $takenDays += $openingBalance->getTakenDays();
                }
                $previousRemainingDays      = max(0.0, $acquiredDays - $takenDays);
                $previousRemainingSaturdays = 0.0;

                continue;
            }

            $suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace(
                $this->resolveSuspensionsForEmployeePeriod($employee, $from, $to)
            );

            $longMaladiePeriods         = [];
            $longMaladieReductionFactor = 1.0;
            if (4 !== $employee->getContract()?->getWeeklyHours()) {
                $longMaladiePeriods = $this->longMaladieService->findReducedRatePeriods($employee, $effectiveFrom, $to);
                if ([] !== $longMaladiePeriods) {
                    $totalNormalAccrual         = $this->resolveDaysAccrualPerMonth($employee) + $this->resolveSaturdayAccrualPerMonth($employee);
                    $longMaladieReductionFactor = self::LONG_MALADIE_MONTHLY_ACCRUAL / $totalNormalAccrual;
                }
            }

            $generatedDays = $this->computeAccruedDays(
                $this->resolveAnnualDays($employee),
                $this->resolveDaysAccrualPerMonth($employee),
                $effectiveFrom,
                $to,
                $suspensions,
                $longMaladiePeriods,
                $longMaladieReductionFactor
            );
            $generatedSaturdays = $this->computeAccruedDays(
                $this->resolveAnnualSaturdays($employee),
                $this->resolveSaturdayAccrualPerMonth($employee),
                $effectiveFrom,
                $to,
                $suspensions,
                $longMaladiePeriods,
                $longMaladieReductionFactor
            );

            $absences                     = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
            [$takenDays, $takenSaturdays] = $this->computeTakenAbsences($absences, $effectiveFrom, $to, true, true);
            if (null !== $openingBalance) {
                $takenDays += $openingBalance->getTakenDays();
                $takenSaturdays += $openingBalance->getTakenSaturdays();
            }

            $acquiredWithFractioned = $carryDays + $fractionedDays;
            $takenFromAcquired      = min(max(0.0, $acquiredWithFractioned), $takenDays);
            $remainingAcquired      = $acquiredWithFractioned - $takenFromAcquired;
            $remainingToImpute      = max(0.0, $takenDays - $takenFromAcquired);
            $remainingGenerated     = $generatedDays - $remainingToImpute;

            $takenFromAcquiredSaturdays  = min(max(0.0, $carrySaturdays), $takenSaturdays);
            $remainingAcquiredSaturdays  = $carrySaturdays - $takenFromAcquiredSaturdays;
            $remainingSaturdaysToImpute  = max(0.0, $takenSaturdays - $takenFromAcquiredSaturdays);
            $remainingGeneratedSaturdays = $generatedSaturdays - $remainingSaturdaysToImpute;

            $previousRemainingDays      = $remainingAcquired + $remainingGenerated;
            $previousRemainingSaturdays = $remainingAcquiredSaturdays + $remainingGeneratedSaturdays;
        }

        return [$previousRemainingDays, $previousRemainingSaturdays];
    }

    /**
     * @return array{DateTimeImmutable, DateTimeImmutable}
     */
    public function resolvePeriodBounds(LeaveRuleCode $ruleCode, int $year): array
    {
        if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
            return [
                new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year)),
                new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year)),
            ];
        }

        return [
            new DateTimeImmutable(sprintf('%d-06-01 00:00:00', $year - 1)),
            new DateTimeImmutable(sprintf('%d-05-31 00:00:00', $year)),
        ];
    }

    public function hasPaidLeaveSettledClosureBetween(
        Employee $employee,
        DateTimeImmutable $from,
        DateTimeImmutable $to
    ): bool {
        return $this->periodRepository->hasPaidLeaveSettledClosureBetween($employee, $from, $to);
    }

    private function resolveFirstComputationYear(Employee $employee, LeaveRuleCode $ruleCode, int $fallbackYear): int
    {
        $history = $employee->getContractHistory();
        if ([] === $history) {
            return $fallbackYear;
        }

        $oldestStartDate = null;
        foreach ($history as $item) {
            $start = $this->parseYmdDate($item->startDate);
            if (!$start) {
                continue;
            }
            if (null === $oldestStartDate || $start < $oldestStartDate) {
                $oldestStartDate = $start;
            }
        }

        if (null === $oldestStartDate) {
            return $fallbackYear;
        }

        if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
            return (int) $oldestStartDate->format('Y');
        }

        $startYear  = (int) $oldestStartDate->format('Y');
        $startMonth = (int) $oldestStartDate->format('n');

        return $startMonth >= 6 ? $startYear + 1 : $startYear;
    }

    private function resolveEffectivePeriodStart(
        Employee $employee,
        DateTimeImmutable $from,
        DateTimeImmutable $to
    ): DateTimeImmutable {
        $latestSettledClosure = $this->periodRepository->findLatestPaidLeaveSettledClosureDateBetween($employee, $from, $to);
        $start                = $from;
        if (null !== $latestSettledClosure) {
            $nextDay = $latestSettledClosure->modify('+1 day');
            if ($nextDay > $start) {
                $start = $nextDay;
            }
        }

        $earliestContractStart = $this->resolveEarliestContractStartWithinRange($employee, $from, $to);
        if (null !== $earliestContractStart && $earliestContractStart > $start) {
            $start = $earliestContractStart;
        }

        return $start;
    }

    private function resolveEarliestContractStartWithinRange(
        Employee $employee,
        DateTimeImmutable $from,
        DateTimeImmutable $to
    ): ?DateTimeImmutable {
        $earliest = null;
        foreach ($employee->getContractHistory() as $period) {
            $start = $this->parseYmdDate($period->startDate);
            if (!$start) {
                continue;
            }

            $end = null;
            if (null !== $period->endDate && '' !== trim($period->endDate)) {
                $end = $this->parseYmdDate($period->endDate);
            }

            if ($start > $to) {
                continue;
            }
            if ($end instanceof DateTimeImmutable && $end < $from) {
                continue;
            }

            $candidate = $start < $from ? $from : $start;
            if (null === $earliest || $candidate < $earliest) {
                $earliest = $candidate;
            }
        }

        return $earliest;
    }

    private function resolveFractionedDays(Employee $employee, LeaveRuleCode $ruleCode, int $year): float
    {
        $balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);

        return null !== $balance ? $balance->getFractionedDays() : 0.0;
    }

    private function resolveAnnualDays(Employee $employee): float
    {
        return 4 === $employee->getContract()?->getWeeklyHours()
            ? self::FOUR_HOUR_ANNUAL_DAYS
            : self::STANDARD_ANNUAL_DAYS;
    }

    private function resolveAnnualSaturdays(Employee $employee): float
    {
        return 4 === $employee->getContract()?->getWeeklyHours()
            ? 0.0
            : self::STANDARD_ANNUAL_SATURDAYS;
    }

    private function resolveDaysAccrualPerMonth(Employee $employee): float
    {
        return 4 === $employee->getContract()?->getWeeklyHours()
            ? self::FOUR_HOUR_ACCRUAL_PER_MONTH
            : self::STANDARD_ACCRUAL_PER_MONTH;
    }

    private function resolveSaturdayAccrualPerMonth(Employee $employee): float
    {
        return 4 === $employee->getContract()?->getWeeklyHours()
            ? 0.0
            : self::STANDARD_SATURDAY_ACCRUAL_PER_MONTH;
    }

    /**
     * @param list<ContractSuspension>                                      $suspensions
     * @param list<array{start: DateTimeImmutable, end: DateTimeImmutable}> $longMaladiePeriods
     */
    private function computeAccruedDays(
        float $annualCap,
        float $accrualPerMonth,
        DateTimeImmutable $periodStart,
        DateTimeImmutable $periodEnd,
        array $suspensions = [],
        array $longMaladiePeriods = [],
        float $longMaladieReductionFactor = 1.0
    ): float {
        if ($accrualPerMonth <= 0.0 || $periodEnd < $periodStart) {
            return 0.0;
        }

        $periodStart    = $this->normalizeDate($periodStart);
        $periodEnd      = $this->normalizeDate($periodEnd);
        $publicHolidays = [] !== $suspensions ? $this->buildPublicHolidayMap($periodStart, $periodEnd) : [];
        $normalMonths   = 0.0;
        $reducedMonths  = 0.0;
        $cursor         = $periodStart->modify('first day of this month')->setTime(0, 0);
        while ($cursor <= $periodEnd) {
            $monthStart = $cursor > $periodStart ? $cursor : $periodStart;
            $monthEnd   = $cursor->modify('last day of this month')->setTime(0, 0);
            if ($monthEnd > $periodEnd) {
                $monthEnd = $periodEnd;
            }

            if ([] !== $suspensions) {
                $suspendedDays = $this->suspensionDaysCalculator->countSuspendedDaysInMonth($monthStart, $monthEnd, $suspensions);
                if ($suspendedDays > 0) {
                    $businessDays          = $this->countBusinessDaysInRange($monthStart, $monthEnd, $publicHolidays);
                    $suspendedBusinessDays = $this->suspensionDaysCalculator->countSuspendedBusinessDays($monthStart, $monthEnd, $suspensions, $publicHolidays);
                    $normalMonths += max(0, $businessDays - $suspendedBusinessDays) / 22.0;
                    $cursor = $cursor->modify('first day of next month');

                    continue;
                }
            }

            $coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
            $daysInMonth = (int) $cursor->format('t');

            if ([] !== $longMaladiePeriods) {
                $reducedDays = $this->longMaladieService->countReducedDaysInMonth($monthStart, $monthEnd, $longMaladiePeriods);
                if ($reducedDays > 0) {
                    $normalDays = max(0, $coveredDays - $reducedDays);
                    $normalMonths += $normalDays / $daysInMonth;
                    $reducedMonths += min($coveredDays, $reducedDays) / $daysInMonth;
                    $cursor = $cursor->modify('first day of next month');

                    continue;
                }
            }

            $normalMonths += $coveredDays / $daysInMonth;

            $cursor = $cursor->modify('first day of next month');
        }

        return min($annualCap, ($normalMonths + $reducedMonths * $longMaladieReductionFactor) * $accrualPerMonth);
    }

    private function parseYmdDate(string $value): ?DateTimeImmutable
    {
        $date = DateTimeImmutable::createFromFormat('!Y-m-d', trim($value));

        return $date instanceof DateTimeImmutable ? $date : null;
    }

    private function normalizeDate(DateTimeImmutable $date): DateTimeImmutable
    {
        return $date->setTime(0, 0);
    }

    private function countBusinessDays(DateTimeImmutable $from, DateTimeImmutable $to): int
    {
        return $this->countBusinessDaysInRange($from, $to, $this->buildPublicHolidayMap($from, $to));
    }

    /**
     * @param array<string, string> $publicHolidays pre-built map
     */
    private function countBusinessDaysInRange(DateTimeImmutable $from, DateTimeImmutable $to, array $publicHolidays): int
    {
        $count = 0;
        for ($cursor = $from; $cursor <= $to; $cursor = $cursor->modify('+1 day')) {
            $weekDay = (int) $cursor->format('N');
            $dayKey  = $cursor->format('Y-m-d');
            if ($weekDay <= 5 && !isset($publicHolidays[$dayKey])) {
                ++$count;
            }
        }

        return $count;
    }

    /**
     * @return array<string, string>
     */
    private function buildPublicHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
    {
        $map       = [];
        $startYear = (int) $from->format('Y');
        $endYear   = (int) $to->format('Y');

        try {
            for ($year = $startYear; $year <= $endYear; ++$year) {
                $holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year);
                foreach ($holidays as $date => $label) {
                    $map[(string) $date] = (string) $label;
                }
            }
        } catch (Throwable) {
            return [];
        }

        return $map;
    }

    /**
     * @return array<string, string>
     */
    private function buildRawPublicHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
    {
        $map       = [];
        $startYear = (int) $from->format('Y');
        $endYear   = (int) $to->format('Y');

        try {
            for ($year = $startYear; $year <= $endYear; ++$year) {
                $holidays = $this->publicHolidayService->getRawHolidaysDayByYears('metropole', (string) $year);
                foreach ($holidays as $date => $label) {
                    $map[(string) $date] = (string) $label;
                }
            }
        } catch (Throwable) {
            return [];
        }

        return $map;
    }

    /**
     * @param list<Absence> $absences
     *
     * @return array{float, float}
     */
    private function computeTakenAbsences(
        array $absences,
        DateTimeImmutable $from,
        DateTimeImmutable $to,
        bool $countOnlyCp,
        bool $splitSaturdays
    ): array {
        $takenDays      = 0.0;
        $takenSaturdays = 0.0;

        foreach ($absences as $absence) {
            if ($countOnlyCp) {
                $typeCode = strtoupper((string) $absence->getType()?->getCode());
                if ('C' !== $typeCode) {
                    continue;
                }
            }

            if (null === $absence->getType()) {
                continue;
            }

            $start      = DateTimeImmutable::createFromInterface($absence->getStartDate());
            $end        = DateTimeImmutable::createFromInterface($absence->getEndDate());
            $rangeStart = $start < $from ? $from : $start;
            $rangeEnd   = $end > $to ? $to : $end;
            if ($rangeEnd < $rangeStart) {
                continue;
            }

            for ($cursor = $rangeStart; $cursor <= $rangeEnd; $cursor = $cursor->modify('+1 day')) {
                $dayOfWeek = (int) $cursor->format('N');

                if ($splitSaturdays) {
                    if (7 === $dayOfWeek) {
                        continue;
                    }
                } else {
                    if ($dayOfWeek >= 6) {
                        continue;
                    }
                }

                [$am, $pm] = $this->resolveSegmentsForDate($absence, $cursor->format('Y-m-d'));
                $dayAmount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0);
                if ($dayAmount <= 0.0) {
                    continue;
                }

                if ($splitSaturdays && 6 === $dayOfWeek) {
                    $takenSaturdays += $dayAmount;
                } else {
                    $takenDays += $dayAmount;
                }
            }
        }

        return [$takenDays, $takenSaturdays];
    }

    /**
     * @return list<ContractSuspension>
     */
    private function resolveSuspensionsForEmployeePeriod(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array
    {
        $suspensions = [];
        foreach ($employee->getContractPeriods() as $period) {
            $periodStart = $period->getStartDate();
            $periodEnd   = $period->getEndDate();

            if ($periodStart > $to) {
                continue;
            }
            if ($periodEnd instanceof DateTimeImmutable && $periodEnd < $from) {
                continue;
            }

            foreach ($period->getSuspensions() as $suspension) {
                $suspensions[] = $suspension;
            }
        }

        return $suspensions;
    }

    /**
     * @return array{bool, bool}
     */
    private function resolveSegmentsForDate(Absence $absence, string $date): array
    {
        $startYmd  = DateTimeImmutable::createFromInterface($absence->getStartDate())->format('Y-m-d');
        $endYmd    = DateTimeImmutable::createFromInterface($absence->getEndDate())->format('Y-m-d');
        $startHalf = $absence->getStartHalf()->value;
        $endHalf   = $absence->getEndHalf()->value;

        $isSingleDay = $startYmd === $endYmd;
        $isStartDay  = $date === $startYmd;
        $isEndDay    = $date === $endYmd;

        if ($isSingleDay) {
            return ['AM' === $startHalf, 'PM' === $endHalf];
        }
        if ($isStartDay) {
            return ['AM' === $startHalf, true];
        }
        if ($isEndDay) {
            return [true, 'PM' === $endHalf];
        }

        return [true, true];
    }
}
