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

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\Common\Collections\Order;
use Doctrine\ORM\Event\PrePersistEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Persistence\Event\LifecycleEventArgs;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Entity\Participant\ParticipantInterface;
use Ferienpass\CoreBundle\Enum\AttendanceStatus;
use Ferienpass\CoreBundle\Enum\AttendanceTransition;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Uid\Uuid;

#[ORM\Entity]
#[ORM\UniqueConstraint(columns: ['offer_id', 'participant_id'])]
#[ORM\HasLifecycleCallbacks]
#[ORM\Index(columns: ['offer_id', 'status', 'sorting'], name: 'offer_id_status_sorting_idx')]
class Attendance implements \Stringable, TenantAwareInterface
{
    use TenantAwareTrait;

    /**
     * The "Sofortstornierung" window: for this long after getting the spot, giving
     * it back costs nothing — no matter what the withdrawal grace period says.
     */
    public const INSTANT_WITHDRAWAL_PERIOD = '30 minutes';

    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer', options: ['unsigned' => true])]
    private ?int $id = null;

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

    #[ORM\Column(type: 'integer', options: ['unsigned' => true])]
    private int $sorting = 0;

    #[ORM\Column(type: 'string', length: 32, nullable: true)]
    private ?string $status = AttendanceStatus::WAITING;

    #[ORM\Column(type: 'boolean', options: ['default' => 0])]
    private bool $paid = false;

    #[ORM\Column(type: 'boolean', options: ['default' => 0])]
    private bool $payable = false;

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

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

    #[ORM\ManyToOne(targetEntity: EditionTask::class)]
    #[ORM\JoinColumn(name: 'task_id', referencedColumnName: 'id', onDelete: 'SET NULL')]
    private ?EditionTask $task = null;

    #[ORM\OneToMany(mappedBy: 'attendance', targetEntity: ParticipantLog::class, cascade: ['persist'], orphanRemoval: true)]
    private Collection $activity;

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

    #[ORM\Column(type: 'integer', options: ['unsigned' => true])]
    private int $userPriority = 0;

    #[ORM\Column(type: 'boolean', options: ['default' => true])]
    private bool $allAlternateDates = true;

    #[ORM\Column(type: 'integer', length: 3, nullable: true, options: ['unsigned' => true])]
    private ?int $age = null;

    // Only used for data retention.
    #[ORM\Column(name: 'participant_id_original', type: 'string', length: 10, nullable: true)]
    private ?string $participantPseudonym = null;

    #[ORM\OneToMany(mappedBy: 'attendance', targetEntity: PaymentItem::class, fetch: 'EXTRA_LAZY')]
    private Collection $paymentItems;

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

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

    #[ORM\Column(type: 'boolean', options: ['default' => false])]
    private bool $consentOutstanding = false;

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

    #[ORM\ManyToOne(targetEntity: AttendanceStash::class, cascade: ['persist'], inversedBy: 'attendances')]
    #[ORM\JoinColumn(onDelete: 'SET NULL')]
    private ?AttendanceStash $stash = null;

    /**
     * Postal address captured for this specific application. Populated for
     * anonymous applications when the system requires an address; signed-in
     * members reference their account's address instead (User.postalAddresses).
     */
    #[ORM\OneToOne(targetEntity: PostalAddress::class, cascade: ['persist'])]
    #[ORM\JoinColumn(name: 'postal_address_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
    private ?PostalAddress $postalAddress = null;

    /**
     * Transient (never persisted) decision context for a status change made by the
     * lottery. Set via {@see self::lottingDecision()} right before the status changes;
     * the {@see \Ferienpass\CoreBundle\EventListener\Doctrine\Attendance\AttendanceLogListener}
     * reads it at flush time to attribute the resulting log row to the "Losverfahren"
     * system and to record why the participant was (not) admitted.
     */
    private bool $lottingActor = false;
    private ?string $lottingDecisionReason = null;

    public function __construct(#[ORM\ManyToOne(targetEntity: OfferInterface::class, inversedBy: 'attendances')]
        #[ORM\JoinColumn(name: 'offer_id', referencedColumnName: 'id')]
        private OfferInterface $offer, #[ORM\ManyToOne(targetEntity: ParticipantInterface::class, inversedBy: 'attendances')]
        #[ORM\JoinColumn(name: 'participant_id', referencedColumnName: 'id', nullable: true)]
        private ?ParticipantInterface $participant)
    {
        $this->uuid = Uuid::v7();
        $this->createdAt = new \DateTimeImmutable();
        $this->activity = new ArrayCollection();
        $this->conversation = new ArrayCollection();
        $this->messengerLogs = new ArrayCollection();
        $this->paymentItems = new ArrayCollection();

        $this->setModifiedAt();
    }

    public function __toString(): string
    {
        return (string) $this->getId();
    }

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

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

    public function getSorting(): int
    {
        return $this->sorting;
    }

    public function setSorting(int $sorting): void
    {
        $this->sorting = $sorting;
    }

    #[Groups('notification')]
    public function getStatus(): ?string
    {
        return $this->status;
    }

    public function resetStatus(): void
    {
        $this->status = AttendanceStatus::WAITING;
    }

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

        $this->setModifiedAt();
    }

    /**
     * Change the status as a decision of the lottery, carrying the (optional) reason.
     *
     * The transient context is read by the attendance log listener at flush time, so
     * the resulting log row is attributed to the "Losverfahren" system actor (no user)
     * and annotated with the decision reason ({@see DecisionReason}).
     */
    public function lottingDecision(string $status, ?string $reason = null): void
    {
        $this->lottingActor = true;
        $this->lottingDecisionReason = $reason;

        $this->setStatus($status);
    }

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

    public function getLottingDecisionReason(): ?string
    {
        return $this->lottingDecisionReason;
    }

    /**
     * The decision reason ({@see \Ferienpass\CoreBundle\Enum\DecisionReason}) behind the
     * current status, read from the most recent log entry that transitioned into it —
     * e.g. why the lottery left this attendance unfulfilled. Null when the current status
     * was set without a reason (a manual change supersedes an earlier lottery reason).
     */
    public function getDecisionReason(): ?string
    {
        $latest = null;
        foreach ($this->activity as $log) {
            if ($log->getTransitionTo() !== $this->status) {
                continue;
            }
            if (null === $latest || $log->getCreatedAt() >= $latest->getCreatedAt()) {
                $latest = $log;
            }
        }

        return $latest?->getDecisionReason();
    }

    /**
     * When the current status was reached, read from the most recent log entry that
     * transitioned into it. Null for attendances that never moved (or predate the
     * log). Lets a list say *when* a seat was given back, which is the part a host
     * needs to reconcile against the participant list they printed.
     */
    public function getStatusChangedAt(): ?\DateTimeInterface
    {
        $latest = null;
        foreach ($this->activity as $log) {
            if ($log->getTransitionTo() !== $this->status) {
                continue;
            }
            if (null === $latest || $log->getCreatedAt() >= $latest->getCreatedAt()) {
                $latest = $log;
            }
        }

        return $latest?->getCreatedAt();
    }

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

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

    public function setPaid($paid = true): void
    {
        $this->paid = $paid;
    }

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

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

    public function setPayable(bool $payable): void
    {
        $this->payable = $payable;
    }

    /**
     * When the spot became this family's, i.e. when the attendance was last moved
     * into the confirmed status — the moment a fee starts to be owed. Read from
     * the activity log, which covers both routes: the first-come registration
     * (the CREATE entry, already confirmed) and a later move-up from the waiting
     * list (the CONFIRM entry). Null for an attendance that was never confirmed.
     */
    public function getConfirmedAt(): ?\DateTimeInterface
    {
        $latest = null;
        foreach ($this->activity as $log) {
            if (AttendanceStatus::CONFIRMED !== $log->getTransitionTo()) {
                continue;
            }
            if (null === $latest || $log->getCreatedAt() >= $latest->getCreatedAt()) {
                $latest = $log;
            }
        }

        return $latest?->getCreatedAt();
    }

    /**
     * Whether a withdrawal happening now is a "Sofortstornierung": the family got
     * the spot only minutes ago and gives it straight back — a misclick or a
     * second thought, not a late cancellation. Such a withdrawal carries no fee
     * ({@see \Ferienpass\CoreBundle\EventListener\Workflow\AttendanceWithdrawTransactionListener})
     * and is not held back by the organiser's no-withdraw rules
     * ({@see \Ferienpass\CoreBundle\Workflow\AttendanceGuard}).
     *
     * Measured from the confirmation, so someone who moves up from the waiting
     * list weeks after applying gets the same window as a first-come registration
     * (where the two moments coincide anyway). An attendance that was never
     * confirmed — nothing is owed for it — falls back to its registration.
     */
    public function isInstantWithdrawal(?\DateTimeImmutable $now = null): bool
    {
        $now ??= new \DateTimeImmutable();

        return ($this->getConfirmedAt() ?? $this->createdAt) > $now->modify('-'.self::INSTANT_WITHDRAWAL_PERIOD);
    }

    /**
     * Whether this attendance has a pay status at all — i.e. whether its season
     * collects fees ({@see Edition::usePayments()}). An offer that lost its
     * season falls back to "yes" so an outstanding fee never silently
     * disappears from the payment lists.
     */
    public function usesPayments(): bool
    {
        return $this->offer->getEdition()?->usePayments() ?? true;
    }

    public function isConfirmed(): bool
    {
        return AttendanceStatus::CONFIRMED === $this->status;
    }

    public function isParticipated(): bool
    {
        return AttendanceStatus::PARTICIPATED === $this->status;
    }

    public function isWithdrawn(): bool
    {
        return AttendanceStatus::WITHDRAWN === $this->status;
    }

    public function isHidden(): bool
    {
        return $this->isWithdrawn() || $this->isUnfulfilled();
    }

    public function isUnfulfilled(): bool
    {
        return AttendanceStatus::UNFULFILLED === $this->status;
    }

    public function isWaitlisted(): bool
    {
        return AttendanceStatus::WAITLISTED === $this->status;
    }

    public function isWaiting(): bool
    {
        return AttendanceStatus::WAITING === $this->status;
    }

    public function isRejected(): bool
    {
        return AttendanceStatus::REJECTED === $this->status;
    }

    public function isEmailUnconfirmed(): bool
    {
        return AttendanceStatus::EMAIL_UNCONFIRMED === $this->status;
    }

    /**
     * Held until the legal guardian signed the consents via the shared link
     * (status-based; not to be confused with the {@see $consentOutstanding} flag,
     * which also covers allocated attendances with unsigned consents).
     */
    public function isAwaitingConsents(): bool
    {
        return AttendanceStatus::CONSENTS_OUTSTANDING === $this->status;
    }

    /**
     * Whether the Ferienpass team created this attendance on the family's behalf
     * (rather than the family applying themselves or an anonymous/lottery hold).
     *
     * Read from the CREATE activity row: a creator who is set and is not the
     * participant's own account holder can only be a backend/admin user, since a
     * member can never create attendances for another family. Drives the "vom
     * Team angelegt" review banner on the confirmation page.
     */
    public function isCreatedByAdmin(): bool
    {
        foreach ($this->activity as $log) {
            if (AttendanceTransition::CREATE !== $log->getTransitionName()) {
                continue;
            }

            $creator = $log->getCreatedBy();
            if (null === $creator) {
                return false;
            }

            return $creator->getId() !== $this->participant?->getUser()?->getId();
        }

        return false;
    }

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

    public function getOffer(): OfferInterface
    {
        return $this->offer;
    }

    public function setOffer(OfferInterface $offer): void
    {
        $this->offer = $offer;
    }

    public function getParticipant(): ?ParticipantInterface
    {
        return $this->participant;
    }

    public function getTask(): ?EditionTask
    {
        return $this->task;
    }

    public function setTask(?EditionTask $task): void
    {
        $this->task = $task;
    }

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

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

    public function getUserPriority(): int
    {
        return $this->userPriority;
    }

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

    public function setAllAlternateDates(bool $allAlternateDates): void
    {
        $this->allAlternateDates = $allAlternateDates;
    }

    public function setUserPriority(int $userPriority): void
    {
        $this->userPriority = $userPriority;
    }

    public function getAge(): ?int
    {
        return $this->age;
    }

    public function setExtra(array $values): void
    {
        $this->extra = $values;
    }

    public function getExtra(string $key): string|bool|int|null
    {
        return $this->extra[$key] ?? null;
    }

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

    public function setConsentOutstanding(bool $consentOutstanding): void
    {
        $this->consentOutstanding = $consentOutstanding;
    }

    public function getConsentToken(): ?string
    {
        return $this->consentToken;
    }

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

    public function getPostalAddress(): ?PostalAddress
    {
        return $this->postalAddress;
    }

    public function setPostalAddress(?PostalAddress $postalAddress): void
    {
        $this->postalAddress = $postalAddress;
    }

    public function getStash(): ?AttendanceStash
    {
        return $this->stash;
    }

    public function setStash(?AttendanceStash $stash): void
    {
        if ($this->stash && $this->stash !== $stash) {
            $this->stash->removeAttendance($this);
        }

        $this->stash = $stash;

        if ($stash) {
            $stash->addAttendance($this);
        }
    }

    public function getName(): string
    {
        return $this->participant?->getName() ?? '';
    }

    #[Groups('docx_export')]
    public function getPhone(): string
    {
        return $this->participant?->getPhone() ?? '';
    }

    #[Groups('docx_export')]
    public function getEmail(): string
    {
        return $this->participant?->getEmail() ?? '';
    }

    #[Groups('docx_export')]
    public function getFee(): string
    {
        $fee = $this->offer->getFee();
        if (!$fee) {
            return '';
        }

        return \sprintf('%s €', number_format($fee / 100, 2, ',', '.'));
    }

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

    /**
     * The issued (paid) receipts — an original or a Stornobeleg — that reference
     * this attendance. Such documents are immutable, so their existence must
     * block a hard delete of the attendance: deleting it would sever the receipt
     * line (silently rewriting the receipt to "Gelöschte Teilnahme") and leave
     * the fee impossible to reverse.
     *
     * @return array<int, Payment>
     */
    public function getIssuedReceipts(): array
    {
        $receipts = [];
        foreach ($this->paymentItems as $paymentItem) {
            $payment = $paymentItem->getPayment();
            if ($payment->isPaid()) {
                $receipts[(int) $payment->getId()] = $payment;
            }
        }

        return array_values($receipts);
    }

    public function hasIssuedReceipt(): bool
    {
        return [] !== $this->getIssuedReceipts();
    }

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

    public function getParticipantPseudonym(): ?string
    {
        return $this->participantPseudonym;
    }

    public function setParticipantPseudonym(string $participantPseudonym): void
    {
        $this->participantPseudonym = $participantPseudonym;
    }

    public function setParticipant(?ParticipantInterface $participant): void
    {
        $this->participant = $participant;
    }

    public function unsetParticipant(): void
    {
        $this->setParticipant(null);
    }

    public function getFriendCode(): ?FriendCode
    {
        return $this->participant?->getFriendCodes()->findFirst(fn (int $i, FriendCode $c) => $c->getOffer() === $this->offer);
    }

    public function getFriendedAttendances(): array
    {
        $code = $this->getFriendCode();
        if (!$code instanceof FriendCode) {
            return [];
        }

        $attendances = [];
        foreach ($code->getParticipants() as $participant) {
            if ($participant === $this->participant) {
                continue;
            }
            $attendances[] = $participant->getAttendancesNotHidden()->filter(fn (Attendance $a) => $a->getOffer() === $this->offer)->toArray();
        }

        return array_merge(...$attendances);
    }

    public function getDateVariants(): Collection
    {
        return $this->getParticipant()->getAttendancesNotWithdrawn()->filter(fn (Attendance $a) => $this->offer === $a->getOffer() || $a->getOffer()->isVariantOf($this->offer));
    }

    #[ORM\PrePersist]
    #[ORM\PreUpdate]
    public function updateSorting(LifecycleEventArgs $eventArgs): void
    {
        // Only called when new entity is created with explicit status (this is not the case in the front end)
        if ($eventArgs instanceof PrePersistEventArgs && null === $this->status) {
            return;
        }

        // Only called when status was updated (e.g., by an application system, or by an admin) and no explicit sorting was given
        if ($eventArgs instanceof PreUpdateEventArgs && (!$eventArgs->hasChangedField('status') || $eventArgs->hasChangedField('sorting'))) {
            return;
        }

        $lastAttendance = $this->offer
            ->getAttendancesWithStatus($this->status)
            ->filter(static fn (Attendance $attendance) => $attendance->getSorting() > 0)->last() ?: null;

        $sorting = $lastAttendance?->getSorting() ?? 0;
        $sorting += 128;

        $this->setSorting($sorting);

        if ($eventArgs instanceof PrePersistEventArgs && (null === $this->status || $this->isWaiting())) {
            /** @var Attendance|false $lastAttendanceParticipant */
            $lastAttendanceParticipant = $this->participant
                ?->getAttendancesWaiting()
                ?->matching(Criteria::create()->orderBy(['userPriority' => Order::Ascending]))
                ?->last()
            ;

            $priority = $lastAttendanceParticipant ? $lastAttendanceParticipant->getUserPriority() + 1 : 1;
            if ($maxApplications = $this->task?->getMaxApplications()) {
                $priority = min($maxApplications + 1, $priority);
            }

            $this->setUserPriority($priority);
        }
    }
}
