<?php

declare(strict_types=1);

/*
 * This file is part of the Ferienpass package.
 *
 * (c) Richard Henkenjohann <richard@ferienpass.online>
 *
 * For more information visit the project website <https://ferienpass.online>
 * or the documentation under <https://docs.ferienpass.online>.
 */

namespace Ferienpass\CmsBundle\Application;

use Doctrine\Common\Collections\Collection;
use Ferienpass\CmsBundle\Dto\FriendCodeDto;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\FriendCode;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Entity\Participant\ParticipantInterface;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\Repository\FriendCodeRepository;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\RateLimiter\RateLimiterFactoryInterface;

/**
 * Friend-code / lottery "buddy booking" lookups + persistence — pulled out of
 * the application form component so that surface stays focused on validation
 * and form state. Stateless: every method takes the offer/participant context
 * it needs.
 */
class FriendCodeManager
{
    public function __construct(
        private readonly FriendCodeRepository $friendCodes,
        #[Autowire(param: 'kernel.secret')]
        private readonly string $secret,
        private readonly RateLimiterFactoryInterface $friendCodeLimiter,
        private readonly RequestStack $requestStack,
    ) {
    }

    /**
     * Deterministic code derived from (offer, participant, kernel secret).
     * Stable across requests so the same buddy code keeps working until either
     * side is deleted.
     *
     * Returns the leading 8 hex chars of the HMAC-SHA-256 of the tuple. The
     * previous scheme exposed only the first 4 hex chars (16 bits) of a plain
     * SHA-1, which made the buddy token guessable and let an anonymous visitor
     * enumerate minors' first names through the live friend-code lookup. Codes
     * are always looked up scoped to the offer (see participantsFor /
     * attachFriendBooking / FriendCodeValidator), so the value no longer embeds
     * the offer id.
     */
    public function tokenFor(OfferInterface $offer, ?ParticipantInterface $participant): string
    {
        if (!$participant instanceof ParticipantInterface) {
            return '';
        }

        $token = strtoupper(hash_hmac('sha256', \sprintf('%d-%d', $offer->getId(), $participant->getId()), $this->secret));

        return substr($token, 0, 8);
    }

    /**
     * @return ParticipantInterface[]
     */
    public function participantsFor(OfferInterface $offer, ?string $code): array
    {
        if (null === $code || '' === $code) {
            return [];
        }

        // Rate-limit the lookup itself, not just the redeem-submit branch
        // (FriendCodeValidator). This render path runs on every live re-render
        // of the anonymous application form and discloses the children (and
        // their first names) tied to a code, so an unthrottled caller could
        // brute-force codes here. Keyed by offer + client IP, sharing the same
        // friend_code limiter the validator uses.
        $limiter = $this->friendCodeLimiter->create(\sprintf('%d-%s', $offer->getId(), $this->requestStack->getCurrentRequest()?->getClientIp() ?? ''));
        if (false === $limiter->consume(1)->isAccepted()) {
            return [];
        }

        $entity = $this->friendCodes->findOneBy(['offer' => $offer, 'code' => $code]);

        return $entity?->getParticipants()->toArray() ?? [];
    }

    /**
     * Friend codes the user has used on this offer (i.e. any of their
     * participants currently belongs to a code on this offer).
     *
     * @return FriendCode[]
     */
    public function siblingCodes(OfferInterface $offer, ?User $user): array
    {
        if (!$user instanceof User) {
            return [];
        }

        return $this->friendCodes->createQueryBuilder('friendCode')
            ->where('friendCode.offer = :offer')
            ->innerJoin('friendCode.participants', 'participant')
            ->innerJoin('participant.user', 'user')
            ->andWhere('user.id = :userId')
            ->setParameter('offer', $offer)
            ->setParameter('userId', $user)
            ->getQuery()
            ->getResult()
        ;
    }

    /**
     * Other participants of the same user already registered for this offer but
     * not yet tied to any friend code — eligible to share one with `$exclude`.
     *
     * @return Collection<int, ParticipantInterface>
     */
    public function siblingsWithoutCode(OfferInterface $offer, ?User $user, ?ParticipantInterface $exclude): Collection
    {
        return $offer->getAttendancesNotHidden()
            ->filter(static fn (Attendance $a) => $a->getParticipant()?->getUser() === $user)
            ->filter(static fn (Attendance $a) => $a->getParticipant()?->getId() !== $exclude?->getId())
            ->filter(static fn (Attendance $a) => $a->getParticipant()
                ?->getFriendCodes()
                ->filter(static fn (FriendCode $fc) => $fc->getOffer() === $offer)
                ->isEmpty() ?? false)
            ->map(static fn (Attendance $a) => $a->getParticipant())
        ;
    }

    /**
     * Side-effect of `apply()` when the applicant opted into buddy booking:
     * resolve which friend code to use, persist its membership, and attach the
     * code to the applying participant.
     */
    public function attachFriendBooking(
        ParticipantInterface $participant,
        OfferInterface $offer,
        string $useFriendCodeChoice,
        ?FriendCodeDto $friendCodeDto,
        ?User $user,
    ): void {
        $ownToken = $this->tokenFor($offer, $participant);
        $code = $this->friendCodes->findOneBy(['offer' => $offer, 'code' => $ownToken]);
        $code ??= new FriendCode($offer, $ownToken);

        if ('redeem' === $useFriendCodeChoice) {
            // The redeem branch may run with a null FriendCodeDto or an empty
            // code (the ValidFriendCode validator short-circuits on a null value,
            // so an empty "redeem" can reach here). Never construct a FriendCode
            // with a null code — skip buddy booking instead of throwing.
            $redeemCode = $friendCodeDto?->getCode();
            if (null === $redeemCode || '' === trim($redeemCode)) {
                $code = null;
            } else {
                $code = $this->friendCodes->findOneBy(['offer' => $offer, 'code' => $redeemCode]);
                $code ??= new FriendCode($offer, $redeemCode);
            }
        } elseif (is_numeric($useFriendCodeChoice)) {
            /** @var ParticipantInterface|null $sibling */
            $sibling = $this->siblingsWithoutCode($offer, $user, $participant)
                ->findFirst(static fn (int $i, ParticipantInterface $p) => $p->getId() === (int) $useFriendCodeChoice);
            $sibling?->addFriendCode($code);
        } elseif ('on-hold' !== $useFriendCodeChoice) {
            $code = $this->friendCodes->findOneBy(['offer' => $offer, 'code' => $useFriendCodeChoice]);
        }

        if ($code instanceof FriendCode) {
            $participant->addFriendCode($code);
        }
    }
}
