<?php

declare(strict_types=1);

namespace App\Service\Rtt;

use App\Dto\Rtt\WeekRecoveryDetail;
use App\Dto\WorkHours\WorkMetrics;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\WorkHour;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\AbsenceRepository;
use App\Repository\WorkHourRepository;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\DailyReferenceMinutesResolver;
use App\Service\WorkHours\HolidayVirtualHoursResolver;
use App\Service\WorkHours\NightHoursCalculator;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateTimeImmutable;

final readonly class RttRecoveryComputationService
{
    private ?DateTimeImmutable $rttStartDate;

    public function __construct(
        private WorkHourRepository $workHourRepository,
        private AbsenceRepository $absenceRepository,
        private AbsenceSegmentsResolver $absenceSegmentsResolver,
        private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
        private EmployeeContractResolver $contractResolver,
        private DailyReferenceMinutesResolver $dailyReferenceResolver,
        private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
        private SolidarityDayResolver $solidarityDayResolver,
        private NightHoursCalculator $nightHoursCalculator,
        string $rttStartDate = '',
    ) {
        $this->rttStartDate = '' !== $rttStartDate ? new DateTimeImmutable($rttStartDate) : null;
    }

    /**
     * @return array{DateTimeImmutable, DateTimeImmutable}
     */
    public function resolveExerciseBounds(int $exerciseYear): array
    {
        return [
            new DateTimeImmutable(sprintf('%d-06-01', $exerciseYear - 1)),
            new DateTimeImmutable(sprintf('%d-05-31', $exerciseYear)),
        ];
    }

    /**
     * @return list<array{weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}>
     */
    public function buildWeeksForExercise(DateTimeImmutable $from, DateTimeImmutable $to): array
    {
        $dayOfWeek = (int) $from->format('N');
        $weekStart = $from->modify(sprintf('-%d days', $dayOfWeek - 1));

        $weeks = [];
        while ($weekStart <= $to) {
            $start          = $weekStart;
            $end            = $start->modify('+6 days');
            $effectiveStart = $start < $from ? $from : $start;
            $effectiveEnd   = $end > $to ? $to : $end;

            if ($effectiveEnd >= $effectiveStart) {
                $weeks[] = [
                    'weekNumber' => (int) $effectiveStart->format('W'),
                    'start'      => $start,
                    'end'        => $end,
                ];
            }
            $weekStart = $weekStart->modify('+7 days');
        }

        return $weeks;
    }

    public function computeTotalRecoveryForExercise(Employee $employee, int $exerciseYear, ?DateTimeImmutable $limitDate = null): WeekRecoveryDetail
    {
        [$from, $to] = $this->resolveExerciseBounds($exerciseYear);
        $weeks       = $this->buildWeeksForExercise($from, $to);
        $weekRanges  = array_map(
            static fn (array $week): array => [
                'weekNumber' => (int) $week['weekNumber'],
                'start'      => $week['start'],
                'end'        => $week['end'],
            ],
            $weeks
        );

        $byWeek = $this->computeRecoveryByWeek($employee, $weekRanges, $from, $to, $limitDate);

        $total = new WeekRecoveryDetail();
        foreach ($byWeek as $detail) {
            $total = new WeekRecoveryDetail(
                overtimeMinutes: $total->overtimeMinutes + $detail->overtimeMinutes,
                base25Minutes: $total->base25Minutes + $detail->base25Minutes,
                bonus25Minutes: $total->bonus25Minutes + $detail->bonus25Minutes,
                base50Minutes: $total->base50Minutes + $detail->base50Minutes,
                bonus50Minutes: $total->bonus50Minutes + $detail->bonus50Minutes,
                totalMinutes: $total->totalMinutes + $detail->totalMinutes,
            );
        }

        return $total;
    }

    /**
     * @param list<array{weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}> $weeks
     *
     * @return array<string, WeekRecoveryDetail>
     */
    public function computeRecoveryByWeek(
        Employee $employee,
        array $weeks,
        DateTimeImmutable $periodFrom,
        DateTimeImmutable $periodTo,
        ?DateTimeImmutable $limitDate
    ): array {
        if ([] === $weeks) {
            return [];
        }

        $days = [];
        for ($cursor = $periodFrom; $cursor <= $periodTo; $cursor = $cursor->modify('+1 day')) {
            $days[] = $cursor->format('Y-m-d');
        }

        $contractsByDate = $this->contractResolver->resolveForEmployeesAndDays([$employee], $days);
        $naturesByDate   = $this->contractResolver->resolveNaturesForEmployeesAndDays([$employee], $days);
        $workDaysByDate  = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays([$employee], $days);
        $employeeId      = (int) $employee->getId();

        $workHours = $this->workHourRepository->findByDateRangeAndEmployees($periodFrom, $periodTo, [$employee]);
        $absences  = $this->absenceRepository->findForPrint($periodFrom, $periodTo, [$employee]);

        $metricsByDate = [];
        foreach ($workHours as $workHour) {
            $dateKey                 = $workHour->getWorkDate()->format('Y-m-d');
            $metricsByDate[$dateKey] = $this->computeMetrics($workHour);
        }

        $creditedByDate   = [];
        $hasAbsenceByDate = [];
        foreach ($absences as $absence) {
            $start = $absence->getStartDate()->format('Y-m-d');
            $end   = $absence->getEndDate()->format('Y-m-d');
            for ($cursor = $periodFrom; $cursor <= $periodTo; $cursor = $cursor->modify('+1 day')) {
                $date = $cursor->format('Y-m-d');
                if ($date < $start || $date > $end) {
                    continue;
                }

                [$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
                if ($absentMorning || $absentAfternoon) {
                    $hasAbsenceByDate[$date] = true;
                }
                $creditedByDate[$date] = ($creditedByDate[$date] ?? 0)
                    + $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon);
            }
        }

        $results         = [];
        $solidarityDates = $this->resolveSolidarityDatesInRange($periodFrom, $periodTo);
        foreach ($weeks as $week) {
            $weekStart      = $week['start'];
            $weekEnd        = $week['end'];
            $weekKey        = $weekStart->format('Y-m-d');
            $effectiveStart = $weekStart < $periodFrom ? $periodFrom : $weekStart;
            $effectiveEnd   = $weekEnd > $periodTo ? $periodTo : $weekEnd;

            if ($effectiveEnd < $effectiveStart) {
                $results[$weekKey] = new WeekRecoveryDetail();

                continue;
            }

            if ($limitDate instanceof DateTimeImmutable && $effectiveStart > $limitDate) {
                $results[$weekKey] = new WeekRecoveryDetail();

                continue;
            }

            if ($this->rttStartDate instanceof DateTimeImmutable && $effectiveEnd < $this->rttStartDate) {
                $results[$weekKey] = new WeekRecoveryDetail();

                continue;
            }

            $weekDays = [];
            for ($cursor = $effectiveStart; $cursor <= $effectiveEnd; $cursor = $cursor->modify('+1 day')) {
                $weekDays[] = $cursor->format('Y-m-d');
            }

            $weeklyTotalMinutes      = 0;
            $dailyWorkedMinutes      = [];
            $employeeContractsByDate = [];
            foreach ($weekDays as $date) {
                $contractAtDate                 = $contractsByDate[$employeeId][$date] ?? null;
                $employeeContractsByDate[$date] = $contractAtDate;
                if ($limitDate instanceof DateTimeImmutable && new DateTimeImmutable($date) > $limitDate) {
                    continue;
                }
                $metrics = $metricsByDate[$date] ?? new WorkMetrics();
                $metrics->addCreditedMinutes($creditedByDate[$date] ?? 0);
                $effectiveMinutes = $this->holidayVirtualHoursResolver->resolveEffectiveDailyMinutes(
                    $contractAtDate,
                    new DateTimeImmutable($date),
                    $metrics->totalMinutes,
                    $hasAbsenceByDate[$date] ?? false,
                    $workDaysByDate[$employeeId][$date] ?? null,
                );
                $weeklyTotalMinutes += $effectiveMinutes;
                $dailyWorkedMinutes[$date] = $effectiveMinutes;
            }

            if ([] === $weekDays) {
                $results[$weekKey] = new WeekRecoveryDetail();

                continue;
            }

            $weekAnchorDate         = $this->resolveWeekAnchorDate($weekDays, $employeeContractsByDate);
            $weekAnchorNature       = $naturesByDate[$employeeId][$weekAnchorDate] ?? ContractNature::CDI;
            $weekAnchorContract     = $employeeContractsByDate[$weekAnchorDate] ?? null;
            $isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
            $disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorNature);
            $weekContractType       = ContractType::resolve(
                $weekAnchorContract?->getName(),
                $weekAnchorContract?->getTrackingMode(),
                $weekAnchorContract?->getWeeklyHours()
            );
            $isCustomContract         = ContractType::CUSTOM === $weekContractType;
            $overtimeReferenceMinutes = $isCustomContract
                ? $this->computeWeeklyCustomReferenceMinutes($weekDays, $employeeContractsByDate)
                : $this->computeWeeklyOvertimeReferenceMinutes($weekDays, $employeeContractsByDate);
            $overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($weekDays, $employeeContractsByDate);
            // Plafond séparant 25 %/50 % : seuil de départ proraté + largeur de la bande +25 %
            // (4h pour un 39h, 8h pour un 35h). Il se décale ainsi avec une embauche en milieu
            // de semaine au lieu de rester bloqué à 43h, ce qui ouvre la tranche 50 %.
            $overtime50StartMinutes     = $overtime25StartMinutes + $this->resolveOvertime25BandWidthMinutes($weekAnchorContract);
            $weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
                ? 0
                : $weeklyTotalMinutes - $overtimeReferenceMinutes;

            foreach ($solidarityDates as $solidarityDate) {
                // isset ⇒ le jour de solidarité fait partie du sommage de CETTE semaine
                // (donc ≤ limitDate et ≥ rttStartDate). Sinon : jour futur ou hors service → pas de déficit.
                if (!isset($dailyWorkedMinutes[$solidarityDate])) {
                    continue;
                }

                $contractAtSolidarity = $employeeContractsByDate[$solidarityDate] ?? null;
                // Le Lundi de Pentecôte est toujours un lundi (ISO 1), mais on le dérive pour rester explicite.
                $solidarityIsoDay = (int) new DateTimeImmutable($solidarityDate)->format('N');
                // Attendu RÉEL du jour (planning workDaysHours), pas la répartition uniforme :
                // c'est ce qui rend la neutralisation correcte (cf. spec).
                $solidarityExpected = $this->dailyReferenceResolver->resolve(
                    $contractAtSolidarity?->getWeeklyHours(),
                    $solidarityIsoDay,
                    $workDaysByDate[$employeeId][$solidarityDate] ?? null,
                );

                $weeklyOvertimeTotalMinutes += $this->computeSolidarityDeficitAdjustment(
                    $contractAtSolidarity,
                    $solidarityExpected,
                    $dailyWorkedMinutes[$solidarityDate],
                );
            }

            [$rawBase25, $rawBase50] = $this->computeOvertimeBaseMinutes($weeklyTotalMinutes, $overtime25StartMinutes, $overtime50StartMinutes);

            $results[$weekKey] = $this->buildWeekRecoveryDetail(
                $isWeekPresenceTracking,
                $disableOvertimeBonuses,
                $isCustomContract,
                $weeklyOvertimeTotalMinutes,
                $rawBase25,
                $rawBase50,
                $dailyWorkedMinutes,
            );
        }

        return $results;
    }

    /**
     * Assemble le détail de récupération d'une semaine à partir des drapeaux résolus et
     * des bandes d'heures sup brutes.
     *
     * - PRESENCE / INTERIM (bonus désactivés) : aucune récupération.
     * - CUSTOM : récupération plate 1h = 1h, sans tranches 25/50 ; l'heure sup signée EST
     *   le total, donc une semaine travaillée sous les heures contractuelles produit un
     *   total négatif (déficit qui réduit le solde). Marquée isFlatRecovery pour que le
     *   provider ne draine pas les tranches 25/50.
     * - Standard 35h/39h : heures sup + bonus 25 %/50 %.
     *
     * @param array<string, int> $dailyMinutes
     */
    private function buildWeekRecoveryDetail(
        bool $isPresence,
        bool $disableBonuses,
        bool $isCustom,
        int $overtimeTotalMinutes,
        int $rawBase25,
        int $rawBase50,
        array $dailyMinutes,
    ): WeekRecoveryDetail {
        $noBands = $isPresence || $disableBonuses || $isCustom;

        $base25  = $noBands ? 0 : $rawBase25;
        $bonus25 = $noBands ? 0 : (int) round($base25 * 0.25);
        $base50  = $noBands ? 0 : $rawBase50;
        $bonus50 = $noBands ? 0 : (int) round($base50 * 0.5);

        if ($isPresence || $disableBonuses) {
            $totalMinutes = 0;
        } elseif ($isCustom) {
            $totalMinutes = $overtimeTotalMinutes; // signé : le déficit réduit le solde
        } else {
            $totalMinutes = $overtimeTotalMinutes + $bonus25 + $bonus50;
        }

        return new WeekRecoveryDetail(
            overtimeMinutes: $overtimeTotalMinutes,
            base25Minutes: $base25,
            bonus25Minutes: $bonus25,
            base50Minutes: $base50,
            bonus50Minutes: $bonus50,
            totalMinutes: $totalMinutes,
            dailyMinutes: $dailyMinutes,
            isFlatRecovery: $isCustom,
        );
    }

    private function computeMetrics(WorkHour $workHour): WorkMetrics
    {
        $driverDay      = $workHour->getDayHoursMinutes() ?? 0;
        $driverNight    = $workHour->getNightHoursMinutes() ?? 0;
        $driverWorkshop = $workHour->getWorkshopHoursMinutes() ?? 0;

        if ($driverDay > 0 || $driverNight > 0 || $driverWorkshop > 0) {
            $totalMinutes = $driverDay + $driverNight + $driverWorkshop;

            return new WorkMetrics(
                dayMinutes: $driverDay + $driverWorkshop,
                nightMinutes: $driverNight,
                totalMinutes: $totalMinutes,
            );
        }

        $ranges = [
            [$workHour->getMorningFrom(), $workHour->getMorningTo()],
            [$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
            [$workHour->getEveningFrom(), $workHour->getEveningTo()],
        ];

        $totalMinutes = 0;
        foreach ($ranges as [$from, $to]) {
            $totalMinutes += $this->intervalMinutes($from, $to);
        }

        $nightMinutes = $this->nightHoursCalculator->nightMinutesFromRanges($workHour);
        $dayMinutes   = max(0, $totalMinutes - $nightMinutes);

        return new WorkMetrics(
            dayMinutes: $dayMinutes,
            nightMinutes: $nightMinutes,
            totalMinutes: $totalMinutes,
        );
    }

    /**
     * @return null|array{int, int}
     */
    private function resolveInterval(?string $from, ?string $to): ?array
    {
        $fromMinutes = $this->toMinutes($from);
        $toMinutes   = $this->toMinutes($to);
        if (null === $fromMinutes || null === $toMinutes) {
            return null;
        }

        $end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes;

        return [$fromMinutes, $end];
    }

    private function toMinutes(?string $time): ?int
    {
        if (null === $time || '' === $time) {
            return null;
        }
        [$hours, $minutes] = array_map('intval', explode(':', $time));

        return ($hours * 60) + $minutes;
    }

    private function intervalMinutes(?string $from, ?string $to): int
    {
        $interval = $this->resolveInterval($from, $to);
        if (null === $interval) {
            return 0;
        }
        [$start, $end] = $interval;

        return max(0, $end - $start);
    }

    /**
     * @param list<string>             $days
     * @param array<string, ?Contract> $contractsByDate
     */
    private function computeWeeklyCustomReferenceMinutes(array $days, array $contractsByDate): int
    {
        $total = 0;
        foreach ($days as $date) {
            $isoDay   = (int) new DateTimeImmutable($date)->format('N');
            $contract = $contractsByDate[$date] ?? null;
            $hours    = $contract?->getWeeklyHours();
            $total += $this->resolveDailyReferenceMinutes($hours, $isoDay);
        }

        return $total;
    }

    /**
     * Date d'ancrage de la semaine pour résoudre le type/nature de contrat : premier jour
     * de la semaine couvert par un contrat. Évite qu'une semaine d'embauche en milieu de
     * semaine (premiers jours hors contrat) soit classée CUSTOM — ce qui désactiverait à
     * tort les bonus 25 %/50 % d'un contrat 35h/39h. Fallback sur le 1er jour si aucun jour
     * n'est contracté (semaine entièrement hors contrat → 0 de toute façon).
     *
     * @param list<string>             $weekDays
     * @param array<string, ?Contract> $contractsByDate
     */
    private function resolveWeekAnchorDate(array $weekDays, array $contractsByDate): string
    {
        foreach ($weekDays as $date) {
            if (null !== ($contractsByDate[$date] ?? null)) {
                return $date;
            }
        }

        return $weekDays[0];
    }

    /**
     * Lundi(s) de Pentecôte (jour de solidarité) inclus dans [from, to]. Un exercice
     * Juin N-1 → Mai N couvre les années civiles N-1 et N ; on retient les dates dans la fenêtre.
     *
     * @return list<string> dates au format 'Y-m-d'
     */
    private function resolveSolidarityDatesInRange(DateTimeImmutable $from, DateTimeImmutable $to): array
    {
        $dates     = [];
        $firstYear = (int) $from->format('Y');
        $lastYear  = (int) $to->format('Y');

        for ($year = $firstYear; $year <= $lastYear; ++$year) {
            $candidate = $this->solidarityDayResolver->pentecostMonday($year);
            if ($candidate >= $from && $candidate <= $to) {
                $dates[] = $candidate->format('Y-m-d');
            }
        }

        return $dates;
    }

    /**
     * Déficit forfaitaire du jour de solidarité pour les contrats CUSTOM < 35h.
     *
     * Le jour est neutralisé puis chargé du prorata légal : on remplace la valeur réelle
     * du jour ($workedMinutes : RTT posé, heures saisies, vide, ou crédit férié virtuel)
     * par l'attendu contractuel du jour ($expectedMinutes = workDaysHours), puis on
     * retranche le prorata = 7h/35h × heuresHebdo = 12 min par heure hebdo. Sur une
     * semaine par ailleurs normale, le net vaut exactement −prorata. Renvoie le delta à
     * ajouter à weeklyOvertimeTotalMinutes (0 hors périmètre : non-CUSTOM ou ≥ 35h).
     */
    private function computeSolidarityDeficitAdjustment(
        ?Contract $contractAtSolidarity,
        int $expectedMinutes,
        int $workedMinutes,
    ): int {
        $weeklyHours = $contractAtSolidarity?->getWeeklyHours();
        $type        = ContractType::resolve(
            $contractAtSolidarity?->getName(),
            $contractAtSolidarity?->getTrackingMode(),
            $weeklyHours,
        );

        if (ContractType::CUSTOM !== $type || null === $weeklyHours || $weeklyHours >= 35) {
            return 0;
        }

        // Le salarié ne travaille pas le jour de solidarité (lundi non planifié au contrat,
        // workDaysHours[lundi] absent → attendu = 0) : le jour ne le concerne pas, aucun
        // déficit n'est imputé. Sans cette garde, (0 − 0) − prorata facturerait à tort le prorata
        // à un temps partiel qui ne travaille jamais le lundi (ex. Nadia, Mar+Ven).
        if (0 === $expectedMinutes) {
            return 0;
        }

        $prorata = (int) round($weeklyHours * 12);

        return ($expectedMinutes - $workedMinutes) - $prorata;
    }

    /**
     * @param list<string>             $days
     * @param array<string, ?Contract> $contractsByDate
     */
    private function computeWeeklyOvertimeReferenceMinutes(array $days, array $contractsByDate): int
    {
        $total = 0;
        foreach ($days as $date) {
            $isoDay         = (int) new DateTimeImmutable($date)->format('N');
            $contract       = $contractsByDate[$date] ?? null;
            $hours          = $contract?->getWeeklyHours();
            $referenceHours = (null !== $hours && $hours > 0) ? max(35, $hours) : null;
            $total += $this->resolveDailyReferenceMinutes($referenceHours, $isoDay);
        }

        return $total;
    }

    /**
     * @param list<string>             $days
     * @param array<string, ?Contract> $contractsByDate
     */
    private function computeWeeklyOvertime25StartMinutes(array $days, array $contractsByDate): int
    {
        $total = 0;
        foreach ($days as $date) {
            $isoDay   = (int) new DateTimeImmutable($date)->format('N');
            $contract = $contractsByDate[$date] ?? null;
            $hours    = $contract?->getWeeklyHours();
            // Days without an active contract (pre-hire, post-termination, contract
            // gaps) must NOT contribute to the weekly 25% overtime threshold —
            // otherwise hiring mid-week artificially inflates the threshold and
            // erases legitimate overtime.
            if (null === $hours || $hours <= 0) {
                continue;
            }
            $startHours = $hours >= 39 ? 39 : 35;
            $total += $this->resolveDailyReferenceMinutes($startHours, $isoDay);
        }

        return $total;
    }

    /**
     * Largeur (en minutes) de la tranche +25 % pour le contrat d'ancrage de la semaine :
     * 4h pour un 39h (39→43), 8h pour un 35h (35→43). Ajoutée au seuil de départ proraté
     * pour obtenir le plafond 25 %/50 %.
     */
    private function resolveOvertime25BandWidthMinutes(?Contract $contract): int
    {
        $hours      = $contract?->getWeeklyHours();
        $startHours = (null !== $hours && $hours >= 39) ? 39 : 35;

        return (43 - $startHours) * 60;
    }

    /**
     * Répartit les heures supplémentaires hebdomadaires entre les bases 25 % et 50 %.
     * La tranche 25 % court du seuil de départ au plafond ; au-delà du plafond, c'est du 50 %.
     *
     * @return array{int, int} [base25Minutes, base50Minutes]
     */
    private function computeOvertimeBaseMinutes(int $weeklyTotalMinutes, int $overtime25StartMinutes, int $overtime50StartMinutes): array
    {
        $base25 = max(0, min($weeklyTotalMinutes, $overtime50StartMinutes) - $overtime25StartMinutes);
        $base50 = max(0, $weeklyTotalMinutes - $overtime50StartMinutes);

        return [$base25, $base50];
    }

    private function hasDisabledOvertimeBonuses(?Contract $contract, ContractNature $contractNature): bool
    {
        if (ContractNature::INTERIM === $contractNature) {
            return true;
        }

        $type = ContractType::resolve(
            $contract?->getName(),
            $contract?->getTrackingMode(),
            $contract?->getWeeklyHours()
        );

        return ContractType::INTERIM === $type;
    }

    private function resolveDailyReferenceMinutes(?int $weeklyHours, int $isoWeekDay): int
    {
        return $this->dailyReferenceResolver->resolve($weeklyHours, $isoWeekDay);
    }
}
