<?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\Tests\Application;

use Ferienpass\CmsBundle\Application\FriendCodeManager;
use Ferienpass\CmsBundle\Dto\FriendCodeDto;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\FriendCode;
use Ferienpass\CoreBundle\Entity\Offer\Base;
use Ferienpass\CoreBundle\Entity\Participant\BaseParticipant;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\Repository\FriendCodeRepository;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\RateLimiter\Storage\InMemoryStorage;

/**
 * Tests the friend-code (buddy-booking) lookups + persistence used by
 * ApplicationForm::apply(). Two scenarios drive the design:
 *
 *  1. **Friend-code linking across users**: User A applies first with their own
 *     deterministic code; User B then redeems A's code. Both participants end
 *     up sharing the same FriendCode entity.
 *  2. **Sibling buddy-booking**: A single user applies with a sibling chosen
 *     from their other participants. A new FriendCode is created, both
 *     participants get it.
 */
final class FriendCodeManagerTest extends TestCase
{
    private const string SECRET = 'test-kernel-secret';

    // ────────────────────────────────────────────────────────────────
    //  tokenFor
    // ────────────────────────────────────────────────────────────────

    public function testTokenForReturnsEmptyWhenParticipantIsNull(): void
    {
        $manager = $this->manager($this->stubRepo());

        self::assertSame('', $manager->tokenFor($this->offer(99), null));
    }

    public function testTokenForIsDeterministicAcrossCalls(): void
    {
        $offer = $this->offer(42);
        $participant = $this->participant(7);
        $manager = $this->manager($this->stubRepo());

        $a = $manager->tokenFor($offer, $participant);
        $b = $manager->tokenFor($offer, $participant);

        self::assertSame($a, $b);
        self::assertMatchesRegularExpression('/^[A-F0-9]{8}$/', $a);
    }

    public function testTokenForIsDistinctPerParticipant(): void
    {
        $offer = $this->offer(42);
        $manager = $this->manager($this->stubRepo());

        $a = $manager->tokenFor($offer, $this->participant(1));
        $b = $manager->tokenFor($offer, $this->participant(2));

        self::assertNotSame($a, $b);
    }

    // ────────────────────────────────────────────────────────────────
    //  siblingsWithoutCode
    // ────────────────────────────────────────────────────────────────

    public function testSiblingsWithoutCodeExcludesActingParticipant(): void
    {
        $user = $this->user();
        $offer = $this->offer(50);

        $p1 = $this->participant(101, user: $user);
        $p2 = $this->participant(102, user: $user);
        $this->attendance($offer, $p1);
        $this->attendance($offer, $p2);

        $manager = $this->manager($this->stubRepo());

        $result = $manager->siblingsWithoutCode($offer, $user, $p1)->toArray();

        // p1 is the actor, must be excluded; p2 must remain.
        self::assertCount(1, $result);
        self::assertSame($p2, array_values($result)[0]);
    }

    public function testSiblingsWithoutCodeExcludesParticipantsAlreadyOnAFriendCode(): void
    {
        $user = $this->user();
        $offer = $this->offer(60);

        $p1 = $this->participant(201, user: $user);
        $p2 = $this->participant(202, user: $user);

        // p2 already belongs to a friend code for this offer → not eligible to be invited again.
        $existing = $this->friendCode($offer, 'XXXX-YYYY');
        $p2->addFriendCode($existing);

        $this->attendance($offer, $p1);
        $this->attendance($offer, $p2);

        $manager = $this->manager($this->stubRepo());

        $result = $manager->siblingsWithoutCode($offer, $user, $p1);

        self::assertTrue($result->isEmpty());
    }

    // ────────────────────────────────────────────────────────────────
    //  Scenario 1: friend-code linking (User A → User B redeems)
    // ────────────────────────────────────────────────────────────────

    public function testRedeemingAnotherUsersCodeLinksBothParticipantsToTheSameCode(): void
    {
        $offerId = 70;
        $offer = $this->offer($offerId);

        // User A applied first → their token already corresponds to a stored FriendCode.
        $userA = $this->user();
        $participantA = $this->participant(301, user: $userA);
        $userB = $this->user();
        $participantB = $this->participant(302, user: $userB);

        $repo = $this->stubRepo();
        $manager = $this->manager($repo);
        $codeAStr = $manager->tokenFor($offer, $participantA);

        $codeEntity = $this->friendCode($offer, $codeAStr);
        $codeEntity->getParticipants()->add($participantA);
        $participantA->addFriendCode($codeEntity);

        // Repository: when asked for B's own token there's no row; when asked for
        // the code-string A shared (the one in $codeEntity), it returns A's row.
        $repo->method('findOneBy')->willReturnCallback(
            static fn (array $criteria) => $criteria['code'] === $codeAStr ? $codeEntity : null,
        );

        // B redeems A's code (the redeem branch consults $friendCodeDto->getCode()).
        $dto = new FriendCodeDto($offer, $codeAStr);

        $manager->attachFriendBooking($participantB, $offer, 'redeem', $dto, $userB);

        // Both participants now reference the SAME FriendCode entity.
        self::assertTrue(
            $codeEntity->getParticipants()->contains($participantA),
            'A must still be on the shared friend code after B redeems it.',
        );
        self::assertTrue(
            $participantB->getFriendCodes()->contains($codeEntity),
            'B must now reference the shared friend code.',
        );
    }

    // ────────────────────────────────────────────────────────────────
    //  Scenario 2: sibling buddy-booking
    // ────────────────────────────────────────────────────────────────

    public function testSiblingBookingCreatesCodeAndLinksBothSiblings(): void
    {
        $offer = $this->offer(80);
        $user = $this->user();

        $p1 = $this->participant(401, user: $user);
        $p2 = $this->participant(402, user: $user);

        // Both already applied to this offer (so they show up via getAttendancesNotHidden)
        // but neither belongs to a friend code yet.
        $this->attendance($offer, $p1);
        $this->attendance($offer, $p2);

        // No existing row in the repo for any code.
        $repo = $this->stubRepo();
        $repo->method('findOneBy')->willReturn(null);

        $manager = $this->manager($repo);

        // P1 applies, picking P2 as their buddy → numeric sibling-id branch.
        $manager->attachFriendBooking($p1, $offer, (string) 402, null, $user);

        // A fresh FriendCode entity now connects P1 and P2 — the manager has
        // attached it to BOTH participants (P2 via sibling.addFriendCode, P1 via
        // participant.addFriendCode at the end).
        self::assertCount(1, $p1->getFriendCodes(), 'P1 must receive the new buddy code.');
        self::assertCount(1, $p2->getFriendCodes(), 'P2 must receive the same buddy code.');

        $codeP1 = $p1->getFriendCodes()->first();
        $codeP2 = $p2->getFriendCodes()->first();
        self::assertSame($codeP1, $codeP2, 'Both siblings must share the same FriendCode entity.');
        self::assertSame($offer, $codeP1->getOffer());
    }

    // ────────────────────────────────────────────────────────────────
    //  attachFriendBooking: on-hold + explicit-code branches
    // ────────────────────────────────────────────────────────────────

    public function testOnHoldCreatesAndAttachesOwnDeterministicCode(): void
    {
        $offer = $this->offer(90);
        $participant = $this->participant(501);

        // No existing row → manager creates a FriendCode with our deterministic token.
        $repo = $this->stubRepo();
        $repo->method('findOneBy')->willReturn(null);

        $manager = $this->manager($repo);
        $expectedCode = $manager->tokenFor($offer, $participant);

        $manager->attachFriendBooking($participant, $offer, 'on-hold', null, null);

        self::assertCount(1, $participant->getFriendCodes());
        self::assertSame($expectedCode, $participant->getFriendCodes()->first()->getCode());
    }

    public function testExplicitCodeStringLooksUpExisting(): void
    {
        $offer = $this->offer(91);
        $participant = $this->participant(601);

        $stored = $this->friendCode($offer, 'AAAA-1234');

        $repo = $this->stubRepo();
        $repo->method('findOneBy')->willReturnCallback(
            static fn (array $criteria) => 'AAAA-1234' === $criteria['code'] ? $stored : null,
        );

        $manager = $this->manager($repo);
        $manager->attachFriendBooking($participant, $offer, 'AAAA-1234', null, null);

        self::assertTrue($participant->getFriendCodes()->contains($stored));
    }

    // ────────────────────────────────────────────────────────────────
    //  helpers
    // ────────────────────────────────────────────────────────────────

    private function manager(FriendCodeRepository $repo): FriendCodeManager
    {
        // A real no-op limiter + empty RequestStack: the rate limiter is only
        // exercised by participantsFor(), which these tests do not cover.
        $limiter = new RateLimiterFactory(
            ['id' => 'friend_code', 'policy' => 'no_limit'],
            new InMemoryStorage(),
        );

        return new FriendCodeManager($repo, self::SECRET, $limiter, new RequestStack());
    }

    private function stubRepo(): FriendCodeRepository
    {
        return $this->createMock(FriendCodeRepository::class);
    }

    private function offer(int $id): Base
    {
        $offer = new Base();
        self::idOf($offer, $id);

        return $offer;
    }

    private function participant(int $id, ?User $user = null): BaseParticipant
    {
        $participant = new BaseParticipant($user);
        self::idOf($participant, $id);

        return $participant;
    }

    private function attendance(Base $offer, BaseParticipant $participant): Attendance
    {
        $attendance = new Attendance($offer, $participant);
        $offer->addAttendance($attendance);

        return $attendance;
    }

    private function friendCode(Base $offer, string $code): FriendCode
    {
        return new FriendCode($offer, $code);
    }

    private function user(): User
    {
        $user = new User();
        self::idOf($user, random_int(1_000, 9_999));

        return $user;
    }

    /**
     * Force a private `id` on entities whose constructors don't accept one —
     * needed because tokenFor/equality checks rely on getId().
     */
    private static function idOf(object $entity, int $id): void
    {
        $ref = new \ReflectionClass($entity);
        while ($ref && !$ref->hasProperty('id')) {
            $ref = $ref->getParentClass();
        }
        if (!$ref) {
            return;
        }
        $prop = $ref->getProperty('id');
        $prop->setAccessible(true);
        $prop->setValue($entity, $id);
    }
}
