<?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\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\Edition;
use Ferienpass\CoreBundle\Entity\EditionTask;
use Ferienpass\CoreBundle\Entity\FriendCode;
use Ferienpass\CoreBundle\Entity\Offer\Base;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Entity\Participant\BaseParticipant;
use Ferienpass\CoreBundle\Entity\Participant\ParticipantInterface;
use Ferienpass\CoreBundle\Entity\ParticipantLog;
use Ferienpass\CoreBundle\Entity\Payment;
use Ferienpass\CoreBundle\Entity\PaymentItem;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\Enum\AttendanceStatus;
use Ferienpass\CoreBundle\Enum\AttendanceTransition;
use Ferienpass\CoreBundle\Enum\DecisionReason;
use Ferienpass\CoreBundle\Enum\PaymentStatus;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Workflow\Transition;

final class AttendanceTest extends TestCase
{
    public function testDefaultStatusIsWaiting(): void
    {
        $attendance = $this->makeAttendance();

        self::assertSame(AttendanceStatus::WAITING, $attendance->getStatus());
        self::assertTrue($attendance->isWaiting());
    }

    public function testIsConfirmed(): void
    {
        $attendance = $this->makeAttendance();
        $attendance->setStatus(AttendanceStatus::CONFIRMED);

        self::assertTrue($attendance->isConfirmed());
        self::assertFalse($attendance->isWaiting());
        self::assertFalse($attendance->isWaitlisted());
    }

    public function testIsWaitlisted(): void
    {
        $attendance = $this->makeAttendance();
        $attendance->setStatus(AttendanceStatus::WAITLISTED);

        self::assertTrue($attendance->isWaitlisted());
    }

    public function testIsWithdrawn(): void
    {
        $attendance = $this->makeAttendance();
        $attendance->setStatus(AttendanceStatus::WITHDRAWN);

        self::assertTrue($attendance->isWithdrawn());
        self::assertTrue($attendance->isHidden());
    }

    public function testIsUnfulfilled(): void
    {
        $attendance = $this->makeAttendance();
        $attendance->setStatus(AttendanceStatus::UNFULFILLED);

        self::assertTrue($attendance->isUnfulfilled());
        self::assertTrue($attendance->isHidden());
    }

    public function testIsParticipated(): void
    {
        $attendance = $this->makeAttendance();
        $attendance->setStatus(AttendanceStatus::PARTICIPATED);

        self::assertTrue($attendance->isParticipated());
    }

    public function testIsRejected(): void
    {
        $attendance = $this->makeAttendance();
        $attendance->setStatus(AttendanceStatus::REJECTED);

        self::assertTrue($attendance->isRejected());
    }

    public function testIsHiddenFalseWhenConfirmed(): void
    {
        $attendance = $this->makeAttendance();
        $attendance->setStatus(AttendanceStatus::CONFIRMED);

        self::assertFalse($attendance->isHidden());
    }

    public function testResetStatus(): void
    {
        $attendance = $this->makeAttendance();
        $attendance->setStatus(AttendanceStatus::CONFIRMED);
        $attendance->resetStatus();

        self::assertSame(AttendanceStatus::WAITING, $attendance->getStatus());
    }

    public function testSortingDefaultsToZero(): void
    {
        $attendance = $this->makeAttendance();

        self::assertSame(0, $attendance->getSorting());
    }

    public function testSetSorting(): void
    {
        $attendance = $this->makeAttendance();
        $attendance->setSorting(128);

        self::assertSame(128, $attendance->getSorting());
    }

    public function testPaidDefaultsFalse(): void
    {
        $attendance = $this->makeAttendance();

        self::assertFalse($attendance->isPaid());
    }

    public function testSetPaid(): void
    {
        $attendance = $this->makeAttendance();
        $attendance->setPaid(true);

        self::assertTrue($attendance->isPaid());
    }

    public function testPayableDefaultsFalse(): void
    {
        $attendance = $this->makeAttendance();

        self::assertFalse($attendance->isPayable());
    }

    public function testSetPayable(): void
    {
        $attendance = $this->makeAttendance();
        $attendance->setPayable(true);

        self::assertTrue($attendance->isPayable());
    }

    public function testConfirmedAtIsNullWithoutAConfirmation(): void
    {
        $attendance = $this->makeAttendance();
        $attendance->getActivity()->add($this->logTo($attendance, AttendanceStatus::WAITLISTED, null));

        self::assertNull($attendance->getConfirmedAt());
    }

    public function testConfirmedAtReadsTheMostRecentConfirmation(): void
    {
        $now = new \DateTimeImmutable('2026-08-10 12:00:00');

        // Confirmed, moved back to the waiting list, then confirmed again.
        $attendance = $this->makeAttendance();
        $this->addLog($attendance, AttendanceStatus::CONFIRMED, $now->modify('-3 days'));
        $this->addLog($attendance, AttendanceStatus::WAITLISTED, $now->modify('-2 days'));
        $this->addLog($attendance, AttendanceStatus::CONFIRMED, $now->modify('-1 day'));

        // Formatted: php-cs-fixer's php_unit_strict rewrites assertEquals to
        // assertSame, which would compare the DateTime objects by identity.
        self::assertSame($now->modify('-1 day')->format('c'), $attendance->getConfirmedAt()?->format('c'));
    }

    public function testAFreshRegistrationIsAnInstantWithdrawal(): void
    {
        $attendance = $this->makeAttendance();

        self::assertTrue($attendance->isInstantWithdrawal());
    }

    /**
     * First come, first served: registration and confirmation are the same
     * moment, recorded on the CREATE entry.
     */
    public function testTheWindowRunsFromAFirstComeRegistration(): void
    {
        $now = new \DateTimeImmutable('2026-08-10 12:00:00');

        $attendance = $this->makeAttendance();
        $attendance->setStatus(AttendanceStatus::CONFIRMED);
        $this->registeredAt($attendance, $now->modify('-29 minutes'));
        $this->addLog($attendance, AttendanceStatus::CONFIRMED, $now->modify('-29 minutes'), AttendanceTransition::CREATE);

        self::assertTrue($attendance->isInstantWithdrawal($now));
    }

    /**
     * Moving up from the waiting list starts the window anew: the family owes a
     * fee from the confirmation, so it gets the same minutes to hand the spot
     * back — however long ago it applied.
     */
    public function testTheWindowRunsFromTheConfirmationNotTheRegistration(): void
    {
        $now = new \DateTimeImmutable('2026-08-10 12:00:00');

        $attendance = $this->makeAttendance();
        $this->registeredAt($attendance, $now->modify('-3 weeks'));
        $this->addLog($attendance, AttendanceStatus::CONFIRMED, $now->modify('-5 minutes'));

        self::assertTrue($attendance->isInstantWithdrawal($now));
    }

    public function testTheWindowEndsAfterTheConfiguredPeriod(): void
    {
        $now = new \DateTimeImmutable('2026-08-10 12:00:00');

        $attendance = $this->makeAttendance();
        $this->registeredAt($attendance, $now->modify('-3 weeks'));
        $this->addLog($attendance, AttendanceStatus::CONFIRMED, $now->modify('-31 minutes'));

        self::assertFalse($attendance->isInstantWithdrawal($now));
    }

    public function testTheWindowIsClosedAtItsBoundary(): void
    {
        $now = new \DateTimeImmutable('2026-08-10 12:00:00');

        $attendance = $this->makeAttendance();
        $this->registeredAt($attendance, $now->modify('-3 weeks'));
        $this->addLog($attendance, AttendanceStatus::CONFIRMED, $now->modify('-'.Attendance::INSTANT_WITHDRAWAL_PERIOD));

        self::assertFalse($attendance->isInstantWithdrawal($now));
    }

    /**
     * A registration still waiting for a decision owes nothing, so the window
     * falls back to the moment the family applied.
     */
    public function testAnUnconfirmedAttendanceFallsBackToItsRegistration(): void
    {
        $now = new \DateTimeImmutable('2026-08-10 12:00:00');

        $attendance = $this->makeAttendance();
        $this->registeredAt($attendance, $now->modify('-5 minutes'));
        self::assertTrue($attendance->isInstantWithdrawal($now));

        $this->registeredAt($attendance, $now->modify('-31 minutes'));
        self::assertFalse($attendance->isInstantWithdrawal($now));
    }

    public function testUsesPaymentsFollowsTheSeason(): void
    {
        $edition = new Edition();

        $offer = new Base();
        $offer->setEdition($edition);

        $attendance = new Attendance($offer, new BaseParticipant());

        self::assertTrue($attendance->usesPayments());

        $edition->setUsePayments(false);

        self::assertFalse($attendance->usesPayments());
    }

    public function testUsesPaymentsWithoutASeason(): void
    {
        // An offer that lost its season keeps its pay status.
        self::assertTrue($this->makeAttendance()->usesPayments());
    }

    public function testUserPriorityDefaultsToZero(): void
    {
        $attendance = $this->makeAttendance();

        self::assertSame(0, $attendance->getUserPriority());
    }

    public function testSetUserPriority(): void
    {
        $attendance = $this->makeAttendance();
        $attendance->setUserPriority(3);

        self::assertSame(3, $attendance->getUserPriority());
    }

    public function testAllAlternateDatesTrueByDefault(): void
    {
        $attendance = $this->makeAttendance();

        self::assertTrue($attendance->isAllAlternateDates());
    }

    public function testSetAllAlternateDates(): void
    {
        $attendance = $this->makeAttendance();
        $attendance->setAllAlternateDates(false);

        self::assertFalse($attendance->isAllAlternateDates());
    }

    public function testConsentOutstandingDefaultsFalse(): void
    {
        $attendance = $this->makeAttendance();

        self::assertFalse($attendance->isConsentOutstanding());
    }

    public function testSetConsentOutstanding(): void
    {
        $attendance = $this->makeAttendance();
        $attendance->setConsentOutstanding(true);

        self::assertTrue($attendance->isConsentOutstanding());
    }

    public function testConsentTokenNullByDefault(): void
    {
        $attendance = $this->makeAttendance();

        self::assertNull($attendance->getConsentToken());
    }

    public function testSetConsentToken(): void
    {
        $attendance = $this->makeAttendance();
        $attendance->setConsentToken('abc-token');

        self::assertSame('abc-token', $attendance->getConsentToken());
    }

    public function testExtraGetReturnsNullForMissingKey(): void
    {
        $attendance = $this->makeAttendance();

        self::assertNull($attendance->getExtra('missing_key'));
    }

    public function testSetAndGetExtra(): void
    {
        $attendance = $this->makeAttendance();
        $attendance->setExtra(['key' => 'value', 'flag' => true]);

        self::assertSame('value', $attendance->getExtra('key'));
        self::assertTrue($attendance->getExtra('flag'));
        self::assertNull($attendance->getExtra('nonexistent'));
    }

    public function testGetOffer(): void
    {
        $offer = new Base();
        $attendance = new Attendance($offer, new BaseParticipant());

        self::assertSame($offer, $attendance->getOffer());
    }

    public function testGetParticipant(): void
    {
        $participant = new BaseParticipant();
        $attendance = new Attendance(new Base(), $participant);

        self::assertSame($participant, $attendance->getParticipant());
    }

    public function testUnsetParticipant(): void
    {
        $attendance = $this->makeAttendance();
        $attendance->unsetParticipant();

        self::assertNull($attendance->getParticipant());
    }

    public function testParticipantPseudonymNullByDefault(): void
    {
        $attendance = $this->makeAttendance();

        self::assertNull($attendance->getParticipantPseudonym());
    }

    public function testSetParticipantPseudonym(): void
    {
        $attendance = $this->makeAttendance();
        $attendance->setParticipantPseudonym('ANON-001');

        self::assertSame('ANON-001', $attendance->getParticipantPseudonym());
    }

    public function testCreatedAtIsSet(): void
    {
        $before = new \DateTimeImmutable();
        $attendance = $this->makeAttendance();
        $after = new \DateTimeImmutable();

        self::assertGreaterThanOrEqual($before, $attendance->getCreatedAt());
        self::assertLessThanOrEqual($after, $attendance->getCreatedAt());
    }

    public function testSetModifiedAt(): void
    {
        $attendance = $this->makeAttendance();
        $time = new \DateTimeImmutable('2024-01-15 12:00:00');
        $attendance->setModifiedAt($time);

        self::assertSame($time, $attendance->getModifiedAt());
    }

    public function testSetModifiedAtWithNullSetsNow(): void
    {
        $attendance = $this->makeAttendance();
        $before = new \DateTimeImmutable();
        $attendance->setModifiedAt(null);
        $after = new \DateTimeImmutable();

        self::assertGreaterThanOrEqual($before, $attendance->getModifiedAt());
        self::assertLessThanOrEqual($after, $attendance->getModifiedAt());
    }

    public function testUuidIsGeneratedAutomatically(): void
    {
        $a1 = $this->makeAttendance();
        $a2 = $this->makeAttendance();

        self::assertNotSame((string) $a1->getUuid(), (string) $a2->getUuid());
    }

    public function testTaskNullByDefault(): void
    {
        $attendance = $this->makeAttendance();

        self::assertNull($attendance->getTask());
    }

    public function testGetNameFromParticipant(): void
    {
        $participant = new BaseParticipant();
        $attendance = new Attendance(new Base(), $participant);

        // BaseParticipant default name is empty/null — just verify it doesn't throw
        self::assertIsString($attendance->getName());
    }

    public function testGetNameWithParticipantName(): void
    {
        $participant = new BaseParticipant();
        $participant->setFirstname('John');
        $participant->setLastname('Doe');
        $attendance = new Attendance(new Base(), $participant);

        self::assertSame('John Doe', $attendance->getName());
    }

    public function testGetNameWithNullParticipant(): void
    {
        $attendance = new Attendance(new Base(), null);

        self::assertSame('', $attendance->getName());
    }

    public function testGetPhoneWithParticipant(): void
    {
        $participant = new BaseParticipant();
        $participant->setPhone('+49 30 12345');
        $attendance = new Attendance(new Base(), $participant);

        self::assertSame('+49 30 12345', $attendance->getPhone());
    }

    public function testGetPhoneWithNullParticipant(): void
    {
        $attendance = new Attendance(new Base(), null);

        self::assertSame('', $attendance->getPhone());
    }

    public function testGetEmailWithParticipant(): void
    {
        $participant = new BaseParticipant();
        $participant->setEmail('test@example.com');
        $attendance = new Attendance(new Base(), $participant);

        self::assertSame('test@example.com', $attendance->getEmail());
    }

    public function testGetEmailWithNullParticipant(): void
    {
        $attendance = new Attendance(new Base(), null);

        self::assertSame('', $attendance->getEmail());
    }

    public function testGetFeeFormatsCorrectly(): void
    {
        $offer = new Base();
        $offer->setFee(1500); // 15.00 EUR in cents
        $attendance = new Attendance($offer, null);

        self::assertSame('15,00 €', $attendance->getFee());
    }

    public function testGetFeeReturnsEmptyWhenNoFee(): void
    {
        $attendance = $this->makeAttendance();

        self::assertSame('', $attendance->getFee());
    }

    public function testGetAgeNullByDefault(): void
    {
        $attendance = $this->makeAttendance();

        self::assertNull($attendance->getAge());
    }

    public function testPaymentItemsInitiallyEmpty(): void
    {
        $attendance = $this->makeAttendance();

        self::assertCount(0, $attendance->getPaymentItems());
    }

    public function testHasIssuedReceiptFalseWithoutPaymentItems(): void
    {
        self::assertFalse($this->makeAttendance()->hasIssuedReceipt());
        self::assertSame([], $this->makeAttendance()->getIssuedReceipts());
    }

    public function testGetIssuedReceiptsReturnsPaidReceipts(): void
    {
        $attendance = $this->makeAttendance();
        $payment = $this->makePayment(7, PaymentStatus::PAID);
        $attendance->getPaymentItems()->add(new PaymentItem($payment, 500, $attendance));

        self::assertTrue($attendance->hasIssuedReceipt());
        self::assertSame([$payment], $attendance->getIssuedReceipts());
    }

    public function testGetIssuedReceiptsIgnoresUnpaidReceipts(): void
    {
        // A "created" (unpaid) open bill is not an issued document — it may be
        // regenerated/abandoned, so it must not block a delete.
        $attendance = $this->makeAttendance();
        $payment = $this->makePayment(7, PaymentStatus::CREATED);
        $attendance->getPaymentItems()->add(new PaymentItem($payment, 500, $attendance));

        self::assertFalse($attendance->hasIssuedReceipt());
        self::assertSame([], $attendance->getIssuedReceipts());
    }

    public function testGetIssuedReceiptsDeduplicatesByPayment(): void
    {
        // Two lines of the same receipt point at the attendance — the receipt is
        // listed once.
        $attendance = $this->makeAttendance();
        $payment = $this->makePayment(7, PaymentStatus::PAID);
        $attendance->getPaymentItems()->add(new PaymentItem($payment, 500, $attendance));
        $attendance->getPaymentItems()->add(new PaymentItem($payment, 300, $attendance));

        self::assertSame([$payment], $attendance->getIssuedReceipts());
    }

    public function testMessengerLogsInitiallyEmpty(): void
    {
        $attendance = $this->makeAttendance();

        self::assertCount(0, $attendance->getMessengerLogs());
    }

    public function testActivityInitiallyEmpty(): void
    {
        $attendance = $this->makeAttendance();

        self::assertCount(0, $attendance->getActivity());
    }

    public function testCommentsInitiallyEmpty(): void
    {
        $attendance = $this->makeAttendance();

        self::assertCount(0, $attendance->getConversation());
    }

    public function testIdIsNullByDefault(): void
    {
        $attendance = $this->makeAttendance();

        self::assertNull($attendance->getId());
    }

    public function testToStringReturnsId(): void
    {
        $attendance = $this->makeAttendance();

        // ID is null by default, so toString should return empty string (cast of null)
        self::assertSame('', (string) $attendance);
    }

    public function testSetTask(): void
    {
        $attendance = $this->makeAttendance();
        $task = $this->createStub(EditionTask::class);
        $attendance->setTask($task);

        self::assertSame($task, $attendance->getTask());
    }

    public function testSetTaskNull(): void
    {
        $attendance = $this->makeAttendance();
        $task = $this->createStub(EditionTask::class);
        $attendance->setTask($task);
        $attendance->setTask(null);

        self::assertNull($attendance->getTask());
    }

    public function testGetStashNullByDefault(): void
    {
        $attendance = $this->makeAttendance();

        self::assertNull($attendance->getStash());
    }

    public function testGetFriendCodeNullWhenNoParticipant(): void
    {
        $attendance = new Attendance(new Base(), null);

        self::assertNull($attendance->getFriendCode());
    }

    public function testGetFriendCodeNullWhenParticipantHasNoFriendCodes(): void
    {
        $participant = new BaseParticipant();
        $attendance = new Attendance(new Base(), $participant);

        self::assertNull($attendance->getFriendCode());
    }

    public function testGetFriendedAttendancesEmptyWhenNoFriendCode(): void
    {
        $participant = new BaseParticipant();
        $attendance = new Attendance(new Base(), $participant);

        self::assertSame([], $attendance->getFriendedAttendances());
    }

    public function testGetUuidIsGeneratedOnConstruct(): void
    {
        $attendance = new Attendance(new Base(), null);

        self::assertInstanceOf(\Symfony\Component\Uid\Uuid::class, $attendance->getUuid());
    }

    public function testIsWaitingReturnsTrueByDefault(): void
    {
        $attendance = new Attendance(new Base(), null);

        self::assertTrue($attendance->isWaiting());
    }

    public function testIsWaitingReturnsFalseWhenConfirmed(): void
    {
        $attendance = new Attendance(new Base(), null);
        $attendance->setStatus(AttendanceStatus::CONFIRMED);

        self::assertFalse($attendance->isWaiting());
    }

    public function testGetDateVariantsReturnsFilteredCollection(): void
    {
        $offer = new Base();
        $participant = $this->createMock(ParticipantInterface::class);
        $attendance = new Attendance($offer, $participant);

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

        $result = $attendance->getDateVariants();

        self::assertCount(1, $result);
    }

    public function testGetDateVariantsReturnsEmptyWhenNoMatchingOffer(): void
    {
        $offer = new Base();
        $otherOffer = $this->createStub(OfferInterface::class);
        $otherOffer->method('isVariantOf')->willReturn(false);

        $otherAttendance = new Attendance($otherOffer, null);

        $participant = $this->createMock(ParticipantInterface::class);
        $participant->method('getAttendancesNotWithdrawn')
            ->willReturn(new ArrayCollection([$otherAttendance]));

        $attendance = new Attendance($offer, $participant);

        $result = $attendance->getDateVariants();

        self::assertCount(0, $result);
    }

    public function testGetFriendedAttendancesReturnsOtherParticipantAttendances(): void
    {
        $offer = new Base();

        $otherParticipant = $this->createMock(ParticipantInterface::class);
        $otherAttendance = new Attendance($offer, $otherParticipant);
        $otherAttendances = new ArrayCollection([$otherAttendance]);
        $otherParticipant->method('getAttendancesNotHidden')->willReturn($otherAttendances);

        $friendCode = new FriendCode($offer, 'FRIEND1');
        $friendCode->addParticipant($otherParticipant);

        $participant = $this->createMock(ParticipantInterface::class);
        $participant->method('getFriendCodes')->willReturn(new ArrayCollection([$friendCode]));

        $attendance = new Attendance($offer, $participant);

        $result = $attendance->getFriendedAttendances();
        self::assertCount(1, $result);
        self::assertSame($otherAttendance, $result[0]);
    }

    public function testGetFriendedAttendancesSkipsSelf(): void
    {
        $offer = new Base();

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

        $friendCode = new FriendCode($offer, 'FRIEND1');
        // Add the SAME participant to the friend code → should be skipped in getFriendedAttendances
        $friendCode->addParticipant($participant);

        $participant->method('getFriendCodes')->willReturn(new ArrayCollection([$friendCode]));

        $attendance = new Attendance($offer, $participant);

        $result = $attendance->getFriendedAttendances();
        self::assertSame([], $result);
    }

    public function testUpdateSortingSkipsWhenPrePersistAndNoStatus(): void
    {
        $offer = new Base();
        $participant = new BaseParticipant();
        $attendance = new Attendance($offer, $participant);
        $rp = new \ReflectionProperty($attendance, 'status');
        $rp->setValue($attendance, null);

        $event = new PrePersistEventArgs($attendance, $this->makeEm());
        $attendance->updateSorting($event);

        // sorting stays at default 0 (not updated because status is null)
        self::assertSame(0, $attendance->getSorting());
    }

    public function testUpdateSortingSkipsPreUpdateWhenStatusNotChanged(): void
    {
        $offer = new Base();
        $participant = new BaseParticipant();
        $attendance = new Attendance($offer, $participant);
        $attendance->setStatus(AttendanceStatus::CONFIRMED);

        $changeSet = ['name' => ['old', 'new']];
        $event = new PreUpdateEventArgs($attendance, $this->makeEm(), $changeSet);

        $attendance->updateSorting($event);

        // sorting stays at default 0 (not updated because status didn't change)
        self::assertSame(0, $attendance->getSorting());
    }

    public function testUpdateSortingSetsSortingOnPreUpdateWithStatusChange(): void
    {
        $offer = new Base();
        $participant = new BaseParticipant();
        $attendance = new Attendance($offer, $participant);
        $attendance->setStatus(AttendanceStatus::CONFIRMED);

        $changeSet = ['status' => [AttendanceStatus::WAITING, AttendanceStatus::CONFIRMED]];
        $event = new PreUpdateEventArgs($attendance, $this->makeEm(), $changeSet);

        $attendance->updateSorting($event);

        self::assertSame(128, $attendance->getSorting());
    }

    public function testUpdateSortingSetsPriorityOnPrePersistWaiting(): void
    {
        $offer = new Base();
        $participant = new BaseParticipant();
        $attendance = new Attendance($offer, $participant);
        // Default status is WAITING

        $event = new PrePersistEventArgs($attendance, $this->makeEm());
        $attendance->updateSorting($event);

        self::assertSame(128, $attendance->getSorting());
        self::assertSame(1, $attendance->getUserPriority());
    }

    public function testUpdateSortingCapsUserPriorityAtMaxApplications(): void
    {
        $edition = new Edition();
        $task = new EditionTask($edition);
        $task->setMaxApplications(3);

        $offer = new Base();
        $participant = new BaseParticipant();
        $attendance = new Attendance($offer, $participant);
        // Set the task on the attendance
        $rp = new \ReflectionProperty($attendance, 'task');
        $rp->setValue($attendance, $task);

        $event = new PrePersistEventArgs($attendance, $this->makeEm());
        $attendance->updateSorting($event);

        // priority = 1, maxApplications = 3, min(3+1, 1) = 1
        self::assertSame(1, $attendance->getUserPriority());
    }

    public function testGetDecisionReasonReturnsReasonOfCurrentStatus(): void
    {
        $attendance = $this->makeAttendance();
        $attendance->setStatus(AttendanceStatus::UNFULFILLED);
        $attendance->getActivity()->add($this->logTo($attendance, AttendanceStatus::UNFULFILLED, DecisionReason::WAITLIST_EXHAUSTED));

        self::assertSame(DecisionReason::WAITLIST_EXHAUSTED, $attendance->getDecisionReason());
    }

    public function testGetDecisionReasonIsNullWithoutActivity(): void
    {
        $attendance = $this->makeAttendance();
        $attendance->setStatus(AttendanceStatus::UNFULFILLED);

        self::assertNull($attendance->getDecisionReason());
    }

    public function testGetDecisionReasonIsNullWhenSupersededByManualChange(): void
    {
        // Lottery left it unfulfilled with a reason, but an admin later re-confirmed and
        // manually unfulfilled it again (no reason): the stale lottery reason must not show.
        $attendance = $this->makeAttendance();
        $attendance->setStatus(AttendanceStatus::UNFULFILLED);
        $attendance->getActivity()->add($this->logTo($attendance, AttendanceStatus::UNFULFILLED, DecisionReason::OUTCOMPETED));
        $attendance->getActivity()->add($this->logTo($attendance, AttendanceStatus::CONFIRMED, null));
        $attendance->getActivity()->add($this->logTo($attendance, AttendanceStatus::UNFULFILLED, null));

        self::assertNull($attendance->getDecisionReason());
    }

    public function testIsCreatedByAdminIsFalseWithoutActivity(): void
    {
        self::assertFalse($this->makeAttendance()->isCreatedByAdmin());
    }

    public function testIsCreatedByAdminIsFalseForAnonymousOrLotteryCreate(): void
    {
        // A create with no actor (anonymous application / lottery hold).
        $attendance = $this->makeAttendance();
        $attendance->getActivity()->add($this->createLog($attendance, null));

        self::assertFalse($attendance->isCreatedByAdmin());
    }

    public function testIsCreatedByAdminIsFalseForSelfRegistration(): void
    {
        // The create actor is the participant's own account holder.
        $member = $this->makeUser(5);
        $participant = new BaseParticipant();
        $participant->setUser($member);
        $attendance = new Attendance(new Base(), $participant);
        $attendance->getActivity()->add($this->createLog($attendance, $member));

        self::assertFalse($attendance->isCreatedByAdmin());
    }

    public function testIsCreatedByAdminIsTrueWhenTeamCreatedForAccountHolder(): void
    {
        $member = $this->makeUser(5);
        $admin = $this->makeUser(99);
        $participant = new BaseParticipant();
        $participant->setUser($member);
        $attendance = new Attendance(new Base(), $participant);
        $attendance->getActivity()->add($this->createLog($attendance, $admin));

        self::assertTrue($attendance->isCreatedByAdmin());
    }

    public function testIsCreatedByAdminIsTrueWhenTeamCreatedForAccountlessFamily(): void
    {
        // No account on the participant, but a backend user created it.
        $attendance = $this->makeAttendance();
        $attendance->getActivity()->add($this->createLog($attendance, $this->makeUser(99)));

        self::assertTrue($attendance->isCreatedByAdmin());
    }

    private function makeAttendance(): Attendance
    {
        return new Attendance(new Base(), new BaseParticipant());
    }

    private function makePayment(int $id, string $status): Payment
    {
        $payment = new Payment();
        $payment->setStatus($status);
        new \ReflectionProperty(Payment::class, 'id')->setValue($payment, $id);

        return $payment;
    }

    private function makeEm(): EntityManagerInterface
    {
        return $this->createStub(EntityManagerInterface::class);
    }

    private function logTo(Attendance $attendance, string $to, ?string $reason): ParticipantLog
    {
        return new ParticipantLog(
            $attendance->getParticipant(),
            null,
            $attendance,
            transition: new Transition('t', [], [$to]),
            decisionReason: $reason,
        );
    }

    private function createLog(Attendance $attendance, ?User $creator): ParticipantLog
    {
        return new ParticipantLog(
            $attendance->getParticipant(),
            $creator,
            $attendance,
            transition: new Transition(AttendanceTransition::CREATE, [], [(string) $attendance->getStatus()]),
        );
    }

    private function registeredAt(Attendance $attendance, \DateTimeImmutable $createdAt): void
    {
        new \ReflectionProperty(Attendance::class, 'createdAt')->setValue($attendance, $createdAt);
    }

    /**
     * An activity entry moving the attendance into $to at a given point in time
     * (ParticipantLog stamps itself with "now", so the time is back-dated).
     */
    private function addLog(Attendance $attendance, string $to, \DateTimeImmutable $at, string $transitionName = 't'): void
    {
        $log = new ParticipantLog(
            $attendance->getParticipant(),
            null,
            $attendance,
            transition: new Transition($transitionName, [], [$to]),
        );

        new \ReflectionProperty(ParticipantLog::class, 'createdAt')->setValue($log, $at);

        $attendance->getActivity()->add($log);
    }

    private function makeUser(int $id): User
    {
        $user = new User();
        new \ReflectionProperty(User::class, 'id')->setValue($user, $id);

        return $user;
    }
}
