<?php

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\EmployeeLeaveSummary;
use App\Dto\Contracts\ContractPhase;
use App\Entity\Absence;
use App\Entity\ContractSuspension;
use App\Entity\Employee;
use App\Entity\User;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\LeaveRuleCode;
use App\Repository\AbsenceRepository;
use App\Repository\EmployeeContractPeriodRepository;
use App\Repository\EmployeeLeaveBalanceRepository;
use App\Repository\EmployeeRepository;
use App\Repository\WorkHourRepository;
use App\Security\EmployeeScopeService;
use App\Service\Contracts\EmployeeContractPhaseResolver;
use App\Service\Exercise\ExerciseYearResolver;
use App\Service\Leave\LeaveBalanceComputationService;
use App\Service\Leave\LongMaladieService;
use App\Service\Leave\SuspensionDaysCalculator;
use App\Service\PublicHolidayServiceInterface;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Throwable;

final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
{
    private const int FORFAIT_TARGET_WORKED_DAYS                            = 218;
    private const int FORFAIT_STANDARD_CP_DAYS                              = 25;
    private const float CDI_NON_FORFAIT_STANDARD_ACQUIRED_DAYS              = 25.0;
    private const float CDI_NON_FORFAIT_STANDARD_ACQUIRED_SATURDAYS         = 5.0;
    private const float CDI_NON_FORFAIT_STANDARD_ACCRUAL_PER_MONTH          = self::CDI_NON_FORFAIT_STANDARD_ACQUIRED_DAYS / 12.0;
    private const float CDI_NON_FORFAIT_STANDARD_SATURDAY_ACCRUAL_PER_MONTH = self::CDI_NON_FORFAIT_STANDARD_ACQUIRED_SATURDAYS / 12.0;
    private const float CDI_NON_FORFAIT_4H_ACQUIRED_DAYS                    = 10.0;
    private const float CDI_NON_FORFAIT_4H_ACQUIRED_SATURDAYS               = 0.0;
    private const float CDI_NON_FORFAIT_4H_ACCRUAL_PER_MONTH                = 0.83;
    private const float CDI_NON_FORFAIT_4H_SATURDAY_ACCRUAL_PER_MONTH       = 0.0;
    private const float LONG_MALADIE_MONTHLY_ACCRUAL                        = 2.0;

    private ?string $dataStartDate;

    public function __construct(
        private Security $security,
        private RequestStack $requestStack,
        private EmployeeRepository $employeeRepository,
        private EmployeeScopeService $employeeScopeService,
        private AbsenceRepository $absenceRepository,
        private EmployeeContractPeriodRepository $periodRepository,
        private EmployeeLeaveBalanceRepository $leaveBalanceRepository,
        private LeaveBalanceComputationService $leaveBalanceComputationService,
        private LongMaladieService $longMaladieService,
        private PublicHolidayServiceInterface $publicHolidayService,
        private SuspensionDaysCalculator $suspensionDaysCalculator,
        private WorkHourRepository $workHourRepository,
        private EmployeeContractPhaseResolver $phaseResolver,
        private ExerciseYearResolver $exerciseYearResolver,
        string $dataStartDate = '',
    ) {
        $this->dataStartDate = '' !== $dataStartDate ? $dataStartDate : null;
    }

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeLeaveSummary
    {
        $user = $this->security->getUser();
        if (!$user instanceof User) {
            throw new AccessDeniedHttpException('Authentication required.');
        }

        $employeeId = (int) ($uriVariables['id'] ?? 0);
        if ($employeeId <= 0) {
            throw new UnprocessableEntityHttpException('id must be a positive integer.');
        }

        $employee = $this->employeeRepository->find($employeeId);
        if (!$employee instanceof Employee) {
            throw new NotFoundHttpException('Employee not found.');
        }

        if (!$this->employeeScopeService->canAccessEmployee($user, $employee)) {
            throw new AccessDeniedHttpException('Employee outside your scope.');
        }

        $phase = $this->resolveTargetPhase($employee);
        $year  = $this->resolveYear($employee, $phase);

        $summary                = new EmployeeLeaveSummary();
        $summary->year          = $year;
        $summary->ruleCode      = LeaveRuleCode::UNSUPPORTED->value;
        $summary->dataStartDate = $this->dataStartDate;

        $yearSummary = $this->computeYearSummary($employee, $year, 0.0, null, $phase);
        if (null === $yearSummary) {
            return $summary;
        }

        $fractionedDays = $this->resolveFractionedDays($employee, $yearSummary['ruleCode'], $year);
        $paidLeaveDays  = $this->resolvePaidLeaveDays($employee, $yearSummary['ruleCode'], $year);

        // For forfait contracts, paid days reduce N-1 stock before taken-day attribution.
        // Recompute with paidLeaveDays so taken days shift from N-1 to N when N-1 is consumed by payment.
        if ($paidLeaveDays > 0.0) {
            $yearSummary = $this->computeYearSummary($employee, $year, $paidLeaveDays, null, $phase);
            if (null === $yearSummary) {
                return $summary;
            }
        }

        $summary->isSupported               = true;
        $summary->ruleCode                  = $yearSummary['ruleCode'];
        $summary->acquiredDays              = $yearSummary['acquiredDays'] + $fractionedDays;
        $summary->acquiredSaturdays         = $yearSummary['acquiredSaturdays'];
        $summary->fractionedDays            = $fractionedDays;
        $summary->accruingDays              = $yearSummary['accruingDays'];
        $summary->accruingDaysTotal         = $yearSummary['accruingDaysTotal'];
        $summary->takenDays                 = $yearSummary['takenDays'];
        $summary->takenSaturdays            = $yearSummary['takenSaturdays'];
        $summary->remainingDays             = $yearSummary['remainingDays'] + $fractionedDays;
        $summary->remainingSaturdays        = $yearSummary['remainingSaturdays'];
        $summary->previousYearAcquiredDays  = $yearSummary['previousYearAcquiredDays'];
        $summary->previousYearTakenDays     = $yearSummary['previousYearTakenDays'];
        $summary->previousYearRemainingDays = $yearSummary['previousYearRemainingDays'];
        $summary->previousYearPaidDays      = $paidLeaveDays;

        [$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year, $phase);

        // Forfait : jours à travailler sur l'exercice.
        // Année pleine → cible contractuelle 218 ; les bonus week-end/férié et les jours
        // fractionnés sont des congés EN PLUS, ils ne réduisent pas la cible. Entrée en cours
        // d'année → jours ouvrés de la période − congés acquis de l'entrée (repos proratisés +
        // CP reportés), via yearSummary['acquiredDays'] (hors fractionnés/bonus). Ex. Grégory : 168 − 13 ≈ 155.
        if (LeaveRuleCode::FORFAIT_218->value === $summary->ruleCode) {
            $businessDaysInPeriod           = $this->countBusinessDays($periodFrom, $periodTo, $this->buildRawPublicHolidayMap($periodFrom, $periodTo));
            $summary->forfaitWorkTargetDays = $this->computeForfaitWorkTargetDays(
                $businessDaysInPeriod,
                $this->isForfaitEntryYear($phase, $year),
                $yearSummary['acquiredDays'],
            );
        }

        // La présence est bornée au début de contrat de l'employé : on ne compte pas comme
        // « présents » les jours ouvrés antérieurs à l'embauche (cas d'une entrée en cours
        // d'exercice, ex. CDD). Sans effet pour un employé présent depuis avant l'exercice,
        // ni pour le forfait (déjà capé au début de phase).
        $presenceFrom = $this->resolveEarliestContractStartWithinRange($employee, $periodFrom, $periodTo) ?? $periodFrom;

        // Forfait-only: leaves taken from N-1 stock do NOT decrement presence days.
        // For non-forfait, previousYearTakenDays is always 0, so the budget has no effect.
        $n1AbsencesBudget             = $yearSummary['previousYearTakenDays'];
        $summary->presenceDaysByMonth = $this->computePresenceDaysByMonth(
            $employee,
            $presenceFrom,
            $periodTo,
            $n1AbsencesBudget
        );

        // Same logic as presenceDaysByMonth but bounded at today: number of presence days
        // accumulated from contract start up to today (inclusive).
        $today                        = new DateTimeImmutable('today');
        $cappedTo                     = $today < $periodTo ? $today : $periodTo;
        $summary->presenceDaysToToday = $today < $presenceFrom
            ? 0.0
            : array_sum($this->computePresenceDaysByMonth(
                $employee,
                $presenceFrom,
                $cappedTo,
                $n1AbsencesBudget
            ));

        return $summary;
    }

    /**
     * @return null|array{
     *   ruleCode: string,
     *   acquiredDays: float,
     *   acquiredSaturdays: float,
     *   accruingDays: float,
     *   accruingDaysTotal: float,
     *   takenDays: float,
     *   takenSaturdays: float,
     *   remainingDays: float,
     *   remainingSaturdays: float,
     *   previousYearAcquiredDays: float,
     *   previousYearTakenDays: float,
     *   previousYearRemainingDays: float
     * }
     */
    public function computeYearSummary(Employee $employee, int $targetYear, float $paidLeaveDays = 0.0, ?DateTimeImmutable $asOfDate = null, ?ContractPhase $phase = null): ?array
    {
        // Track whether a phase was provided explicitly. When the caller supplies $phase,
        // we apply the phase-end cap on period bounds. When we fall back to resolveCurrentPhase
        // (legacy callers without phase awareness, e.g. LeaveRecapRowBuilder), we preserve
        // the pre-phase-cap behavior to avoid changing observable results for terminated
        // employees (the resolved fallback phase would otherwise unduly cap `to`).
        $applyPhaseEndCap = null !== $phase;
        $phase ??= $this->resolveCurrentPhase($employee);
        if (null === $phase) {
            return null;
        }

        $firstYear = max($this->resolveFirstComputationYear($employee, $phase), $targetYear - 1);
        if ($targetYear < $firstYear) {
            $targetYear = $firstYear;
        }

        $previousRemainingDays      = 0.0;
        $previousRemainingSaturdays = 0.0;
        $targetSummary              = null;

        for ($year = $firstYear; $year <= $targetYear; ++$year) {
            [$from, $to] = $this->resolvePeriodBounds($employee, $year, $phase, $applyPhaseEndCap);
            $leavePolicy = $this->resolveLeavePolicy($employee, $phase, $from, $to);
            if (null === $leavePolicy) {
                if ($year === $targetYear) {
                    return null;
                }

                continue;
            }

            $carryDays      = 0.0;
            $carrySaturdays = 0.0;
            $openingBalance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear(
                $employee,
                $leavePolicy['ruleCode'],
                $year
            );
            if (null !== $openingBalance) {
                $carryDays      = $openingBalance->getOpeningDays();
                $carrySaturdays = $leavePolicy['splitSaturdays'] ? $openingBalance->getOpeningSaturdays() : 0.0;
            } elseif ($year > $firstYear) {
                $ruleCode                     = LeaveRuleCode::from($leavePolicy['ruleCode']);
                [$carryDays, $carrySaturdays] = $this->leaveBalanceComputationService
                    ->computeDynamicClosingForYear($employee, $ruleCode, $year - 1)
                ;
                [$previousFrom, $previousTo] = $this->leaveBalanceComputationService->resolvePeriodBounds($ruleCode, $year - 1);
                $hasSettlement               = $this->leaveBalanceComputationService
                    ->hasPaidLeaveSettledClosureBetween($employee, $previousFrom, $previousTo)
                ;
                if ($hasSettlement) {
                    $carryDays      = 0.0;
                    $carrySaturdays = 0.0;
                } elseif (!$leavePolicy['splitSaturdays']) {
                    $carrySaturdays = 0.0;
                }
            }

            $effectiveFrom   = $this->resolveEffectivePeriodStart($employee, $from, $to);
            $hasShiftedStart = $effectiveFrom > $from;
            if ($hasShiftedStart && null === $openingBalance) {
                $carryDays      = 0.0;
                $carrySaturdays = 0.0;
            }

            $effectiveAsOfDate     = ($year === $targetYear) ? $asOfDate : null;
            $accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee, $phase, $effectiveAsOfDate, $applyPhaseEndCap);
            $takenCalculationEnd   = $this->resolveTakenCalculationEndDate($to, $employee, $phase, $effectiveAsOfDate, $applyPhaseEndCap);
            $suspensions           = $this->suspensionDaysCalculator->applyFirstMonthGrace(
                $this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to)
            );

            $longMaladiePeriods         = [];
            $longMaladieReductionFactor = 1.0;
            if (LeaveRuleCode::CDI_CDD_NON_FORFAIT->value === $leavePolicy['ruleCode']
                && 4 !== $phase->weeklyHours
                && null !== $accrualCalculationEnd
            ) {
                $longMaladiePeriods = $this->longMaladieService->findReducedRatePeriods($employee, $effectiveFrom, $accrualCalculationEnd);
                if ([] !== $longMaladiePeriods) {
                    $totalNormalAccrual         = $leavePolicy['accrualPerMonth'] + $leavePolicy['saturdayAccrualPerMonth'];
                    $longMaladieReductionFactor = self::LONG_MALADIE_MONTHLY_ACCRUAL / $totalNormalAccrual;
                }
            }

            $generatedDays = $leavePolicy['accrualPerMonth'] > 0.0
                ? $this->computeAccruedDaysFromStart(
                    $leavePolicy['acquiredDays'],
                    $leavePolicy['accrualPerMonth'],
                    $effectiveFrom,
                    $accrualCalculationEnd,
                    $suspensions,
                    $longMaladiePeriods,
                    $longMaladieReductionFactor
                )
                : 0.0;
            $generatedSaturdays = $leavePolicy['saturdayAccrualPerMonth'] > 0.0
                ? $this->computeAccruedDaysFromStart(
                    $leavePolicy['acquiredSaturdays'],
                    $leavePolicy['saturdayAccrualPerMonth'],
                    $effectiveFrom,
                    $accrualCalculationEnd,
                    $suspensions,
                    $longMaladiePeriods,
                    $longMaladieReductionFactor
                )
                : 0.0;
            $absences                     = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
            [$takenDays, $takenSaturdays] = $this->computeTakenAbsences(
                $absences,
                $effectiveFrom,
                $takenCalculationEnd,
                $leavePolicy['countOnlyCp'],
                $leavePolicy['splitSaturdays']
            );

            // Bootstrap support: if the opening balance has pre-filled taken days
            // (e.g. manual data entry for production bootstrap), add them as an offset.
            if (null !== $openingBalance) {
                $takenDays += $openingBalance->getTakenDays();
                $takenSaturdays += $openingBalance->getTakenSaturdays();
            }
            $previousYearAcquired  = 0.0;
            $previousYearTaken     = 0.0;
            $previousYearRemaining = 0.0;

            if (LeaveRuleCode::CDI_CDD_NON_FORFAIT->value === $leavePolicy['ruleCode']) {
                $availableAcquired  = max(0.0, $carryDays);
                $takenFromAcquired  = min($availableAcquired, $takenDays);
                $remainingAcquired  = $carryDays - $takenFromAcquired;
                $remainingToImpute  = max(0.0, $takenDays - $takenFromAcquired);
                $remainingGenerated = $generatedDays - $remainingToImpute;

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

                $acquiredDays = $carryDays;
                $accruingDays = $remainingGenerated + $remainingGeneratedSaturdays;
                // Brut généré à ce jour, AVANT imputation des congés pris en anticipé
                // (dénominateur de l'affichage « net / brut » sur l'onglet Congés).
                $accruingDaysTotal  = $generatedDays + $generatedSaturdays;
                $remainingDays      = $remainingAcquired;
                $acquiredSaturdays  = $carrySaturdays;
                $remainingSaturdays = max(0.0, $remainingAcquiredSaturdays);

                $previousRemainingDays      = $remainingAcquired + $remainingGenerated;
                $previousRemainingSaturdays = $remainingAcquiredSaturdays + $remainingGeneratedSaturdays;
            } else {
                // Forfait: no "en cours d'acquisition" counter, all rights are in acquired.
                // Suspensions do not impact forfait 218 leave calculation.
                // Paid days reduce N-1 stock first, then taken days are attributed to what remains in N-1.
                $previousYearAcquired  = $carryDays;
                $effectivePaidDays     = ($year === $targetYear) ? $paidLeaveDays : 0.0;
                $availableAfterPayment = max(0.0, $previousYearAcquired - $effectivePaidDays);
                $takenFromPrevious     = min($availableAfterPayment, $takenDays);
                $previousYearTaken     = $takenFromPrevious;
                $takenFromCurrent      = $takenDays - $takenFromPrevious;

                $previousYearRemaining = max(0.0, $availableAfterPayment - $takenFromPrevious);

                $acquiredDays       = $leavePolicy['acquiredDays'];
                $accruingDays       = 0.0;
                $accruingDaysTotal  = 0.0;
                $remainingDays      = max(0.0, $acquiredDays - $takenFromCurrent);
                $acquiredSaturdays  = 0.0;
                $remainingSaturdays = 0.0;

                $previousRemainingDays      = $previousYearRemaining + $remainingDays;
                $previousRemainingSaturdays = 0.0;
            }

            if ($year === $targetYear) {
                $targetSummary = [
                    'ruleCode'                  => $leavePolicy['ruleCode'],
                    'acquiredDays'              => $acquiredDays,
                    'acquiredSaturdays'         => $acquiredSaturdays,
                    'accruingDays'              => $accruingDays,
                    'accruingDaysTotal'         => $accruingDaysTotal,
                    'takenDays'                 => $takenDays,
                    'takenSaturdays'            => $takenSaturdays,
                    'remainingDays'             => $remainingDays,
                    'remainingSaturdays'        => $remainingSaturdays,
                    'previousYearAcquiredDays'  => $previousYearAcquired,
                    'previousYearTakenDays'     => $previousYearTaken,
                    'previousYearRemainingDays' => $previousYearRemaining,
                ];
            }
        }

        return $targetSummary;
    }

    public function resolveLeaveYearForToday(Employee $employee): int
    {
        $today = new DateTimeImmutable('today');
        if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
            return (int) $today->format('Y');
        }

        return $this->resolveCurrentLeaveYear($today);
    }

    public function resolvePaidLeaveDays(Employee $employee, string $ruleCode, int $year): float
    {
        $balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);

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

    /**
     * Budget N-1 = nombre de jours de congé pris imputés sur le stock de l'année précédente,
     * pour l'exercice de l'année donnée. Reproduit exactement la dérivation de provide()
     * (phase courante + recalcul avec les jours payés) afin que les consommateurs externes
     * (ex. récap salaire) voient le même budget que la fiche employé. 0 si non supporté.
     */
    public function resolvePreviousYearTakenDays(Employee $employee, int $year): float
    {
        $phase = $this->resolveCurrentPhase($employee);
        if (null === $phase) {
            return 0.0;
        }

        $summary = $this->computeYearSummary($employee, $year, 0.0, null, $phase);
        if (null === $summary) {
            return 0.0;
        }

        $paidLeaveDays = $this->resolvePaidLeaveDays($employee, $summary['ruleCode'], $year);
        if ($paidLeaveDays > 0.0) {
            $summary = $this->computeYearSummary($employee, $year, $paidLeaveDays, null, $phase);
            if (null === $summary) {
                return 0.0;
            }
        }

        return (float) $summary['previousYearTakenDays'];
    }

    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;
    }

    /**
     * Début de contrat le plus ancien chevauchant [$from, $to], capé à $from.
     *
     * NB : ne tient pas compte des trous entre deux périodes de contrat à l'intérieur de
     * l'intervalle (une période qui chevauche $from fixe l'ancre à $from même s'il existe
     * un trou ensuite). Suffisant pour borner la présence au début d'emploi ; un employé
     * avec un trou de contrat intra-exercice verrait les jours du trou comptés en présence.
     */
    private function resolveEarliestContractStartWithinRange(
        Employee $employee,
        DateTimeImmutable $from,
        DateTimeImmutable $to
    ): ?DateTimeImmutable {
        $earliest = null;
        foreach ($employee->getContractHistory() as $period) {
            $start = $this->parseYmdDate($period->startDate);
            if (!$start instanceof DateTimeImmutable) {
                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 resolveYear(Employee $employee, ContractPhase $phase): int
    {
        $isForfait       = ContractType::FORFAIT === $phase->contractType;
        $raw             = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? '');
        $phaseIdRaw      = $this->requestStack->getCurrentRequest()?->query->get('phaseId');
        $phaseIdProvided = null !== $phaseIdRaw && '' !== (string) $phaseIdRaw;

        if ('' === $raw) {
            // When a phaseId is explicitly provided, default to the year derived from
            // the phase's end date (or today if the phase is still current).
            if ($phaseIdProvided) {
                $reference = $phase->endDate ?? new DateTimeImmutable('today');

                return $isForfait
                    ? (int) $reference->format('Y')
                    : $this->resolveCurrentLeaveYear($reference);
            }

            $today = new DateTimeImmutable('today');

            return $isForfait
                ? (int) $today->format('Y')
                : $this->resolveCurrentLeaveYear($today);
        }

        if (!preg_match('/^\d{4}$/', $raw)) {
            throw new UnprocessableEntityHttpException('year must use YYYY format.');
        }

        $year = (int) $raw;
        if ($year < 2000 || $year > 2100) {
            throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.');
        }

        // When a phaseId is explicit, silently clamp the requested year to the
        // first/last exercise covered by the phase.
        if ($phaseIdProvided) {
            $year = $this->clampYearToPhase($year, $phase, $isForfait);
        }

        return $year;
    }

    private function clampYearToPhase(int $year, ContractPhase $phase, bool $isForfait): int
    {
        $firstYear = $this->exerciseYearResolver->forDate($phase->startDate, $isForfait);
        $lastYear  = $phase->endDate instanceof DateTimeImmutable
            ? $this->exerciseYearResolver->forDate($phase->endDate, $isForfait)
            : null;

        if ($year < $firstYear) {
            return $firstYear;
        }
        if (null !== $lastYear && $year > $lastYear) {
            return $lastYear;
        }

        return $year;
    }

    private function resolveTargetPhase(Employee $employee): ContractPhase
    {
        $raw    = $this->requestStack->getCurrentRequest()?->query->get('phaseId');
        $phases = $this->phaseResolver->resolvePhases($employee);
        if ([] === $phases) {
            throw new UnprocessableEntityHttpException('Employee has no contract phase.');
        }

        if (null === $raw || '' === (string) $raw) {
            // Phase courante par défaut = celle marquée isCurrent ou, à défaut, la plus récente.
            foreach ($phases as $phase) {
                if ($phase->isCurrent) {
                    return $phase;
                }
            }

            return $phases[0];
        }

        if (!preg_match('/^\d+$/', (string) $raw)) {
            throw new UnprocessableEntityHttpException('phaseId must be a positive integer.');
        }
        $phaseId = (int) $raw;
        foreach ($phases as $phase) {
            if ($phase->id === $phaseId) {
                return $phase;
            }
        }

        throw new UnprocessableEntityHttpException('phaseId does not match any phase of this employee.');
    }

    private function resolveCurrentPhase(Employee $employee): ?ContractPhase
    {
        $phases = $this->phaseResolver->resolvePhases($employee);
        if ([] === $phases) {
            return null;
        }
        foreach ($phases as $phase) {
            if ($phase->isCurrent) {
                return $phase;
            }
        }

        return $phases[0];
    }

    /**
     * Phase dont la date de début est la plus proche en deçà de celle de $phase
     * (la phase qui précède immédiatement). Null si $phase est la première.
     */
    private function resolvePhaseImmediatelyBefore(Employee $employee, ContractPhase $phase): ?ContractPhase
    {
        $prior = null;
        foreach ($this->phaseResolver->resolvePhases($employee) as $candidate) {
            if ($candidate->startDate >= $phase->startDate) {
                continue;
            }
            if (null === $prior || $candidate->startDate > $prior->startDate) {
                $prior = $candidate;
            }
        }

        return $prior;
    }

    /**
     * CP à reporter d'une phase non-forfait vers une entrée en FORFAIT : jours ouvrés
     * NETS (acquis + en cours d'acquisition − jours ouvrés posés) + samedis BRUTS (acquis,
     * sans déduction des samedis posés). Retourne 0 si aucune phase précédente (nouvel
     * embauché → cas 2) ou si la précédente est elle-même un FORFAIT (ré-embauche / double
     * transition forfait — pas de report CP non-forfait à reprendre).
     *
     * Composition du retour (clés de computeYearSummary, branche CDI_CDD_NON_FORFAIT) :
     *   remainingDays      : acquis (report N-1) restant après jours ouvrés posés
     *   accruingDays       : généré de l'exercice restant, NET des jours posés en débordement
     *                        (= remainingGenerated + remainingGeneratedSaturdays)
     *   remainingSaturdays : samedis acquis (report N-1) restants
     *   + takenSaturdays   : ré-ajout des samedis posés (règle métier ci-dessous). Invariant :
     *                        comme accruingDays a déjà déduit les samedis posés en débordement,
     *                        ce ré-ajout laisse le solde samedi BRUT (généré + acquis), pas net.
     *
     * Règle (validée comptable) : seuls les congés en JOURS OUVRÉS déjà posés réduisent
     * le report ; les SAMEDIS déjà posés ne le réduisent pas. computeYearSummary déduit
     * tous les congés posés (samedis inclus), d'où le ré-ajout de takenSaturdays.
     * Ex. Grégory : 12 acquis − 5 jours ouvrés posés = 7 (le samedi posé reste crédité).
     *
     * Les jours fractionnés (fractionedDays, ajustement manuel ajouté par provide() à
     * l'affichage) sont volontairement EXCLUS : on ne reporte que le solde CP acquis/généré
     * de la phase précédente, pas les bonus de fractionnement.
     */
    private function resolveCarriedCpFromPriorPhase(Employee $employee, ContractPhase $forfaitPhase): float
    {
        $prior = $this->resolvePhaseImmediatelyBefore($employee, $forfaitPhase);
        if (null === $prior || ContractType::FORFAIT === $prior->contractType) {
            return 0.0;
        }

        $reference = $prior->endDate ?? new DateTimeImmutable('today');
        $priorYear = $this->exerciseYearResolver->forDate($reference, false);

        $summary = $this->computeYearSummary($employee, $priorYear, 0.0, null, $prior);
        if (null === $summary) {
            return 0.0;
        }

        return $summary['remainingDays']
            + $summary['accruingDays']
            + $summary['remainingSaturdays']
            + $summary['takenSaturdays'];
    }

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

        if (!$periodEnd instanceof DateTimeImmutable || $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->countBusinessDays($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($acquiredDays, ($normalMonths + $reducedMonths * $longMaladieReductionFactor) * $accrualPerMonth);
    }

    private function resolveAccrualCalculationEndDate(
        string $ruleCode,
        int $year,
        DateTimeImmutable $periodEnd,
        Employee $employee,
        ContractPhase $phase,
        ?DateTimeImmutable $asOfDate = null,
        bool $applyPhaseEndCap = true
    ): ?DateTimeImmutable {
        $reference   = $asOfDate ?? new DateTimeImmutable('today');
        $currentYear = LeaveRuleCode::FORFAIT_218->value === $ruleCode
            ? (int) $reference->format('Y')
            : $this->resolveCurrentLeaveYear($reference);

        // When viewing a closed phase explicitly, treat its end date as the reference cutoff:
        // accrual is bounded to the phase end, never running to "today".
        // Legacy callers (no explicit phase) skip this cap to preserve pre-phase behavior.
        if ($applyPhaseEndCap && !$phase->isCurrent && null !== $phase->endDate) {
            $end = $phase->endDate < $periodEnd ? $phase->endDate : $periodEnd;
        } elseif ($year < $currentYear) {
            $end = $periodEnd;
        } elseif ($year > $currentYear) {
            $end = null;
        } else {
            $lastDayPreviousMonth = $reference
                ->modify('first day of this month')
                ->modify('-1 day')
                ->setTime(0, 0)
            ;
            $end = $lastDayPreviousMonth < $periodEnd ? $lastDayPreviousMonth : $periodEnd;
        }

        // Cap at contract end date if the employee has left (only meaningful when
        // viewing the current phase; closed phases are already capped above).
        // Legacy callers (no explicit phase) always evaluate this branch to mimic
        // the pre-phase behavior, which relied on getCurrentContractEndDate().
        if (!$applyPhaseEndCap || $phase->isCurrent) {
            $contractEndRaw = $employee->getCurrentContractEndDate();
            if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
                $contractEnd = $this->parseYmdDate($contractEndRaw);
                if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
                    $end = $contractEnd;
                }
            }
        }

        return $end;
    }

    private function resolveTakenCalculationEndDate(
        DateTimeImmutable $periodEnd,
        Employee $employee,
        ContractPhase $phase,
        ?DateTimeImmutable $asOfDate = null,
        bool $applyPhaseEndCap = true
    ): ?DateTimeImmutable {
        $end = $periodEnd;

        if ($asOfDate instanceof DateTimeImmutable && $asOfDate < $end) {
            $end = $asOfDate;
        }

        // Closed phase: cap taken-absence accounting at the phase end.
        // Skip for legacy callers (no explicit phase) to preserve pre-phase behavior.
        if ($applyPhaseEndCap && !$phase->isCurrent && null !== $phase->endDate && $phase->endDate < $end) {
            $end = $phase->endDate;
        }

        // Legacy callers (no explicit phase) always use the live contract end date,
        // mirroring the pre-phase implementation.
        if (!$applyPhaseEndCap || $phase->isCurrent) {
            $contractEndRaw = $employee->getCurrentContractEndDate();
            if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
                $contractEnd = $this->parseYmdDate($contractEndRaw);
                if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
                    $end = $contractEnd;
                }
            }
        }

        return $end;
    }

    /**
     * @return null|array{
     *   ruleCode: string,
     *   acquiredDays: float,
     *   acquiredSaturdays: float,
     *   accrualPerMonth: float,
     *   saturdayAccrualPerMonth: float,
     *   countOnlyCp: bool,
     *   splitSaturdays: bool
     * }
     */
    private function resolveLeavePolicy(Employee $employee, ContractPhase $phase, DateTimeImmutable $from, DateTimeImmutable $to): ?array
    {
        $type = $phase->contractType;
        if (ContractType::FORFAIT === $type) {
            $year = (int) $from->format('Y'); // période forfait = année civile

            // Entrée en FORFAIT en cours d'année : repos proratisés + CP nets reportés de
            // la phase précédente, au lieu de max(0, businessDays − 218) qui donnerait 0.
            if ($this->isForfaitEntryYear($phase, $year)) {
                $yearStart       = new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year));
                $yearEnd         = new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year));
                $rawYearHolidays = $this->buildRawPublicHolidayMap($yearStart, $yearEnd);

                $businessDaysYear   = $this->countBusinessDays($yearStart, $yearEnd, $rawYearHolidays);
                $businessDaysPeriod = $this->countBusinessDays($from, $to, $rawYearHolidays);

                $repoDays  = $this->computeProratedForfaitRepoDays($businessDaysYear, $businessDaysPeriod);
                $carriedCp = $this->resolveCarriedCpFromPriorPhase($employee, $phase);

                // NB : le bonus week-end/férié travaillé (bonusDays du chemin année pleine)
                // n'est volontairement PAS ajouté ici. L'acquis de l'année d'entrée = repos
                // proratisés + CP reportés (règle comptable). À revoir si la RH veut créditer
                // le travail week-end/férié posé pendant la période forfait partielle.
                return [
                    'ruleCode'                => LeaveRuleCode::FORFAIT_218->value,
                    'acquiredDays'            => $repoDays + $carriedCp,
                    'acquiredSaturdays'       => 0.0,
                    'accrualPerMonth'         => 0.0,
                    'saturdayAccrualPerMonth' => 0.0,
                    'countOnlyCp'             => false,
                    'splitSaturdays'          => false,
                ];
            }

            // Année pleine : calcul 218 existant (INCHANGÉ).
            // 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).
            $businessDaysInPeriod = $this->countBusinessDays($from, $to, $this->buildRawPublicHolidayMap($from, $to));
            $publicHolidays       = $this->buildPublicHolidayMap($from, $to);
            $weekdayHolidays      = array_filter(
                array_keys($publicHolidays),
                static fn (string $date): bool => (int) new DateTimeImmutable($date)->format('N') <= 5
            );
            $bonusDays = $this->workHourRepository->countWeekendAndHolidayWorkedDays(
                $employee,
                $from,
                $to,
                array_values($weekdayHolidays)
            );

            return [
                'ruleCode'                => LeaveRuleCode::FORFAIT_218->value,
                'acquiredDays'            => (float) max(0, $businessDaysInPeriod - self::FORFAIT_TARGET_WORKED_DAYS) + $bonusDays,
                'acquiredSaturdays'       => 0.0,
                'accrualPerMonth'         => 0.0,
                'saturdayAccrualPerMonth' => 0.0,
                'countOnlyCp'             => false,
                'splitSaturdays'          => false,
            ];
        }

        // Resolve nature directly from the phase DTO (populated by EmployeeContractPhaseResolver).
        $nature = $phase->contractNature;
        if (ContractNature::CDI !== $nature && ContractNature::CDD !== $nature) {
            return null;
        }

        $weeklyHours = $phase->weeklyHours;
        if (4 === $weeklyHours) {
            return [
                'ruleCode'                => LeaveRuleCode::CDI_CDD_NON_FORFAIT->value,
                'acquiredDays'            => self::CDI_NON_FORFAIT_4H_ACQUIRED_DAYS,
                'acquiredSaturdays'       => self::CDI_NON_FORFAIT_4H_ACQUIRED_SATURDAYS,
                'accrualPerMonth'         => self::CDI_NON_FORFAIT_4H_ACCRUAL_PER_MONTH,
                'saturdayAccrualPerMonth' => self::CDI_NON_FORFAIT_4H_SATURDAY_ACCRUAL_PER_MONTH,
                'countOnlyCp'             => true,
                'splitSaturdays'          => true,
            ];
        }

        return [
            'ruleCode'                => LeaveRuleCode::CDI_CDD_NON_FORFAIT->value,
            'acquiredDays'            => self::CDI_NON_FORFAIT_STANDARD_ACQUIRED_DAYS,
            'acquiredSaturdays'       => self::CDI_NON_FORFAIT_STANDARD_ACQUIRED_SATURDAYS,
            'accrualPerMonth'         => self::CDI_NON_FORFAIT_STANDARD_ACCRUAL_PER_MONTH,
            'saturdayAccrualPerMonth' => self::CDI_NON_FORFAIT_STANDARD_SATURDAY_ACCRUAL_PER_MONTH,
            'countOnlyCp'             => true,
            'splitSaturdays'          => true,
        ];
    }

    /**
     * Jours de repos forfait proratisés sur la fraction de jours ouvrés couverte.
     *
     * Repos année pleine = jours_ouvrés_année − 218 (cible travaillée) − 25 (CP standard).
     * Pour 2026 : 252 − 218 − 25 = 9, proratisés au ratio jours_ouvrés_période / jours_ouvrés_année.
     */
    private function computeProratedForfaitRepoDays(int $businessDaysYear, int $businessDaysPeriod): float
    {
        if ($businessDaysYear <= 0) {
            return 0.0;
        }

        $repoDaysYear = max(0, $businessDaysYear - self::FORFAIT_TARGET_WORKED_DAYS - self::FORFAIT_STANDARD_CP_DAYS);

        return $repoDaysYear * $businessDaysPeriod / $businessDaysYear;
    }

    /**
     * Jours à travailler d'un forfait sur l'exercice consulté.
     *
     * - Année pleine : cible contractuelle 218 (bornée aux jours ouvrés de la période si
     *   celle-ci en compte moins). Les bonus week-end/férié et jours fractionnés sont des
     *   congés EN PLUS et ne réduisent pas la cible.
     * - Entrée en cours d'année : jours ouvrés de la période − congés acquis de l'entrée
     *   (repos proratisés + CP reportés, hors fractionnés/bonus). Ex. Grégory : 168 − 13 ≈ 155.
     */
    private function computeForfaitWorkTargetDays(int $businessDaysInPeriod, bool $isEntryYear, float $entryAcquiredDays): float
    {
        if ($isEntryYear) {
            return $businessDaysInPeriod - $entryAcquiredDays;
        }

        return (float) min($businessDaysInPeriod, self::FORFAIT_TARGET_WORKED_DAYS);
    }

    /**
     * Vrai si la phase FORFAIT démarre en cours de l'année civile consultée
     * (donc avec une période partielle), faux pour une année pleine ou un démarrage le 1er janvier.
     */
    private function isForfaitEntryYear(ContractPhase $phase, int $year): bool
    {
        if (ContractType::FORFAIT !== $phase->contractType) {
            return false;
        }

        return (int) $phase->startDate->format('Y') === $year
            && '01-01' !== $phase->startDate->format('m-d');
    }

    /**
     * @param null|array<string, string> $publicHolidays pre-built map (built if null)
     */
    private function countBusinessDays(DateTimeImmutable $from, DateTimeImmutable $to, ?array $publicHolidays = null): int
    {
        $publicHolidays ??= $this->buildPublicHolidayMap($from, $to);
        $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;
    }

    /**
     * Presence days = business days (Mon-Fri) - public holidays + weekend worked days - absence days.
     *
     * @return array<string, float> YYYY-MM => presence day count
     */
    private function computePresenceDaysByMonth(
        Employee $employee,
        DateTimeImmutable $from,
        DateTimeImmutable $to,
        float $n1AbsencesBudget = 0.0
    ): array {
        $publicHolidays    = $this->buildPublicHolidayMap($from, $to);
        $weekendWorkedDays = $this->workHourRepository->countWeekendWorkedDaysByMonth($employee, $from, $to);
        $absences          = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);

        // Find which public holidays were actually worked (should count as presence).
        $workedHolidays = [] !== $publicHolidays
            ? $this->workHourRepository->findWorkedDatesAmong($employee, array_keys($publicHolidays))
            : [];

        // Sort absences chronologically so N-1 budget (forfait only) is consumed in date order:
        // earliest absences attribute to N-1 first, later ones overflow to N and reduce presence.
        $sortedAbsences = $absences;
        usort(
            $sortedAbsences,
            static fn ($a, $b): int => $a->getStartDate() <=> $b->getStartDate()
        );

        $remainingN1Budget = $n1AbsencesBudget;

        // Count absence days per month, iterating day by day to handle multi-day absences
        // and properly distribute across months.
        $absenceDaysByMonth = [];
        foreach ($sortedAbsences as $absence) {
            $start = DateTimeImmutable::createFromInterface($absence->getStartDate())->setTime(0, 0);
            $end   = DateTimeImmutable::createFromInterface($absence->getEndDate())->setTime(0, 0);

            for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) {
                $weekDay = (int) $day->format('N');
                // Skip weekends
                if ($weekDay >= 6) {
                    continue;
                }

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

                // Forfait: leaves taken from N-1 stock do NOT decrement presence days.
                // We chronologically consume the N-1 budget before counting any absence.
                if ($remainingN1Budget > 0.0) {
                    $consumed = min($remainingN1Budget, $dayAmount);
                    $remainingN1Budget -= $consumed;
                    $dayAmount -= $consumed;
                    if ($dayAmount <= 0.0) {
                        continue;
                    }
                }

                $absenceDaysByMonth[$monthKey] = ($absenceDaysByMonth[$monthKey] ?? 0.0) + $dayAmount;
            }
        }

        // Count business days and public holidays per month.
        $result = [];
        $cursor = $from->modify('first day of this month')->setTime(0, 0);
        while ($cursor <= $to) {
            $monthKey   = $cursor->format('Y-m');
            $monthStart = $cursor < $from ? $from : $cursor;
            $monthEnd   = $cursor->modify('last day of this month')->setTime(0, 0);
            if ($monthEnd > $to) {
                $monthEnd = $to;
            }

            $businessDays = 0;
            for (
                $day = $monthStart;
                $day <= $monthEnd;
                $day = $day->modify('+1 day')
            ) {
                $weekDay = (int) $day->format('N');
                $dayKey  = $day->format('Y-m-d');
                if ($weekDay <= 5 && (!isset($publicHolidays[$dayKey]) || isset($workedHolidays[$dayKey]))) {
                    ++$businessDays;
                }
            }

            $weekend  = $weekendWorkedDays[$monthKey] ?? 0.0;
            $absenced = $absenceDaysByMonth[$monthKey] ?? 0.0;

            $presence = max(0.0, (float) $businessDays + $weekend - $absenced);
            if ($presence > 0.0) {
                $result[$monthKey] = $presence;
            }

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

        return $result;
    }

    /**
     * @return array{DateTimeImmutable, DateTimeImmutable}
     */
    private function resolvePeriodBounds(Employee $employee, int $year, ContractPhase $phase, bool $applyPhaseEndCap = true): array
    {
        if (ContractType::FORFAIT === $phase->contractType) {
            [$from, $to] = $this->resolveForfaitYearBounds($employee, $year, $phase);

            // For FORFAIT, cap from at phase.startDate: the 218-day FORFAIT accrual
            // is calendar-year scoped and only counts the FORFAIT portion of the year.
            if ($phase->startDate > $from) {
                $from = $phase->startDate;
            }
        } else {
            [$from, $to] = $this->resolveLeavePeriodBounds($year);

            // For non-forfait, do NOT cap from at phase.startDate: CP accrual is
            // annual (Juin→Mai) and continuous across signature changes within the
            // same leave rule (e.g. 35h → 39h, driver flag flip, weeklyHours bump).
            // The contract-entry-date cap is handled by resolveEffectivePeriodStart().
        }

        // End cap applies to both modes. Skipped when the phase was not explicitly
        // provided (legacy callers) to preserve pre-phase-cap behavior for
        // terminated employees.
        if ($applyPhaseEndCap && null !== $phase->endDate && $phase->endDate < $to) {
            $to = $phase->endDate;
        }

        return [$from, $to];
    }

    /**
     * @return array{DateTimeImmutable, DateTimeImmutable}
     */
    private function resolveLeavePeriodBounds(int $leaveYear): array
    {
        // Exercice CP "2026" = du 1er juin 2025 au 31 mai 2026.
        $from = new DateTimeImmutable(sprintf('%d-06-01 00:00:00', $leaveYear - 1));
        $to   = new DateTimeImmutable(sprintf('%d-05-31 00:00:00', $leaveYear));

        return [$from, $to];
    }

    /**
     * @return array{DateTimeImmutable, DateTimeImmutable}
     */
    private function resolveForfaitYearBounds(Employee $employee, int $year, ContractPhase $phase): array
    {
        $from = new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year));
        $to   = new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year));

        // When viewing the current phase, prefer the live "current contract" dates
        // for backward compat with existing tests/usage. Closed phases rely on the
        // generic cap applied in resolvePeriodBounds().
        if ($phase->isCurrent) {
            $contractStartRaw = $employee->getCurrentContractStartDate();
            if (null !== $contractStartRaw && '' !== trim($contractStartRaw)) {
                $contractStart = $this->parseYmdDate($contractStartRaw);
                if ($contractStart instanceof DateTimeImmutable && $contractStart > $from) {
                    $from = $contractStart;
                }
            }

            $contractEndRaw = $employee->getCurrentContractEndDate();
            if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
                $contractEnd = $this->parseYmdDate($contractEndRaw);
                if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $to) {
                    $to = $contractEnd;
                }
            }
        }

        return [$from, $to];
    }

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

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

    private function resolveCurrentLeaveYear(DateTimeImmutable $today): int
    {
        $year  = (int) $today->format('Y');
        $month = (int) $today->format('n');

        return $month >= 6 ? $year + 1 : $year;
    }

    private function resolveFirstComputationYear(Employee $employee, ContractPhase $phase): int
    {
        $isForfait    = ContractType::FORFAIT === $phase->contractType;
        $fallbackYear = $isForfait
            ? (int) new DateTimeImmutable('today')->format('Y')
            : $this->resolveCurrentLeaveYear(new DateTimeImmutable('today'));

        // Do not go before the exercice containing $phase->startDate.
        $phaseFirstYear = $this->exerciseYearResolver->forDate($phase->startDate, $isForfait);

        $history = $employee->getContractHistory();
        if ([] === $history) {
            return max($phaseFirstYear, $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) {
            $oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee);
            $candidate         = null === $oldestBalanceYear ? $fallbackYear : min($fallbackYear, $oldestBalanceYear);

            return max($phaseFirstYear, $candidate);
        }

        $firstYear = $this->exerciseYearResolver->forDate($oldestStartDate, $isForfait);

        $oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee);
        if (null !== $oldestBalanceYear && $oldestBalanceYear < $firstYear) {
            $firstYear = $oldestBalanceYear;
        }

        return max($phaseFirstYear, $firstYear);
    }

    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);
    }

    /**
     * @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;

        if (!$to instanceof DateTimeImmutable || $to < $from) {
            return [$takenDays, $takenSaturdays];
        }

        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) {
                    // Mode CDI/CDD : dimanche ignoré, samedi compté séparément.
                    if (7 === $dayOfWeek) {
                        continue;
                    }
                } else {
                    // Mode forfait : seuls les jours ouvrés (lun-ven) comptent.
                    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 resolveSuspensionsForPeriod(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
    {
        $startDate = $absence->getStartDate()->format('Y-m-d');
        $endDate   = $absence->getEndDate()->format('Y-m-d');
        $startHalf = $absence->getStartHalf()->value;
        $endHalf   = $absence->getEndHalf()->value;

        $isStart     = $date === $startDate;
        $isEnd       = $date === $endDate;
        $isSingleDay = $startDate === $endDate;

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

        return [true, true];
    }
}
