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

use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use Contao\StringUtil;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\ORM\Mapping as ORM;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\ConsentForm;
use Ferienpass\CoreBundle\Entity\ConversationItem;
use Ferienpass\CoreBundle\Entity\DbafsAttachment;
use Ferienpass\CoreBundle\Entity\DbafsMedia;
use Ferienpass\CoreBundle\Entity\Edition;
use Ferienpass\CoreBundle\Entity\FriendCode;
use Ferienpass\CoreBundle\Entity\Host;
use Ferienpass\CoreBundle\Entity\MessengerLog;
use Ferienpass\CoreBundle\Entity\OfferAttachment;
use Ferienpass\CoreBundle\Entity\OfferDate;
use Ferienpass\CoreBundle\Entity\OfferLog;
use Ferienpass\CoreBundle\Entity\OfferMedia;
use Ferienpass\CoreBundle\Entity\OfferMemberAssociation;
use Ferienpass\CoreBundle\Entity\Participant\ParticipantInterface;
use Ferienpass\CoreBundle\Entity\PaymentItem;
use Ferienpass\CoreBundle\Entity\TenantAwareInterface;
use Ferienpass\CoreBundle\Entity\TenantAwareTrait;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\Enum\AttendanceStatus;
use Ferienpass\CoreBundle\Enum\OfferStatus;
use Ferienpass\CoreBundle\Payments\OfferFeeEvent;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
use Symfony\Component\String\Slugger\SluggerInterface;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Mapping\ClassMetadata;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

#[ORM\MappedSuperclass]
#[ORM\Index(columns: ['edition', 'id'])]
class Base implements OfferInterface, TenantAwareInterface
{
    use TenantAwareTrait;

    use VariantsTrait;

    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer', options: ['unsigned' => true])]
    #[Groups(['docx_export', 'api:offer:read', 'list:export:admin', 'list:export:default'])]
    private ?int $id = null;

    #[ORM\Column(type: UuidType::NAME, unique: true)]
    private Uuid $uuid;

    #[ORM\Column(type: 'datetime_immutable', options: ['default' => 'CURRENT_TIMESTAMP'])]
    #[Groups(['notification', 'list:export:admin', 'list:export:default', 'api:offer:read'])]
    private \DateTimeInterface $createdAt;

    #[ORM\Column(type: 'datetime_immutable', options: ['default' => 'CURRENT_TIMESTAMP'])]
    #[Groups(['api:offer:read'])]
    private \DateTimeImmutable $modifiedAt;

    #[ORM\ManyToMany(targetEntity: Host::class, inversedBy: 'offers', cascade: ['persist'], fetch: 'EXTRA_LAZY')]
    #[ORM\JoinTable(name: 'HostOfferAssociation')]
    #[ORM\JoinColumn(name: 'offer_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
    #[ORM\InverseJoinColumn(name: 'host_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
    #[Groups(['api:offer:anon'])]
    #[ApiFilter(SearchFilter::class, properties: ['hosts.alias' => 'exact', 'hosts.name' => 'partial'])]
    private Collection $hosts;

    /**
     * Individual accounts (of those with access to this offer, i.e. members of
     * its hosts) that receive the participant and attendance lists by email
     * shortly before the event.
     */
    #[ORM\ManyToMany(targetEntity: User::class, fetch: 'EXTRA_LAZY')]
    #[ORM\JoinTable(name: 'OfferParticipantListRecipient')]
    #[ORM\JoinColumn(name: 'offer_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
    #[ORM\InverseJoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
    private Collection $participantListRecipients;

    /**
     * @psalm-var Collection<int, OfferMemberAssociation>
     */
    #[ORM\OneToMany(mappedBy: 'offer', targetEntity: OfferMemberAssociation::class, fetch: 'EXTRA_LAZY')]
    private Collection $memberAssociations;

    #[ORM\Column(type: 'string', length: 255, nullable: false, options: ['default' => ''])]
    #[Groups(['docx_export', 'notification', 'list:export:admin', 'list:export:default', 'api:offer:anon'])]
    private string $name = '';

    #[ORM\Column(type: 'string', length: 255, nullable: true)]
    #[Groups(['docx_export', 'api:offer:anon'])]
    private ?string $alias = null;

    /**
     * Per-offer secret seed for the private share link. Never appears in any
     * URL or serializer group.
     */
    #[ORM\Column(type: 'string', length: 32, nullable: true)]
    private ?string $randomSeed = null;

    #[ORM\Column(type: 'json')]
    #[Groups(['api:offer:read'])]
    private array $status = [];

    #[ORM\Column(type: 'string', length: 16, options: ['default' => 'public'])]
    #[Groups(['api:offer:anon'])]
    private string $publish = 'public';

    #[ORM\Column(type: 'string', length: 255, nullable: true)]
    #[Groups(['docx_export'])]
    private ?string $comment = null;

    #[ORM\Column(type: 'text', nullable: true)]
    #[Groups(['docx_export', 'notification', 'list:export:admin', 'list:export:default', 'api:offer:anon'])]
    private ?string $description = null;

    #[ORM\Column(type: 'text', nullable: true)]
    #[Groups('docx_export')]
    private ?string $teaser = null;

    #[ORM\OneToMany(mappedBy: 'offer', targetEntity: OfferMedia::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
    #[ORM\OrderBy(['sorting' => 'ASC'])]
    private Collection $media;

    #[ORM\Column(type: 'binary_string', length: 16, nullable: true)]
    private ?string $agreementLetter = null;

    #[ORM\Column(name: 'downloads', type: 'binary_string', nullable: true)]
    private ?string $downloadsOld = null;

    #[ORM\OneToMany(targetEntity: OfferAttachment::class, mappedBy: 'offer', cascade: ['persist'])]
    #[ORM\OrderBy(['sorting' => 'ASC'])]
    private Collection $attachments;

    #[ORM\Column(type: 'boolean', nullable: true)]
    #[Groups(['list:export:admin', 'list:export:default'])]
    private ?bool $requiresAgreementLetter = null;

    #[ORM\Column(type: 'boolean')]
    private bool $deferAgreementLetter = false;

    #[ORM\Column(type: 'boolean', options: ['default' => 0])]
    #[Groups(['docx_export', 'api:offer:anon', 'list:export:admin', 'list:export:default'])]
    private bool $requiresApplication = false;

    #[ORM\Column(type: 'boolean', options: ['default' => 0])]
    #[Groups(['docx_export', 'api:offer:anon', 'list:export:admin', 'list:export:default'])]
    private bool $onlineApplication = false;

    #[ORM\Column(type: 'boolean', options: ['default' => 0])]
    #[Groups(['list:export:admin', 'list:export:default'])]
    private bool $disableOnlinePayment = false;

    #[ORM\Column(type: 'boolean', options: ['default' => 0])]
    #[Groups(['list:export:admin', 'list:export:default'])]
    private bool $collectDateOfBirth = false;

    #[ORM\Column(type: 'datetime_immutable', nullable: true)]
    #[Groups(['docx_export', 'api:offer:anon', 'list:export:admin', 'list:export:default'])]
    private ?\DateTimeImmutable $applicationDeadline = null;

    #[ORM\Column(type: 'smallint', nullable: true, options: ['unsigned' => true])]
    #[Groups(['docx_export', 'api:offer:read', 'list:export:admin', 'list:export:default'])]
    private ?int $minParticipants = null;

    #[ORM\Column(type: 'smallint', nullable: true, options: ['unsigned' => true])]
    #[Groups(['docx_export', 'api:offer:read', 'list:export:admin', 'list:export:default'])]
    private ?int $maxParticipants = null;

    #[ORM\Column(type: 'smallint', length: 2, nullable: true, options: ['unsigned' => true])]
    #[Groups(['docx_export', 'api:offer:anon', 'list:export:admin', 'list:export:default'])]
    private ?int $minAge = null;

    #[ORM\Column(type: 'smallint', length: 2, nullable: true, options: ['unsigned' => true])]
    #[Groups(['docx_export', 'api:offer:anon', 'list:export:admin', 'list:export:default'])]
    private ?int $maxAge = null;

    #[ORM\Column(type: 'integer', nullable: true, options: ['unsigned' => true])]
    #[Groups(['docx_export', 'api:offer:anon', 'list:export:admin', 'list:export:default'])]
    #[ApiFilter(RangeFilter::class)]
    private ?int $fee = null;

    #[ORM\Column(type: 'string', length: 255, nullable: true)]
    #[Groups(['docx_export', 'api:offer:anon', 'list:export:admin', 'list:export:default'])]
    #[ApiFilter(SearchFilter::class, strategy: 'partial')]
    private ?string $meetingPoint = null;

    #[ORM\Column(type: 'string', length: 255, nullable: true)]
    #[Groups(['docx_export', 'api:offer:anon', 'list:export:admin', 'list:export:default'])]
    private ?string $meetingAddress = null;

    #[ORM\Column(type: 'decimal', precision: 10, scale: 7, nullable: true)]
    #[Groups(['api:offer:anon'])]
    private ?string $meetingLat = null;

    #[ORM\Column(type: 'decimal', precision: 10, scale: 7, nullable: true)]
    #[Groups(['api:offer:anon'])]
    private ?string $meetingLng = null;

    #[ORM\Column(type: 'string', length: 255, nullable: true)]
    #[Groups(['docx_export', 'api:offer:anon', 'list:export:admin', 'list:export:default'])]
    private ?string $applyText = null;

    #[ORM\ManyToOne(targetEntity: User::class)]
    #[ORM\JoinColumn(name: 'contact_id', referencedColumnName: 'id', onDelete: 'SET NULL')]
    #[Groups('docx_export')]
    private ?User $contactUser = null;

    #[ORM\Column(type: 'string', length: 255, nullable: true)]
    #[Groups(['docx_export', 'api:offer:anon', 'list:export:admin', 'list:export:default'])]
    private ?string $bring = null;

    #[ORM\OneToMany(mappedBy: 'offer', targetEntity: OfferDate::class, cascade: ['persist'], fetch: 'EAGER', orphanRemoval: true)]
    #[ORM\OrderBy(['begin' => 'ASC'])]
    #[Groups(['api:offer:anon'])]
    #[ApiFilter(DateFilter::class, strategy: DateFilter::EXCLUDE_NULL, properties: ['dates.begin'])]
    private Collection $dates;

    #[ORM\Column(type: 'json', nullable: true)]
    #[Groups(['api:offer:anon'])]
    private ?array $accessibility = null;

    #[ORM\Column(type: 'boolean', nullable: true)]
    #[Groups(['api:offer:anon', 'list:export:admin', 'list:export:default'])]
    private ?bool $wheelchairAccessible = null;

    #[ORM\OneToMany(mappedBy: 'offer', targetEntity: Attendance::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY')]
    #[ORM\OrderBy(['status' => 'ASC', 'sorting' => 'ASC'])]
    private Collection $attendances;

    #[ORM\OneToMany(mappedBy: 'offer', targetEntity: ConsentForm::class, cascade: ['persist'], orphanRemoval: true)]
    private Collection $consentForms;

    #[ORM\OneToMany(mappedBy: 'offer', targetEntity: OfferLog::class, cascade: ['persist', 'remove'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]
    #[ORM\OrderBy(['createdAt' => 'DESC'])]
    private Collection $activity;

    #[ORM\OneToMany(mappedBy: 'offer', targetEntity: ConversationItem::class, fetch: 'EXTRA_LAZY')]
    #[ORM\OrderBy(['createdAt' => 'ASC'])]
    private Collection $conversation;

    #[ORM\ManyToMany(targetEntity: MessengerLog::class, mappedBy: 'offers', fetch: 'EXTRA_LAZY')]
    private Collection $messengerLogs;

    #[ORM\Column(type: 'json', nullable: true)]
    private ?array $applicationExtra = null;

    #[ORM\Column(type: 'json', nullable: true)]
    #[ORM\OrderBy(['createdAt' => 'DESC'])]
    private ?array $contact = null;

    #[ORM\OneToMany(mappedBy: 'offer', targetEntity: FriendCode::class, cascade: ['persist'], fetch: 'EXTRA_LAZY', orphanRemoval: true)]
    private Collection $friendCodes;

    #[Groups(['api:offer:anon'])]
    private string $url;

    #[Groups(['api:offer:anon'])]
    #[SerializedName('media')]
    private array $mediaForApi;

    #[ORM\ManyToOne(targetEntity: Edition::class, inversedBy: 'offers')]
    #[ORM\JoinColumn(name: 'edition', referencedColumnName: 'id')]
    #[Groups(['api:offer:read'])]
    #[ApiFilter(SearchFilter::class, properties: ['edition.id' => 'exact', 'edition.alias' => 'exact', 'edition.name' => 'partial'])]
    private ?Edition $edition = null;

    public function __construct()
    {
        $this->uuid = Uuid::v7();
        $this->randomSeed = self::generateRandomSeed();

        $this->createdAt = new \DateTimeImmutable();
        $this->modifiedAt = new \DateTimeImmutable();
        $this->hosts = new ArrayCollection();
        $this->participantListRecipients = new ArrayCollection();
        $this->dates = new ArrayCollection();
        $this->variants = new ArrayCollection();
        $this->attendances = new ArrayCollection();
        $this->activity = new ArrayCollection();
        $this->conversation = new ArrayCollection();
        $this->messengerLogs = new ArrayCollection();
        $this->friendCodes = new ArrayCollection();
        $this->media = new ArrayCollection();
        $this->attachments = new ArrayCollection();
        $this->consentForms = new ArrayCollection();
        $this->memberAssociations = new ArrayCollection();
    }

    public function getEdition(): ?Edition
    {
        return $this->edition;
    }

    public function setEdition(Edition $edition): void
    {
        $this->edition = $edition;
    }

    #[Groups(['list:export:admin', 'list:export:default'])]
    public function getHumanEdition(): ?string
    {
        return $this->edition?->getName();
    }

    public static function loadValidatorMetadata(ClassMetadata $metadata): void
    {
        $metadata->addPropertyConstraint('name', new Assert\NotBlank());
        $metadata->addPropertyConstraint('minParticipants', new Assert\GreaterThan(0));
        $metadata->addPropertyConstraint('maxParticipants', new Assert\GreaterThan(0));
        $metadata->addPropertyConstraint('minAge', new Assert\GreaterThan(0));
        $metadata->addPropertyConstraint('maxAge', new Assert\GreaterThan(0));
        $metadata->addPropertyConstraint('fee', new Assert\PositiveOrZero());

        $metadata->addPropertyConstraint('contact', new Assert\Count(min: 1, groups: ['warning']));
        $metadata->addPropertyConstraint('media', new Assert\Count(min: 1, groups: ['warning']));
        $metadata->addPropertyConstraint('dates', new Assert\Count(min: 1, groups: ['error']));

        // Soft validations used for UI warnings
        $metadata->addConstraint(new Assert\Callback(callback: 'validateWarnings', groups: ['warning']));
    }

    /**
     * Add non-blocking warnings for the UI (group "warning").
     * - Warn if any date is outside the edition's holiday period.
     * - Warn if application deadline is after the first date begins.
     */
    public function validateWarnings(ExecutionContextInterface $context): void
    {
        // Edition holiday boundaries
        $edition = $this->getEdition();
        $holiday = $edition?->getHoliday();

        if (null !== $holiday && null !== $holiday->getPeriodBegin() && null !== $holiday->getPeriodEnd()) {
            foreach ($this->getDates() as $date) {
                $begin = $date->getBegin();
                $end = $date->getEnd();

                if ($begin instanceof \DateTimeInterface && $begin < $holiday->getPeriodBegin()) {
                    $context->buildViolation('Mindestens ein Termin beginnt vor dem Ferienzeitraum der Ausgabe.')
                        ->atPath('dates')
                        ->addViolation();
                    break;
                }

                if ($end instanceof \DateTimeInterface && $end > $holiday->getPeriodEnd()) {
                    $context->buildViolation('Mindestens ein Termin endet nach dem Ferienzeitraum der Ausgabe.')
                        ->atPath('dates')
                        ->addViolation();
                    break;
                }
            }
        }

        // Deadline after first date begin
        $deadline = $this->getApplicationDeadline();
        if ($deadline instanceof \DateTimeInterface && $this->getDates()->count() > 0) {
            $firstBegin = null;
            foreach ($this->getDates() as $date) {
                $begin = $date->getBegin();
                if ($begin instanceof \DateTimeInterface) {
                    if (null === $firstBegin || $begin < $firstBegin) {
                        $firstBegin = $begin;
                    }
                }
            }

            if ($firstBegin instanceof \DateTimeInterface && $deadline > $firstBegin) {
                $context->buildViolation('Der Anmeldeschluss liegt nach dem ersten Termin.')
                    ->atPath('applicationDeadline')
                    ->addViolation();
            }
        }
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getUuid(): Uuid
    {
        return $this->uuid;
    }

    public function addDate(OfferDate $offerDate): void
    {
        $this->dates->add($offerDate);
    }

    public function removeDate(OfferDate $offerDate): void
    {
        $this->dates->removeElement($offerDate);
    }

    public function getDates(): Collection
    {
        return $this->dates;
    }

    public function getDateBegin(): ?\DateTimeInterface
    {
        return ($this->getDates()->first() ?: null)?->getBegin();
    }

    public function getDateEnd(): ?\DateTimeInterface
    {
        $lastEnd = null;
        foreach ($this->getDates() as $date) {
            foreach ($date->getOccurrences() as $occurrence) {
                if (null === $lastEnd || $occurrence['end'] > $lastEnd) {
                    $lastEnd = $occurrence['end'];
                }
            }
        }

        return $lastEnd;
    }

    public function isFuture(): bool
    {
        return new \DateTimeImmutable() > ($this->getDates()->first() ?: null)?->getBegin();
    }

    public function isPast(): bool
    {
        $end = $this->getDateEnd();
        if (null === $end) {
            return true;
        }

        return $end < new \DateTimeImmutable();
    }

    public function getHosts(): Collection
    {
        return $this->hosts;
    }

    public function setHosts(Collection $hosts): void
    {
        $this->hosts = $hosts;
    }

    public function addHost(Host $host): void
    {
        $this->hosts[] = $host;
    }

    /**
     * @return Collection<int, User>
     */
    public function getParticipantListRecipients(): Collection
    {
        return $this->participantListRecipients;
    }

    public function addParticipantListRecipient(User $user): void
    {
        if (!$this->participantListRecipients->contains($user)) {
            $this->participantListRecipients->add($user);
        }
    }

    public function removeParticipantListRecipient(User $user): void
    {
        $this->participantListRecipients->removeElement($user);
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function getAlias(): ?string
    {
        return $this->alias;
    }

    public function getDescription(): ?string
    {
        return $this->description;
    }

    public function getTeaser(): ?string
    {
        return $this->teaser;
    }

    public function isPublished(): bool
    {
        return \array_key_exists(OfferStatus::PUBLISHED, $this->status);
    }

    public function isFinalized(): bool
    {
        return \array_key_exists(OfferStatus::FINALIZED, $this->status);
    }

    public function isReviewed(): bool
    {
        return \array_key_exists(OfferStatus::REVIEWED, $this->status);
    }

    public function isDraft(): bool
    {
        return \array_key_exists(OfferStatus::DRAFT, $this->status);
    }

    public function requiresApplication(): bool
    {
        return $this->requiresApplication;
    }

    public function isOnlinePayable(): bool
    {
        return !$this->isDisableOnlinePayment();
    }

    public function isDisableOnlinePayment(): bool
    {
        return $this->disableOnlinePayment;
    }

    public function setDisableOnlinePayment(bool $disableOnlinePayment): void
    {
        $this->disableOnlinePayment = $disableOnlinePayment;
    }

    public function isCollectDateOfBirth(): bool
    {
        return $this->collectDateOfBirth;
    }

    public function setCollectDateOfBirth(bool $collectDateOfBirth): void
    {
        $this->collectDateOfBirth = $collectDateOfBirth;
    }

    public function isOnlineApplication(): bool
    {
        return $this->requiresApplication && $this->onlineApplication;
    }

    public function isCancelled(): bool
    {
        return \array_key_exists(OfferStatus::CANCELLED, $this->status);
    }

    public function isCompleted(): bool
    {
        return \array_key_exists(OfferStatus::COMPLETED, $this->status);
    }

    public function getMinParticipants(): ?int
    {
        return $this->minParticipants;
    }

    public function getMaxParticipants(): ?int
    {
        return $this->maxParticipants;
    }

    /**
     * Spots left, or null when the offer works without a participant limit
     * (no limit, or a limit of 0 — both are read as "unlimited" everywhere).
     *
     * Occupancy counts confirmed *and* waitlisted attendances, the same rule
     * {@see \Ferienpass\CoreBundle\ApplicationSystem\FirstComeApplicationSystem::doGetTransitionName()}
     * allocates by. Counting the confirmed alone reports free spots on an offer
     * whose waiting list has already claimed them — the booking is then turned
     * away at the very end of the application form.
     */
    public function getVacancies(): ?int
    {
        $max = $this->getMaxParticipants();
        if (!$max) {
            return null;
        }

        // `attendances` is EXTRA_LAZY, so matching() counts in the database
        // instead of hydrating every attendance — this runs once per card in
        // the offer list, and the offer_id_status_sorting index covers it.
        $occupied = $this->attendances->matching(
            Criteria::create()->where(Criteria::expr()->in('status', [AttendanceStatus::CONFIRMED, AttendanceStatus::WAITLISTED]))
        )->count();

        return max(0, $max - $occupied);
    }

    public function isFullyBooked(): bool
    {
        return 0 === $this->getVacancies();
    }

    public function isApplicationDeadlineExpired(): bool
    {
        return null !== $this->applicationDeadline && $this->applicationDeadline < new \DateTimeImmutable();
    }

    /**
     * Whether a visitor could book this offer online right now — the rule behind
     * both the "Sofort buchbar" chip on an offer card and the `sofortbuchung`
     * filter, which MUST agree. Advertising an offer as instantly bookable when
     * the deadline has passed or the last spot is gone sends families down a
     * dead end, which is exactly what the label promises to spare them.
     *
     * Season-level gating (no application period open at all) is not visible
     * from the entity; {@see \Ferienpass\CmsBundle\Components\ApplicationForm}
     * adds it on the offer's own page.
     */
    public function isInstantlyBookable(): bool
    {
        return $this->isOnlineApplication()
            && !$this->isCancelled()
            && !$this->isApplicationDeadlineExpired()
            && !$this->isFullyBooked();
    }

    public function getFee(): ?int
    {
        return $this->fee;
    }

    public function getMeetingPoint(): ?string
    {
        return $this->meetingPoint;
    }

    public function setName(?string $name): void
    {
        $this->name = (string) $name;
    }

    public function setAlias(?string $alias): void
    {
        $this->alias = $alias;
    }

    public function getRandomSeed(): ?string
    {
        return $this->randomSeed;
    }

    public function setRandomSeed(?string $randomSeed): void
    {
        $this->randomSeed = $randomSeed;
    }

    /**
     * Generate a fresh secret seed: 16 random bytes, base64url-encoded. Used
     * only as the server-side HMAC key for the private-link signature; never
     * exposed, so its encoding/length is internal.
     */
    public static function generateRandomSeed(): string
    {
        return rtrim(strtr(base64_encode(random_bytes(16)), '+/', '-_'), '=');
    }

    public function setDescription(?string $description): void
    {
        $this->description = $description;
    }

    public function setTeaser(?string $teaser): void
    {
        $this->teaser = $teaser;
    }

    public function setRequiresApplication(bool $requiresApplication): void
    {
        $this->requiresApplication = $requiresApplication;
    }

    public function setOnlineApplication(bool $onlineApplication): void
    {
        if ($onlineApplication) {
            $this->setRequiresApplication(true);
        }

        $this->onlineApplication = $onlineApplication;
    }

    public function setMinParticipants(?int $minParticipants): void
    {
        $this->minParticipants = $minParticipants;
    }

    public function setMaxParticipants(?int $maxParticipants): void
    {
        $this->maxParticipants = $maxParticipants;
    }

    public function setFee(?int $fee): void
    {
        $this->fee = $fee;
    }

    public function setMeetingPoint(?string $meetingPoint): void
    {
        $this->meetingPoint = $meetingPoint;
    }

    public function getMeetingAddress(): ?string
    {
        return $this->meetingAddress;
    }

    public function setMeetingAddress(?string $meetingAddress): void
    {
        $this->meetingAddress = $meetingAddress;
    }

    public function getMeetingLat(): ?float
    {
        return null !== $this->meetingLat ? (float) $this->meetingLat : null;
    }

    public function setMeetingLat(?float $meetingLat): void
    {
        $this->meetingLat = null !== $meetingLat ? (string) $meetingLat : null;
    }

    public function getMeetingLng(): ?float
    {
        return null !== $this->meetingLng ? (float) $this->meetingLng : null;
    }

    public function setMeetingLng(?float $meetingLng): void
    {
        $this->meetingLng = null !== $meetingLng ? (string) $meetingLng : null;
    }

    public function getMinAge(): ?int
    {
        return $this->minAge;
    }

    public function setMinAge(?int $minAge): void
    {
        $this->minAge = $minAge;
    }

    public function getMaxAge(): ?int
    {
        return $this->maxAge;
    }

    public function setMaxAge(?int $maxAge): void
    {
        $this->maxAge = $maxAge;
    }

    public function getDownloadsOld(): ?array
    {
        if ('' === $this->downloadsOld) {
            return null;
        }

        return StringUtil::deserialize($this->downloadsOld);
    }

    public function getDownloads($withAgreementLetter = false): Collection
    {
        $criteria = Criteria::create();

        if (!$withAgreementLetter) {
            $criteria->andWhere(Criteria::expr()->eq('agreementLetter', 0));
        }

        return $this->attachments->matching($criteria);
    }

    public function getAttachments(): Collection
    {
        return $this->attachments;
    }

    public function getApplicationDeadline(): ?\DateTimeImmutable
    {
        return $this->applicationDeadline;
    }

    public function getConsentForms(): Collection
    {
        return $this->consentForms;
    }

    public function setApplicationDeadline(?\DateTimeImmutable $applicationDeadline): void
    {
        $this->applicationDeadline = $applicationDeadline;
    }

    /** @return Collection<int, Attendance> */
    public function getAttendances(): Collection
    {
        return $this->attendances;
    }

    /** @return Collection<int, Attendance> */
    public function getAttendancesNotHidden(): Collection
    {
        return $this->getAttendances()->filter(static fn (Attendance $attendance) => AttendanceStatus::WITHDRAWN !== $attendance->getStatus() && AttendanceStatus::UNFULFILLED !== $attendance->getStatus());
    }

    /** @return Collection<int, Attendance> */
    public function getAttendancesConfirmed(): Collection
    {
        return $this->getAttendancesWithStatus(AttendanceStatus::CONFIRMED);
    }

    /** @return Collection<int, Attendance> */
    public function getAttendancesWaiting(): Collection
    {
        return $this->getAttendancesWithStatus(AttendanceStatus::WAITING);
    }

    /** @return Collection<int, Attendance> */
    public function getAttendancesWaitlisted(): Collection
    {
        return $this->getAttendancesWithStatus(AttendanceStatus::WAITLISTED);
    }

    /**
     * Whether this offer has attendances that still need to be assigned a spot
     * even though spots are (or might still be) available.
     *
     * Triggers on people in the "waiting" status (awaiting assignment), or — as
     * long as confirmed attendances haven't filled the offer — on people who are
     * "waitlisted" or "unfulfilled".
     */
    public function hasUnassignedAttendances(): bool
    {
        if ($this->isCancelled()) {
            return false;
        }

        if (!$this->getAttendancesWaiting()->isEmpty()) {
            return true;
        }

        $maxParticipants = $this->getMaxParticipants();
        $hasFreeSpots = null !== $maxParticipants && $this->getAttendancesConfirmed()->count() < $maxParticipants;

        if (!$hasFreeSpots) {
            return false;
        }

        return !$this->getAttendancesWaitlisted()->isEmpty()
            || !$this->getAttendancesWithStatus(AttendanceStatus::UNFULFILLED)->isEmpty();
    }

    /** @return Collection<int, Attendance> */
    public function getAttendancesConfirmedOrWaiting(): Collection
    {
        return $this->getAttendancesWithStatuses([AttendanceStatus::CONFIRMED, AttendanceStatus::WAITING]);
    }

    /** @return Collection<int, Attendance> */
    public function getAttendancesConfirmedOrWaitlisted(): Collection
    {
        return $this->getAttendancesWithStatuses([AttendanceStatus::CONFIRMED, AttendanceStatus::WAITLISTED]);
    }

    /** @return Collection<int, Attendance> */
    public function getAttendancesWithStatus(string $status): Collection
    {
        return $this->getAttendances()->filter(static fn (Attendance $attendance) => $status === $attendance->getStatus());
    }

    /** @return Collection<int, Attendance> */
    public function getAttendancesWithStatuses(array $status): Collection
    {
        return $this->getAttendances()->filter(static fn (Attendance $attendance) => \in_array($attendance->getStatus(), $status, true));
    }

    public function addAttendance(Attendance $attendance): void
    {
        $this->attendances->add($attendance);
    }

    public function getBring(): ?string
    {
        return $this->bring;
    }

    public function setBring(?string $bring): void
    {
        $this->bring = $bring;
    }

    public function getComment(): ?string
    {
        return $this->comment;
    }

    public function setComment(?string $comment): void
    {
        $this->comment = $comment;
    }

    public function getStatus(): array
    {
        return $this->status;
    }

    public function setStatus(array $status): void
    {
        $this->status = $status;
    }

    public function getPublish(): string
    {
        return $this->publish;
    }

    public function setPublish(string $publish): void
    {
        $this->publish = $publish;
    }

    public function getMedia(): Collection
    {
        return $this->media;
    }

    public function setMedia(Collection $media): void
    {
        $this->media = $media->map(fn (OfferMedia $m) => new OfferMedia(offer: $this, media: $m->getMedia()));
    }

    public function addMedia(OfferMedia|DbafsMedia $offerMedia): void
    {
        if ($offerMedia instanceof DbafsMedia) {
            $offerMedia = new OfferMedia(offer: $this, media: $offerMedia);
        } else {
            $offerMedia->setOffer($this);
        }

        $this->media->add($offerMedia);
    }

    public function addAttachment(OfferAttachment|DbafsAttachment $offerAttachment): void
    {
        if ($offerAttachment instanceof DbafsAttachment) {
            $offerAttachment = new OfferAttachment(offer: $this, attachment: $offerAttachment);
        } else {
            $offerAttachment->setOffer($this);
        }

        $this->attachments->add($offerAttachment);
    }

    public function addConsentForm(ConsentForm $consentForm): void
    {
        $consentForm->setOffer($this);

        $this->consentForms->add($consentForm);
    }

    public function removeMedia(OfferMedia $offerMedia): void
    {
        $this->media->removeElement($offerMedia);
    }

    public function removeAttachment(OfferAttachment $offerAttachment): void
    {
        $this->attachments->removeElement($offerAttachment);
    }

    public function removeConsentForm(ConsentForm $consentForm): void
    {
        $this->consentForms->removeElement($consentForm);
    }

    public function getApplyText(): ?string
    {
        return $this->applyText;
    }

    public function setApplyText(?string $applyText): void
    {
        $this->applyText = $applyText;
    }

    public function getContactUser(): ?User
    {
        return $this->contactUser;
    }

    public function setContactUser(?User $contactUser): void
    {
        $this->contactUser = $contactUser;
    }

    public function getActivity(): Collection
    {
        return $this->activity;
    }

    public function getConversation(): Collection
    {
        return $this->conversation;
    }

    #[Groups(['docx_export', 'notification'])]
    public function getDate(): string
    {
        if (false === $date = $this->dates->first()) {
            return '';
        }

        if (null === $begin = $date->getBegin()) {
            return '';
        }

        return $begin->format('d.m.Y H:i');
    }

    public function getAccessibility(): ?array
    {
        return $this->accessibility;
    }

    public function setAccessibility(?array $accessibility): void
    {
        $this->accessibility = $accessibility;
    }

    public function isWheelchairAccessible(): ?bool
    {
        return $this->wheelchairAccessible;
    }

    public function setWheelchairAccessible(?bool $wheelchairAccessible): void
    {
        $this->wheelchairAccessible = $wheelchairAccessible;
    }

    public function requiresAgreementLetter(): ?bool
    {
        return $this->requiresAgreementLetter;
    }

    public function setRequiresAgreementLetter(?bool $requiresAgreementLetter): void
    {
        $this->requiresAgreementLetter = $requiresAgreementLetter;
    }

    public function isDeferAgreementLetter(): bool
    {
        return $this->deferAgreementLetter;
    }

    public function setDeferAgreementLetter(bool $deferAgreementLetter): void
    {
        $this->deferAgreementLetter = $deferAgreementLetter;
    }

    public function getAgreementLetterOld(): ?string
    {
        return $this->agreementLetter;
    }

    public function getAgreementLetter(): ?OfferAttachment
    {
        $criteria = Criteria::create();
        $criteria->andWhere(Criteria::expr()->eq('agreementLetter', true));

        return $this->attachments->matching($criteria)->first() ?: null;
    }

    public function setAgreementLetter(OfferAttachment|DbafsAttachment $agreementLetter): void
    {
        if ($agreementLetter instanceof DbafsAttachment) {
            $agreementLetter = new OfferAttachment(offer: $this, attachment: $agreementLetter);
        } else {
            $agreementLetter->setOffer($this);
        }

        $agreementLetter->setAgreementLetter(true);

        if (!$this->attachments->contains($agreementLetter)) {
            $this->attachments->add($agreementLetter);
        }
    }

    public function getMemberAssociations(): Collection
    {
        return $this->memberAssociations;
    }

    public function addMemberAssociation(OfferMemberAssociation $association): void
    {
        $association->setOffer($this);

        $this->memberAssociations->add($association);
    }

    public function getApplicationExtra(): ?array
    {
        return $this->applicationExtra;
    }

    public function setApplicationExtra(?array $extra): void
    {
        // Normalise every extra question to a stable, fully-populated shape so
        // consumers (frontend application form, participant-list exports, admin
        // preview) can rely on all four keys being present. `required` was
        // added with the "Anmeldeformular" section redesign; older rows lack it
        // and default to false.
        $extra = array_map(static fn (array $e): array => [
            'uuid' => ($e['uuid'] ?? '') ?: Uuid::v7()->toRfc4122(),
            'question' => (string) ($e['question'] ?? ''),
            'type' => \in_array($e['type'] ?? null, ['yn', 'int', 'txt'], true) ? $e['type'] : 'txt',
            'required' => (bool) ($e['required'] ?? false),
        ], $extra ?? []);

        $this->applicationExtra = $extra;
    }

    public function getContact(): ?array
    {
        return $this->contact;
    }

    public function setContact(?array $contact): void
    {
        $this->contact = $contact;
    }

    public function generateAlias(SluggerInterface $slugger): void
    {
        $this->alias = (string) $slugger->slug((string) $this->getName())->trimEnd('-')->lower();
    }

    public function getCreatedAt(): \DateTimeInterface
    {
        return $this->createdAt;
    }

    public function getModifiedAt(): \DateTimeImmutable
    {
        return $this->modifiedAt;
    }

    public function setModifiedAt(\DateTimeImmutable $modifiedAt = new \DateTimeImmutable()): void
    {
        $this->modifiedAt = $modifiedAt;
    }

    /**
     * @return Collection|PaymentItem[]
     *
     * @psalm-return Collection<int, PaymentItem>
     */
    public function getMessengerLogs(): Collection
    {
        return $this->messengerLogs;
    }

    public function getFeePayable(?ParticipantInterface $participant, ?EventDispatcherInterface $dispatcher = null): int
    {
        if (!$participant instanceof ParticipantInterface || !$dispatcher instanceof EventDispatcherInterface) {
            return (int) $this->getFee();
        }

        return $dispatcher->dispatch(new OfferFeeEvent($this->getFee(), $this, $participant))->getFee();
    }

    public function getFriendCodes(): Collection
    {
        return $this->friendCodes;
    }

    public function setStateManually(string $state): void
    {
        $this->setStatus([$state => true]);
    }

    public function resetStatus(): void
    {
        $this->setStatus([]);
    }

    public function getUrl(): string
    {
        return $this->url;
    }

    public function setUrl(string $url): void
    {
        $this->url = $url;
    }

    #[Groups(['list:export:admin', 'list:export:default'])]
    public function getHumanDates()
    {
        return implode(', ', $this->dates->map(static fn (OfferDate $date) => $date->toHumanReadable())->toArray());
    }

    #[Groups(['list:export:admin', 'list:export:default'])]
    public function getHumanHosts()
    {
        return implode(', ', $this->hosts->map(static fn (Host $host) => $host->getName())->toArray());
    }

    public function getMediaForApi(): array
    {
        return $this->mediaForApi;
    }

    public function setMediaForApi(array $mediaForApi): void
    {
        $this->mediaForApi = $mediaForApi;
    }
}
