<?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\Dto\Currency;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\Debtor;
use Ferienpass\CoreBundle\Entity\Offer\Base;
use Ferienpass\CoreBundle\Entity\Payment;
use Ferienpass\CoreBundle\Entity\PaymentItem;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\Enum\PaymentStatus;
use PHPUnit\Framework\TestCase;

final class PaymentTest extends TestCase
{
    public function testIdIsNullByDefault(): void
    {
        $payment = $this->makePayment();

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

    public function testUuidIsGenerated(): void
    {
        $payment = $this->makePayment();

        self::assertNotNull($payment->getUuid());
    }

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

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

    public function testDefaultStatusIsCreated(): void
    {
        $payment = $this->makePayment();

        self::assertSame(PaymentStatus::CREATED, $payment->getStatus());
    }

    public function testIsPaidFalseByDefault(): void
    {
        $payment = $this->makePayment();

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

    public function testIsPaidTrueWhenStatusIsPaid(): void
    {
        $payment = $this->makePayment();
        $payment->setStatus(PaymentStatus::PAID);

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

    public function testTotalAmountStartsAtZero(): void
    {
        $payment = $this->makePayment();

        self::assertSame(0, $payment->getTotalAmount());
    }

    public function testGetTotalAmountExcelReturnsCurrency(): void
    {
        $payment = $this->makePayment();

        self::assertInstanceOf(Currency::class, $payment->getTotalAmountExcel());
    }

    public function testGetReceiptNumber(): void
    {
        $payment = $this->makePayment('REC-001');

        self::assertSame('REC-001', $payment->getReceiptNumber());
    }

    public function testSetReceiptNumber(): void
    {
        $payment = $this->makePayment();
        $payment->setReceiptNumber('REC-100');

        self::assertSame('REC-100', $payment->getReceiptNumber());
    }

    public function testSetReceiptNumberThrowsIfAlreadySet(): void
    {
        $payment = $this->makePayment('REC-001');

        $this->expectException(\BadMethodCallException::class);
        $payment->setReceiptNumber('REC-002');
    }

    public function testGetBillingAddress(): void
    {
        $payment = $this->makePayment();
        $payment->setBillingAddress('123 Main St');

        self::assertSame('123 Main St', $payment->getBillingAddress());
    }

    public function testGetBillingEmail(): void
    {
        $payment = $this->makePayment();
        $payment->setBillingEmail('test@example.com');

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

    public function testGetDebtorReturnsDebtor(): void
    {
        $user = new User();
        $debtor = new Debtor($user);
        $payment = new Payment($debtor);

        self::assertSame($debtor, $payment->getDebtor());
    }

    public function testIsReversalWhenNegativeTotal(): void
    {
        $payment = $this->makePayment();

        $rp = new \ReflectionProperty($payment, 'totalAmount');
        $rp->setValue($payment, -100);

        self::assertTrue($payment->isReversal());
    }

    public function testIsNotReversalWhenPositiveTotal(): void
    {
        $payment = $this->makePayment();

        $rp = new \ReflectionProperty($payment, 'totalAmount');
        $rp->setValue($payment, 100);

        self::assertFalse($payment->isReversal());
    }

    public function testReversalOfIsNullByDefault(): void
    {
        self::assertNull($this->makePayment()->getReversalOf());
    }

    public function testReversalsEmptyByDefault(): void
    {
        $payment = $this->makePayment();

        self::assertCount(0, $payment->getReversals());
        self::assertFalse($payment->hasReversals());
        self::assertSame(0, $payment->getReversedAmount());
    }

    public function testSetReversalOfLinksInverseSide(): void
    {
        $original = $this->makePayment('REC-1');
        $reversal = $this->makePayment('REC-1-S');

        $reversal->setReversalOf($original);

        self::assertSame($original, $reversal->getReversalOf());
        self::assertTrue($original->hasReversals());
        self::assertTrue($original->getReversals()->contains($reversal));
    }

    /**
     * setReversalOf() wires a freshly created reversal into the (already
     * managed) original payment's reversals collection before the reversal is
     * persisted. A flush can happen in between — e.g. the payment workflow
     * dispatches the loggable PaymentReceiptCreated message during the "pay"
     * transition, and the messenger log handler flushes the EntityManager.
     * Without cascade persist, that flush aborts with "A new entity was found
     * through the relationship Payment#reversals". Keep it cascading.
     */
    public function testReversalsAssociationCascadesPersist(): void
    {
        $property = new \ReflectionProperty(Payment::class, 'reversals');
        $attributes = $property->getAttributes(\Doctrine\ORM\Mapping\OneToMany::class);

        self::assertCount(1, $attributes);
        self::assertContains('persist', (array) $attributes[0]->newInstance()->cascade);
    }

    public function testSetReversalOfRejectsSelfReference(): void
    {
        $payment = $this->makePayment();

        $this->expectException(\InvalidArgumentException::class);
        $payment->setReversalOf($payment);
    }

    public function testGetReversedAmountSumsAbsoluteReversalTotals(): void
    {
        $original = $this->makePayment('REC-1');

        foreach ([-300, -200] as $i => $amount) {
            $reversal = $this->makePayment('REC-1-S'.$i);
            new \ReflectionProperty($reversal, 'totalAmount')->setValue($reversal, $amount);
            $reversal->setReversalOf($original);
        }

        self::assertSame(500, $original->getReversedAmount());
    }

    public function testIsFinalized(): void
    {
        $payment = $this->makePayment();

        self::assertFalse($payment->isFinalized());

        $payment->setStatus(PaymentStatus::PAID);
        self::assertTrue($payment->isFinalized());
    }

    public function testGetItemsEmpty(): void
    {
        $payment = $this->makePayment();

        self::assertEmpty($payment->getItems());
    }

    public function testGetPmPaymentTransactionIdNullByDefault(): void
    {
        $payment = $this->makePayment();

        self::assertNull($payment->getPmPaymentTransactionId());
    }

    public function testSetAndGetPmPaymentTransactionId(): void
    {
        $payment = $this->makePayment();
        $payment->setPmPaymentTransactionId('TX-123');

        self::assertSame('TX-123', $payment->getPmPaymentTransactionId());
    }

    public function testGetUserNullByDefault(): void
    {
        $payment = $this->makePayment();

        self::assertNull($payment->getUser());
    }

    public function testGetUserEmailNullWhenNoUser(): void
    {
        $payment = $this->makePayment();

        self::assertNull($payment->getUserEmail());
    }

    public function testGetUserEmailFromUser(): void
    {
        $user = new User();
        $user->setEmail('user@example.com');
        $debtor = new Debtor($user);
        $payment = new Payment($debtor, null, $user);

        self::assertSame('user@example.com', $payment->getUserEmail());
    }

    public function testCreateBookEntryWithDefaults(): void
    {
        $user = new User();
        $debtor = new Debtor($user);
        $payment = new Payment($debtor, 'REC-1');

        $rp = new \ReflectionProperty($payment, 'totalAmount');
        $rp->setValue($payment, 500);

        $payment->createBookEntry();

        $rpEntries = new \ReflectionProperty($payment, 'bookEntries');
        self::assertCount(1, $rpEntries->getValue($payment));
    }

    public function testCreateBookEntryWithManualAmount(): void
    {
        $payment = $this->makePayment();
        $payment->createBookEntry(100);

        $rp = new \ReflectionProperty($payment, 'bookEntries');
        self::assertCount(1, $rp->getValue($payment));
    }

    public function testPopulatesAddressFromUserOnConstruct(): void
    {
        $address = new \Ferienpass\CoreBundle\Entity\PostalAddress();
        $address->setStreet('Baker Street 221b');
        $address->setPostalCode('NW1 6XE');
        $address->setCity('London');

        $user = new User();
        $user->setEmail('sherlock@example.com');
        $user->addPostalAddress($address);

        $debtor = new Debtor($user);
        $payment = new Payment($debtor);

        self::assertStringContainsString('Baker Street', $payment->getBillingAddress() ?? '');
        self::assertSame('sherlock@example.com', $payment->getBillingEmail());
    }

    public function testAddItemCalculatesTotalAmount(): void
    {
        $payment = $this->makePayment();

        $attendance = new Attendance(new Base(), null);
        $item = new PaymentItem($payment, 300, $attendance);
        $payment->addItem($item);

        self::assertSame(300, $payment->getTotalAmount());
    }

    public function testSetDebtor(): void
    {
        $payment = $this->makePayment();
        $user = new User();
        $newDebtor = new Debtor($user);
        $payment->setDebtor($newDebtor);

        self::assertSame($newDebtor, $payment->getDebtor());
    }

    public function testSetUser(): void
    {
        $payment = $this->makePayment();
        $user = new User();
        $payment->setUser($user);

        self::assertSame($user, $payment->getUser());
    }

    public function testCalculateTotalAmount(): void
    {
        $user = new User();
        $debtor = new Debtor($user);
        $payment = new Payment($debtor);

        $attendance = new Attendance(new Base(), null);
        $item1 = new PaymentItem($payment, 200, $attendance);
        $item2 = new PaymentItem($payment, 300, $attendance);
        $payment->addItem($item1);
        $payment->addItem($item2);
        $payment->calculateTotalAmount();

        self::assertSame(500, $payment->getTotalAmount());
    }

    public function testGetPmPaymentTransactionStatusNullByDefault(): void
    {
        $payment = $this->makePayment();

        self::assertNull($payment->getPmPaymentTransactionStatus());
    }

    public function testIsFinalizedWhenStatusIsFailed(): void
    {
        $payment = $this->makePayment();
        $payment->setStatus(PaymentStatus::FAILED);

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

    public function testIsFinalizedWhenStatusIsAbandoned(): void
    {
        $payment = $this->makePayment();
        $payment->setStatus(PaymentStatus::ABANDONED);

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

    public function testPopulateAddressFromUserSkipsWhenNoUser(): void
    {
        $debtor = new Debtor(); // no user
        $payment = new Payment($debtor);

        self::assertNull($payment->getBillingAddress());
        self::assertNull($payment->getBillingEmail());
    }

    public function testFromAttendancesCreatesPaymentWithItems(): void
    {
        $user = new User();
        $debtor = new Debtor($user);

        $dispatcher = $this->createMock(\Symfony\Contracts\EventDispatcher\EventDispatcherInterface::class);
        $dispatcher->method('dispatch')->willReturnArgument(0);

        $offer = new Base();
        $attendance = new Attendance($offer, null);

        $payment = Payment::fromAttendances($debtor, [$attendance], $dispatcher);

        self::assertSame(0, $payment->getTotalAmount()); // fee is 0 for a base offer
        self::assertCount(1, $payment->getItems());
    }

    public function testSetPmPaymentTransactionStatusThrowsOnMismatch(): void
    {
        $payment = $this->makePayment();
        $payment->setPmPaymentTransactionId('TX-1');

        $status = $this->createMock(\Ferienpass\CoreBundle\Entity\PmPaymentTransactionStatus::class);
        $status->method('getTxid')->willReturn('TX-2'); // mismatch

        $this->expectException(\InvalidArgumentException::class);
        $payment->setPmPaymentTransactionStatus($status);
    }

    public function testSetPmPaymentTransactionStatusSetsWhenMatching(): void
    {
        $payment = $this->makePayment();
        $payment->setPmPaymentTransactionId('TX-1');

        $status = $this->createMock(\Ferienpass\CoreBundle\Entity\PmPaymentTransactionStatus::class);
        $status->method('getTxid')->willReturn('TX-1');

        $payment->setPmPaymentTransactionStatus($status);
        self::assertSame($status, $payment->getPmPaymentTransactionStatus());
    }

    public function testCreateBookEntryWithExplicitType(): void
    {
        $user = new User();
        $debtor = new Debtor($user);
        $payment = new Payment($debtor, 'REC-99');

        $rp = new \ReflectionProperty($payment, 'totalAmount');
        $rp->setValue($payment, 500);

        $payment->createBookEntry(200, 'Manual entry', 'manual');

        $rpEntries = new \ReflectionProperty($payment, 'bookEntries');
        $entries = $rpEntries->getValue($payment);
        self::assertCount(1, $entries);
        self::assertSame(200, $entries->first()->getValue());
    }

    public function testCanBeAdjustedWhenPaidCashNotReversalAndNotReversed(): void
    {
        $payment = $this->makePayment();
        $payment->setStatus(PaymentStatus::PAID);

        self::assertTrue($payment->canBeAdjusted());
    }

    public function testCannotBeAdjustedWhenNotPaid(): void
    {
        $payment = $this->makePayment(); // status CREATED

        self::assertFalse($payment->canBeAdjusted());
    }

    public function testCannotBeAdjustedWhenOnlinePayment(): void
    {
        $payment = $this->makePayment();
        $payment->setStatus(PaymentStatus::PAID);
        $payment->setPmPaymentTransactionId('tx-123'); // carries a transaction => not cash

        self::assertFalse($payment->isCash());
        self::assertFalse($payment->canBeAdjusted());
    }

    public function testCannotBeAdjustedWhenItselfAReversal(): void
    {
        $payment = $this->makePayment();
        $payment->setStatus(PaymentStatus::PAID);

        $rp = new \ReflectionProperty($payment, 'totalAmount');
        $rp->setValue($payment, -100); // negative total => reversal

        self::assertTrue($payment->isReversal());
        self::assertFalse($payment->canBeAdjusted());
    }

    public function testCannotBeAdjustedWhenAlreadyReversed(): void
    {
        $payment = $this->makePayment();
        $payment->setStatus(PaymentStatus::PAID);

        $reversal = $this->makePayment();
        $reversal->setReversalOf($payment);

        self::assertTrue($payment->hasReversals());
        self::assertFalse($payment->canBeAdjusted());
    }

    private function makePayment(?string $receiptNumber = null): Payment
    {
        $user = new User();
        $debtor = new Debtor($user);

        return new Payment($debtor, $receiptNumber);
    }
}
