<?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\CmsBundle\Tests\Components;

use Doctrine\Persistence\ManagerRegistry;
use Ferienpass\CmsBundle\Components\Payment;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\BookEntry;
use Ferienpass\CoreBundle\Entity\Debtor;
use Ferienpass\CoreBundle\Entity\Payment as PaymentEntity;
use Ferienpass\CoreBundle\Entity\PaymentItem;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\Enum\AttendanceStatus;
use Ferienpass\CoreBundle\Enum\PaymentStatus;
use Ferienpass\CoreBundle\EventListener\Workflow\PaymentPayTransactionListener;
use Ferienpass\CoreBundle\Facade\PaymentsFacade;
use Ferienpass\CoreBundle\Fixtures\Doctrine\OrmTestCase;
use Ferienpass\CoreBundle\Payments\Provider\PaymentProviderInterface;
use Ferienpass\CoreBundle\Payments\ReceiptNumberGenerator;
use Ferienpass\CoreBundle\Repository\AttendanceRepository;
use Ferienpass\CoreBundle\Repository\PaymentRepository;
use Ferienpass\CoreBundle\Settings\SystemSettingsManager;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Store\InMemoryStore;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Workflow\DefinitionBuilder;
use Symfony\Component\Workflow\MarkingStore\MethodMarkingStore;
use Symfony\Component\Workflow\StateMachine;
use Symfony\Component\Workflow\Transition;
use Symfony\Component\Workflow\WorkflowInterface;

/**
 * Paying a fee entirely out of the debtor's credit.
 *
 * This reached production: a 7 EUR credit against a 5 EUR fee left nothing to
 * collect, so the frontend took the "nothing to pay" branch — which only ever
 * confirmed *fee-free* offers. The family saw a success screen while the
 * attendance stayed unpaid, the credit stayed at 7 EUR and the 5 EUR stayed
 * open in the Debitorenübersicht. The money side of a settlement hangs off the
 * payment's "pay" transition, so a payment has to be billed here too, even
 * though no cent moves through the gateway.
 */
final class PaymentCreditSettlementTest extends OrmTestCase
{
    public function testCreditLargerThanTheFeeSettlesTheAttendance(): void
    {
        [$user, $attendance] = $this->scenario(fee: 500, credit: 700);

        $this->component($user)->settleWithoutPayment($user, $this->em, $this->paymentStateMachine());

        self::assertTrue($this->refresh($attendance)->isPaid(), 'The attendance must end up paid.');
        self::assertSame(200, $this->debtor()->getBalance(), 'The credit must be consumed down to the remainder.');
    }

    public function testCreditExactlyCoveringTheFeeSettlesTheAttendance(): void
    {
        [$user, $attendance] = $this->scenario(fee: 500, credit: 500);

        $this->component($user)->settleWithoutPayment($user, $this->em, $this->paymentStateMachine());

        self::assertTrue($this->refresh($attendance)->isPaid());
        self::assertSame(0, $this->debtor()->getBalance());
    }

    public function testTheSettlementIsBilledAsAReceiptCarryingTheDiscount(): void
    {
        [$user] = $this->scenario(fee: 500, credit: 700);

        $this->component($user)->settleWithoutPayment($user, $this->em, $this->paymentStateMachine());

        $payments = $this->payments();

        self::assertCount(1, $payments, 'The settlement must leave a receipt behind.');
        self::assertSame(PaymentStatus::PAID, $payments[0]->getStatus());
        self::assertSame(0, $payments[0]->getTotalAmount(), 'Nothing was collected — the fee is offset by the credit.');

        $amounts = array_map(static fn (PaymentItem $i) => [$i->getType(), $i->getAmount()], $payments[0]->getItems()->toArray());
        self::assertEqualsCanonicalizing([['attendance', 500], ['discount', -500]], $amounts);
    }

    public function testAnUncoveredFeeIsNeverConfirmed(): void
    {
        // The action is reachable with any selection the client sends, and only
        // the credit can clear a fee here. A 2 EUR credit does not buy a 5 EUR
        // offer.
        [$user, $attendance] = $this->scenario(fee: 500, credit: 200);

        $component = $this->component($user);
        $component->settleWithoutPayment($user, $this->em, $this->paymentStateMachine());

        self::assertFalse($this->refresh($attendance)->isPaid(), 'An unpaid fee must not be confirmed.');
        self::assertSame(200, $this->debtor()->getBalance(), 'The credit must be left untouched.');
        self::assertCount(0, $this->payments());
        self::assertNotSame('confirmed', $component->step, 'The user must not see a success screen.');
    }

    public function testFeeFreeOffersAreStillConfirmedWithoutAReceipt(): void
    {
        [$user, $attendance] = $this->scenario(fee: 0, credit: 0);

        $this->component($user)->settleWithoutPayment($user, $this->em, $this->paymentStateMachine());

        self::assertTrue($this->refresh($attendance)->isPaid());
        self::assertCount(0, $this->payments(), 'There is nothing to bill.');
    }

    public function testTheDisplayedAmountNeverGoesBelowZero(): void
    {
        [$user] = $this->scenario(fee: 500, credit: 700);

        $component = $this->component($user);

        self::assertSame(500, $component->creditApplied(), 'Only the part of the credit the bill uses is applied.');
        self::assertSame(0, $component->totalAmount());
    }

    // ────────────────────────────────────────────────────────────────
    //  fixtures
    // ────────────────────────────────────────────────────────────────

    /**
     * One user with one credit-carrying debtor and one due attendance.
     *
     * @return array{0: User, 1: Attendance}
     */
    private function scenario(int $fee, int $credit): array
    {
        $offer = $this->createOffer();
        $offer->setFee($fee);

        $user = $this->createUser('viola@example.com');
        $participant = $this->createParticipant();
        $participant->setUser($user);
        $user->addParticipant($participant);

        $attendance = new Attendance($offer, $participant);
        $attendance->setStatus(AttendanceStatus::CONFIRMED);
        $attendance->setPayable(true);
        $this->em->persist($attendance);

        $user->createDebtor();
        $debtor = $user->getDebtor();
        $this->em->persist($debtor);

        if (0 !== $credit) {
            $this->em->persist(new BookEntry($credit, $debtor, type: 'manual'));
        }

        $this->em->flush();

        $userId = $user->getId();
        $attendanceId = $attendance->getId();

        // The debtor and its book entries are both inverse sides — only a fresh
        // read gives the component the balance the database actually holds.
        $this->em->clear();

        return [
            $this->em->find(User::class, $userId),
            $this->em->find(Attendance::class, $attendanceId),
        ];
    }

    private function refresh(Attendance $attendance): Attendance
    {
        $id = $attendance->getId();
        $this->em->clear();

        return $this->em->find(Attendance::class, $id);
    }

    private function debtor(): Debtor
    {
        $this->em->clear();

        return $this->em->createQuery('SELECT d FROM '.Debtor::class.' d')->getResult()[0];
    }

    /** @return list<PaymentEntity> */
    private function payments(): array
    {
        $this->em->clear();

        return $this->em->createQuery('SELECT p FROM '.PaymentEntity::class.' p')->getResult();
    }

    // ────────────────────────────────────────────────────────────────
    //  wiring
    // ────────────────────────────────────────────────────────────────

    private function component(User $user): Payment
    {
        $registry = $this->createStub(ManagerRegistry::class);
        $registry->method('getManagerForClass')->willReturn($this->em);

        $attendances = new AttendanceRepository($registry);
        $payments = new PaymentRepository($registry);

        $settings = $this->createStub(SystemSettingsManager::class);
        // Without the selection step every due attendance is billed at once.
        $settings->method('isPaymentAllowSelect')->willReturn(false);

        // Test subclass: short-circuit getUser() so no Security container is needed.
        return new class($attendances, $payments, $this->createStub(PaymentProviderInterface::class), $settings, new EventDispatcher(), new PaymentsFacade($attendances), new LockFactory(new InMemoryStore()), $user) extends Payment {
            public function __construct(
                AttendanceRepository $attendances,
                PaymentRepository $payments,
                PaymentProviderInterface $paymentProvider,
                SystemSettingsManager $systemSettingsManager,
                EventDispatcher $dispatcher,
                PaymentsFacade $paymentsFacade,
                LockFactory $lockFactory,
                private readonly User $testUser,
            ) {
                parent::__construct($attendances, $payments, $paymentProvider, $systemSettingsManager, $dispatcher, $paymentsFacade, $lockFactory);
            }

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

    /**
     * The real payment state machine with the real "entered paid" listener, so
     * the assertions cover what the transition actually does to the attendance
     * and to the ledger.
     */
    private function paymentStateMachine(): WorkflowInterface
    {
        $numberGenerator = $this->createStub(ReceiptNumberGenerator::class);
        $numberGenerator->method('generate')->willReturn('R-1');

        $eventBus = $this->createStub(MessageBusInterface::class);
        $eventBus->method('dispatch')->willReturnCallback(static fn ($message) => new Envelope($message));

        $dispatcher = new EventDispatcher();
        $dispatcher->addListener('workflow.payment.entered.'.PaymentStatus::PAID, new PaymentPayTransactionListener($numberGenerator, $eventBus));

        $builder = new DefinitionBuilder([PaymentStatus::CREATED, PaymentStatus::PAID, PaymentStatus::FAILED, PaymentStatus::ABANDONED]);
        $builder->addTransition(new Transition(PaymentStatus::TRANSITION_PAY, [PaymentStatus::CREATED], [PaymentStatus::PAID]));

        return new StateMachine($builder->build(), new MethodMarkingStore(true, 'status'), $dispatcher, 'payment');
    }
}
