<?php

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\WorkHourBulkUpsert;
use App\ApiResource\WorkHourBulkUpsertResult;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Enum\TrackingMode;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\AuditLogger;
use App\Service\Contracts\EmployeeContractResolver;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;

final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
{
    public function __construct(
        private EntityManagerInterface $entityManager,
        private Security $security,
        private EmployeeScopedRepositoryInterface $employeeRepository,
        private WorkHourReadRepositoryInterface $workHourRepository,
        private AbsenceReadRepositoryInterface $absenceRepository,
        private EmployeeContractResolver $contractResolver,
        private AuditLogger $auditLogger,
    ) {}

    public function process(
        mixed $data,
        Operation $operation,
        array $uriVariables = [],
        array $context = []
    ): WorkHourBulkUpsertResult {
        // Endpoint dédié au bulk: on refuse tout autre payload.
        if (!$data instanceof WorkHourBulkUpsert) {
            throw new BadRequestHttpException('Invalid payload.');
        }

        $user = $this->security->getUser();
        if (!$user instanceof User) {
            throw new AccessDeniedHttpException('Authentication required.');
        }

        $workDate = DateTimeImmutable::createFromFormat('Y-m-d', $data->workDate);
        if (!$workDate || $workDate->format('Y-m-d') !== $data->workDate) {
            throw new UnprocessableEntityHttpException('workDate must use Y-m-d format.');
        }

        if ([] === $data->entries) {
            throw new UnprocessableEntityHttpException('entries must contain at least one employee.');
        }

        // Vérifie que tous les employés envoyés sont dans le scope de l'utilisateur courant.
        $employeeIds   = $this->extractEmployeeIds($data->entries);
        $employeesById = $this->employeeRepository->findAccessibleByIds($employeeIds, $user);

        if (count($employeesById) !== count($employeeIds)) {
            throw new AccessDeniedHttpException('At least one employee is unknown or outside your scope.');
        }

        $existingByEmployeeId = $this->workHourRepository
            ->findByDateAndEmployeesIndexedByEmployeeId($workDate, array_values($employeesById))
        ;
        $absenceByEmployeeId = [];
        foreach ($this->absenceRepository->findByDateAndEmployees($workDate, array_values($employeesById)) as $absence) {
            $absenceEmployeeId = $absence->getEmployee()?->getId();
            if ($absenceEmployeeId) {
                $absenceByEmployeeId[$absenceEmployeeId] = true;
            }
        }

        $result = new WorkHourBulkUpsertResult();

        foreach ($data->entries as $entry) {
            $employeeId = (int) $entry['employeeId'];
            $employee   = $employeesById[$employeeId] ?? null;
            if (!$employee) {
                throw new AccessDeniedHttpException(sprintf('Employee %d is outside your scope.', $employeeId));
            }

            $contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
            if (null === $contract) {
                throw new UnprocessableEntityHttpException(sprintf(
                    'Employee %d has no active contract on %s.',
                    $employeeId,
                    $data->workDate
                ));
            }
            $isPresenceTracking = TrackingMode::PRESENCE->value === $contract?->getTrackingMode();
            $isDriver           = $this->contractResolver->resolveIsDriverForEmployeeAndDate($employee, $workDate);
            $normalized         = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking, $isDriver);
            $existing           = $existingByEmployeeId[$employeeId] ?? null;
            $isAdmin            = in_array('ROLE_ADMIN', $user->getRoles(), true);
            $isSelf             = in_array('ROLE_SELF', $user->getRoles(), true);

            if ($existing?->isValid()) {
                if (!$this->isSameAsExisting($existing, $normalized)) {
                    throw new UnprocessableEntityHttpException(sprintf(
                        'Employee %d: validated work hour cannot be modified.',
                        $employeeId
                    ));
                }

                ++$result->processed;

                continue;
            }

            if (!$isAdmin && $existing?->isSiteValid()) {
                if (!$this->isSameAsExisting($existing, $normalized)) {
                    throw new UnprocessableEntityHttpException(sprintf(
                        'Employee %d: site validated work hour cannot be modified.',
                        $employeeId
                    ));
                }

                ++$result->processed;

                continue;
            }

            // Si aucune donnée n'a changé, on ne touche pas la ligne:
            // cela évite de perdre les validations existantes (site/RH) sur un simple enregistrement.
            if (null !== $existing && $this->isSameAsExisting($existing, $normalized)) {
                ++$result->processed;

                continue;
            }

            $is4hContract = 4 === $contract->getWeeklyHours();

            $empName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));

            if ($this->isEntryEmpty($normalized)) {
                // Garde anti-perte de données : une ligne vide ne supprime l'enregistrement
                // existant QUE si la suppression est explicitement demandée (`delete: true`).
                // Sans ce flag, une grille périmée (ex. vieil onglet sans dirty-tracking front)
                // ne peut plus détruire une ligne saisie entre-temps par un autre utilisateur :
                // l'entrée vide est traitée comme un no-op.
                $deleteRequested = true === ($entry['delete'] ?? false);

                if ($existing && $deleteRequested) {
                    $this->auditLogger->log(
                        $employee,
                        'delete',
                        'work_hour',
                        $existing->getId(),
                        sprintf('Heures supprimées pour %s le %s', $empName, $data->workDate),
                        ['old' => $this->snapshotWorkHour($existing)],
                        $workDate,
                    );
                    $this->entityManager->remove($existing);
                    ++$result->deleted;
                } elseif (null === $existing && (($absenceByEmployeeId[$employeeId] ?? false) === true || $is4hContract)) {
                    // Si une absence existe ce jour ou contrat 4h, on garde une ligne technique pour pouvoir valider la journée.
                    $workHour = new WorkHour()
                        ->setEmployee($employee)
                        ->setWorkDate($workDate)
                    ;
                    $this->hydrateWorkHour($workHour, $normalized);
                    if ($isSelf) {
                        $workHour->setUpdatedAt(new DateTimeImmutable());
                    }
                    $this->entityManager->persist($workHour);
                    $existingByEmployeeId[$employeeId] = $workHour;
                    ++$result->created;
                }

                ++$result->processed;

                continue;
            }

            if ($existing) {
                $oldSnapshot = $this->snapshotWorkHour($existing);
                $workHour    = $existing;
                ++$result->updated;
            } else {
                $oldSnapshot = null;
                // Upsert: création si aucune ligne n'existe pour (employé, date).
                $workHour = new WorkHour()
                    ->setEmployee($employee)
                    ->setWorkDate($workDate)
                ;
                $this->entityManager->persist($workHour);
                ++$result->created;
            }

            $this->hydrateWorkHour($workHour, $normalized);
            if (!$isAdmin) {
                $workHour->setUpdatedAt(new DateTimeImmutable());
            }

            $newSnapshot = $this->snapshotWorkHour($workHour);
            $action      = null !== $oldSnapshot ? 'update' : 'create';
            $changes     = null !== $oldSnapshot
                ? ['old' => $oldSnapshot, 'new' => $newSnapshot]
                : ['new' => $newSnapshot];

            $this->auditLogger->log(
                $employee,
                $action,
                'work_hour',
                $workHour->getId(),
                sprintf('Heures %s pour %s le %s', null !== $oldSnapshot ? 'modifiées' : 'créées', $empName, $data->workDate),
                $changes,
                $workDate,
            );

            ++$result->processed;
        }

        $this->entityManager->flush();

        return $result;
    }

    /**
     * @param list<array<string, mixed>> $entries
     *
     * @return list<int>
     */
    private function extractEmployeeIds(array $entries): array
    {
        $ids = [];
        foreach ($entries as $index => $entry) {
            if (!is_array($entry) || !array_key_exists('employeeId', $entry)) {
                throw new UnprocessableEntityHttpException(sprintf('entries[%d].employeeId is required.', $index));
            }

            $employeeId = (int) $entry['employeeId'];
            if ($employeeId <= 0) {
                throw new UnprocessableEntityHttpException(sprintf('entries[%d].employeeId must be a positive integer.', $index));
            }

            if (isset($ids[$employeeId])) {
                throw new UnprocessableEntityHttpException(sprintf('Employee %d appears multiple times in the same bulk payload.', $employeeId));
            }

            $ids[$employeeId] = $employeeId;
        }

        return array_values($ids);
    }

    /**
     * @param array<string, mixed> $entry
     *
     * @return array{
     *     morningFrom:?string,
     *     morningTo:?string,
     *     afternoonFrom:?string,
     *     afternoonTo:?string,
     *     eveningFrom:?string,
     *     eveningTo:?string,
     *     isPresentMorning:bool,
     *     isPresentAfternoon:bool,
     *     dayHoursMinutes:?int,
     *     nightHoursMinutes:?int,
     *     workshopHoursMinutes:?int,
     *     hasBreakfast:bool,
     *     hasLunch:bool,
     *     hasDinner:bool,
     *     hasOvernight:bool
     * }
     */
    private function normalizeEntry(array $entry, int $employeeId, bool $isPresenceTracking, bool $isDriver): array
    {
        if ($isDriver) {
            return [
                'morningFrom'          => null,
                'morningTo'            => null,
                'afternoonFrom'        => null,
                'afternoonTo'          => null,
                'eveningFrom'          => null,
                'eveningTo'            => null,
                'isPresentMorning'     => false,
                'isPresentAfternoon'   => false,
                'dayHoursMinutes'      => $this->normalizeMinutes($entry['dayHoursMinutes'] ?? null, $employeeId, 'dayHoursMinutes'),
                'nightHoursMinutes'    => $this->normalizeMinutes($entry['nightHoursMinutes'] ?? null, $employeeId, 'nightHoursMinutes'),
                'workshopHoursMinutes' => $this->normalizeMinutes($entry['workshopHoursMinutes'] ?? null, $employeeId, 'workshopHoursMinutes'),
                'hasBreakfast'         => $this->normalizePresence($entry['hasBreakfast'] ?? false, $employeeId, 'hasBreakfast'),
                'hasLunch'             => $this->normalizePresence($entry['hasLunch'] ?? false, $employeeId, 'hasLunch'),
                'hasDinner'            => $this->normalizePresence($entry['hasDinner'] ?? false, $employeeId, 'hasDinner'),
                'hasOvernight'         => $this->normalizePresence($entry['hasOvernight'] ?? false, $employeeId, 'hasOvernight'),
            ];
        }

        if ($isPresenceTracking) {
            return [
                'morningFrom'          => null,
                'morningTo'            => null,
                'afternoonFrom'        => null,
                'afternoonTo'          => null,
                'eveningFrom'          => null,
                'eveningTo'            => null,
                'isPresentMorning'     => $this->normalizePresence($entry['isPresentMorning'] ?? false, $employeeId, 'isPresentMorning'),
                'isPresentAfternoon'   => $this->normalizePresence($entry['isPresentAfternoon'] ?? false, $employeeId, 'isPresentAfternoon'),
                'dayHoursMinutes'      => null,
                'nightHoursMinutes'    => null,
                'workshopHoursMinutes' => null,
                'hasBreakfast'         => false,
                'hasLunch'             => false,
                'hasDinner'            => false,
                'hasOvernight'         => false,
            ];
        }

        return [
            'morningFrom'   => $this->normalizeTime($entry['morningFrom'] ?? null, $employeeId, 'morningFrom'),
            'morningTo'     => $this->normalizeTime($entry['morningTo'] ?? null, $employeeId, 'morningTo'),
            'afternoonFrom' => $this->normalizeTime($entry['afternoonFrom'] ?? null, $employeeId, 'afternoonFrom'),
            'afternoonTo'   => $this->normalizeTime($entry['afternoonTo'] ?? null, $employeeId, 'afternoonTo'),
            'eveningFrom'   => $this->normalizeTime($entry['eveningFrom'] ?? null, $employeeId, 'eveningFrom'),
            'eveningTo'     => $this->normalizeTime($entry['eveningTo'] ?? null, $employeeId, 'eveningTo'),
            // On conserve aussi la présence si envoyée (cas forfait affiché côté UI),
            // même si le contrat résolu ce jour est en suivi horaire.
            'isPresentMorning'     => $this->normalizePresence($entry['isPresentMorning'] ?? false, $employeeId, 'isPresentMorning'),
            'isPresentAfternoon'   => $this->normalizePresence($entry['isPresentAfternoon'] ?? false, $employeeId, 'isPresentAfternoon'),
            'dayHoursMinutes'      => null,
            'nightHoursMinutes'    => null,
            'workshopHoursMinutes' => null,
            'hasBreakfast'         => false,
            'hasLunch'             => false,
            'hasDinner'            => false,
            'hasOvernight'         => false,
        ];
    }

    private function normalizeTime(mixed $value, int $employeeId, string $field): ?string
    {
        if (null === $value || '' === $value) {
            return null;
        }

        if (!is_string($value)) {
            throw new UnprocessableEntityHttpException(sprintf(
                'Employee %d: %s must be a string in HH:MM format.',
                $employeeId,
                $field
            ));
        }

        $time = trim($value);
        if (!preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time)) {
            throw new UnprocessableEntityHttpException(sprintf(
                'Employee %d: %s must use HH:MM format.',
                $employeeId,
                $field
            ));
        }

        return $time;
    }

    private function normalizeMinutes(mixed $value, int $employeeId, string $field): ?int
    {
        if (null === $value || '' === $value) {
            return null;
        }

        if (!is_int($value) && !is_float($value)) {
            throw new UnprocessableEntityHttpException(sprintf(
                'Employee %d: %s must be an integer (minutes).',
                $employeeId,
                $field
            ));
        }

        $minutes = (int) $value;
        if ($minutes < 0) {
            throw new UnprocessableEntityHttpException(sprintf(
                'Employee %d: %s must be >= 0.',
                $employeeId,
                $field
            ));
        }

        return $minutes;
    }

    private function normalizePresence(mixed $value, int $employeeId, string $field): bool
    {
        if (!is_bool($value)) {
            throw new UnprocessableEntityHttpException(sprintf(
                'Employee %d: %s must be a boolean.',
                $employeeId,
                $field
            ));
        }

        return $value;
    }

    /**
     * @param array{
     *     morningFrom:?string,
     *     morningTo:?string,
     *     afternoonFrom:?string,
     *     afternoonTo:?string,
     *     eveningFrom:?string,
     *     eveningTo:?string,
     *     isPresentMorning:bool,
     *     isPresentAfternoon:bool,
     *     dayHoursMinutes:?int,
     *     nightHoursMinutes:?int,
     *     workshopHoursMinutes:?int,
     *     hasBreakfast:bool,
     *     hasLunch:bool,
     *     hasDinner:bool,
     *     hasOvernight:bool
     * } $entry
     */
    private function isEntryEmpty(array $entry): bool
    {
        return null === $entry['morningFrom']
            && null === $entry['morningTo']
            && null === $entry['afternoonFrom']
            && null === $entry['afternoonTo']
            && null === $entry['eveningFrom']
            && null === $entry['eveningTo']
            && false === $entry['isPresentMorning']
            && false === $entry['isPresentAfternoon']
            && (null === $entry['dayHoursMinutes'] || 0 === $entry['dayHoursMinutes'])
            && (null === $entry['nightHoursMinutes'] || 0 === $entry['nightHoursMinutes'])
            && (null === $entry['workshopHoursMinutes'] || 0 === $entry['workshopHoursMinutes'])
            && false === $entry['hasBreakfast']
            && false === $entry['hasLunch']
            && false === $entry['hasDinner']
            && false === $entry['hasOvernight'];
    }

    /**
     * @param array{
     *     morningFrom:?string,
     *     morningTo:?string,
     *     afternoonFrom:?string,
     *     afternoonTo:?string,
     *     eveningFrom:?string,
     *     eveningTo:?string,
     *     isPresentMorning:bool,
     *     isPresentAfternoon:bool,
     *     dayHoursMinutes:?int,
     *     nightHoursMinutes:?int,
     *     workshopHoursMinutes:?int,
     *     hasBreakfast:bool,
     *     hasLunch:bool,
     *     hasDinner:bool,
     *     hasOvernight:bool
     * } $entry
     */
    private function hydrateWorkHour(WorkHour $workHour, array $entry): void
    {
        $workHour
            ->setMorningFrom($entry['morningFrom'])
            ->setMorningTo($entry['morningTo'])
            ->setAfternoonFrom($entry['afternoonFrom'])
            ->setAfternoonTo($entry['afternoonTo'])
            ->setEveningFrom($entry['eveningFrom'])
            ->setEveningTo($entry['eveningTo'])
            ->setIsPresentMorning($entry['isPresentMorning'])
            ->setIsPresentAfternoon($entry['isPresentAfternoon'])
            ->setDayHoursMinutes($entry['dayHoursMinutes'])
            ->setNightHoursMinutes($entry['nightHoursMinutes'])
            ->setWorkshopHoursMinutes($entry['workshopHoursMinutes'])
            ->setHasBreakfast($entry['hasBreakfast'])
            ->setHasLunch($entry['hasLunch'])
            ->setHasDinner($entry['hasDinner'])
            ->setHasOvernight($entry['hasOvernight'])
            // Toute modification invalide la validation chef de site.
            ->setIsSiteValid(false)
            // Toute modification utilisateur repasse la ligne en attente de validation RH.
            ->setIsValid(false)
        ;
    }

    /**
     * @return array<string, mixed>
     */
    private function snapshotWorkHour(WorkHour $wh): array
    {
        return [
            'morningFrom'          => $wh->getMorningFrom(),
            'morningTo'            => $wh->getMorningTo(),
            'afternoonFrom'        => $wh->getAfternoonFrom(),
            'afternoonTo'          => $wh->getAfternoonTo(),
            'eveningFrom'          => $wh->getEveningFrom(),
            'eveningTo'            => $wh->getEveningTo(),
            'isPresentMorning'     => $wh->getIsPresentMorning(),
            'isPresentAfternoon'   => $wh->getIsPresentAfternoon(),
            'dayHoursMinutes'      => $wh->getDayHoursMinutes(),
            'nightHoursMinutes'    => $wh->getNightHoursMinutes(),
            'workshopHoursMinutes' => $wh->getWorkshopHoursMinutes(),
            'hasBreakfast'         => $wh->getHasBreakfast(),
            'hasLunch'             => $wh->getHasLunch(),
            'hasDinner'            => $wh->getHasDinner(),
            'hasOvernight'         => $wh->getHasOvernight(),
        ];
    }

    /**
     * @param array{
     *     morningFrom:?string,
     *     morningTo:?string,
     *     afternoonFrom:?string,
     *     afternoonTo:?string,
     *     eveningFrom:?string,
     *     eveningTo:?string,
     *     isPresentMorning:bool,
     *     isPresentAfternoon:bool,
     *     dayHoursMinutes:?int,
     *     nightHoursMinutes:?int,
     *     workshopHoursMinutes:?int,
     *     hasBreakfast:bool,
     *     hasLunch:bool,
     *     hasDinner:bool,
     *     hasOvernight:bool
     * } $entry
     */
    private function isSameAsExisting(WorkHour $workHour, array $entry): bool
    {
        return $workHour->getMorningFrom() === $entry['morningFrom']
            && $workHour->getMorningTo() === $entry['morningTo']
            && $workHour->getAfternoonFrom() === $entry['afternoonFrom']
            && $workHour->getAfternoonTo() === $entry['afternoonTo']
            && $workHour->getEveningFrom() === $entry['eveningFrom']
            && $workHour->getEveningTo() === $entry['eveningTo']
            && $workHour->getIsPresentMorning() === $entry['isPresentMorning']
            && $workHour->getIsPresentAfternoon() === $entry['isPresentAfternoon']
            && $workHour->getDayHoursMinutes() === $entry['dayHoursMinutes']
            && $workHour->getNightHoursMinutes() === $entry['nightHoursMinutes']
            && $workHour->getWorkshopHoursMinutes() === $entry['workshopHoursMinutes']
            && $workHour->getHasBreakfast() === $entry['hasBreakfast']
            && $workHour->getHasLunch() === $entry['hasLunch']
            && $workHour->getHasDinner() === $entry['hasDinner']
            && $workHour->getHasOvernight() === $entry['hasOvernight'];
    }
}
