<?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\EventListener\Workflow;

use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\Debtor;
use Ferienpass\CoreBundle\Entity\Payment;
use Ferienpass\CoreBundle\Entity\PaymentItem;
use Ferienpass\CoreBundle\EventListener\Workflow\PaymentPayTransactionListener;
use Ferienpass\CoreBundle\Message\PaymentReceiptCreated;
use Ferienpass\CoreBundle\Payments\ReceiptNumberGenerator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Workflow\Event\EnteredEvent;
use Symfony\Component\Workflow\Marking;

/**
 * This listener is the mechanism behind what "storno + re-bill" (the payment
 * adjust flow) does to an attendance: the reversal enters PAID with reverse=true
 * and unpays its attendances, the corrected receipt enters PAID without reverse
 * and re-pays the ones it still bills. It only ever touches the paid flag — the
 * attendance status (CONFIRMED / WITHDRAWN / …) is deliberately left alone.
 */
class PaymentPayTransactionListenerTest extends TestCase
{
    private ReceiptNumberGenerator $numberGenerator;
    private MessageBusInterface $eventBus;

    protected function setUp(): void
    {
        // Plain collaborators — the assertions live on the attendance mock.
        $this->numberGenerator = $this->createStub(ReceiptNumberGenerator::class);
        $this->numberGenerator->method('generate')->willReturn('R-1');

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

    public function testReversePayMarksAttendancesUnpaidAndLeavesStatusUntouched(): void
    {
        $attendance = $this->createMock(Attendance::class);
        $attendance->expects($this->once())->method('setPaid')->with(false);
        $attendance->expects($this->never())->method('setStatus');

        ($this->listener())($this->enteredEvent($this->paymentWith($attendance), ['reverse' => true]));
    }

    public function testNormalPayMarksAttendancesPaidAndLeavesStatusUntouched(): void
    {
        $attendance = $this->createMock(Attendance::class);
        $attendance->expects($this->once())->method('setPaid')->with(true);
        $attendance->expects($this->never())->method('setStatus');

        // No 'reverse' key at all — the abrechnen / re-bill path.
        ($this->listener())($this->enteredEvent($this->paymentWith($attendance), ['notify' => false]));
    }

    public function testReverseFalseLeavesPaidStateUntouched(): void
    {
        $attendance = $this->createMock(Attendance::class);
        $attendance->expects($this->never())->method('setPaid');
        $attendance->expects($this->never())->method('setStatus');

        ($this->listener())($this->enteredEvent($this->paymentWith($attendance), ['reverse' => false]));
    }

    /**
     * Regression guard for the discount branch. SettleAttendances appends a
     * Guthaben/balance discount item (amount < 0) whenever the debtor has a
     * positive balance, so a discount item routinely rides along with a cash
     * receipt. The listener must still book that discount, mark every attendance
     * paid, and dispatch PaymentReceiptCreated — the message that
     * NotificationHandler\PaymentReceiptCreated turns into the debtor's receipt
     * email. The discount sits between two attendances so this also proves the
     * loop no longer aborts at a discount: the bug was a `return` that skipped
     * both the trailing attendance's setPaid() and the dispatch entirely.
     */
    public function testDiscountItemStillBooksDispatchesReceiptAndPaysAllAttendances(): void
    {
        $before = $this->createMock(Attendance::class);
        $before->expects($this->once())->method('setPaid')->with(true);

        $after = $this->createMock(Attendance::class);
        $after->expects($this->once())->method('setPaid')->with(true);

        // The whole point of the fix: the receipt message is dispatched exactly
        // once. (setUp's eventBus is a stub and cannot assert calls, so use a
        // dedicated mock here.)
        $eventBus = $this->createMock(MessageBusInterface::class);
        $eventBus->expects($this->once())
            ->method('dispatch')
            ->with($this->isInstanceOf(PaymentReceiptCreated::class))
            ->willReturnCallback(static fn ($msg) => new Envelope($msg));

        // Spy on createBookEntry (Payment exposes no getBookEntries()) to assert
        // the discount's book entry. A real Debtor is required: the discount
        // branch books against it and BookEntry rejects a null debtor.
        $payment = new class(new Debtor()) extends Payment {
            /** @var list<int|null> */
            public array $bookedAmounts = [];

            public function createBookEntry(?int $amount = null, ?string $postingRecord = null, ?string $type = null): void
            {
                $this->bookedAmounts[] = $amount;
            }
        };
        $payment->addItem(new PaymentItem($payment, 1000, $before));
        $payment->addItem(new PaymentItem($payment, -250, null, 'discount'));
        $payment->addItem(new PaymentItem($payment, 1000, $after));

        $listener = new PaymentPayTransactionListener($this->numberGenerator, $eventBus);
        $listener($this->enteredEvent($payment, []));

        // The discount still books an entry with its own (negative) amount —
        // unchanged by the return -> continue fix.
        self::assertContains(-250, $payment->bookedAmounts);
    }

    public function testReversePayToleratesDeletedAttendance(): void
    {
        // Refunding the third participant of Caroline Kling's case: the offer was
        // deleted, so the paid line's attendance is null. The reversal must still
        // go through — there is simply no attendance paid-flag to flip.
        $payment = new Payment(null);
        $payment->addItem(new PaymentItem($payment, -200, null, 'attendance'));

        ($this->listener())($this->enteredEvent($payment, ['reverse' => true]));

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

    public function testThrowsForNonPaymentSubject(): void
    {
        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('Unexpected event subject');

        ($this->listener())($this->enteredEvent(new \stdClass()));
    }

    private function paymentWith(Attendance $attendance): Payment
    {
        // No debtor, so the listener skips book-entry creation and the test stays
        // focused on the attendance paid-state transition.
        $payment = new Payment(null);
        $payment->addItem(new PaymentItem($payment, 1000, $attendance));

        return $payment;
    }

    private function listener(): PaymentPayTransactionListener
    {
        return new PaymentPayTransactionListener($this->numberGenerator, $this->eventBus);
    }

    private function enteredEvent(object $subject, array $context = []): EnteredEvent
    {
        return new EnteredEvent($subject, new Marking(), null, null, $context);
    }
}
