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

use Doctrine\ORM\EntityManagerInterface;
use Ferienpass\CoreBundle\DependencyInjection\FerienpassCoreExtension;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\Offer\Base;
use Ferienpass\CoreBundle\Entity\Participant\BaseParticipant;
use Ferienpass\CoreBundle\Enum\AttendanceStatus;
use Ferienpass\CoreBundle\Enum\AttendanceTransition;
use Ferienpass\CoreBundle\EventListener\Workflow\AttendanceConfirmTransactionListener;
use Ferienpass\CoreBundle\EventListener\Workflow\AttendanceStashListener;
use Ferienpass\CoreBundle\EventListener\Workflow\AttendanceUnconfirmTransactionListener;
use Ferienpass\CoreBundle\Repository\AttendanceStashRepository;
use PHPUnit\Framework\TestCase;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\Workflow\Definition;
use Symfony\Component\Workflow\MarkingStore\MethodMarkingStore;
use Symfony\Component\Workflow\StateMachine;
use Symfony\Component\Workflow\Transition;

/**
 * Moving up from the waiting list ("Nachrücken") must leave the attendance with a
 * payment liability, no matter which code path performs the transition.
 *
 * The state machine under test is built from the very definition the bundle
 * prepends onto the framework config, so the test cannot drift from production.
 */
final class AttendanceMoveUpPayableTest extends TestCase
{
    public function testWaitlistedToConfirmedIsAnEnabledTransition(): void
    {
        $attendance = $this->attendance();
        $attendance->setStatus(AttendanceStatus::WAITLISTED);

        self::assertTrue($this->stateMachine()->can($attendance, AttendanceTransition::CONFIRM));
    }

    /**
     * The admin assign board with "sofort festschreiben" enabled: OfferAssign
     * passes commit = autoCommit = true.
     */
    public function testMovingUpWithAutoCommitSetsPayable(): void
    {
        $attendance = $this->attendance();
        $attendance->setStatus(AttendanceStatus::WAITLISTED);

        $this->stateMachine()->apply($attendance, AttendanceTransition::CONFIRM, ['commit' => true, 'notify' => false, 'reorder' => false]);

        self::assertSame(AttendanceStatus::CONFIRMED, $attendance->getStatus());
        self::assertTrue($attendance->isPayable());
        self::assertNull($attendance->getStash(), 'An auto-committed attendance must not be staged');
    }

    /**
     * The automatic move-up after a withdrawal (ReorderParticipantList defaults to
     * commit = true) and the manual "confirm" button take the same route.
     */
    public function testMovingUpFromEveryOriginSetsPayable(): void
    {
        foreach ([AttendanceStatus::WAITLISTED, AttendanceStatus::WAITING, AttendanceStatus::WITHDRAWN, AttendanceStatus::REJECTED, AttendanceStatus::UNFULFILLED] as $from) {
            $attendance = $this->attendance();
            $attendance->setStatus($from);

            $this->stateMachine()->apply($attendance, AttendanceTransition::CONFIRM, ['commit' => true, 'notify' => false]);

            self::assertTrue($attendance->isPayable(), \sprintf('Confirming from "%s" must set the payable flag', $from));
        }
    }

    /**
     * Without auto-commit the attendance is staged instead; the payable flag is
     * set later, by the bulk UPDATE in the CommitAttendances handler.
     */
    public function testMovingUpWithoutAutoCommitStagesInsteadOfBilling(): void
    {
        $attendance = $this->attendance();
        $attendance->setStatus(AttendanceStatus::WAITLISTED);

        $this->stateMachine()->apply($attendance, AttendanceTransition::CONFIRM, ['commit' => false, 'notify' => false]);

        self::assertFalse($attendance->isPayable());
        self::assertNotNull($attendance->getStash());
    }

    public function testFreeOfferStaysUnpayable(): void
    {
        $attendance = $this->attendance(fee: null);
        $attendance->setStatus(AttendanceStatus::WAITLISTED);

        $this->stateMachine()->apply($attendance, AttendanceTransition::CONFIRM, ['commit' => true, 'notify' => false]);

        self::assertFalse($attendance->isPayable());
    }

    public function testOfflinePaymentOfferStaysUnpayable(): void
    {
        $attendance = $this->attendance();
        $attendance->getOffer()->setDisableOnlinePayment(true);
        $attendance->setStatus(AttendanceStatus::WAITLISTED);

        $this->stateMachine()->apply($attendance, AttendanceTransition::CONFIRM, ['commit' => true, 'notify' => false]);

        self::assertFalse($attendance->isPayable());
    }

    /**
     * The reverse move (confirmed → waiting list) ends the liability again.
     */
    public function testMovingDownToTheWaitingListClearsThePayableFlag(): void
    {
        $attendance = $this->attendance();
        $attendance->setStatus(AttendanceStatus::WAITLISTED);

        $stateMachine = $this->stateMachine();
        $stateMachine->apply($attendance, AttendanceTransition::CONFIRM, ['commit' => true, 'notify' => false]);
        self::assertTrue($attendance->isPayable());

        $stateMachine->apply($attendance, AttendanceTransition::WAITLIST, ['commit' => true, 'notify' => false]);

        self::assertSame(AttendanceStatus::WAITLISTED, $attendance->getStatus());
        self::assertFalse($attendance->isPayable());
    }

    public function testResettingToWaitingClearsThePayableFlag(): void
    {
        $attendance = $this->attendance();
        $attendance->setStatus(AttendanceStatus::WAITLISTED);

        $stateMachine = $this->stateMachine();
        $stateMachine->apply($attendance, AttendanceTransition::CONFIRM, ['commit' => true, 'notify' => false]);
        $stateMachine->apply($attendance, AttendanceTransition::RESET, ['commit' => true, 'notify' => false]);

        self::assertSame(AttendanceStatus::WAITING, $attendance->getStatus());
        self::assertFalse($attendance->isPayable());
    }

    /**
     * The move down is not deferred by the staging toggle: an already billed
     * attendance loses its liability even when the change is only staged.
     */
    public function testMovingDownWithoutAutoCommitClearsThePayableFlag(): void
    {
        $attendance = $this->attendance();
        $attendance->setStatus(AttendanceStatus::CONFIRMED);
        $attendance->setPayable(true);

        $this->stateMachine()->apply($attendance, AttendanceTransition::WAITLIST, ['commit' => false, 'notify' => false]);

        self::assertFalse($attendance->isPayable());
    }

    /**
     * Moving back up re-creates the liability, so the flag survives a round trip.
     */
    public function testMovingDownAndUpAgainRestoresThePayableFlag(): void
    {
        $attendance = $this->attendance();
        $attendance->setStatus(AttendanceStatus::CONFIRMED);
        $attendance->setPayable(true);

        $stateMachine = $this->stateMachine();
        $stateMachine->apply($attendance, AttendanceTransition::WAITLIST, ['commit' => true, 'notify' => false]);
        $stateMachine->apply($attendance, AttendanceTransition::CONFIRM, ['commit' => true, 'notify' => false]);

        self::assertTrue($attendance->isPayable());
    }

    private function attendance(?int $fee = 500): Attendance
    {
        $offer = new Base();
        $offer->setFee($fee);

        return new Attendance($offer, new BaseParticipant());
    }

    private function stateMachine(): StateMachine
    {
        $dispatcher = new EventDispatcher();
        $dispatcher->addListener('workflow.attendance.entered.'.AttendanceStatus::CONFIRMED, new AttendanceConfirmTransactionListener());

        $unconfirm = new AttendanceUnconfirmTransactionListener();
        $dispatcher->addListener('workflow.attendance.entered.'.AttendanceStatus::WAITLISTED, $unconfirm);
        $dispatcher->addListener('workflow.attendance.entered.'.AttendanceStatus::WAITING, $unconfirm);

        $stashRepository = $this->createStub(AttendanceStashRepository::class);
        $stashRepository->method('findOneBy')->willReturn(null);
        $dispatcher->addListener('workflow.attendance.entered', new AttendanceStashListener($this->createStub(EntityManagerInterface::class), $stashRepository));

        return new StateMachine($this->definition(), new MethodMarkingStore(true, 'status'), $dispatcher, 'attendance');
    }

    private function definition(): Definition
    {
        $container = new ContainerBuilder();
        new \ReflectionMethod(FerienpassCoreExtension::class, 'prependWorkflow')->invoke(new FerienpassCoreExtension(), $container);

        $config = $container->getExtensionConfig('framework')[0]['workflows']['attendance'];

        // A state machine gets one Transition object per "from" place, exactly as
        // the FrameworkBundle builds it from the very same config.
        $transitions = [];
        foreach ($config['transitions'] as $name => $transition) {
            foreach ((array) ($transition['from'] ?? $config['places']) as $from) {
                $transitions[] = new Transition($name, $from, $transition['to']);
            }
        }

        return new Definition($config['places'], $transitions, $config['initial_marking']);
    }
}
