<?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\DataAccess\HostDataAccess;
use Ferienpass\CoreBundle\Entity\Edition;
use Ferienpass\CoreBundle\Entity\Host;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\Enum\OfferTransition;
use Ferienpass\CoreBundle\Repository\HostRepository;
use Ferienpass\CoreBundle\Security\Voter\OfferVoter;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

class OfferVoterTest extends TestCase
{
    public function testCanFinalizeOnlyWithId(): void
    {
        $security = $this->createStub(Security::class);
        $hosts = $this->createStub(HostRepository::class);
        $voter = new OfferVoter($security, $hosts, $this->hostDataAccess(), true, true);

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

        // Case 1: Offer without ID
        $offerWithoutId = $this->createStub(OfferInterface::class);
        $offerWithoutId->method('getId')->willReturn(null);

        // Mock canView (ROLE_ADMIN gives view permission)
        $security->method('isGranted')->willReturn(true);

        $this->assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offerWithoutId, [OfferTransition::FINALIZE]));

        // Case 2: Offer with ID
        $offerWithId = $this->createStub(OfferInterface::class);
        $offerWithId->method('getId')->willReturn(1);

        $this->assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offerWithId, [OfferTransition::FINALIZE]));
    }

    public function testSupportsReturnsFalseForNonOfferSubject(): void
    {
        [$voter, $token] = $this->makeAdminVoter();

        $result = $voter->vote($token, new \stdClass(), ['view']);

        self::assertSame(Voter::ACCESS_ABSTAIN, $result);
    }

    public function testSupportsReturnsFalseForUnknownAttribute(): void
    {
        [$voter, $token] = $this->makeAdminVoter();
        $offer = $this->createStub(OfferInterface::class);

        $result = $voter->vote($token, $offer, ['unknown_operation']);

        self::assertSame(Voter::ACCESS_ABSTAIN, $result);
    }

    public function testNonUserTokenDeniesAccess(): void
    {
        $security = $this->createStub(Security::class);
        $hosts = $this->createStub(HostRepository::class);
        $voter = new OfferVoter($security, $hosts, $this->hostDataAccess());

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

        $offer = $this->createStub(OfferInterface::class);

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, ['view']));
    }

    public function testAdminCanView(): void
    {
        [$voter, $token] = $this->makeAdminVoter();
        $offer = $this->createStub(OfferInterface::class);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, ['view']));
    }

    public function testAdminCanPublish(): void
    {
        [$voter, $token] = $this->makeAdminVoter();
        $offer = $this->createStub(OfferInterface::class);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, [OfferTransition::PUBLISH]));
    }

    public function testAdminCanUnpublish(): void
    {
        [$voter, $token] = $this->makeAdminVoter();
        $offer = $this->createStub(OfferInterface::class);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, [OfferTransition::UNPUBLISH]));
    }

    public function testAdminCanApprove(): void
    {
        [$voter, $token] = $this->makeAdminVoter();
        $offer = $this->createStub(OfferInterface::class);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, [OfferTransition::APPROVE]));
    }

    public function testAdminCanSetToDraft(): void
    {
        [$voter, $token] = $this->makeAdminVoter();
        $offer = $this->createStub(OfferInterface::class);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, [OfferTransition::TO_DRAFT]));
    }

    public function testNonAdminCannotPublish(): void
    {
        [$voter, $token] = $this->makeNonAdminVoter();
        $offer = $this->createStub(OfferInterface::class);

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, [OfferTransition::PUBLISH]));
    }

    public function testNonAdminCannotApprove(): void
    {
        [$voter, $token] = $this->makeNonAdminVoter();
        $offer = $this->createStub(OfferInterface::class);

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, [OfferTransition::APPROVE]));
    }

    public function testNonAdminCannotSetToDraft(): void
    {
        [$voter, $token] = $this->makeNonAdminVoter();
        $offer = $this->createStub(OfferInterface::class);

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, [OfferTransition::TO_DRAFT]));
    }

    public function testNonAdminWithNoHostsDeniedView(): void
    {
        [$voter, $token] = $this->makeNonAdminVoter();
        $offer = $this->createStub(OfferInterface::class);
        $offer->method('getHosts')->willReturn(new ArrayCollection());

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, ['view']));
    }

    public function testAdminCanAddParticipants(): void
    {
        [$voter, $token] = $this->makeAdminVoter();
        $offer = $this->createStub(OfferInterface::class);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, ['participants.add']));
    }

    public function testNonAdminWithNoHostsCannotAddParticipants(): void
    {
        [$voter, $token] = $this->makeNonAdminVoter(hostsCanAdd: true);
        $offer = $this->createStub(OfferInterface::class);
        $offer->method('getHosts')->willReturn(new ArrayCollection());

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, ['participants.add']));
    }

    public function testAdminCanEditParticipants(): void
    {
        [$voter, $token] = $this->makeAdminVoter();
        $offer = $this->createStub(OfferInterface::class);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, ['participants.edit']));
    }

    public function testAdminCanRejectParticipants(): void
    {
        [$voter, $token] = $this->makeAdminVoter();
        $offer = $this->createStub(OfferInterface::class);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, ['participants.reject']));
    }

    public function testAdminCanConfirmParticipants(): void
    {
        [$voter, $token] = $this->makeAdminVoter();
        $offer = $this->createStub(OfferInterface::class);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, ['participants.confirm']));
    }

    public function testAdminCanComplete(): void
    {
        [$voter, $token] = $this->makeAdminVoter();
        $offer = $this->createStub(OfferInterface::class);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, [OfferTransition::COMPLETE]));
    }

    public function testAdminCanCancel(): void
    {
        [$voter, $token] = $this->makeAdminVoter();
        $offer = $this->createStub(OfferInterface::class);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, [OfferTransition::CANCEL]));
    }

    public function testAdminCanRelaunch(): void
    {
        [$voter, $token] = $this->makeAdminVoter();
        $offer = $this->createStub(OfferInterface::class);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, [OfferTransition::RELAUNCH]));
    }

    public function testNonAdminWithMatchingHostCanView(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost();
        $offer = $this->makeOfferWithHost($host);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, ['view']));
    }

    public function testNonAdminCanCancelWhenHostsCanEdit(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost(hostsCanEdit: true);
        $offer = $this->makeOfferWithHost($host);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, [OfferTransition::CANCEL]));
    }

    public function testNonAdminCannotCancelWhenHostsCannotEdit(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost(hostsCanEdit: false);
        $offer = $this->makeOfferWithHost($host);

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, [OfferTransition::CANCEL]));
    }

    public function testNonAdminCanRelaunchWhenHostsCanEdit(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost(hostsCanEdit: true);
        $offer = $this->makeOfferWithHost($host);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, [OfferTransition::RELAUNCH]));
    }

    public function testNonAdminCanAddParticipantsWhenHostsCanAdd(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost(hostsCanAdd: true);
        $offer = $this->makeOfferWithHost($host);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, ['participants.add']));
    }

    public function testNonAdminCannotAddParticipantsWhenHostsCannotAdd(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost(hostsCanAdd: false);
        $offer = $this->makeOfferWithHost($host);

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, ['participants.add']));
    }

    public function testNonAdminCanEditParticipantsWhenHostsCanEdit(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost(hostsCanEdit: true);
        $offer = $this->makeOfferWithHost($host);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, ['participants.edit']));
    }

    public function testNonAdminCanRejectParticipantsWhenHostsCanEdit(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost(hostsCanEdit: true);
        $offer = $this->makeOfferWithHost($host);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, ['participants.reject']));
    }

    public function testNonAdminCanConfirmParticipantsWhenHostsCanEdit(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost(hostsCanEdit: true);
        $offer = $this->makeOfferWithHost($host);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, ['participants.confirm']));
    }

    public function testNonAdminWithMatchingHostCanComplete(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost();
        $offer = $this->makeOfferWithHost($host);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, [OfferTransition::COMPLETE]));
    }

    public function testNonAdminCannotCompleteWithoutView(): void
    {
        [$voter, $token] = $this->makeNonAdminVoter();
        $offer = $this->createStub(OfferInterface::class);
        $offer->method('getHosts')->willReturn(new ArrayCollection());

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, [OfferTransition::COMPLETE]));
    }

    public function testAdminCanViewParticipants(): void
    {
        [$voter, $token] = $this->makeAdminVoter();
        $offer = $this->createStub(OfferInterface::class);
        $edition = $this->createStub(Edition::class);
        $offer->method('getEdition')->willReturn($edition);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, ['participants.view']));
    }

    public function testNonAdminCannotViewParticipantsWithoutEdition(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost();
        $offer = $this->makeOfferWithHost($host);
        $offer->method('getEdition')->willReturn(null);

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, ['participants.view']));
    }

    public function testNonAdminCannotViewParticipantsWhenListNotReleased(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost();
        $offer = $this->makeOfferWithHost($host);
        $edition = $this->createStub(Edition::class);
        $edition->method('isParticipantListReleased')->willReturn(false);
        $offer->method('getEdition')->willReturn($edition);

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, ['participants.view']));
    }

    public function testNonAdminCanViewParticipantsWhenListReleased(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost();
        $offer = $this->makeOfferWithHost($host);
        $edition = $this->createStub(Edition::class);
        $edition->method('isParticipantListReleased')->willReturn(true);
        $offer->method('getEdition')->willReturn($edition);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, ['participants.view']));
    }

    public function testAdminCanTransferParticipants(): void
    {
        [$voter, $token] = $this->makeAdminVoter();
        $offer = $this->createStub(OfferInterface::class);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, ['participants.transfer']));
    }

    public function testNonAdminCanTransferParticipantsWhenAllowedAndListReleased(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost(hostsCanTransfer: true);
        $offer = $this->makeOfferWithHost($host);
        $edition = $this->createStub(Edition::class);
        $edition->method('isParticipantListReleased')->willReturn(true);
        $offer->method('getEdition')->willReturn($edition);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, ['participants.transfer']));
    }

    public function testNonAdminCannotTransferParticipantsWhenFlagDisabled(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost(hostsCanTransfer: false);
        $offer = $this->makeOfferWithHost($host);
        $edition = $this->createStub(Edition::class);
        $edition->method('isParticipantListReleased')->willReturn(true);
        $offer->method('getEdition')->willReturn($edition);

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, ['participants.transfer']));
    }

    public function testNonAdminCannotTransferParticipantsWhenListNotReleased(): void
    {
        // Even with the flag on, transfer requires participant-view access,
        // which is denied until the edition releases the participant list.
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost(hostsCanTransfer: true);
        $offer = $this->makeOfferWithHost($host);
        $edition = $this->createStub(Edition::class);
        $edition->method('isParticipantListReleased')->willReturn(false);
        $offer->method('getEdition')->willReturn($edition);

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, ['participants.transfer']));
    }

    public function testNonAdminCanFinalizeWithIdAndView(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost();
        $offer = $this->makeOfferWithHost($host);
        $offer->method('getId')->willReturn(99);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, [OfferTransition::FINALIZE]));
    }

    public function testNonAdminCannotFinalizeWithoutId(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost();
        $offer = $this->makeOfferWithHost($host);
        $offer->method('getId')->willReturn(null);

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, [OfferTransition::FINALIZE]));
    }

    public function testNonAdminCanEditWhenNotPublishedAndEditionEditable(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost();
        $offer = $this->makeOfferWithHost($host);
        $offer->method('isPublished')->willReturn(false);
        $offer->method('isReviewed')->willReturn(false);

        $edition = $this->createStub(Edition::class);
        $edition->method('isEditableForHosts')->willReturn(true);
        $edition->method('getHosts')->willReturn(new ArrayCollection());
        $offer->method('getEdition')->willReturn($edition);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, ['edit']));
    }

    public function testNonAdminCannotEditWhenPublished(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost();
        $offer = $this->makeOfferWithHost($host);
        $offer->method('isPublished')->willReturn(true);
        $offer->method('isReviewed')->willReturn(false);

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, ['edit']));
    }

    public function testNonAdminCannotEditWhenReviewed(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost();
        $offer = $this->makeOfferWithHost($host);
        $offer->method('isPublished')->willReturn(false);
        $offer->method('isReviewed')->willReturn(true);

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, ['edit']));
    }

    public function testNonAdminCannotUnpublish(): void
    {
        [$voter, $token] = $this->makeNonAdminVoter();
        $offer = $this->createStub(OfferInterface::class);

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, [OfferTransition::UNPUBLISH]));
    }

    public function testNonAdminCanPublishWhenEditionAllowsPublishing(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost();
        $offer = $this->makePublishableOffer($host, true);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, [OfferTransition::PUBLISH]));
    }

    public function testNonAdminCanUnpublishWhenEditionAllowsPublishing(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost();
        $offer = $this->makePublishableOffer($host, true);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, [OfferTransition::UNPUBLISH]));
    }

    public function testNonAdminCanApproveWhenEditionAllowsPublishing(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost();
        $offer = $this->makePublishableOffer($host, true);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, [OfferTransition::APPROVE]));
    }

    public function testNonAdminCanUnapproveWhenEditionAllowsPublishing(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost();
        $offer = $this->makePublishableOffer($host, true);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, [OfferTransition::UNAPPROVE]));
    }

    public function testNonAdminCanSetToDraftWhenEditionAllowsPublishing(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost();
        $offer = $this->makePublishableOffer($host, true);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, [OfferTransition::TO_DRAFT]));
    }

    public function testNonAdminCannotPublishWhenEditionDisallowsPublishing(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost();
        $offer = $this->makePublishableOffer($host, false);

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, [OfferTransition::PUBLISH]));
    }

    public function testNonAdminCannotPublishWhenCannotViewEvenIfEditionAllows(): void
    {
        // Publishing edition, but the offer belongs to a different host → no view access.
        [$voter, $token] = $this->makeNonAdminVoter();
        $offer = $this->createStub(OfferInterface::class);
        $offer->method('getHosts')->willReturn(new ArrayCollection());
        $edition = $this->createStub(Edition::class);
        $edition->method('hostsCanPublish')->willReturn(true);
        $offer->method('getEdition')->willReturn($edition);

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, [OfferTransition::PUBLISH]));
    }

    public function testAdminCanCreate(): void
    {
        [$voter, $token] = $this->makeAdminVoter();
        $offer = $this->createStub(OfferInterface::class);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, ['create']));
    }

    public function testNonAdminCannotCreateWithoutEdition(): void
    {
        [$voter, $token] = $this->makeNonAdminVoter();
        $offer = $this->createStub(OfferInterface::class);
        $offer->method('getEdition')->willReturn(null);

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, ['create']));
    }

    public function testNonAdminCanCreateWhenEditionHasActiveTasksAndUserIsAllowedHost(): void
    {
        [$voter, $token] = $this->makeNonAdminVoter();

        $edition = $this->createStub(Edition::class);
        $edition->method('getHosts')->willReturn(new ArrayCollection());
        $edition->method('getActiveTasks')->willReturn(new ArrayCollection(['task']));

        $offer = $this->createStub(OfferInterface::class);
        $offer->method('getEdition')->willReturn($edition);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, ['create']));
    }

    public function testNonAdminCannotCreateWhenEditionHasNoActiveTasks(): void
    {
        [$voter, $token] = $this->makeNonAdminVoter();

        $edition = $this->createStub(Edition::class);
        $edition->method('getHosts')->willReturn(new ArrayCollection());
        $edition->method('getActiveTasks')->willReturn(new ArrayCollection());

        $offer = $this->createStub(OfferInterface::class);
        $offer->method('getEdition')->willReturn($edition);

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, ['create']));
    }

    public function testAdminCanDelete(): void
    {
        [$voter, $token] = $this->makeAdminVoter();
        $offer = $this->createStub(OfferInterface::class);
        $offer->method('getHosts')->willReturn(new ArrayCollection());

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, ['delete']));
    }

    public function testNonAdminCannotDeleteWithoutView(): void
    {
        [$voter, $token] = $this->makeNonAdminVoter();
        $offer = $this->createStub(OfferInterface::class);
        $offer->method('getHosts')->willReturn(new ArrayCollection());

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, ['delete']));
    }

    public function testNonAdminCannotDeleteWhenAttendancesExist(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost();
        $offer = $this->makeOfferWithHost($host);
        $offer->method('getAttendances')->willReturn(new ArrayCollection(['attendance']));

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, ['delete']));
    }

    public function testNonAdminCanDeleteWhenNoAttendancesAndEditionEditable(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost();
        $offer = $this->makeOfferWithHost($host);
        $offer->method('getAttendances')->willReturn(new ArrayCollection());

        $edition = $this->createStub(Edition::class);
        $edition->method('isEditableForHosts')->willReturn(true);
        $edition->method('getHosts')->willReturn(new ArrayCollection());
        $offer->method('getEdition')->willReturn($edition);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, ['delete']));
    }

    public function testAdminCanCopy(): void
    {
        [$voter, $token] = $this->makeAdminVoter();
        $offer = $this->createStub(OfferInterface::class);
        $offer->method('getHosts')->willReturn(new ArrayCollection());

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, ['copy']));
    }

    public function testNonAdminCannotCopyWithoutEdition(): void
    {
        [$voter, $token] = $this->makeNonAdminVoter();
        $offer = $this->createStub(OfferInterface::class);
        $offer->method('getEdition')->willReturn(null);
        $offer->method('getHosts')->willReturn(new ArrayCollection());

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, ['copy']));
    }

    public function testNonAdminCanEditWhenEditionRestrictsToAllowedHost(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost();
        $offer = $this->makeOfferWithHost($host);
        $offer->method('isPublished')->willReturn(false);
        $offer->method('isReviewed')->willReturn(false);

        $edition = $this->createStub(Edition::class);
        $edition->method('isEditableForHosts')->willReturn(true);
        // Edition has non-empty hosts including the user's host → userIsAllowedHost closures run
        $edition->method('getHosts')->willReturn(new ArrayCollection([$host]));
        $offer->method('getEdition')->willReturn($edition);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, ['edit']));
    }

    public function testNonAdminCannotEditWhenEditionRestrictsHostsAndUserNotAllowed(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost();
        $offer = $this->makeOfferWithHost($host);
        $offer->method('isPublished')->willReturn(false);
        $offer->method('isReviewed')->willReturn(false);

        $otherHost = $this->createStub(Host::class);
        $otherHost->method('getId')->willReturn(99);

        $edition = $this->createStub(Edition::class);
        $edition->method('isEditableForHosts')->willReturn(true);
        // Edition has a different host → user is not in the allowed list
        $edition->method('getHosts')->willReturn(new ArrayCollection([$otherHost]));
        $offer->method('getEdition')->willReturn($edition);

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, ['edit']));
    }

    public function testNonAdminCanCreateWhenEditionRestrictsToAllowedHost(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost();
        $offer = $this->makeOfferWithHost($host);

        $edition = $this->createStub(Edition::class);
        // Non-empty hosts list including user's host
        $edition->method('getHosts')->willReturn(new ArrayCollection([$host]));
        $edition->method('getActiveTasks')->willReturn(new ArrayCollection(['task']));
        $offer->method('getEdition')->willReturn($edition);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, ['create']));
    }

    public function testAdminCanEdit(): void
    {
        [$voter, $token] = $this->makeAdminVoter();
        $offer = $this->createStub(OfferInterface::class);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, ['edit']));
    }

    public function testNonAdminCannotEditWhenCannotView(): void
    {
        [$voter, $token] = $this->makeNonAdminVoter();
        $offer = $this->createStub(OfferInterface::class);
        $offer->method('getHosts')->willReturn(new ArrayCollection());

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, ['edit']));
    }

    public function testNonAdminCannotCreateWhenEditionRestrictsHostsAndUserNotAllowed(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost();
        $offer = $this->makeOfferWithHost($host);

        $otherHost = $this->createStub(Host::class);
        $otherHost->method('getId')->willReturn(99);

        $edition = $this->createStub(Edition::class);
        $edition->method('getHosts')->willReturn(new ArrayCollection([$otherHost]));
        $offer->method('getEdition')->willReturn($edition);

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, ['create']));
    }

    public function testNonAdminCannotCancelWhenCannotView(): void
    {
        [$voter, $token] = $this->makeNonAdminVoter(hostsCanEdit: true);
        $offer = $this->createStub(OfferInterface::class);
        $offer->method('getHosts')->willReturn(new ArrayCollection());

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, [OfferTransition::CANCEL]));
    }

    public function testNonAdminCannotEditParticipantsWhenCannotView(): void
    {
        [$voter, $token] = $this->makeNonAdminVoter(hostsCanEdit: true);
        $offer = $this->createStub(OfferInterface::class);
        $offer->method('getHosts')->willReturn(new ArrayCollection());

        self::assertSame(Voter::ACCESS_DENIED, $voter->vote($token, $offer, ['participants.edit']));
    }

    public function testNonAdminCannotDeleteWhenEditionRestrictsToAllowedHost(): void
    {
        [$voter, $token, $host] = $this->makeNonAdminVoterWithHost();
        $offer = $this->makeOfferWithHost($host);
        $offer->method('getAttendances')->willReturn(new ArrayCollection());

        $edition = $this->createStub(Edition::class);
        $edition->method('isEditableForHosts')->willReturn(true);
        // Non-empty hosts list including user's host
        $edition->method('getHosts')->willReturn(new ArrayCollection([$host]));
        $offer->method('getEdition')->willReturn($edition);

        self::assertSame(Voter::ACCESS_GRANTED, $voter->vote($token, $offer, ['delete']));
    }

    private function hostDataAccess(bool $hasAccess = true): HostDataAccess
    {
        $hostDataAccess = $this->createStub(HostDataAccess::class);
        $hostDataAccess->method('hasAccess')->willReturn($hasAccess);

        return $hostDataAccess;
    }

    private function makeAdminVoter(): array
    {
        $security = $this->createStub(Security::class);
        $security->method('isGranted')->willReturn(true);
        $hosts = $this->createStub(HostRepository::class);
        $voter = new OfferVoter($security, $hosts, $this->hostDataAccess());

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

        return [$voter, $token];
    }

    private function makeNonAdminVoter(bool $hostsCanAdd = false, bool $hostsCanEdit = false, bool $hostsCanTransfer = false): array
    {
        $security = $this->createStub(Security::class);
        $security->method('isGranted')->willReturn(false);
        $hosts = $this->createStub(HostRepository::class);
        $hosts->method('findByUser')->willReturn([]);
        $voter = new OfferVoter($security, $hosts, $this->hostDataAccess(), $hostsCanAdd, $hostsCanEdit, $hostsCanTransfer);

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

        return [$voter, $token];
    }

    /**
     * Returns [voter, token, host] where user's hosts include $host (so canView returns true for offers with $host).
     */
    private function makeNonAdminVoterWithHost(bool $hostsCanAdd = false, bool $hostsCanEdit = false, bool $hostsCanTransfer = false): array
    {
        $host = $this->createStub(Host::class);
        $host->method('getId')->willReturn(42);

        $security = $this->createStub(Security::class);
        $security->method('isGranted')->willReturn(false);

        $hostRepo = $this->createStub(HostRepository::class);
        $hostRepo->method('findByUser')->willReturn([$host]);

        $voter = new OfferVoter($security, $hostRepo, $this->hostDataAccess(), $hostsCanAdd, $hostsCanEdit, $hostsCanTransfer);

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

        return [$voter, $token, $host];
    }

    private function makeOfferWithHost(Host $host): OfferInterface
    {
        $offer = $this->createStub(OfferInterface::class);
        $offer->method('getHosts')->willReturn(new ArrayCollection([$host]));

        return $offer;
    }

    private function makePublishableOffer(Host $host, bool $hostsCanPublish): OfferInterface
    {
        $offer = $this->makeOfferWithHost($host);
        $edition = $this->createStub(Edition::class);
        $edition->method('hostsCanPublish')->willReturn($hostsCanPublish);
        $offer->method('getEdition')->willReturn($edition);

        return $offer;
    }
}
