<?php

declare(strict_types=1);

namespace App\Tests\State;

use App\Dto\Contracts\ContractPhase;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Service\Contracts\EmployeeContractPhaseResolver;
use App\Service\Exercise\ExerciseYearResolver;
use App\State\EmployeeLeaveSummaryProvider;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use ReflectionProperty;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;

/**
 * @internal
 */
final class EmployeeLeaveSummaryProviderTest extends TestCase
{
    // -----------------------------------------------------------------------
    // Existing tests (unchanged) — verify accrual prorating arithmetic.
    // -----------------------------------------------------------------------

    public function testComputeAccruedDaysFromStartProratesPartialFirstMonth(): void
    {
        $provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
        $method   = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->getMethod('computeAccruedDaysFromStart');

        $result = $method->invoke(
            $provider,
            25.0,
            25.0 / 12.0,
            new DateTimeImmutable('2025-06-10'),
            new DateTimeImmutable('2026-02-28')
        );

        self::assertEqualsWithDelta(18.125, $result, 0.0001);
    }

    public function testComputeAccruingDaysTotalMatchesAlainCase(): void
    {
        $provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
        $method   = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->getMethod('computeAccruedDaysFromStart');

        $days = $method->invoke(
            $provider,
            25.0,
            25.0 / 12.0,
            new DateTimeImmutable('2025-06-10'),
            new DateTimeImmutable('2026-02-28')
        );
        $saturdays = $method->invoke(
            $provider,
            5.0,
            5.0 / 12.0,
            new DateTimeImmutable('2025-06-10'),
            new DateTimeImmutable('2026-02-28')
        );

        self::assertEqualsWithDelta(21.75, $days + $saturdays, 0.0001);
    }

    public function testComputeAccruedDaysFromStartIncludesLastDayOfMonth(): void
    {
        $provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
        $method   = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->getMethod('computeAccruedDaysFromStart');

        $result = $method->invoke(
            $provider,
            25.0,
            25.0 / 12.0,
            new DateTimeImmutable('2026-02-01 12:50:18'),
            new DateTimeImmutable('2026-02-28 00:00:00')
        );

        self::assertEqualsWithDelta(25.0 / 12.0, $result, 0.0001);
    }

    public function testComputeForfaitWorkTargetDaysEntryYearProrates(): void
    {
        $provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();

        // Grégory : 168 jours ouvrés sur la période − 12.94 congés acquis (repos + CP reportés) ≈ 155.06
        $result = $this->invokePrivate($provider, 'computeForfaitWorkTargetDays', 168, true, 12.94);

        self::assertEqualsWithDelta(155.06, $result, 0.001);
    }

    public function testComputeForfaitWorkTargetDaysFullYearIs218IgnoringBonusAndFractioned(): void
    {
        $provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();

        // Année pleine : la cible reste 218 quelle que soit la valeur des congés acquis
        // (les bonus week-end/férié et jours fractionnés ne réduisent pas la cible).
        self::assertSame(218.0, $this->invokePrivate($provider, 'computeForfaitWorkTargetDays', 252, false, 34.0));
        self::assertSame(218.0, $this->invokePrivate($provider, 'computeForfaitWorkTargetDays', 252, false, 40.0));
    }

    public function testComputeForfaitWorkTargetDaysFullYearCapsAtBusinessDaysWhenFewer(): void
    {
        $provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();

        // Période avec moins de 218 jours ouvrés (phase forfait clôturée en cours d'année) → cap aux jours ouvrés.
        self::assertSame(200.0, $this->invokePrivate($provider, 'computeForfaitWorkTargetDays', 200, false, 5.0));
    }

    public function testComputeProratedForfaitRepoDaysGregoryCase(): void
    {
        $provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();

        // 2026 : 252 jours ouvrés/an, 168 sur la période 01/05→31/12.
        // repos année = 252 - 218 - 25 = 9 ; proratisé = 9 × 168/252 = 6.0
        $result = $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 252, 168);

        self::assertEqualsWithDelta(6.0, $result, 0.001);
    }

    public function testComputeProratedForfaitRepoDaysFullYearEquals9(): void
    {
        $provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();

        // Année pleine : 9 × 252/252 = 9.0
        $result = $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 252, 252);

        self::assertEqualsWithDelta(9.0, $result, 0.001);
    }

    public function testComputeProratedForfaitRepoDaysClampsNegativeToZero(): void
    {
        $provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();

        // Année avec trop peu de jours ouvrés (240 - 218 - 25 < 0) → 0
        $result = $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 240, 160);

        self::assertSame(0.0, $result);
    }

    public function testComputeProratedForfaitRepoDaysZeroYearGuard(): void
    {
        $provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();

        self::assertSame(0.0, $this->invokePrivate($provider, 'computeProratedForfaitRepoDays', 0, 0));
    }

    // -----------------------------------------------------------------------
    // Phase resolution tests (Task 3 — phaseId support).
    // The repository / service dependencies are typed against final classes
    // which PHPUnit cannot double, so phase resolution is exercised via
    // reflection on private methods to avoid instantiating the full DI graph.
    // -----------------------------------------------------------------------

    public function testResolveTargetPhasePicksH39PhaseFromPhaseId(): void
    {
        $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
        $phases   = new EmployeeContractPhaseResolver()->resolvePhases($employee);
        $h39Phase = $phases[1]; // oldest = 39h

        $provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id]);
        $resolved = $this->invokePrivate($provider, 'resolveTargetPhase', $employee);

        self::assertInstanceOf(ContractPhase::class, $resolved);
        self::assertSame($h39Phase->id, $resolved->id);
        self::assertSame(ContractType::H39, $resolved->contractType);
        self::assertFalse($resolved->isCurrent);
    }

    public function testResolveTargetPhaseDefaultsToCurrentPhaseWhenPhaseIdAbsent(): void
    {
        $employee     = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
        $phases       = new EmployeeContractPhaseResolver()->resolvePhases($employee);
        $currentPhase = $phases[0]; // most recent = FORFAIT

        $provider = $this->buildProvider([]);
        $resolved = $this->invokePrivate($provider, 'resolveTargetPhase', $employee);

        self::assertSame($currentPhase->id, $resolved->id);
        self::assertSame(ContractType::FORFAIT, $resolved->contractType);
        self::assertTrue($resolved->isCurrent);
    }

    public function testPastH39PhaseAppliesNonForfaitRuleCodeEvenWhenCurrentIsForfait(): void
    {
        // Verifies resolveLeavePolicy uses the phase's contractType (not the current contract).
        $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
        $phases   = new EmployeeContractPhaseResolver()->resolvePhases($employee);
        $h39Phase = $phases[1];

        $provider    = $this->buildProvider(['phaseId' => (string) $h39Phase->id]);
        $from        = new DateTimeImmutable('2025-06-01');
        $to          = new DateTimeImmutable('2026-04-30');
        $leavePolicy = $this->invokePrivate($provider, 'resolveLeavePolicy', $employee, $h39Phase, $from, $to);

        self::assertNotNull($leavePolicy);
        self::assertSame('CDI_CDD_NON_FORFAIT', $leavePolicy['ruleCode']);
        self::assertSame(25.0, $leavePolicy['acquiredDays']);
        self::assertEqualsWithDelta(25.0 / 12.0, $leavePolicy['accrualPerMonth'], 0.0001);
    }

    public function testResolvePeriodBoundsCapsAtPhaseEndDate(): void
    {
        // 39h phase (June 2020 → April 30 2026). Exercise 2026 spans June 2025 → May 31 2026.
        // The phase cap should clip the upper bound to April 30 2026.
        $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
        $phases   = new EmployeeContractPhaseResolver()->resolvePhases($employee);
        $h39Phase = $phases[1];

        $provider    = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2026']);
        [$from, $to] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2026, $h39Phase);

        self::assertSame('2025-06-01', $from->format('Y-m-d'));
        self::assertSame('2026-04-30', $to->format('Y-m-d'));
    }

    public function testTransitionExerciseOnH39PhaseAccruesAround22Point9Days(): void
    {
        // 11 full months of accrual at 25/12 ≈ 22.917 days.
        $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
        $phases   = new EmployeeContractPhaseResolver()->resolvePhases($employee);
        $h39Phase = $phases[1];

        $provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2026']);
        $method   = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->getMethod('computeAccruedDaysFromStart');

        // Period bounds for exercise 2026 on H39 phase = June 1 2025 → April 30 2026.
        [$from, $to] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2026, $h39Phase);
        $acquired    = $method->invoke($provider, 25.0, 25.0 / 12.0, $from, $to);

        self::assertEqualsWithDelta(22.92, $acquired, 0.1);
    }

    public function testIsForfaitEntryYearTrueOnStartYear(): void
    {
        $employee     = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
        $forfaitPhase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[0];
        $provider     = $this->buildProvider();

        self::assertTrue($this->invokePrivate($provider, 'isForfaitEntryYear', $forfaitPhase, 2026));
    }

    public function testIsForfaitEntryYearFalseOnSubsequentFullYear(): void
    {
        $employee     = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
        $forfaitPhase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[0];
        $provider     = $this->buildProvider();

        self::assertFalse($this->invokePrivate($provider, 'isForfaitEntryYear', $forfaitPhase, 2027));
    }

    public function testIsForfaitEntryYearFalseWhenForfaitStartsJan1(): void
    {
        $employee     = $this->buildEmployeeWithTransition('2020-06-01', '2025-12-31', '2026-01-01');
        $forfaitPhase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[0];
        $provider     = $this->buildProvider();

        // Forfait démarrant un 1er janvier = année pleine, pas une entrée en cours d'année.
        self::assertFalse($this->invokePrivate($provider, 'isForfaitEntryYear', $forfaitPhase, 2026));
    }

    public function testIsForfaitEntryYearFalseForNonForfaitPhase(): void
    {
        $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
        $h39Phase = new EmployeeContractPhaseResolver()->resolvePhases($employee)[1];
        $provider = $this->buildProvider();

        self::assertFalse($this->invokePrivate($provider, 'isForfaitEntryYear', $h39Phase, 2026));
    }

    public function testResolvePhaseImmediatelyBeforeReturnsPriorH39Phase(): void
    {
        $employee     = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
        $phases       = new EmployeeContractPhaseResolver()->resolvePhases($employee);
        $forfaitPhase = $phases[0]; // current FORFAIT
        $h39Phase     = $phases[1];
        $provider     = $this->buildProvider();

        $prior = $this->invokePrivate($provider, 'resolvePhaseImmediatelyBefore', $employee, $forfaitPhase);

        self::assertNotNull($prior);
        self::assertSame($h39Phase->id, $prior->id);
        self::assertSame(ContractType::H39, $prior->contractType);
    }

    public function testResolvePhaseImmediatelyBeforeReturnsNullForFirstPhase(): void
    {
        $employee   = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
        $phases     = new EmployeeContractPhaseResolver()->resolvePhases($employee);
        $firstPhase = $phases[1]; // the H39 (earliest)
        $provider   = $this->buildProvider();

        self::assertNull($this->invokePrivate($provider, 'resolvePhaseImmediatelyBefore', $employee, $firstPhase));
    }

    public function testNonForfaitPhaseStartingMidExerciseUsesFullExerciseFromAsStart(): void
    {
        // Scenario: 35h CDI from 2014-07-01 to 2025-10-31, then 39h CDI from 2025-11-01.
        // Both phases are non-forfait (same leave rule CDI_CDD_NON_FORFAIT).
        // Viewing exercise 2026 on the current 39h phase, accrual must run from the
        // exercise start (June 1, 2025), NOT from the phase start (November 1, 2025).
        // Otherwise the 5 months of June-October under 35h would be lost from the
        // annual CP accrual, which is wrong (CP exercise is annual, not per-phase).
        $employee = $this->buildH35ToH39Transition('2014-07-01', '2025-10-31', '2025-11-01');
        $phases   = new EmployeeContractPhaseResolver()->resolvePhases($employee);
        $h39Phase = $phases[0]; // current

        $provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2026']);

        [$from, $to] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2026, $h39Phase);

        self::assertSame('2025-06-01', $from->format('Y-m-d'));
        self::assertSame('2026-05-31', $to->format('Y-m-d'));
    }

    public function testForfaitPhaseStartingMidYearCapsFromAtPhaseStart(): void
    {
        // Scenario: 39h CDI ends 2026-04-30, FORFAIT from 2026-05-01.
        // Viewing year 2026 on the FORFAIT phase, the period must be capped at
        // phase start (May 1) so that only the FORFAIT portion of the calendar
        // year is counted.
        $employee     = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
        $phases       = new EmployeeContractPhaseResolver()->resolvePhases($employee);
        $forfaitPhase = $phases[0]; // current FORFAIT

        $provider = $this->buildProvider(['phaseId' => (string) $forfaitPhase->id, 'year' => '2026']);

        [$from, $to] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2026, $forfaitPhase);

        self::assertSame('2026-05-01', $from->format('Y-m-d'));
        self::assertSame('2026-12-31', $to->format('Y-m-d'));
    }

    public function testYearOutsidePhaseRangeIsSilentlyClampedToPhaseLastExercise(): void
    {
        $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
        $phases   = new EmployeeContractPhaseResolver()->resolvePhases($employee);
        $h39Phase = $phases[1];

        $provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2030']);
        $year     = $this->invokePrivate($provider, 'resolveYear', $employee, $h39Phase);

        self::assertSame(2026, $year);
    }

    public function testYearBeforePhaseIsClampedToPhaseFirstExercise(): void
    {
        $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
        $phases   = new EmployeeContractPhaseResolver()->resolvePhases($employee);
        $h39Phase = $phases[1];

        // Phase starts 2020-06-01 → first exercise (non-forfait) = 2021 (since month >=6 = year+1).
        $provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id, 'year' => '2010']);
        $year     = $this->invokePrivate($provider, 'resolveYear', $employee, $h39Phase);

        self::assertSame(2021, $year);
    }

    public function testInvalidPhaseIdReturns422(): void
    {
        $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
        $provider = $this->buildProvider(['phaseId' => '99999']);

        $this->expectException(UnprocessableEntityHttpException::class);
        $this->invokePrivate($provider, 'resolveTargetPhase', $employee);
    }

    public function testNonNumericPhaseIdReturns422(): void
    {
        $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
        $provider = $this->buildProvider(['phaseId' => 'abc']);

        $this->expectException(UnprocessableEntityHttpException::class);
        $this->invokePrivate($provider, 'resolveTargetPhase', $employee);
    }

    public function testDefaultYearForPhaseIdOnClosedPhaseUsesPhaseEndDate(): void
    {
        // No `year` param + explicit phaseId → default year is derived from $phase->endDate.
        // H39 phase ends 2026-04-30 → non-forfait exercise containing that date = 2026.
        $employee = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
        $phases   = new EmployeeContractPhaseResolver()->resolvePhases($employee);
        $h39Phase = $phases[1];

        $provider = $this->buildProvider(['phaseId' => (string) $h39Phase->id]);
        $year     = $this->invokePrivate($provider, 'resolveYear', $employee, $h39Phase);

        self::assertSame(2026, $year);
    }

    public function testNoQueryParamsKeepsLegacyYearDefaulting(): void
    {
        $employee     = $this->buildEmployeeWithTransition('2020-06-01', '2026-04-30', '2026-05-01');
        $phases       = new EmployeeContractPhaseResolver()->resolvePhases($employee);
        $currentPhase = $phases[0];

        $provider = $this->buildProvider([]);
        $year     = $this->invokePrivate($provider, 'resolveYear', $employee, $currentPhase);

        // Today is 2026-05-19, FORFAIT phase → year is the current calendar year (2026).
        self::assertSame(2026, $year);
    }

    // -----------------------------------------------------------------------
    // Regression: terminated-employee path through `computeYearSummary` without
    // an explicit phase (legacy callers: LeaveRecapRowBuilder,
    // DumpVerificationSnapshotCommand). Before the phase-aware refactor, the
    // period bounds were NOT capped at the contract end for terminated
    // employees (because Employee::getCurrentContractEndDate() returns null
    // when no period covers "today"). The new code resolves a fallback phase
    // whose `isCurrent` is false, which would otherwise cap `to` at the phase
    // end — a behavior change for legacy callers. The flag `applyPhaseEndCap`
    // toggles this cap so legacy callers get the pre-refactor behavior.
    // -----------------------------------------------------------------------

    public function testTerminatedEmployeeWithoutExplicitPhaseSkipsPhaseEndCap(): void
    {
        // Terminated employee: H39 phase ending 2024-12-31 (well in the past).
        $employee = $this->buildTerminatedEmployee('2020-06-01', '2024-12-31');
        $phases   = new EmployeeContractPhaseResolver()->resolvePhases($employee);
        self::assertCount(1, $phases);
        $phase = $phases[0];
        self::assertFalse($phase->isCurrent, 'Sanity: terminated phase must not be flagged as current.');

        $provider = $this->buildProvider([]);

        // applyPhaseEndCap=false → mimics legacy callers (no explicit phase):
        // the upper bound MUST stay at the natural leave-year end (May 31).
        [$fromLegacy, $toLegacy] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2025, $phase, false);
        self::assertSame('2024-06-01', $fromLegacy->format('Y-m-d'));
        self::assertSame('2025-05-31', $toLegacy->format('Y-m-d'));

        // applyPhaseEndCap=true → explicit-phase callers get the cap at phase end.
        [$fromCap, $toCap] = $this->invokePrivate($provider, 'resolvePeriodBounds', $employee, 2025, $phase, true);
        self::assertSame('2024-06-01', $fromCap->format('Y-m-d'));
        self::assertSame('2024-12-31', $toCap->format('Y-m-d'));
    }

    // -----------------------------------------------------------------------
    // Test harness helpers.
    // -----------------------------------------------------------------------

    /**
     * Build a terminated-employee fixture: a single H39 period ending before today.
     */
    private function buildTerminatedEmployee(string $start, string $end): Employee
    {
        $employee = new Employee();
        $this->setEntityId($employee, 2);

        $contract = new Contract();
        $contract->setName('39H');
        $contract->setTrackingMode(TrackingMode::TIME->value);
        $contract->setWeeklyHours(39);

        $period = new EmployeeContractPeriod();
        $this->setEntityId($period, 10);
        $period->setEmployee($employee);
        $period->setContract($contract);
        $period->setStartDate(new DateTimeImmutable($start));
        $period->setEndDate(new DateTimeImmutable($end));
        $period->setContractNature(ContractNature::CDI);
        $period->setIsDriver(false);

        $employee->getContractPeriods()->add($period);

        return $employee;
    }

    /**
     * Build a two-period employee transitioning from H39 to FORFAIT.
     */
    private function buildH35ToH39Transition(string $h35Start, string $h35End, string $h39Start): Employee
    {
        $employee = new Employee();
        $this->setEntityId($employee, 1);

        $h35Contract = new Contract();
        $h35Contract->setName('35H');
        $h35Contract->setTrackingMode(TrackingMode::TIME->value);
        $h35Contract->setWeeklyHours(35);

        $h39Contract = new Contract();
        $h39Contract->setName('39H');
        $h39Contract->setTrackingMode(TrackingMode::TIME->value);
        $h39Contract->setWeeklyHours(39);

        $h35Period = new EmployeeContractPeriod();
        $this->setEntityId($h35Period, 1);
        $h35Period->setEmployee($employee);
        $h35Period->setContract($h35Contract);
        $h35Period->setStartDate(new DateTimeImmutable($h35Start));
        $h35Period->setEndDate(new DateTimeImmutable($h35End));
        $h35Period->setContractNature(ContractNature::CDI);
        $h35Period->setIsDriver(false);

        $h39Period = new EmployeeContractPeriod();
        $this->setEntityId($h39Period, 2);
        $h39Period->setEmployee($employee);
        $h39Period->setContract($h39Contract);
        $h39Period->setStartDate(new DateTimeImmutable($h39Start));
        $h39Period->setEndDate(null);
        $h39Period->setContractNature(ContractNature::CDI);
        $h39Period->setIsDriver(false);

        $employee->getContractPeriods()->add($h35Period);
        $employee->getContractPeriods()->add($h39Period);

        return $employee;
    }

    private function buildEmployeeWithTransition(string $h39Start, string $h39End, string $forfaitStart): Employee
    {
        $employee = new Employee();
        $this->setEntityId($employee, 1);

        $h39Contract = new Contract();
        $h39Contract->setName('39H');
        $h39Contract->setTrackingMode(TrackingMode::TIME->value);
        $h39Contract->setWeeklyHours(39);

        $forfaitContract = new Contract();
        $forfaitContract->setName('Forfait');
        $forfaitContract->setTrackingMode(TrackingMode::PRESENCE->value);
        $forfaitContract->setWeeklyHours(null);

        $h39Period = new EmployeeContractPeriod();
        $this->setEntityId($h39Period, 1);
        $h39Period->setEmployee($employee);
        $h39Period->setContract($h39Contract);
        $h39Period->setStartDate(new DateTimeImmutable($h39Start));
        $h39Period->setEndDate(new DateTimeImmutable($h39End));
        $h39Period->setContractNature(ContractNature::CDI);
        $h39Period->setIsDriver(false);

        $forfaitPeriod = new EmployeeContractPeriod();
        $this->setEntityId($forfaitPeriod, 2);
        $forfaitPeriod->setEmployee($employee);
        $forfaitPeriod->setContract($forfaitContract);
        $forfaitPeriod->setStartDate(new DateTimeImmutable($forfaitStart));
        $forfaitPeriod->setEndDate(null);
        $forfaitPeriod->setContractNature(ContractNature::CDI);
        $forfaitPeriod->setIsDriver(false);

        $employee->getContractPeriods()->add($h39Period);
        $employee->getContractPeriods()->add($forfaitPeriod);

        return $employee;
    }

    /**
     * Build an uninitialized provider with a RequestStack pre-loaded with the given query.
     *
     * The provider's repository/service dependencies are typed against final classes
     * (EmployeeRepository, LeaveBalanceComputationService, etc.) which PHPUnit cannot
     * double. We bypass full instantiation by using newInstanceWithoutConstructor and
     * only setting the properties that the tested private methods actually read:
     * `requestStack` and `phaseResolver`. Tests targeting heavier code paths exercise
     * private methods directly (resolveTargetPhase, resolvePeriodBounds, etc.).
     *
     * @param array<string, string> $request query parameters (year, phaseId, ...)
     */
    private function buildProvider(array $request = []): EmployeeLeaveSummaryProvider
    {
        $requestStack = new RequestStack();
        $requestStack->push(new Request(query: $request));

        $reflection = new ReflectionClass(EmployeeLeaveSummaryProvider::class);
        $provider   = $reflection->newInstanceWithoutConstructor();

        $this->setReadonlyProperty($provider, 'requestStack', $requestStack);
        $this->setReadonlyProperty($provider, 'phaseResolver', new EmployeeContractPhaseResolver());
        $this->setReadonlyProperty($provider, 'exerciseYearResolver', new ExerciseYearResolver());
        $this->setReadonlyProperty($provider, 'dataStartDate', null);

        return $provider;
    }

    private function invokePrivate(object $obj, string $method, mixed ...$args): mixed
    {
        $reflection = new ReflectionClass($obj::class);
        $m          = $reflection->getMethod($method);

        return $m->invoke($obj, ...$args);
    }

    private function setReadonlyProperty(object $obj, string $property, mixed $value): void
    {
        $reflection = new ReflectionProperty($obj::class, $property);
        $reflection->setValue($obj, $value);
    }

    private function setEntityId(object $entity, int $id): void
    {
        $reflection = new ReflectionProperty($entity::class, 'id');
        $reflection->setValue($entity, $id);
    }
}
