<?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 Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\ConsentForm;
use Ferienpass\CoreBundle\Entity\DbafsAttachment;
use Ferienpass\CoreBundle\Entity\DbafsMedia;
use Ferienpass\CoreBundle\Entity\Edition;
use Ferienpass\CoreBundle\Entity\Host;
use Ferienpass\CoreBundle\Entity\Offer\Base;
use Ferienpass\CoreBundle\Entity\OfferAttachment;
use Ferienpass\CoreBundle\Entity\OfferDate;
use Ferienpass\CoreBundle\Entity\OfferMedia;
use Ferienpass\CoreBundle\Entity\Participant\ParticipantInterface;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\Enum\AttendanceStatus;
use Ferienpass\CoreBundle\Enum\OfferStatus;
use Ferienpass\CoreBundle\Payments\OfferFeeEvent;
use PHPUnit\Framework\TestCase;
use Symfony\Component\String\Slugger\AsciiSlugger;
use Symfony\Component\Uid\Uuid;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

final class OfferBaseTest extends TestCase
{
    public function testDefaultStatusIsEmpty(): void
    {
        $offer = new Base();

        self::assertFalse($offer->isPublished());
        self::assertFalse($offer->isDraft());
        self::assertFalse($offer->isCancelled());
        self::assertFalse($offer->isCompleted());
        self::assertFalse($offer->isFinalized());
        self::assertFalse($offer->isReviewed());
    }

    public function testSetStateManuallyPublished(): void
    {
        $offer = new Base();
        $offer->setStateManually(OfferStatus::PUBLISHED);

        self::assertTrue($offer->isPublished());
    }

    public function testSetStateManuallyDraft(): void
    {
        $offer = new Base();
        $offer->setStateManually(OfferStatus::DRAFT);

        self::assertTrue($offer->isDraft());
    }

    public function testSetStateManuallyFinalized(): void
    {
        $offer = new Base();
        $offer->setStateManually(OfferStatus::FINALIZED);

        self::assertTrue($offer->isFinalized());
    }

    public function testSetStateManuallyReviewed(): void
    {
        $offer = new Base();
        $offer->setStateManually(OfferStatus::REVIEWED);

        self::assertTrue($offer->isReviewed());
    }

    public function testSetStateManuallyCancel(): void
    {
        $offer = new Base();
        $offer->setStateManually(OfferStatus::CANCELLED);

        self::assertTrue($offer->isCancelled());
    }

    public function testSetStateManuallyCompleted(): void
    {
        $offer = new Base();
        $offer->setStateManually(OfferStatus::COMPLETED);

        self::assertTrue($offer->isCompleted());
    }

    public function testResetStatus(): void
    {
        $offer = new Base();
        $offer->setStateManually(OfferStatus::PUBLISHED);
        self::assertTrue($offer->isPublished());

        $offer->resetStatus();
        self::assertFalse($offer->isPublished());
    }

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

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

    public function testSetName(): void
    {
        $offer = new Base();
        $offer->setName('My Offer');

        self::assertSame('My Offer', $offer->getName());
    }

    public function testSetDescription(): void
    {
        $offer = new Base();
        $offer->setDescription('Detailed description');

        self::assertSame('Detailed description', $offer->getDescription());
    }

    public function testSetTeaser(): void
    {
        $offer = new Base();
        $offer->setTeaser('Short teaser');

        self::assertSame('Short teaser', $offer->getTeaser());
    }

    public function testSetMaxParticipants(): void
    {
        $offer = new Base();
        $offer->setMaxParticipants(20);

        self::assertSame(20, $offer->getMaxParticipants());
    }

    public function testSetMinParticipants(): void
    {
        $offer = new Base();
        $offer->setMinParticipants(5);

        self::assertSame(5, $offer->getMinParticipants());
    }

    public function testSetFee(): void
    {
        $offer = new Base();
        $offer->setFee(1500); // in cents

        self::assertSame(1500, $offer->getFee());
    }

    public function testSetMeetingPoint(): void
    {
        $offer = new Base();
        $offer->setMeetingPoint('Main Square');

        self::assertSame('Main Square', $offer->getMeetingPoint());
    }

    public function testSetMinAge(): void
    {
        $offer = new Base();
        $offer->setMinAge(8);

        self::assertSame(8, $offer->getMinAge());
    }

    public function testSetMaxAge(): void
    {
        $offer = new Base();
        $offer->setMaxAge(14);

        self::assertSame(14, $offer->getMaxAge());
    }

    public function testIsOnlineApplicationFalseWhenOnlyRequiresApplication(): void
    {
        $offer = new Base();
        $offer->setRequiresApplication(true);
        // online application not enabled
        self::assertFalse($offer->isOnlineApplication());
    }

    public function testIsOnlineApplicationTrueWhenBothEnabled(): void
    {
        $offer = new Base();
        $offer->setRequiresApplication(true);
        $offer->setOnlineApplication(true);
        self::assertTrue($offer->isOnlineApplication());
    }

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

        self::assertNull($offer->getVacancies());
        self::assertFalse($offer->isFullyBooked());
    }

    /**
     * 0 reads as "no limit" everywhere else, so it must not make the offer look
     * full from the very first booking on.
     */
    public function testGetVacanciesTreatsAZeroLimitAsUnlimited(): void
    {
        $offer = new Base();
        $offer->setMaxParticipants(0);
        $this->attend($offer, AttendanceStatus::CONFIRMED);

        self::assertNull($offer->getVacancies());
        self::assertFalse($offer->isFullyBooked());
    }

    public function testGetVacanciesCountsConfirmedAndWaitlistedAttendances(): void
    {
        $offer = new Base();
        $offer->setMaxParticipants(5);
        $this->attend($offer, AttendanceStatus::CONFIRMED);
        $this->attend($offer, AttendanceStatus::WAITLISTED);

        self::assertSame(3, $offer->getVacancies());
        self::assertFalse($offer->isFullyBooked());
    }

    public function testGetVacanciesIgnoresAttendancesThatReleasedTheirSpot(): void
    {
        $offer = new Base();
        $offer->setMaxParticipants(2);
        $this->attend($offer, AttendanceStatus::WITHDRAWN);
        $this->attend($offer, AttendanceStatus::REJECTED);
        $this->attend($offer, AttendanceStatus::UNFULFILLED);

        self::assertSame(2, $offer->getVacancies());
    }

    /**
     * Spots claimed by the waiting list are gone: the show is sold out even
     * though only one attendance is confirmed.
     */
    public function testIsFullyBookedWhenTheWaitingListHoldsTheRemainingSpots(): void
    {
        $offer = new Base();
        $offer->setMaxParticipants(3);
        $this->attend($offer, AttendanceStatus::CONFIRMED);
        $this->attend($offer, AttendanceStatus::WAITLISTED);
        $this->attend($offer, AttendanceStatus::WAITLISTED);

        self::assertSame(0, $offer->getVacancies());
        self::assertTrue($offer->isFullyBooked());
    }

    public function testGetVacanciesNeverGoesNegative(): void
    {
        $offer = new Base();
        $offer->setMaxParticipants(1);
        $this->attend($offer, AttendanceStatus::CONFIRMED);
        $this->attend($offer, AttendanceStatus::WAITLISTED);

        self::assertSame(0, $offer->getVacancies());
    }

    public function testIsApplicationDeadlineExpired(): void
    {
        $offer = new Base();
        self::assertFalse($offer->isApplicationDeadlineExpired());

        $offer->setApplicationDeadline(new \DateTimeImmutable('+1 day'));
        self::assertFalse($offer->isApplicationDeadlineExpired());

        $offer->setApplicationDeadline(new \DateTimeImmutable('-1 day'));
        self::assertTrue($offer->isApplicationDeadlineExpired());
    }

    public function testIsInstantlyBookable(): void
    {
        $offer = new Base();
        $offer->setRequiresApplication(true);
        $offer->setOnlineApplication(true);

        self::assertTrue($offer->isInstantlyBookable());
    }

    public function testIsNotInstantlyBookableWithoutOnlineApplications(): void
    {
        $offer = new Base();
        $offer->setRequiresApplication(true);

        self::assertFalse($offer->isInstantlyBookable());
    }

    public function testIsNotInstantlyBookableOnceTheDeadlineHasPassed(): void
    {
        $offer = new Base();
        $offer->setRequiresApplication(true);
        $offer->setOnlineApplication(true);
        $offer->setApplicationDeadline(new \DateTimeImmutable('-1 hour'));

        self::assertFalse($offer->isInstantlyBookable());
    }

    public function testIsNotInstantlyBookableWhenSoldOut(): void
    {
        $offer = new Base();
        $offer->setRequiresApplication(true);
        $offer->setOnlineApplication(true);
        $offer->setMaxParticipants(1);
        $this->attend($offer, AttendanceStatus::CONFIRMED);

        self::assertFalse($offer->isInstantlyBookable());
    }

    public function testIsNotInstantlyBookableWhenCancelled(): void
    {
        $offer = new Base();
        $offer->setRequiresApplication(true);
        $offer->setOnlineApplication(true);
        $offer->setStateManually(OfferStatus::CANCELLED);

        self::assertFalse($offer->isInstantlyBookable());
    }

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

        self::assertTrue($offer->isOnlinePayable());
    }

    public function testIsNotOnlinePayableWhenDisabled(): void
    {
        $offer = new Base();
        $offer->setDisableOnlinePayment(true);

        self::assertFalse($offer->isOnlinePayable());
    }

    public function testUuidIsGeneratedOnConstruct(): void
    {
        $o1 = new Base();
        $o2 = new Base();

        self::assertNotSame((string) $o1->getUuid(), (string) $o2->getUuid());
    }

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

        self::assertCount(0, $offer->getAttendances());
    }

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

        self::assertCount(0, $offer->getHosts());
    }

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

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

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

        self::assertInstanceOf(\DateTimeInterface::class, $offer->getCreatedAt());
    }

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

        self::assertInstanceOf(\DateTimeInterface::class, $offer->getModifiedAt());
    }

    public function testSetModifiedAt(): void
    {
        $offer = new Base();
        $dt = new \DateTimeImmutable('2023-01-01');
        $offer->setModifiedAt($dt);

        self::assertSame($dt, $offer->getModifiedAt());
    }

    public function testSetAlias(): void
    {
        $offer = new Base();
        $offer->setAlias('my-offer');

        self::assertSame('my-offer', $offer->getAlias());
    }

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

        self::assertNull($offer->getAlias());
    }

    public function testSetBring(): void
    {
        $offer = new Base();
        $offer->setBring('Bring snacks');

        self::assertSame('Bring snacks', $offer->getBring());
    }

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

        self::assertNull($offer->getBring());
    }

    public function testSetComment(): void
    {
        $offer = new Base();
        $offer->setComment('An internal comment');

        self::assertSame('An internal comment', $offer->getComment());
    }

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

        self::assertNull($offer->getComment());
    }

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

        self::assertSame([], $offer->getStatus());
    }

    public function testSetStatus(): void
    {
        $offer = new Base();
        $offer->setStatus([OfferStatus::PUBLISHED => true]);

        self::assertSame([OfferStatus::PUBLISHED => true], $offer->getStatus());
    }

    public function testSetApplyText(): void
    {
        $offer = new Base();
        $offer->setApplyText('How to apply...');

        self::assertSame('How to apply...', $offer->getApplyText());
    }

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

        self::assertNull($offer->getApplyText());
    }

    public function testSetContactUser(): void
    {
        $offer = new Base();
        $user = new User();
        $offer->setContactUser($user);

        self::assertSame($user, $offer->getContactUser());
    }

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

        self::assertNull($offer->getContactUser());
    }

    public function testSetAccessibility(): void
    {
        $offer = new Base();
        $offer->setAccessibility(['wheelchair' => true]);

        self::assertSame(['wheelchair' => true], $offer->getAccessibility());
    }

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

        self::assertNull($offer->getAccessibility());
    }

    public function testSetWheelchairAccessible(): void
    {
        $offer = new Base();
        $offer->setWheelchairAccessible(true);

        self::assertTrue($offer->isWheelchairAccessible());
    }

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

        self::assertNull($offer->isWheelchairAccessible());
    }

    public function testSetRequiresAgreementLetter(): void
    {
        $offer = new Base();
        $offer->setRequiresAgreementLetter(true);

        self::assertTrue($offer->requiresAgreementLetter());
    }

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

        self::assertNull($offer->requiresAgreementLetter());
    }

    public function testSetDeferAgreementLetter(): void
    {
        $offer = new Base();
        $offer->setDeferAgreementLetter(true);

        self::assertTrue($offer->isDeferAgreementLetter());
    }

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

        self::assertFalse($offer->isDeferAgreementLetter());
    }

    public function testSetUrl(): void
    {
        $offer = new Base();
        $offer->setUrl('https://example.com/offer');

        self::assertSame('https://example.com/offer', $offer->getUrl());
    }

    public function testSetContact(): void
    {
        $offer = new Base();
        $offer->setContact(['email' => 'test@example.com']);

        self::assertSame(['email' => 'test@example.com'], $offer->getContact());
    }

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

        self::assertNull($offer->getContact());
    }

    public function testSetApplicationExtra(): void
    {
        $offer = new Base();
        $offer->setApplicationExtra([['question' => 'Allergien?', 'type' => 'txt', 'uuid' => 'test-id-1234', 'required' => true]]);

        $extra = $offer->getApplicationExtra();
        self::assertCount(1, $extra);
        // Each entry is normalised to the strict {uuid, question, type, required,
        // fileTypes} shape.
        self::assertSame(['uuid', 'question', 'type', 'required', 'fileTypes'], array_keys($extra[0]));
        self::assertSame('test-id-1234', $extra[0]['uuid']);
        self::assertSame('Allergien?', $extra[0]['question']);
        self::assertSame('txt', $extra[0]['type']);
        self::assertTrue($extra[0]['required']);
        // Not a file question → no allow-list stored.
        self::assertSame([], $extra[0]['fileTypes']);
    }

    public function testSetApplicationExtraKeepsAllowedFileTypes(): void
    {
        $offer = new Base();
        $offer->setApplicationExtra([['question' => 'Schwimmnachweis?', 'type' => 'file', 'uuid' => 'file-id-1', 'fileTypes' => ['pdf']]]);

        $extra = $offer->getApplicationExtra();
        self::assertSame('file', $extra[0]['type']);
        self::assertSame(['pdf'], $extra[0]['fileTypes']);
    }

    public function testSetApplicationExtraFallsBackToAllFileTypes(): void
    {
        $offer = new Base();
        // A file question with no (or an unrecognised) allow-list must stay
        // answerable, so it accepts everything we support.
        $offer->setApplicationExtra([['question' => 'Nachweis?', 'type' => 'file', 'uuid' => 'file-id-2', 'fileTypes' => ['exe']]]);

        self::assertSame(['image', 'pdf'], $offer->getApplicationExtra()[0]['fileTypes']);
    }

    public function testSetApplicationExtraNormalisesDefaults(): void
    {
        $offer = new Base();
        // Missing uuid → generated; unknown type → 'txt'; missing required → false.
        $offer->setApplicationExtra([['question' => 'Schuhgröße?', 'type' => 'bogus']]);

        $extra = $offer->getApplicationExtra();
        self::assertCount(1, $extra);
        self::assertNotEmpty($extra[0]['uuid']);
        self::assertSame('txt', $extra[0]['type']);
        self::assertFalse($extra[0]['required']);
        self::assertSame([], $extra[0]['fileTypes']);
    }

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

        self::assertNull($offer->getApplicationExtra());
    }

    public function testSetApplicationDeadline(): void
    {
        $offer = new Base();
        $dt = new \DateTimeImmutable('2023-12-31');
        $offer->setApplicationDeadline($dt);

        self::assertSame($dt, $offer->getApplicationDeadline());
    }

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

        self::assertNull($offer->getApplicationDeadline());
    }

    public function testSetEdition(): void
    {
        $offer = new Base();
        $edition = $this->createStub(Edition::class);
        $offer->setEdition($edition);

        self::assertSame($edition, $offer->getEdition());
    }

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

        self::assertNull($offer->getEdition());
    }

    public function testAddHost(): void
    {
        $offer = new Base();
        $host = new Host();

        $offer->addHost($host);

        self::assertCount(1, $offer->getHosts());
        self::assertTrue($offer->getHosts()->contains($host));
    }

    public function testSetHosts(): void
    {
        $offer = new Base();
        $host = new Host();
        $hosts = new \Doctrine\Common\Collections\ArrayCollection([$host]);

        $offer->setHosts($hosts);

        self::assertCount(1, $offer->getHosts());
    }

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

        $offer->addAttendance($attendance);

        self::assertCount(1, $offer->getAttendances());
    }

    public function testGetAttendancesNotHiddenFiltersWithdrawnAndUnfulfilled(): void
    {
        $offer = new Base();
        $confirmed = new Attendance($offer, null);
        $confirmed->setStatus(AttendanceStatus::CONFIRMED);
        $withdrawn = new Attendance($offer, null);
        $withdrawn->setStatus(AttendanceStatus::WITHDRAWN);

        $offer->addAttendance($confirmed);
        $offer->addAttendance($withdrawn);

        self::assertCount(1, $offer->getAttendancesNotHidden());
    }

    public function testGetAttendancesConfirmedFilters(): void
    {
        $offer = new Base();
        $confirmed = new Attendance($offer, null);
        $confirmed->setStatus(AttendanceStatus::CONFIRMED);
        $waiting = new Attendance($offer, null);

        $offer->addAttendance($confirmed);
        $offer->addAttendance($waiting);

        self::assertCount(1, $offer->getAttendancesConfirmed());
    }

    public function testGetAttendancesWaitingFilters(): void
    {
        $offer = new Base();
        $waiting = new Attendance($offer, null);
        // default status is WAITING

        $offer->addAttendance($waiting);

        self::assertCount(1, $offer->getAttendancesWaiting());
    }

    public function testGetAttendancesWaitlistedFilters(): void
    {
        $offer = new Base();
        $waitlisted = new Attendance($offer, null);
        $waitlisted->setStatus(AttendanceStatus::WAITLISTED);
        $confirmed = new Attendance($offer, null);
        $confirmed->setStatus(AttendanceStatus::CONFIRMED);

        $offer->addAttendance($waitlisted);
        $offer->addAttendance($confirmed);

        self::assertCount(1, $offer->getAttendancesWaitlisted());
    }

    public function testGetAttendancesConfirmedOrWaiting(): void
    {
        $offer = new Base();
        $confirmed = new Attendance($offer, null);
        $confirmed->setStatus(AttendanceStatus::CONFIRMED);
        $waiting = new Attendance($offer, null);
        // waiting is default
        $withdrawn = new Attendance($offer, null);
        $withdrawn->setStatus(AttendanceStatus::WITHDRAWN);

        $offer->addAttendance($confirmed);
        $offer->addAttendance($waiting);
        $offer->addAttendance($withdrawn);

        self::assertCount(2, $offer->getAttendancesConfirmedOrWaiting());
    }

    public function testGetAttendancesConfirmedOrWaitlisted(): void
    {
        $offer = new Base();
        $confirmed = new Attendance($offer, null);
        $confirmed->setStatus(AttendanceStatus::CONFIRMED);
        $waitlisted = new Attendance($offer, null);
        $waitlisted->setStatus(AttendanceStatus::WAITLISTED);
        $waiting = new Attendance($offer, null);
        // waiting status

        $offer->addAttendance($confirmed);
        $offer->addAttendance($waitlisted);
        $offer->addAttendance($waiting);

        self::assertCount(2, $offer->getAttendancesConfirmedOrWaitlisted());
    }

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

        $offer->addAttendance($confirmed);

        self::assertCount(1, $offer->getAttendancesWithStatus(AttendanceStatus::CONFIRMED));
        self::assertCount(0, $offer->getAttendancesWithStatus(AttendanceStatus::WITHDRAWN));
    }

    public function testGetAttendancesWithStatuses(): void
    {
        $offer = new Base();
        $confirmed = new Attendance($offer, null);
        $confirmed->setStatus(AttendanceStatus::CONFIRMED);
        $waitlisted = new Attendance($offer, null);
        $waitlisted->setStatus(AttendanceStatus::WAITLISTED);

        $offer->addAttendance($confirmed);
        $offer->addAttendance($waitlisted);

        self::assertCount(2, $offer->getAttendancesWithStatuses([AttendanceStatus::CONFIRMED, AttendanceStatus::WAITLISTED]));
    }

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

        self::assertCount(0, $offer->getMedia());
    }

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

        self::assertCount(0, $offer->getAttachments());
    }

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

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

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

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

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

        self::assertCount(0, $offer->getFriendCodes());
    }

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

        self::assertCount(0, $offer->getDates());
    }

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

        self::assertSame('', $offer->getDate());
    }

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

        self::assertNull($offer->getDateBegin());
    }

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

        self::assertNull($offer->getDateEnd());
    }

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

        self::assertCount(0, $offer->getDownloads());
    }

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

        self::assertNull($offer->getAgreementLetter());
    }

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

        self::assertNull($offer->getAgreementLetterOld());
    }

    public function testAddConsentForm(): void
    {
        $offer = new Base();
        $consentForm = new ConsentForm(Uuid::v7());
        $consentForm->setText('Terms');

        $offer->addConsentForm($consentForm);

        self::assertCount(1, $offer->getConsentForms());
    }

    public function testRemoveConsentForm(): void
    {
        $offer = new Base();
        $consentForm = new ConsentForm(Uuid::v7());
        $consentForm->setText('Terms');

        $offer->addConsentForm($consentForm);
        self::assertCount(1, $offer->getConsentForms());

        $offer->removeConsentForm($consentForm);

        self::assertCount(0, $offer->getConsentForms());
    }

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

        // May not be initialized in constructor, should handle gracefully
        try {
            self::assertCount(0, $offer->getConsentForms());
        } catch (\Error $e) {
            // consentForms property might be uninitialized in Base constructor
            $this->markTestSkipped('consentForms not initialized in constructor');
        }
    }

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

        try {
            self::assertCount(0, $offer->getMemberAssociations());
        } catch (\Error $e) {
            $this->markTestSkipped('memberAssociations not initialized in constructor');
        }
    }

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

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

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

        self::assertFalse($offer->isDisableOnlinePayment());
    }

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

        self::assertFalse($offer->isCollectDateOfBirth());
    }

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

        $offer->setCollectDateOfBirth(true);

        self::assertTrue($offer->isCollectDateOfBirth());
    }

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

        self::assertFalse($offer->requiresApplication());
    }

    public function testSetRequiresApplication(): void
    {
        $offer = new Base();
        $offer->setRequiresApplication(true);

        self::assertTrue($offer->requiresApplication());
    }

    public function testSetMediaForApi(): void
    {
        $offer = new Base();
        $offer->setMediaForApi(['image.jpg']);

        self::assertSame(['image.jpg'], $offer->getMediaForApi());
    }

    public function testAddMediaWithOfferMedia(): void
    {
        $offer = new Base();
        $media = new OfferMedia(offer: $offer);
        $offer->addMedia($media);

        self::assertCount(1, $offer->getMedia());
    }

    public function testRemoveMedia(): void
    {
        $offer = new Base();
        $media = new OfferMedia(offer: $offer);
        $offer->addMedia($media);
        $offer->removeMedia($media);

        self::assertCount(0, $offer->getMedia());
    }

    public function testAddAttachmentWithOfferAttachment(): void
    {
        $offer = new Base();
        $attachment = new OfferAttachment(offer: $offer);
        $offer->addAttachment($attachment);

        self::assertCount(1, $offer->getAttachments());
    }

    public function testRemoveAttachment(): void
    {
        $offer = new Base();
        $attachment = new OfferAttachment(offer: $offer);
        $offer->addAttachment($attachment);
        $offer->removeAttachment($attachment);

        self::assertCount(0, $offer->getAttachments());
    }

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

        self::assertSame('', $offer->getHumanHosts());
    }

    public function testGetHumanHostsWithHosts(): void
    {
        $offer = new Base();
        $host = new Host();
        $host->setName('Test Host');
        $offer->addHost($host);

        self::assertSame('Test Host', $offer->getHumanHosts());
    }

    public function testGenerateAliasSlugsTheName(): void
    {
        $offer = new Base();
        $offer->setName('My Awesome Offer');
        $offer->generateAlias(new AsciiSlugger());

        self::assertSame('my-awesome-offer', $offer->getAlias());
    }

    public function testGenerateAliasWithEmptyNameYieldsEmptyAlias(): void
    {
        $offer = new Base();
        $offer->generateAlias(new AsciiSlugger());

        // No id prefix, no uniqid fallback: an empty name slugs to an empty alias.
        self::assertSame('', $offer->getAlias());
    }

    public function testGetFeePayableWithNoParticipant(): void
    {
        $offer = new Base();
        $offer->setFee(1500);

        self::assertSame(1500, $offer->getFeePayable(null));
    }

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

        self::assertSame(0, $offer->getFeePayable(null));
    }

    public function testSetAgreementLetterWithOfferAttachment(): void
    {
        $offer = new Base();
        $attachment = new OfferAttachment(offer: $offer);
        $offer->setAgreementLetter($attachment);

        self::assertSame($attachment, $offer->getAgreementLetter());
    }

    public function testSetAgreementLetterIdempotent(): void
    {
        $offer = new Base();
        $attachment = new OfferAttachment(offer: $offer);
        $attachment->setAgreementLetter(true);
        $offer->addAttachment($attachment);
        $offer->setAgreementLetter($attachment);

        // Should not duplicate the attachment
        self::assertCount(1, $offer->getAttachments());
    }

    public function testAddDate(): void
    {
        $offer = new Base();
        $date = new OfferDate($offer);
        $offer->addDate($date);

        self::assertCount(1, $offer->getDates());
    }

    public function testRemoveDate(): void
    {
        $offer = new Base();
        $date = new OfferDate($offer);
        $offer->addDate($date);
        $offer->removeDate($date);

        self::assertCount(0, $offer->getDates());
    }

    public function testIsFutureWithFutureDate(): void
    {
        $offer = new Base();
        $date = new OfferDate($offer);
        $date->setBegin(new \DateTime('+1 day'));
        $offer->addDate($date);

        self::assertFalse($offer->isFuture());
    }

    public function testIsPastWithPastDate(): void
    {
        $offer = new Base();
        $date = new OfferDate($offer);
        $date->setEnd(new \DateTime('-1 day'));
        $offer->addDate($date);

        self::assertTrue($offer->isPast());
    }

    public function testIsPastWithRecurringDate(): void
    {
        $offer = new Base();
        $date = new OfferDate($offer);

        // Start in the past
        $date->setBegin(new \DateTime('-10 days 10:00:00'));
        $date->setEnd(new \DateTime('-10 days 12:00:00'));

        // Recur every day for 20 days (so it is still ongoing/has future occurrences)
        $date->setRecurrence('FREQ=DAILY;COUNT=20');

        $offer->addDate($date);

        $this->assertFalse($offer->isPast(), 'Offer should not be past if it has future recurring occurrences');
    }

    public function testGetDateWithDate(): void
    {
        $offer = new Base();
        $date = new OfferDate($offer);
        $date->setBegin(new \DateTime('2024-06-15 10:00:00'));
        $offer->addDate($date);

        self::assertSame('15.06.2024 10:00', $offer->getDate());
    }

    public function testSetAgreementLetterWithDbafsAttachment(): void
    {
        $offer = new Base();
        $attachment = new DbafsAttachment();
        $offer->setAgreementLetter($attachment);

        self::assertNotNull($offer->getAgreementLetter());
    }

    public function testSetMediaReplacesPreviousMedia(): void
    {
        $offer = new Base();
        $media1 = new DbafsMedia();
        $offerMedia = new OfferMedia(offer: $offer, media: $media1);
        $collection = new \Doctrine\Common\Collections\ArrayCollection([$offerMedia]);
        $offer->setMedia($collection);

        self::assertCount(1, $offer->getMedia());
    }

    public function testGetHumanDatesWithDate(): void
    {
        $offer = new Base();
        $date = new OfferDate($offer);
        $offer->addDate($date);

        // toHumanReadable returns '' when begin/end are null
        self::assertSame('', $offer->getHumanDates());
    }

    public function testGetHumanEditionReturnsEditionName(): void
    {
        $offer = new Base();
        $edition = $this->createStub(Edition::class);
        $edition->method('getName')->willReturn('Sommer 2024');
        $offer->setEdition($edition);

        self::assertSame('Sommer 2024', $offer->getHumanEdition());
    }

    public function testGenerateAliasIgnoresIdAndOnlySlugsName(): void
    {
        $offer = new Base();
        // Even with an id present, the alias is just the slugged name (no id prefix).
        $prop = new \ReflectionProperty(Base::class, 'id');
        $prop->setValue($offer, 42);
        $offer->setName('My Offer');
        $offer->generateAlias(new AsciiSlugger());

        self::assertSame('my-offer', $offer->getAlias());
    }

    public function testGenerateAliasOverwritesExistingAliasRegardlessOfStatus(): void
    {
        $offer = new Base();
        $prop = new \ReflectionProperty(Base::class, 'id');
        $prop->setValue($offer, 5);
        $offer->setAlias('5-existing-alias');
        $offer->setName('Renamed Offer');
        // Published state no longer guards alias regeneration.
        $offer->setStateManually(OfferStatus::PUBLISHED);

        $offer->generateAlias(new AsciiSlugger());

        self::assertSame('renamed-offer', $offer->getAlias());
    }

    // VariantsTrait tests

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

        self::assertTrue($offer->isVariantBase());
    }

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

        self::assertFalse($offer->isVariant());
    }

    public function testIsVariantTrueWhenVariantBaseSet(): void
    {
        $base = new Base();
        $variant = new Base();
        $variant->setVariantBase($base);

        self::assertTrue($variant->isVariant());
        self::assertFalse($variant->isVariantBase());
    }

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

        self::assertNull($offer->getVariantBase());
    }

    public function testSetVariantBase(): void
    {
        $base = new Base();
        $variant = new Base();
        $variant->setVariantBase($base);

        self::assertSame($base, $variant->getVariantBase());
    }

    public function testSetVariantBaseNull(): void
    {
        $base = new Base();
        $variant = new Base();
        $variant->setVariantBase($base);
        $variant->setVariantBase(null);

        self::assertNull($variant->getVariantBase());
    }

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

        self::assertFalse($offer->hasVariants());
    }

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

        self::assertCount(0, $offer->getVariants());
    }

    public function testGetVariantsIncludesBaseOfVariant(): void
    {
        $base = new Base();
        $variant = new Base();
        $variant->setVariantBase($base);

        // getVariants(true) from variant's perspective returns all variants including self,
        // called from variantBase path
        $variants = $variant->getVariants(true);
        // Should include $variant itself (not filtered) plus any other variants of $base
        self::assertNotEmpty($variants->toArray());
    }

    public function testIsVariantOfReturnsFalseWhenNotRelated(): void
    {
        $offer1 = new Base();
        $offer2 = new Base();

        // Neither has a variant relationship to the other
        self::assertFalse($offer1->isVariantOf($offer2));
    }

    public function testGetVariantsExcludeSelfWhenVariant(): void
    {
        $base = new Base();
        $variant = new Base();
        $variant->setVariantBase($base);

        // When called with include=false on a variant, returns variants excluding self
        $variants = $variant->getVariants(false);
        // base->getVariants(true) returns {base} only (no other variants), then filter removes any where getId() matches self
        // Since both have null IDs, filter removes all (null !== null = false)
        self::assertCount(0, $variants);
    }

    public function testSetVariantBaseThrowsWhenProposedBaseIsNotRoot(): void
    {
        // $intermediary has a variantBase, so it's not a root itself
        $root = $this->createStub(\Ferienpass\CoreBundle\Entity\Offer\OfferInterface::class);
        $root->method('getId')->willReturn(1);

        $intermediary = $this->createStub(\Ferienpass\CoreBundle\Entity\Offer\OfferInterface::class);
        $intermediary->method('getId')->willReturn(2);
        $intermediary->method('getVariantBase')->willReturn($root);

        $offer = new Base();

        $this->expectException(\LogicException::class);
        $offer->setVariantBase($intermediary);
    }

    public function testGetVariantNumberReturnsRomanNumeralForBase(): void
    {
        $offer = new Base();
        // A standalone base offer with no variants - it IS its own first variant
        // getVariants(true) returns {$offer}, usort preserves order, array_find_key finds at 0 → "I"
        self::assertSame('I', $offer->getVariantNumber());
    }

    public function testGetVariantNumberReturnsNullWhenNotInVariantList(): void
    {
        $base = new Base();
        $variant = new Base();
        $variant->setVariantBase($base);

        // $variant is not in $base->getVariants(true) because $base->variants is empty,
        // so array_find_key returns null → getVariantNumber returns null
        self::assertNull($variant->getVariantNumber());
    }

    public function testAddMediaWithDbafsMedia(): void
    {
        $offer = new Base();
        $media = new DbafsMedia();
        $offer->addMedia($media);

        self::assertCount(1, $offer->getMedia());
    }

    public function testAddAttachmentWithDbafsAttachment(): void
    {
        $offer = new Base();
        $attachment = new DbafsAttachment();
        $offer->addAttachment($attachment);

        self::assertCount(1, $offer->getAttachments());
    }

    public function testGetFeePayableDispatchesEventWhenParticipantAndDispatcherProvided(): void
    {
        $offer = new Base();
        $offer->setFee(1000);

        $participant = $this->createStub(ParticipantInterface::class);
        $dispatcher = $this->createMock(EventDispatcherInterface::class);
        $dispatcher->method('dispatch')->willReturnCallback(static function (OfferFeeEvent $event) {
            $event->setFee(800); // discounted fee

            return $event;
        });

        self::assertSame(800, $offer->getFeePayable($participant, $dispatcher));
    }

    public function testGetDownloadsOldReturnsNullForEmptyString(): void
    {
        $offer = new Base();
        // downloadsOld defaults to null → StringUtil::deserialize(null) returns null
        self::assertNull($offer->getDownloadsOld());
    }

    public function testGetDownloadsOldReturnsNullWhenExplicitlyEmptyString(): void
    {
        $offer = new Base();
        $prop = new \ReflectionProperty(Base::class, 'downloadsOld');
        $prop->setValue($offer, '');

        self::assertNull($offer->getDownloadsOld());
    }

    public function testGetFeePayableEventGettersWork(): void
    {
        $offer = new Base();
        $offer->setFee(2000);

        $participant = $this->createStub(ParticipantInterface::class);
        $dispatcher = $this->createMock(EventDispatcherInterface::class);
        $dispatcher->method('dispatch')->willReturnCallback(static function (OfferFeeEvent $event) use ($offer, $participant) {
            // Verify getOffer and getParticipant work
            self::assertSame($offer, $event->getOffer());
            self::assertSame($participant, $event->getParticipant());

            return $event;
        });

        $offer->getFeePayable($participant, $dispatcher);
    }

    public function testLoadValidatorMetadataAddsConstraints(): void
    {
        $metadata = $this->createMock(\Symfony\Component\Validator\Mapping\ClassMetadata::class);
        $metadata->expects(self::atLeastOnce())->method('addPropertyConstraint');
        $metadata->expects(self::atLeastOnce())->method('addConstraint');

        Base::loadValidatorMetadata($metadata);
    }

    public function testValidateWarningsNoViolationWhenNoEdition(): void
    {
        $offer = new Base();
        $context = $this->createMock(\Symfony\Component\Validator\Context\ExecutionContextInterface::class);
        $context->expects(self::never())->method('buildViolation');

        $offer->validateWarnings($context);
    }

    public function testValidateWarningsNoViolationWhenEditionHasNoHoliday(): void
    {
        $edition = new Edition();
        $offer = new Base();
        $offer->setEdition($edition);

        $context = $this->createMock(\Symfony\Component\Validator\Context\ExecutionContextInterface::class);
        $context->expects(self::never())->method('buildViolation');

        $offer->validateWarnings($context);
    }

    public function testValidateWarningsViolationWhenDateBeforeHolidayBegin(): void
    {
        $edition = new Edition();
        $holidayTask = new \Ferienpass\CoreBundle\Entity\EditionTask($edition);
        $rp = new \ReflectionProperty($holidayTask, 'type');
        $rp->setValue($holidayTask, 'holiday');
        $rpBegin = new \ReflectionProperty($holidayTask, 'periodBegin');
        $rpBegin->setValue($holidayTask, new \DateTimeImmutable('2024-07-01'));
        $rpEnd = new \ReflectionProperty($holidayTask, 'periodEnd');
        $rpEnd->setValue($holidayTask, new \DateTimeImmutable('2024-07-31'));

        $tasks = new \Doctrine\Common\Collections\ArrayCollection([$holidayTask]);
        $rpTasks = new \ReflectionProperty($edition, 'tasks');
        $rpTasks->setValue($edition, $tasks);

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

        $date = new OfferDate($offer);
        $date->setBegin(new \DateTime('2024-06-15')); // before holiday
        $offer->addDate($date);

        $violation = $this->createMock(\Symfony\Component\Validator\Violation\ConstraintViolationBuilderInterface::class);
        $violation->method('atPath')->willReturnSelf();
        $violation->expects(self::once())->method('addViolation');

        $context = $this->createMock(\Symfony\Component\Validator\Context\ExecutionContextInterface::class);
        $context->expects(self::once())->method('buildViolation')->willReturn($violation);

        $offer->validateWarnings($context);
    }

    public function testValidateWarningsViolationWhenDateAfterHolidayEnd(): void
    {
        $edition = new Edition();
        $holidayTask = new \Ferienpass\CoreBundle\Entity\EditionTask($edition);
        $rp = new \ReflectionProperty($holidayTask, 'type');
        $rp->setValue($holidayTask, 'holiday');
        $rpBegin = new \ReflectionProperty($holidayTask, 'periodBegin');
        $rpBegin->setValue($holidayTask, new \DateTimeImmutable('2024-07-01'));
        $rpEnd = new \ReflectionProperty($holidayTask, 'periodEnd');
        $rpEnd->setValue($holidayTask, new \DateTimeImmutable('2024-07-31'));

        $tasks = new \Doctrine\Common\Collections\ArrayCollection([$holidayTask]);
        $rpTasks = new \ReflectionProperty($edition, 'tasks');
        $rpTasks->setValue($edition, $tasks);

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

        $date = new OfferDate($offer);
        $date->setBegin(new \DateTime('2024-07-10'));
        $date->setEnd(new \DateTime('2024-08-05')); // after holiday end
        $offer->addDate($date);

        $violation = $this->createMock(\Symfony\Component\Validator\Violation\ConstraintViolationBuilderInterface::class);
        $violation->method('atPath')->willReturnSelf();
        $violation->expects(self::once())->method('addViolation');

        $context = $this->createMock(\Symfony\Component\Validator\Context\ExecutionContextInterface::class);
        $context->expects(self::once())->method('buildViolation')->willReturn($violation);

        $offer->validateWarnings($context);
    }

    public function testValidateWarningsViolationWhenDeadlineAfterFirstDate(): void
    {
        $offer = new Base();
        $offer->setApplicationDeadline(new \DateTimeImmutable('2024-07-20'));

        $date = new OfferDate($offer);
        $date->setBegin(new \DateTime('2024-07-15')); // before deadline
        $offer->addDate($date);

        $violation = $this->createMock(\Symfony\Component\Validator\Violation\ConstraintViolationBuilderInterface::class);
        $violation->method('atPath')->willReturnSelf();
        $violation->expects(self::once())->method('addViolation');

        $context = $this->createMock(\Symfony\Component\Validator\Context\ExecutionContextInterface::class);
        $context->expects(self::once())->method('buildViolation')->willReturn($violation);

        $offer->validateWarnings($context);
    }

    public function testValidateWarningsNoViolationWhenDeadlineBeforeFirstDate(): void
    {
        $offer = new Base();
        $offer->setApplicationDeadline(new \DateTimeImmutable('2024-07-01'));

        $date = new OfferDate($offer);
        $date->setBegin(new \DateTime('2024-07-15')); // after deadline, ok
        $offer->addDate($date);

        $context = $this->createMock(\Symfony\Component\Validator\Context\ExecutionContextInterface::class);
        $context->expects(self::never())->method('buildViolation');

        $offer->validateWarnings($context);
    }

    private function attend(Base $offer, string $status): void
    {
        $attendance = new Attendance($offer, null);
        $attendance->setStatus($status);

        $offer->addAttendance($attendance);
    }
}
