<?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\ORM\Mapping as ORM;
use Ferienpass\CoreBundle\Dto\Currency;
use Ferienpass\CoreBundle\Enum\PaymentStatus;
use Ferienpass\CoreBundle\Repository\PaymentRepository;
use Symfony\Bridge\Doctrine\Types\UuidType;
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Uid\Uuid;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;

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

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

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

    #[ORM\OneToMany(mappedBy: 'payment', targetEntity: PaymentItem::class, cascade: ['persist'], orphanRemoval: true)]
    private Collection $items;

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

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

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

    #[ORM\OneToMany(mappedBy: 'payment', targetEntity: BookEntry::class, cascade: ['persist'])]
    private Collection $bookEntries;

    /**
     * The original receipt this payment reverses. Set on every Stornobeleg
     * created through the reversal flow so the original and its (possibly
     * several, partial) cancellation receipts stay linked in both directions.
     */
    #[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'reversals')]
    #[ORM\JoinColumn(name: 'reversal_of_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
    private ?Payment $reversalOf = null;

    /** @var Collection<int, Payment> The cancellation receipts (Stornobelege) created for this receipt. */
    #[ORM\OneToMany(mappedBy: 'reversalOf', targetEntity: self::class, cascade: ['persist'])]
    #[ORM\OrderBy(['createdAt' => 'ASC'])]
    private Collection $reversals;

    #[ORM\Column(name: 'pmpayment_txid', type: 'string', length: 64, nullable: true)]
    private ?string $pmPaymentTransactionId = null;

    #[ORM\OneToOne(targetEntity: PmPaymentTransactionStatus::class)]
    #[ORM\JoinColumn(name: 'pmpayment_status', referencedColumnName: 'id')]
    private ?PmPaymentTransactionStatus $pmPaymentTransactionStatus = null;

    #[ORM\ManyToOne(targetEntity: Debtor::class, cascade: ['persist'], inversedBy: 'payments')]
    private ?Debtor $debtor = null;

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

    #[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'payments')]
    #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'SET NULL')]
    private ?User $user = null;

    #[Groups(['list:export:admin', 'list:export:default'])]
    #[ORM\Column(type: 'text', length: 64, nullable: true)]
    private ?string $status = PaymentStatus::CREATED;

    public function __construct(?Debtor $debtor = null, ?string $receiptNumber = null, ?User $user = null, string $status = PaymentStatus::CREATED, ?Uuid $uuid = null)
    {
        $this->debtor = $debtor;
        $this->receiptNumber = $receiptNumber;
        $this->user = $user;
        $this->status = $status;

        $this->uuid = $uuid ?? Uuid::v7();
        $this->createdAt = new \DateTimeImmutable();
        $this->items = new ArrayCollection();
        $this->bookEntries = new ArrayCollection();
        $this->reversals = new ArrayCollection();

        if ($this->debtor?->getUser()) {
            $this->populateAddressFromUser($this->debtor->getUser());
        }
    }

    /**
     * @param array<Attendance> $attendances
     */
    public static function fromAttendances(Debtor $debtor, iterable $attendances, EventDispatcherInterface $dispatcher, ?string $receiptNumber = null, ?User $user = null): static
    {
        $self = new self($debtor, $receiptNumber, user: $user);

        foreach ($attendances as $attendance) {
            $self->items->add(new PaymentItem(
                $self,
                $attendance->getOffer()->getFeePayable($attendance->getParticipant(), $dispatcher),
                $attendance,
            ));
        }

        $self->calculateTotalAmount();

        return $self;
    }

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

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

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

    /** @return Collection<PaymentItem> */
    public function getItems(): Collection
    {
        return $this->items;
    }

    public function addItem(PaymentItem $item): void
    {
        $this->items->add($item);

        $this->calculateTotalAmount();
    }

    public function getTotalAmount(): int
    {
        return $this->totalAmount;
    }

    #[Groups(['list:export:admin', 'list:export:default'])]
    public function getTotalAmountExcel(): Currency
    {
        return new Currency($this->getTotalAmount());
    }

    public function getBillingAddress(): ?string
    {
        return $this->billingAddress;
    }

    public function setBillingAddress(string $billingAddress): void
    {
        $this->billingAddress = $billingAddress;
    }

    public function getDebtor(): ?Debtor
    {
        return $this->debtor;
    }

    public function setDebtor(?Debtor $debtor): void
    {
        $this->debtor = $debtor;
    }

    public function getBillingEmail(): ?string
    {
        return $this->billingEmail;
    }

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

    public function getReceiptNumber(): ?string
    {
        return $this->receiptNumber;
    }

    public function setReceiptNumber(string $receiptNumber): void
    {
        if (null !== $this->receiptNumber) {
            throw new \BadMethodCallException('Receipt number is already set.');
        }

        $this->receiptNumber = $receiptNumber;
    }

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

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

    public function isPaid(): bool
    {
        return PaymentStatus::PAID === $this->status;
    }

    /**
     * Whether this receipt was settled without going through the online payment
     * gateway — i.e. a cash/manual payment booked by an admin (there is no
     * pmPayment transaction). Online payments always carry a transaction id.
     */
    public function isCash(): bool
    {
        return null === $this->pmPaymentTransactionId;
    }

    /**
     * Whether this receipt may be retroactively corrected via the "Nachträglich
     * anpassen" flow (storno + re-bill). Only a paid cash receipt that isn't
     * itself a reversal and hasn't been reversed yet qualifies. This is the
     * single source of truth for the guard that is enforced on the button, the
     * route, and again in the AdjustPayment component's submit().
     */
    public function canBeAdjusted(): bool
    {
        return $this->isPaid() && $this->isCash() && !$this->isReversal() && !$this->hasReversals();
    }

    public function getUser(): ?User
    {
        return $this->user;
    }

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

    public function getPmPaymentTransactionStatus(): ?PmPaymentTransactionStatus
    {
        return $this->pmPaymentTransactionStatus;
    }

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

    public function calculateTotalAmount(): void
    {
        $this->totalAmount = array_sum(array_map(static fn (PaymentItem $item) => $item->getAmount(), $this->items->toArray()));
    }

    public function setPmPaymentTransactionStatus(?PmPaymentTransactionStatus $pmPaymentTransactionStatus): void
    {
        if ($pmPaymentTransactionStatus && $pmPaymentTransactionStatus->getTxid() !== $this->getPmPaymentTransactionId()) {
            throw new \InvalidArgumentException('Transaction ID does not match.');
        }

        $this->pmPaymentTransactionStatus = $pmPaymentTransactionStatus;
    }

    public function getPmPaymentTransactionId(): ?string
    {
        return $this->pmPaymentTransactionId;
    }

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

    public function isFinalized(): bool
    {
        return \in_array($this->status, [PaymentStatus::PAID, PaymentStatus::FAILED, PaymentStatus::ABANDONED], true);
    }

    public function isReversal(): bool
    {
        return $this->totalAmount < 0;
    }

    public function getReversalOf(): ?self
    {
        return $this->reversalOf;
    }

    public function setReversalOf(?self $reversalOf): void
    {
        if ($reversalOf === $this) {
            throw new \InvalidArgumentException('A payment cannot be a reversal of itself.');
        }

        $this->reversalOf = $reversalOf;

        if (null !== $reversalOf && !$reversalOf->reversals->contains($this)) {
            $reversalOf->reversals->add($this);
        }
    }

    /** @return Collection<int, Payment> */
    public function getReversals(): Collection
    {
        return $this->reversals;
    }

    public function hasReversals(): bool
    {
        return !$this->reversals->isEmpty();
    }

    /**
     * Total amount cancelled across all linked Stornobelege, as a positive
     * figure (reversal receipts store negative totals). Lets the receipt page
     * show how much of this receipt has been reversed when there are one or
     * more partial cancellations.
     */
    public function getReversedAmount(): int
    {
        return array_sum(array_map(static fn (Payment $r) => abs($r->getTotalAmount()), $this->reversals->toArray()));
    }

    public function createBookEntry(?int $amount = null, ?string $postingRecord = null, ?string $type = null): void
    {
        $type ??= null === $amount ? 'payment' : 'manual';
        $amount ??= $this->totalAmount;
        $postingRecord ??= $this->receiptNumber;

        $this->bookEntries->add(
            new BookEntry($amount, $this->debtor, $this, $type, $postingRecord)
        );
    }

    private function populateAddressFromUser(?User $user): void
    {
        if (!$user instanceof User) {
            return;
        }

        $this->setBillingEmail($user->getEmail());

        if ($postalAddress = $user->getPostalAddresses()->first()) {
            $this->setBillingAddress(<<<EOF
                {$user->getName()}
                {$postalAddress->getStreet()}
                {$postalAddress->getPostalCode()} {$postalAddress->getCity()}
                {$postalAddress->getCountry()}
                EOF);
        }
    }
}
