<?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\CoreBundle\Tests\Security\Voter;

use Doctrine\Common\Collections\ArrayCollection;
use Ferienpass\CoreBundle\ConsentManager\ConsentState;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\ConsentForm;
use Ferienpass\CoreBundle\Entity\Edition;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Entity\Participant\ParticipantInterface;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\Security\Voter\ConsentStateVoter;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;

final class ConsentStateVoterTest extends TestCase
{
    public function testAbstainsForNonConsentStateSubject(): void
    {
        $voter = new ConsentStateVoter($this->createStub(Security::class));

        self::assertSame(VoterInterface::ACCESS_ABSTAIN, $voter->vote($this->token(), new \stdClass(), ['view']));
    }

    public function testAbstainsForOtherAttributes(): void
    {
        $voter = new ConsentStateVoter($this->createStub(Security::class));

        self::assertSame(VoterInterface::ACCESS_ABSTAIN, $voter->vote($this->token(), new ConsentState(), ['revoke']));
    }

    public function testGrantsForAdminWithoutWalkingAttendances(): void
    {
        $voter = new ConsentStateVoter($this->security(admin: true));

        self::assertSame(VoterInterface::ACCESS_GRANTED, $voter->vote($this->token(), new ConsentState(), ['view']));
    }

    public function testDeniesAnonymousToken(): void
    {
        $voter = new ConsentStateVoter($this->security(admin: true));

        $token = $this->createStub(TokenInterface::class);
        $token->method('getUser')->willReturn(null);

        self::assertSame(VoterInterface::ACCESS_DENIED, $voter->vote($token, new ConsentState(), ['view']));
    }

    /**
     * The host case the whole relaxation exists for: an offer-scoped form for a
     * participant on the host's own roster.
     */
    public function testGrantsHostForOfferScopedFormOnTheirOwnRoster(): void
    {
        $edition = new Edition();
        $offer = $this->offer($edition);
        $participant = $this->participantWithAttendanceOn($offer);

        $form = new ConsentForm();
        $form->setOffer($offer);

        $voter = new ConsentStateVoter($this->security(admin: false, viewableOffers: [$offer]));

        self::assertSame(VoterInterface::ACCESS_GRANTED, $voter->vote($this->token(), new ConsentState($form, participant: $participant), ['view']));
    }

    public function testGrantsHostForEditionScopedFormOnTheirOwnRoster(): void
    {
        $edition = new Edition();
        $offer = $this->offer($edition);
        $participant = $this->participantWithAttendanceOn($offer);

        $form = new ConsentForm();
        $form->setEdition($edition);

        $voter = new ConsentStateVoter($this->security(admin: false, viewableOffers: [$offer]));

        self::assertSame(VoterInterface::ACCESS_GRANTED, $voter->vote($this->token(), new ConsentState($form, participant: $participant), ['view']));
    }

    /**
     * An account-level form (Datenschutz & co.) carries no participant; any
     * child of the account being on the roster is what surfaces it.
     */
    public function testGrantsHostForAccountLevelFormViaTheAccountsParticipants(): void
    {
        $edition = new Edition();
        $offer = $this->offer($edition);
        $participant = $this->participantWithAttendanceOn($offer);

        $user = $this->createMock(User::class);
        $user->method('getParticipants')->willReturn(new ArrayCollection([$participant]));

        $form = new ConsentForm();
        $form->setEdition($edition);

        $voter = new ConsentStateVoter($this->security(admin: false, viewableOffers: [$offer]));

        self::assertSame(VoterInterface::ACCESS_GRANTED, $voter->vote($this->token(), new ConsentState($form, user: $user), ['view']));
    }

    public function testDeniesHostWhenTheParticipantIsOnNobodysRosterOfTheirs(): void
    {
        $edition = new Edition();
        $offer = $this->offer($edition);
        $participant = $this->participantWithAttendanceOn($offer);

        $form = new ConsentForm();
        $form->setOffer($offer);

        // The host may not view participants of that offer.
        $voter = new ConsentStateVoter($this->security(admin: false, viewableOffers: []));

        self::assertSame(VoterInterface::ACCESS_DENIED, $voter->vote($this->token(), new ConsentState($form, participant: $participant), ['view']));
    }

    /**
     * The scope half of the rule: seeing one roster must not open up a form
     * belonging to a different offer the same child attends.
     */
    public function testDeniesHostForAFormScopedToAnotherOffer(): void
    {
        $edition = new Edition();
        $ownOffer = $this->offer($edition);
        $otherOffer = $this->offer($edition);
        $participant = $this->participantWithAttendanceOn($ownOffer);

        $form = new ConsentForm();
        $form->setOffer($otherOffer);

        $voter = new ConsentStateVoter($this->security(admin: false, viewableOffers: [$ownOffer]));

        self::assertSame(VoterInterface::ACCESS_DENIED, $voter->vote($this->token(), new ConsentState($form, participant: $participant), ['view']));
    }

    public function testDeniesHostForAFormScopedToAnotherEdition(): void
    {
        $offer = $this->offer(new Edition());
        $participant = $this->participantWithAttendanceOn($offer);

        $form = new ConsentForm();
        $form->setEdition(new Edition());

        $voter = new ConsentStateVoter($this->security(admin: false, viewableOffers: [$offer]));

        self::assertSame(VoterInterface::ACCESS_DENIED, $voter->vote($this->token(), new ConsentState($form, participant: $participant), ['view']));
    }

    /**
     * The Veranstalter declaration is the host's own consent and never part of
     * the applicant flow, so an attendance must not unlock it.
     */
    public function testDeniesHostForTheHostDeclaration(): void
    {
        $edition = new Edition();
        $offer = $this->offer($edition);
        $participant = $this->participantWithAttendanceOn($offer);

        $form = new ConsentForm();
        $form->setEdition($edition);
        $form->setSubject('host');

        $voter = new ConsentStateVoter($this->security(admin: false, viewableOffers: [$offer]));

        self::assertSame(VoterInterface::ACCESS_DENIED, $voter->vote($this->token(), new ConsentState($form, participant: $participant), ['view']));
    }

    public function testDeniesHostWhenTheStateCarriesNoForm(): void
    {
        $voter = new ConsentStateVoter($this->security(admin: false));

        self::assertSame(VoterInterface::ACCESS_DENIED, $voter->vote($this->token(), new ConsentState(), ['view']));
    }

    private function token(): TokenInterface
    {
        $token = $this->createStub(TokenInterface::class);
        $token->method('getUser')->willReturn(new User());

        return $token;
    }

    /**
     * @param OfferInterface[] $viewableOffers
     */
    private function security(bool $admin, array $viewableOffers = []): Security
    {
        $security = $this->createMock(Security::class);
        $security->method('isGranted')->willReturnCallback(
            static fn (mixed $attribute, mixed $subject = null): bool => match ($attribute) {
                'ROLE_ADMIN' => $admin,
                'participants.view' => \in_array($subject, $viewableOffers, true),
                default => false,
            }
        );

        return $security;
    }

    private function offer(Edition $edition): OfferInterface
    {
        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getEdition')->willReturn($edition);

        return $offer;
    }

    private function participantWithAttendanceOn(OfferInterface $offer): ParticipantInterface
    {
        $participant = $this->createMock(ParticipantInterface::class);
        $participant->method('getAttendances')->willReturn(new ArrayCollection([new Attendance($offer, $participant)]));

        return $participant;
    }
}
