<?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\ApplicationSystem\Lotting;

use Doctrine\Common\Collections\ArrayCollection;
use Ferienpass\CoreBundle\ApplicationSystem\Lotting\Assignment;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\AttendanceStash;
use Ferienpass\CoreBundle\Entity\EditionTask;
use Ferienpass\CoreBundle\Entity\FriendCode;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Entity\Participant\ParticipantInterface;
use Ferienpass\CoreBundle\Enum\AttendanceStatus;
use Ferienpass\CoreBundle\Enum\DecisionReason;
use Ferienpass\CoreBundle\Settings\SystemSettingsManager;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Uid\Uuid;

#[AllowMockObjectsWithoutExpectations]
final class AssignmentTest extends TestCase
{
    private SystemSettingsManager $settingsManager;

    protected function setUp(): void
    {
        $this->settingsManager = $this->createStub(SystemSettingsManager::class);
        $this->settingsManager->method('getPreferredPostalCodesArray')->willReturn([]);
    }

    public function testAllAttendancesFitInAvailableSeatsAllConfirmed(): void
    {
        // We need to create the offer first, but attendances need the offer reference.
        // Use a two-pass: create offer mock, then set attendances.
        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getMaxParticipants')->willReturn(10);
        $offer->method('getAttendancesConfirmed')->willReturn(new ArrayCollection());
        $offer->method('isVariantOf')->willReturn(false);
        $offer->method('getEdition')->willReturn(null);
        $offer->method('getAttendancesNotHidden')->willReturn(new ArrayCollection(array_fill(0, 15, null)));

        $statuses = [[], [], []];
        $attendances = [];
        for ($i = 0; $i < 3; ++$i) {
            $attendances[] = $this->createTrackedAttendance($offer, $statuses[$i]);
        }
        $offer->method('getAttendancesWaiting')->willReturn(new ArrayCollection($attendances));

        $this->createAssignment()->drawForOffer($offer);

        // All 3 should be confirmed (10 available, only 3 needed)
        for ($i = 0; $i < 3; ++$i) {
            self::assertSame([AttendanceStatus::CONFIRMED], $statuses[$i], "Attendance $i should be confirmed");
        }
    }

    public function testMoreAttendancesThanSeatsSomeWaitlisted(): void
    {
        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getMaxParticipants')->willReturn(2);
        $offer->method('getAttendancesConfirmed')->willReturn(new ArrayCollection());
        $offer->method('isVariantOf')->willReturn(false);
        $offer->method('getEdition')->willReturn(null);
        $offer->method('getAttendancesNotHidden')->willReturn(new ArrayCollection(array_fill(0, 15, null)));

        $statuses = [[], [], [], [], []];
        $attendances = [];
        for ($i = 0; $i < 5; ++$i) {
            $attendances[] = $this->createTrackedAttendance($offer, $statuses[$i]);
        }
        $offer->method('getAttendancesWaiting')->willReturn(new ArrayCollection($attendances));

        $this->createAssignment()->drawForOffer($offer);

        $confirmed = 0;
        $waitlisted = 0;
        for ($i = 0; $i < 5; ++$i) {
            self::assertCount(1, $statuses[$i], "Attendance $i should have exactly one status set");
            if (AttendanceStatus::CONFIRMED === $statuses[$i][0]) {
                ++$confirmed;
            } elseif (AttendanceStatus::WAITLISTED === $statuses[$i][0]) {
                ++$waitlisted;
            }
        }

        self::assertSame(2, $confirmed, 'Exactly 2 attendances should be confirmed (available seats)');
        self::assertSame(3, $waitlisted, 'Remaining 3 attendances should be waitlisted');
    }

    public function testMaxWaitlistLimitsWaitlistedExcessUnfulfilled(): void
    {
        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getMaxParticipants')->willReturn(1);
        $offer->method('getAttendancesConfirmed')->willReturn(new ArrayCollection());
        $offer->method('isVariantOf')->willReturn(false);
        $offer->method('getEdition')->willReturn(null);
        $offer->method('getAttendancesNotHidden')->willReturn(new ArrayCollection(array_fill(0, 15, null)));

        $statuses = [[], [], [], []];
        $attendances = [];
        for ($i = 0; $i < 4; ++$i) {
            $attendances[] = $this->createTrackedAttendance($offer, $statuses[$i]);
        }
        $offer->method('getAttendancesWaiting')->willReturn(new ArrayCollection($attendances));

        $this->createAssignment()->drawForOffer($offer, maxWaitlist: 1);

        $confirmed = 0;
        $waitlisted = 0;
        $unfulfilled = 0;
        for ($i = 0; $i < 4; ++$i) {
            self::assertCount(1, $statuses[$i]);
            match ($statuses[$i][0]) {
                AttendanceStatus::CONFIRMED => $confirmed++,
                AttendanceStatus::WAITLISTED => $waitlisted++,
                AttendanceStatus::UNFULFILLED => $unfulfilled++,
                default => null,
            };
        }

        self::assertSame(1, $confirmed);
        self::assertSame(1, $waitlisted);
        self::assertSame(2, $unfulfilled);
    }

    public function testMaxWaitlistZeroAllNonConfirmedUnfulfilled(): void
    {
        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getMaxParticipants')->willReturn(1);
        $offer->method('getAttendancesConfirmed')->willReturn(new ArrayCollection());
        $offer->method('isVariantOf')->willReturn(false);
        $offer->method('getEdition')->willReturn(null);
        $offer->method('getAttendancesNotHidden')->willReturn(new ArrayCollection(array_fill(0, 15, null)));

        $statuses = [[], [], []];
        $attendances = [];
        for ($i = 0; $i < 3; ++$i) {
            $attendances[] = $this->createTrackedAttendance($offer, $statuses[$i]);
        }
        $offer->method('getAttendancesWaiting')->willReturn(new ArrayCollection($attendances));

        $this->createAssignment()->drawForOffer($offer, maxWaitlist: 0);

        $confirmed = 0;
        $unfulfilled = 0;
        for ($i = 0; $i < 3; ++$i) {
            match ($statuses[$i][0]) {
                AttendanceStatus::CONFIRMED => $confirmed++,
                AttendanceStatus::UNFULFILLED => $unfulfilled++,
                default => null,
            };
        }

        self::assertSame(1, $confirmed);
        self::assertSame(2, $unfulfilled, 'All non-confirmed should be unfulfilled when maxWaitlist=0');
    }

    public function testMaxWaitlistNullAllNonConfirmedWaitlisted(): void
    {
        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getMaxParticipants')->willReturn(1);
        $offer->method('getAttendancesConfirmed')->willReturn(new ArrayCollection());
        $offer->method('isVariantOf')->willReturn(false);
        $offer->method('getEdition')->willReturn(null);
        $offer->method('getAttendancesNotHidden')->willReturn(new ArrayCollection(array_fill(0, 15, null)));

        $statuses = [[], [], []];
        $attendances = [];
        for ($i = 0; $i < 3; ++$i) {
            $attendances[] = $this->createTrackedAttendance($offer, $statuses[$i]);
        }
        $offer->method('getAttendancesWaiting')->willReturn(new ArrayCollection($attendances));

        $this->createAssignment()->drawForOffer($offer, maxWaitlist: null);

        $confirmed = 0;
        $waitlisted = 0;
        for ($i = 0; $i < 3; ++$i) {
            match ($statuses[$i][0]) {
                AttendanceStatus::CONFIRMED => $confirmed++,
                AttendanceStatus::WAITLISTED => $waitlisted++,
                default => null,
            };
        }

        self::assertSame(1, $confirmed);
        self::assertSame(2, $waitlisted, 'All non-confirmed should be waitlisted when maxWaitlist=null');
    }

    public function testConfirmedAttendancesReduceAvailableSeats(): void
    {
        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getMaxParticipants')->willReturn(5);
        // 3 already confirmed, so only 2 seats available
        $offer->method('getAttendancesConfirmed')->willReturn(new ArrayCollection(array_fill(0, 3, null)));
        $offer->method('isVariantOf')->willReturn(false);
        $offer->method('getEdition')->willReturn(null);
        $offer->method('getAttendancesNotHidden')->willReturn(new ArrayCollection(array_fill(0, 15, null)));

        $statuses = [[], [], [], []];
        $attendances = [];
        for ($i = 0; $i < 4; ++$i) {
            $attendances[] = $this->createTrackedAttendance($offer, $statuses[$i]);
        }
        $offer->method('getAttendancesWaiting')->willReturn(new ArrayCollection($attendances));

        $this->createAssignment()->drawForOffer($offer);

        $confirmed = 0;
        $waitlisted = 0;
        for ($i = 0; $i < 4; ++$i) {
            match ($statuses[$i][0]) {
                AttendanceStatus::CONFIRMED => $confirmed++,
                AttendanceStatus::WAITLISTED => $waitlisted++,
                default => null,
            };
        }

        self::assertSame(2, $confirmed, 'Only 2 seats available (5 max - 3 confirmed)');
        self::assertSame(2, $waitlisted);
    }

    public function testZeroMaxParticipantsNoSeatsAvailable(): void
    {
        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getMaxParticipants')->willReturn(0);
        $offer->method('getAttendancesConfirmed')->willReturn(new ArrayCollection());
        $offer->method('isVariantOf')->willReturn(false);
        $offer->method('getEdition')->willReturn(null);
        $offer->method('getAttendancesNotHidden')->willReturn(new ArrayCollection(array_fill(0, 15, null)));

        $statuses = [[], []];
        $attendances = [];
        for ($i = 0; $i < 2; ++$i) {
            $attendances[] = $this->createTrackedAttendance($offer, $statuses[$i]);
        }
        $offer->method('getAttendancesWaiting')->willReturn(new ArrayCollection($attendances));

        $this->createAssignment()->drawForOffer($offer);

        for ($i = 0; $i < 2; ++$i) {
            self::assertNotSame(AttendanceStatus::CONFIRMED, $statuses[$i][0], 'No one should be confirmed with 0 max participants');
        }
    }

    public function testDuplicateParticipantsAreSkipped(): void
    {
        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getMaxParticipants')->willReturn(10);
        $offer->method('getAttendancesConfirmed')->willReturn(new ArrayCollection());
        $offer->method('isVariantOf')->willReturn(false);
        $offer->method('getEdition')->willReturn(null);
        $offer->method('getAttendancesNotHidden')->willReturn(new ArrayCollection(array_fill(0, 15, null)));

        $task = $this->createMock(EditionTask::class);
        $task->method('getType')->willReturn('application_system');
        $task->method('getMaxApplications')->willReturn(0);

        // Same participant for two attendances
        $participant = $this->createMock(ParticipantInterface::class);
        $participant->method('getFriendCodes')->willReturn(new ArrayCollection());
        $participant->method('getPostalCode')->willReturn(null);
        $participant->method('getAttendancesNotHidden')->willReturn(new ArrayCollection());

        $drawAttendance = $this->createMock(Attendance::class);
        $drawAttendance->method('getOffer')->willReturn($offer);
        $drawAttendance->method('getTask')->willReturn($task);
        $drawAttendance->method('getUserPriority')->willReturn(1);
        $drawAttendance->method('isWaitlisted')->willReturn(true);
        $drawAttendance->method('isUnfulfilled')->willReturn(false);
        $participant->method('getAttendancesNotWithdrawn')->willReturn(new ArrayCollection([$drawAttendance]));

        $status1 = [];
        $status2 = [];

        $a1 = $this->createMock(Attendance::class);
        $a1->method('getUuid')->willReturn(Uuid::v7());
        $a1->method('getParticipant')->willReturn($participant);
        $a1->method('getOffer')->willReturn($offer);
        $a1->method('getTask')->willReturn(null);
        $a1->method('getUserPriority')->willReturn(1);
        $a1->method('isWaitlisted')->willReturn(true);
        $a1->method('isUnfulfilled')->willReturn(false);
        $a1->method('lottingDecision')->willReturnCallback(static function (string $s) use (&$status1): void { $status1[] = $s; });

        $a2 = $this->createMock(Attendance::class);
        $a2->method('getUuid')->willReturn(Uuid::v7());
        $a2->method('getParticipant')->willReturn($participant);
        $a2->method('getOffer')->willReturn($offer);
        $a2->method('getTask')->willReturn(null);
        $a2->method('getUserPriority')->willReturn(1);
        $a2->method('isWaitlisted')->willReturn(true);
        $a2->method('isUnfulfilled')->willReturn(false);
        $a2->method('lottingDecision')->willReturnCallback(static function (string $s) use (&$status2): void { $status2[] = $s; });

        $offer->method('getAttendancesWaiting')->willReturn(new ArrayCollection([$a1, $a2]));

        $this->createAssignment()->drawForOffer($offer);

        // Only one attendance should get a status; the other's participant is already in a Seat
        $totalStatusCalls = \count($status1) + \count($status2);
        self::assertSame(1, $totalStatusCalls, 'Only one attendance should have setStatus called (duplicate participant is skipped)');
        $theStatus = !empty($status1) ? $status1[0] : $status2[0];
        self::assertSame(AttendanceStatus::CONFIRMED, $theStatus);
    }

    public function testFriendGroupSeatedTogether(): void
    {
        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getMaxParticipants')->willReturn(10);
        $offer->method('getAttendancesConfirmed')->willReturn(new ArrayCollection());
        $offer->method('isVariantOf')->willReturn(false);
        $offer->method('getEdition')->willReturn(null);
        $offer->method('getAttendancesNotHidden')->willReturn(new ArrayCollection(array_fill(0, 15, null)));

        $task = $this->createMock(EditionTask::class);
        $task->method('getType')->willReturn('application_system');
        $task->method('getMaxApplications')->willReturn(0);

        $participant1 = $this->createMock(ParticipantInterface::class);
        $participant2 = $this->createMock(ParticipantInterface::class);

        $friendCode = $this->createMock(FriendCode::class);
        $friendCode->method('getOffer')->willReturn($offer);
        $friendCode->method('getParticipants')->willReturn(new ArrayCollection([$participant1, $participant2]));

        $participant1->method('getFriendCodes')->willReturn(new ArrayCollection([$friendCode]));
        $participant2->method('getFriendCodes')->willReturn(new ArrayCollection([$friendCode]));
        $participant1->method('getPostalCode')->willReturn(null);
        $participant2->method('getPostalCode')->willReturn(null);
        $participant1->method('getAttendancesNotHidden')->willReturn(new ArrayCollection());
        $participant2->method('getAttendancesNotHidden')->willReturn(new ArrayCollection());

        $draw1 = $this->createMock(Attendance::class);
        $draw1->method('getOffer')->willReturn($offer);
        $draw1->method('getTask')->willReturn($task);
        $draw1->method('getUserPriority')->willReturn(1);
        $draw1->method('isWaitlisted')->willReturn(true);
        $draw1->method('isUnfulfilled')->willReturn(false);
        $participant1->method('getAttendancesNotWithdrawn')->willReturn(new ArrayCollection([$draw1]));

        $draw2 = $this->createMock(Attendance::class);
        $draw2->method('getOffer')->willReturn($offer);
        $draw2->method('getTask')->willReturn($task);
        $draw2->method('getUserPriority')->willReturn(1);
        $draw2->method('isWaitlisted')->willReturn(true);
        $draw2->method('isUnfulfilled')->willReturn(false);
        $participant2->method('getAttendancesNotWithdrawn')->willReturn(new ArrayCollection([$draw2]));

        $status1 = [];
        $a1 = $this->createTrackedAttendance($offer, $status1, $participant1);
        $status2 = [];
        $a2 = $this->createTrackedAttendance($offer, $status2, $participant2);

        $offer->method('getAttendancesWaiting')->willReturn(new ArrayCollection([$a1, $a2]));

        $this->createAssignment()->drawForOffer($offer);

        // Both friends should be confirmed together (plenty of seats)
        self::assertSame([AttendanceStatus::CONFIRMED], $status1);
        self::assertSame([AttendanceStatus::CONFIRMED], $status2);
    }

    public function testFriendGroupTooLargeForAvailableSeatsGetsWaitlisted(): void
    {
        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getMaxParticipants')->willReturn(1);
        $offer->method('getAttendancesConfirmed')->willReturn(new ArrayCollection());
        $offer->method('isVariantOf')->willReturn(false);
        $offer->method('getEdition')->willReturn(null);
        $offer->method('getAttendancesNotHidden')->willReturn(new ArrayCollection(array_fill(0, 15, null)));

        $task = $this->createMock(EditionTask::class);
        $task->method('getType')->willReturn('application_system');
        $task->method('getMaxApplications')->willReturn(0);

        $participant1 = $this->createMock(ParticipantInterface::class);
        $participant2 = $this->createMock(ParticipantInterface::class);

        $friendCode = $this->createMock(FriendCode::class);
        $friendCode->method('getOffer')->willReturn($offer);
        $friendCode->method('getParticipants')->willReturn(new ArrayCollection([$participant1, $participant2]));

        $participant1->method('getFriendCodes')->willReturn(new ArrayCollection([$friendCode]));
        $participant2->method('getFriendCodes')->willReturn(new ArrayCollection([$friendCode]));
        $participant1->method('getPostalCode')->willReturn(null);
        $participant2->method('getPostalCode')->willReturn(null);
        $participant1->method('getAttendancesNotHidden')->willReturn(new ArrayCollection());
        $participant2->method('getAttendancesNotHidden')->willReturn(new ArrayCollection());

        $draw1 = $this->createMock(Attendance::class);
        $draw1->method('getOffer')->willReturn($offer);
        $draw1->method('getTask')->willReturn($task);
        $draw1->method('getUserPriority')->willReturn(1);
        $draw1->method('isWaitlisted')->willReturn(true);
        $draw1->method('isUnfulfilled')->willReturn(false);
        $participant1->method('getAttendancesNotWithdrawn')->willReturn(new ArrayCollection([$draw1]));

        $draw2 = $this->createMock(Attendance::class);
        $draw2->method('getOffer')->willReturn($offer);
        $draw2->method('getTask')->willReturn($task);
        $draw2->method('getUserPriority')->willReturn(1);
        $draw2->method('isWaitlisted')->willReturn(true);
        $draw2->method('isUnfulfilled')->willReturn(false);
        $participant2->method('getAttendancesNotWithdrawn')->willReturn(new ArrayCollection([$draw2]));

        $status1 = [];
        $a1 = $this->createTrackedAttendance($offer, $status1, $participant1);
        $status2 = [];
        $a2 = $this->createTrackedAttendance($offer, $status2, $participant2);

        $offer->method('getAttendancesWaiting')->willReturn(new ArrayCollection([$a1, $a2]));

        $this->createAssignment()->drawForOffer($offer);

        // Friend group of 2 can't fit in 1 seat, both should be waitlisted
        self::assertSame([AttendanceStatus::WAITLISTED], $status1);
        self::assertSame([AttendanceStatus::WAITLISTED], $status2);
    }

    public function testStashIsSetOnAllAttendancesWhenProvided(): void
    {
        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getMaxParticipants')->willReturn(10);
        $offer->method('getAttendancesConfirmed')->willReturn(new ArrayCollection());
        $offer->method('isVariantOf')->willReturn(false);
        $offer->method('getEdition')->willReturn(null);
        $offer->method('getAttendancesNotHidden')->willReturn(new ArrayCollection(array_fill(0, 15, null)));

        $stash = new AttendanceStash('test-stash');
        $stashTracker = [false, false];

        $status1 = [];
        $a1 = $this->createTrackedAttendance($offer, $status1);
        $a1->expects(self::once())->method('setStash')->with($stash);

        $status2 = [];
        $a2 = $this->createTrackedAttendance($offer, $status2);
        $a2->expects(self::once())->method('setStash')->with($stash);

        $offer->method('getAttendancesWaiting')->willReturn(new ArrayCollection([$a1, $a2]));

        $this->createAssignment()->drawForOffer($offer, stash: $stash);
    }

    public function testStashNotSetWhenNull(): void
    {
        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getMaxParticipants')->willReturn(10);
        $offer->method('getAttendancesConfirmed')->willReturn(new ArrayCollection());
        $offer->method('isVariantOf')->willReturn(false);
        $offer->method('getEdition')->willReturn(null);
        $offer->method('getAttendancesNotHidden')->willReturn(new ArrayCollection(array_fill(0, 15, null)));

        $status1 = [];
        $a1 = $this->createTrackedAttendance($offer, $status1);
        $a1->expects(self::never())->method('setStash');

        $offer->method('getAttendancesWaiting')->willReturn(new ArrayCollection([$a1]));

        $this->createAssignment()->drawForOffer($offer, stash: null);
    }

    public function testStashIsSetOnBothConfirmedAndWaitlistedAttendances(): void
    {
        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getMaxParticipants')->willReturn(1);
        $offer->method('getAttendancesConfirmed')->willReturn(new ArrayCollection());
        $offer->method('isVariantOf')->willReturn(false);
        $offer->method('getEdition')->willReturn(null);
        $offer->method('getAttendancesNotHidden')->willReturn(new ArrayCollection(array_fill(0, 15, null)));

        $stash = new AttendanceStash('test');

        $status1 = [];
        $a1 = $this->createTrackedAttendance($offer, $status1);
        $a1->expects(self::once())->method('setStash')->with($stash);

        $status2 = [];
        $a2 = $this->createTrackedAttendance($offer, $status2);
        $a2->expects(self::once())->method('setStash')->with($stash);

        $offer->method('getAttendancesWaiting')->willReturn(new ArrayCollection([$a1, $a2]));

        $this->createAssignment()->drawForOffer($offer, stash: $stash);

        // Verify we have one confirmed and one waitlisted
        $allStatuses = array_merge($status1, $status2);
        self::assertContains(AttendanceStatus::CONFIRMED, $allStatuses);
        self::assertContains(AttendanceStatus::WAITLISTED, $allStatuses);
    }

    public function testSingleAttendanceConfirmedWhenSeatsAvailable(): void
    {
        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getMaxParticipants')->willReturn(5);
        $offer->method('getAttendancesConfirmed')->willReturn(new ArrayCollection());
        $offer->method('isVariantOf')->willReturn(false);
        $offer->method('getEdition')->willReturn(null);
        $offer->method('getAttendancesNotHidden')->willReturn(new ArrayCollection(array_fill(0, 15, null)));

        $status1 = [];
        $a1 = $this->createTrackedAttendance($offer, $status1);
        $offer->method('getAttendancesWaiting')->willReturn(new ArrayCollection([$a1]));

        $this->createAssignment()->drawForOffer($offer);

        self::assertSame([AttendanceStatus::CONFIRMED], $status1);
    }

    public function testPreferredPostalCodesFilterUnprivileged(): void
    {
        $postalCodes = ['12345', '67890'];
        $settingsManager = $this->createMock(SystemSettingsManager::class);
        $settingsManager->method('getPreferredPostalCodesArray')->willReturn($postalCodes);

        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getMaxParticipants')->willReturn(10);
        $offer->method('getAttendancesConfirmed')->willReturn(new ArrayCollection());
        $offer->method('isVariantOf')->willReturn(false);
        $offer->method('getEdition')->willReturn(null);
        $offer->method('getAttendancesNotHidden')->willReturn(new ArrayCollection(array_fill(0, 15, null)));

        $task = $this->createMock(EditionTask::class);
        $task->method('getType')->willReturn('application_system');
        $task->method('getMaxApplications')->willReturn(0);

        // Participant with an unprivileged postal code
        $participant = $this->createMock(ParticipantInterface::class);
        $participant->method('getFriendCodes')->willReturn(new ArrayCollection());
        $participant->method('getPostalCode')->willReturn('99999');
        $participant->method('getAttendancesNotHidden')->willReturn(new ArrayCollection());

        $drawAttendance = $this->createMock(Attendance::class);
        $drawAttendance->method('getOffer')->willReturn($offer);
        $drawAttendance->method('getTask')->willReturn($task);
        $drawAttendance->method('getUserPriority')->willReturn(1);
        $drawAttendance->method('isWaitlisted')->willReturn(true);
        $drawAttendance->method('isUnfulfilled')->willReturn(false);
        $participant->method('getAttendancesNotWithdrawn')->willReturn(new ArrayCollection([$drawAttendance]));

        $status1 = [];
        $a1 = $this->createTrackedAttendance($offer, $status1, $participant);
        $offer->method('getAttendancesWaiting')->willReturn(new ArrayCollection([$a1]));

        // Unprivileged postal code yields value 0, not picked by knapsack
        $assignment = new Assignment($settingsManager);
        $assignment->drawForOffer($offer);

        self::assertSame([AttendanceStatus::WAITLISTED], $status1, 'Unprivileged postal code should result in waitlisted');
    }

    public function testKnapsackPrefersHigherValueSeats(): void
    {
        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getMaxParticipants')->willReturn(1);
        $offer->method('getAttendancesConfirmed')->willReturn(new ArrayCollection());
        $offer->method('isVariantOf')->willReturn(false);
        $offer->method('getEdition')->willReturn(null);
        $offer->method('getAttendancesNotHidden')->willReturn(new ArrayCollection(array_fill(0, 15, null)));

        $task = $this->createMock(EditionTask::class);
        $task->method('getType')->willReturn('application_system');
        $task->method('getMaxApplications')->willReturn(0);

        // Participant with value (has draw attendances)
        $participantHigh = $this->createMock(ParticipantInterface::class);
        $participantHigh->method('getFriendCodes')->willReturn(new ArrayCollection());
        $participantHigh->method('getPostalCode')->willReturn(null);
        $participantHigh->method('getAttendancesNotHidden')->willReturn(new ArrayCollection());

        $drawHigh = $this->createMock(Attendance::class);
        $drawHigh->method('getOffer')->willReturn($offer);
        $drawHigh->method('getTask')->willReturn($task);
        $drawHigh->method('getUserPriority')->willReturn(1);
        $drawHigh->method('isWaitlisted')->willReturn(true);
        $drawHigh->method('isUnfulfilled')->willReturn(false);
        $participantHigh->method('getAttendancesNotWithdrawn')->willReturn(new ArrayCollection([$drawHigh]));

        // Participant with zero value (firstcome task)
        $participantLow = $this->createMock(ParticipantInterface::class);
        $participantLow->method('getFriendCodes')->willReturn(new ArrayCollection());
        $participantLow->method('getPostalCode')->willReturn(null);
        $participantLow->method('getAttendancesNotHidden')->willReturn(new ArrayCollection());
        $participantLow->method('getAttendancesNotWithdrawn')->willReturn(new ArrayCollection());

        $firstcomeTask = $this->createMock(EditionTask::class);
        $firstcomeTask->method('getType')->willReturn('firstcome');

        $statusHigh = [];
        $aHigh = $this->createTrackedAttendance($offer, $statusHigh, $participantHigh);
        $statusLow = [];
        $aLow = $this->createTrackedAttendance($offer, $statusLow, $participantLow, $firstcomeTask);

        $offer->method('getAttendancesWaiting')->willReturn(new ArrayCollection([$aLow, $aHigh]));

        $this->createAssignment()->drawForOffer($offer);

        // High-value attendance should be confirmed; low-value waitlisted
        self::assertSame([AttendanceStatus::CONFIRMED], $statusHigh, 'Higher-value seat should be confirmed');
        self::assertSame([AttendanceStatus::WAITLISTED], $statusLow, 'Zero-value seat should be waitlisted');
    }

    public function testFullyBookedOfferAllWaitlisted(): void
    {
        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getMaxParticipants')->willReturn(5);
        // Already 5 confirmed = 0 available
        $offer->method('getAttendancesConfirmed')->willReturn(new ArrayCollection(array_fill(0, 5, null)));
        $offer->method('isVariantOf')->willReturn(false);
        $offer->method('getEdition')->willReturn(null);
        $offer->method('getAttendancesNotHidden')->willReturn(new ArrayCollection(array_fill(0, 15, null)));

        $statuses = [[], [], []];
        $attendances = [];
        for ($i = 0; $i < 3; ++$i) {
            $attendances[] = $this->createTrackedAttendance($offer, $statuses[$i]);
        }
        $offer->method('getAttendancesWaiting')->willReturn(new ArrayCollection($attendances));

        $this->createAssignment()->drawForOffer($offer);

        for ($i = 0; $i < 3; ++$i) {
            self::assertSame(AttendanceStatus::WAITLISTED, $statuses[$i][0], 'All should be waitlisted when offer is fully booked');
        }
    }

    public function testSameSeedProducesReproducibleDraw(): void
    {
        // Five equally-valued applicants competing for a single seat: the winner is
        // determined solely by the shuffle, so a fixed seed must always pick the same
        // one — that reproducibility is what makes the draw auditable.
        $winnerFor = function (): int {
            $offer = $this->createMock(OfferInterface::class);
            $offer->method('getMaxParticipants')->willReturn(1);
            $offer->method('getAttendancesConfirmed')->willReturn(new ArrayCollection());
            $offer->method('isVariantOf')->willReturn(false);
            $offer->method('getEdition')->willReturn(null);
            $offer->method('getAttendancesNotHidden')->willReturn(new ArrayCollection(array_fill(0, 15, null)));

            $statuses = [[], [], [], [], []];
            $attendances = [];
            for ($i = 0; $i < 5; ++$i) {
                $attendances[] = $this->createTrackedAttendance($offer, $statuses[$i]);
            }
            $offer->method('getAttendancesWaiting')->willReturn(new ArrayCollection($attendances));

            $this->createAssignment()->drawForOffer($offer, seed: 4242);

            foreach ($statuses as $i => $status) {
                if ([AttendanceStatus::CONFIRMED] === $status) {
                    return $i;
                }
            }

            self::fail('Expected exactly one confirmed attendance');
        };

        self::assertSame($winnerFor(), $winnerFor(), 'Same seed must confirm the same applicant');
    }

    public function testMaxWaitlistDecrementsByGroupSeatCount(): void
    {
        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getMaxParticipants')->willReturn(0);
        $offer->method('getAttendancesConfirmed')->willReturn(new ArrayCollection());
        $offer->method('isVariantOf')->willReturn(false);
        $offer->method('getEdition')->willReturn(null);
        $offer->method('getAttendancesNotHidden')->willReturn(new ArrayCollection(array_fill(0, 15, null)));

        $statuses = [[], [], []];
        $attendances = [];
        for ($i = 0; $i < 3; ++$i) {
            $attendances[] = $this->createTrackedAttendance($offer, $statuses[$i]);
        }
        $offer->method('getAttendancesWaiting')->willReturn(new ArrayCollection($attendances));

        // maxWaitlist=2: first two seats (each 1 person) get waitlisted, third gets unfulfilled
        $this->createAssignment()->drawForOffer($offer, maxWaitlist: 2);

        $waitlisted = 0;
        $unfulfilled = 0;
        $confirmed = 0;
        for ($i = 0; $i < 3; ++$i) {
            match ($statuses[$i][0]) {
                AttendanceStatus::WAITLISTED => $waitlisted++,
                AttendanceStatus::UNFULFILLED => $unfulfilled++,
                AttendanceStatus::CONFIRMED => $confirmed++,
                default => null,
            };
        }

        self::assertSame(0, $confirmed, 'No seats available');
        self::assertSame(2, $waitlisted, 'First 2 seats should be waitlisted');
        self::assertSame(1, $unfulfilled, 'Third seat should be unfulfilled');
    }

    public function testGateExcludedAttendanceRecordsGateReason(): void
    {
        // A preferred-postal-code list is configured and the applicant lives outside it,
        // so the seat scores 0 and is never admitted even though capacity remains. The
        // decision must explain *why* it could not be confirmed.
        $settingsManager = $this->createMock(SystemSettingsManager::class);
        $settingsManager->method('getPreferredPostalCodesArray')->willReturn(['12345']);

        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getMaxParticipants')->willReturn(10);
        $offer->method('getAttendancesConfirmed')->willReturn(new ArrayCollection());
        $offer->method('isVariantOf')->willReturn(false);
        $offer->method('getEdition')->willReturn(null);
        $offer->method('getAttendancesNotHidden')->willReturn(new ArrayCollection(array_fill(0, 15, null)));

        $task = $this->createMock(EditionTask::class);
        $task->method('getType')->willReturn('application_system');
        $task->method('getMaxApplications')->willReturn(0);

        $participant = $this->createMock(ParticipantInterface::class);
        $participant->method('getFriendCodes')->willReturn(new ArrayCollection());
        $participant->method('getPostalCode')->willReturn('99999');
        $participant->method('getAttendancesNotHidden')->willReturn(new ArrayCollection());

        $drawAttendance = $this->createMock(Attendance::class);
        $drawAttendance->method('getOffer')->willReturn($offer);
        $drawAttendance->method('getTask')->willReturn($task);
        $drawAttendance->method('getUserPriority')->willReturn(1);
        $drawAttendance->method('isWaitlisted')->willReturn(true);
        $drawAttendance->method('isUnfulfilled')->willReturn(false);
        $participant->method('getAttendancesNotWithdrawn')->willReturn(new ArrayCollection([$drawAttendance]));

        $status = [];
        $reasons = [];
        $a1 = $this->createTrackedAttendance($offer, $status, $participant, reasonTracker: $reasons);
        $offer->method('getAttendancesWaiting')->willReturn(new ArrayCollection([$a1]));

        new Assignment($settingsManager)->drawForOffer($offer);

        self::assertSame([AttendanceStatus::WAITLISTED], $status);
        self::assertSame([DecisionReason::POSTAL_CODE_NOT_PREFERRED], $reasons);
    }

    public function testUnfulfilledWithoutWaitlistRecordsOutcompetedReason(): void
    {
        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getMaxParticipants')->willReturn(1);
        $offer->method('getAttendancesConfirmed')->willReturn(new ArrayCollection());
        $offer->method('isVariantOf')->willReturn(false);
        $offer->method('getEdition')->willReturn(null);
        $offer->method('getAttendancesNotHidden')->willReturn(new ArrayCollection(array_fill(0, 15, null)));

        $statuses = [[], [], []];
        $reasons = [[], [], []];
        $attendances = [];
        for ($i = 0; $i < 3; ++$i) {
            $attendances[] = $this->createTrackedAttendance($offer, $statuses[$i], reasonTracker: $reasons[$i]);
        }
        $offer->method('getAttendancesWaiting')->willReturn(new ArrayCollection($attendances));

        // No waitlist offered at all -> losing eligible seats are simply outcompeted.
        $this->createAssignment()->drawForOffer($offer, maxWaitlist: 0);

        for ($i = 0; $i < 3; ++$i) {
            if ([AttendanceStatus::UNFULFILLED] === $statuses[$i]) {
                self::assertSame([DecisionReason::OUTCOMPETED], $reasons[$i]);
            } else {
                self::assertSame([AttendanceStatus::CONFIRMED], $statuses[$i]);
                self::assertSame([null], $reasons[$i], 'Confirmed seats carry no reason');
            }
        }
    }

    public function testUnfulfilledWithExhaustedWaitlistRecordsWaitlistReason(): void
    {
        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getMaxParticipants')->willReturn(1);
        $offer->method('getAttendancesConfirmed')->willReturn(new ArrayCollection());
        $offer->method('isVariantOf')->willReturn(false);
        $offer->method('getEdition')->willReturn(null);
        $offer->method('getAttendancesNotHidden')->willReturn(new ArrayCollection(array_fill(0, 15, null)));

        $statuses = [[], [], [], []];
        $reasons = [[], [], [], []];
        $attendances = [];
        for ($i = 0; $i < 4; ++$i) {
            $attendances[] = $this->createTrackedAttendance($offer, $statuses[$i], reasonTracker: $reasons[$i]);
        }
        $offer->method('getAttendancesWaiting')->willReturn(new ArrayCollection($attendances));

        // 1 seat + 1 waitlist slot -> the remaining 2 lose to an exhausted waitlist.
        $this->createAssignment()->drawForOffer($offer, maxWaitlist: 1);

        $unfulfilledReasons = [];
        for ($i = 0; $i < 4; ++$i) {
            if ([AttendanceStatus::UNFULFILLED] === $statuses[$i]) {
                $unfulfilledReasons[] = $reasons[$i][0];
            }
        }

        self::assertCount(2, $unfulfilledReasons);
        self::assertSame([DecisionReason::WAITLIST_EXHAUSTED, DecisionReason::WAITLIST_EXHAUSTED], $unfulfilledReasons);
    }

    private function createAssignment(?SystemSettingsManager $settingsManager = null): Assignment
    {
        return new Assignment($settingsManager ?? $this->settingsManager);
    }

    /**
     * Creates a mock attendance that produces a positive Seat value.
     *
     * The status changes are tracked via $statusTracker (array passed by reference):
     * each lottingDecision() call appends the status string. When $reasonTracker is
     * given, the decision reason of each call is appended to it as well.
     */
    private function createTrackedAttendance(
        OfferInterface $offer,
        array &$statusTracker,
        ?ParticipantInterface $participant = null,
        ?EditionTask $firstcomeTask = null,
        ?array &$reasonTracker = null,
    ): Attendance {
        $task = $this->createMock(EditionTask::class);
        $task->method('getType')->willReturn('application_system');
        $task->method('getMaxApplications')->willReturn(0);

        $attendance = $this->createMock(Attendance::class);
        $attendance->method('getUuid')->willReturn(Uuid::v7());
        $attendance->method('getOffer')->willReturn($offer);
        $attendance->method('getTask')->willReturn($firstcomeTask);
        $attendance->method('getUserPriority')->willReturn(1);
        $attendance->method('isWaitlisted')->willReturn(true);
        $attendance->method('isUnfulfilled')->willReturn(false);
        $attendance->method('isConfirmed')->willReturn(false);

        if (null === $participant) {
            $participant = $this->createMock(ParticipantInterface::class);
            $participant->method('getFriendCodes')->willReturn(new ArrayCollection());
            $participant->method('getPostalCode')->willReturn(null);
            $participant->method('getAttendancesNotHidden')->willReturn(new ArrayCollection());

            // Make the attendance count in the draw: overbooked offer
            $drawAttendance = $this->createMock(Attendance::class);
            $drawAttendance->method('getOffer')->willReturn($offer);
            $drawAttendance->method('getTask')->willReturn($task);
            $drawAttendance->method('getUserPriority')->willReturn(1);
            $drawAttendance->method('isWaitlisted')->willReturn(true);
            $drawAttendance->method('isUnfulfilled')->willReturn(false);

            $participant->method('getAttendancesNotWithdrawn')->willReturn(new ArrayCollection([$drawAttendance]));
        }

        $attendance->method('getParticipant')->willReturn($participant);
        $attendance->method('lottingDecision')->willReturnCallback(static function (string $status, ?string $reason = null) use (&$statusTracker, &$reasonTracker): void {
            $statusTracker[] = $status;
            if (null !== $reasonTracker) {
                $reasonTracker[] = $reason;
            }
        });
        $attendance->method('setStash')->willReturnCallback(static function () {});

        return $attendance;
    }

    private function createOverbookedOffer(
        array $waitingAttendances,
        int $maxParticipants = 10,
        int $confirmedCount = 0,
    ): OfferInterface {
        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getAttendancesWaiting')->willReturn(new ArrayCollection($waitingAttendances));
        $offer->method('getMaxParticipants')->willReturn($maxParticipants);
        $offer->method('getAttendancesConfirmed')->willReturn(new ArrayCollection(array_fill(0, $confirmedCount, null)));
        $offer->method('isVariantOf')->willReturn(false);
        $offer->method('getEdition')->willReturn(null);
        // Overbooked so Seat::getValue() returns positive
        $offer->method('getAttendancesNotHidden')->willReturn(
            new ArrayCollection(array_fill(0, max($maxParticipants + 5, \count($waitingAttendances) + $confirmedCount + 5), null))
        );

        return $offer;
    }
}
