<?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 Doctrine\ORM\Event\PreUpdateEventArgs;
use Ferienpass\CoreBundle\DependencyInjection\FerienpassCoreExtension;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Entity\Participant\ParticipantInterface;
use Ferienpass\CoreBundle\Enum\AttendanceStatus;
use Ferienpass\CoreBundle\Enum\AttendanceTransition;
use Ferienpass\CoreBundle\EventListener\Doctrine\Attendance\AttendanceLogListener;
use Ferienpass\CoreBundle\EventListener\Workflow\AttendanceStatusTransactionListener;
use Ferienpass\CoreBundle\Message\AttendanceStatusChanged;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Workflow\DefinitionBuilder;
use Symfony\Component\Workflow\Event\Event;
use Symfony\Component\Workflow\MarkingStore\MethodMarkingStore;
use Symfony\Component\Workflow\StateMachine;
use Symfony\Component\Workflow\Transition;

/**
 * "What was this attendance before?" is answered in two places — the
 * ParticipantLog row and the AttendanceStatusChanged event — and both derive it
 * from a Transition's from-place. That only holds because `attendance` is a
 * state_machine: FrameworkExtension explodes each multi-source declaration into
 * one single-source Transition object per from-place, so the transition a
 * listener receives names the place the subject actually came from.
 *
 * The declaration is multi-source (`withdraw` alone lists six from-places), so
 * were the workflow ever switched to `type: workflow`, or were a transition
 * object built by hand from the raw configuration, `getFroms()[0]` would
 * silently degrade into "the first place that happens to be listed" and every
 * withdrawal would be recorded as coming from "waiting".
 *
 * These tests drive the real state machine, built from the transitions the
 * bundle actually prepends so they cannot drift from the configuration.
 */
final class AttendanceTransitionOriginTest extends TestCase
{
    /**
     * The load-bearing property: the state machine narrows a six-source
     * declaration down to the one place the subject was really in.
     */
    public function testStateMachineNarrowsMultiSourceTransitionsToTheActualOrigin(): void
    {
        $config = $this->attendanceConfig();

        self::assertSame('state_machine', $config['type'], 'A "workflow" would keep all declared from-places on one transition');

        $declared = $config['transitions'][AttendanceTransition::WITHDRAW]['from'];
        self::assertGreaterThan(1, \count($declared), 'Nothing to narrow if withdraw became single-source');
        self::assertNotSame(AttendanceStatus::CONFIRMED, $declared[0], 'Reading the declaration would yield a status other than the real one');

        $applied = null;
        $dispatcher = new EventDispatcher();
        $dispatcher->addListener('workflow.attendance.announce', static function (Event $event) use (&$applied) {
            $applied = $event->getTransition();
        });

        $this->stateMachine($dispatcher)->apply($this->attendance(AttendanceStatus::CONFIRMED), AttendanceTransition::WITHDRAW);

        self::assertNotNull($applied);
        self::assertSame([AttendanceStatus::CONFIRMED], $applied->getFroms());
    }

    /**
     * A real withdrawal of a confirmed attendance is logged as coming from
     * "confirmed" — not from "waiting", the first place `withdraw` declares.
     */
    public function testWithdrawFromConfirmedIsLoggedWithConfirmedAsOrigin(): void
    {
        $attendance = $this->attendance(AttendanceStatus::CONFIRMED);

        // The status Doctrine holds as the entity's original value, i.e. what the
        // changeset's old value will be once the workflow has mutated the entity.
        $loadedStatus = $attendance->getStatus();

        $this->stateMachine()->apply($attendance, AttendanceTransition::WITHDRAW);

        self::assertSame(AttendanceStatus::WITHDRAWN, $attendance->getStatus());

        // Replay the flush that follows the transition: preUpdate writes the log row.
        $changeSet = ['status' => [$loadedStatus, $attendance->getStatus()]];
        $args = new PreUpdateEventArgs($attendance, $this->createStub(EntityManagerInterface::class), $changeSet);

        new AttendanceLogListener($this->createStub(Security::class))->preUpdate($attendance, $args);

        self::assertCount(1, $attendance->getActivity());
        $log = $attendance->getActivity()->first();

        self::assertSame(AttendanceTransition::WITHDRAW, $log->getTransitionName());
        self::assertSame(AttendanceStatus::WITHDRAWN, $log->getTransitionTo());
        self::assertSame(AttendanceStatus::CONFIRMED, $log->getTransition()->getFroms()[0]);
    }

    /**
     * The same for the status-changed event: a seat given back by a confirmed
     * applicant announces "confirmed" as the old status, so handlers can tell a
     * freed seat from a withdrawn waiting-list application.
     */
    public function testWithdrawFromConfirmedAnnouncesConfirmedAsOldStatus(): void
    {
        $message = $this->applyAndCaptureStatusChange(AttendanceStatus::CONFIRMED, AttendanceTransition::WITHDRAW);

        self::assertSame(AttendanceStatus::CONFIRMED, $message->getOldStatus());
        self::assertSame(AttendanceStatus::WITHDRAWN, $message->getNewStatus());
    }

    /**
     * The mirror image: `reset` declares "confirmed" first, so a waitlisted
     * attendance reset to waiting would be misreported as previously confirmed.
     */
    public function testResetFromWaitlistedAnnouncesWaitlistedAsOldStatus(): void
    {
        $message = $this->applyAndCaptureStatusChange(AttendanceStatus::WAITLISTED, AttendanceTransition::RESET);

        self::assertSame(AttendanceStatus::WAITLISTED, $message->getOldStatus());
        self::assertSame(AttendanceStatus::WAITING, $message->getNewStatus());
    }

    /**
     * Runs a transition through the real state machine with the announce listener
     * attached, and returns the AttendanceStatusChanged it dispatched.
     */
    private function applyAndCaptureStatusChange(string $from, string $transition): AttendanceStatusChanged
    {
        $dispatched = [];

        $bus = $this->createStub(MessageBusInterface::class);
        $bus->method('dispatch')->willReturnCallback(static function (object $message) use (&$dispatched) {
            $dispatched[] = $message;

            return new Envelope($message);
        });

        $dispatcher = new EventDispatcher();
        $dispatcher->addListener('workflow.attendance.announce', new AttendanceStatusTransactionListener($bus, new RequestStack()));

        $this->stateMachine($dispatcher)->apply($this->attendance($from), $transition);

        $messages = array_values(array_filter($dispatched, static fn ($m) => $m instanceof AttendanceStatusChanged));

        self::assertCount(1, $messages);

        return $messages[0];
    }

    private function attendance(string $status): Attendance
    {
        $attendance = new Attendance($this->createStub(OfferInterface::class), $this->createStub(ParticipantInterface::class));
        $attendance->setStatus($status);

        return $attendance;
    }

    /**
     * The attendance state machine, assembled from the prepended configuration the
     * way FrameworkExtension does it for `type: state_machine` — one single-source
     * Transition per declared from-place.
     */
    private function stateMachine(?EventDispatcher $dispatcher = null): StateMachine
    {
        $config = $this->attendanceConfig();

        $builder = new DefinitionBuilder($config['places']);
        foreach ($config['transitions'] as $name => $transition) {
            foreach ((array) $transition['from'] as $from) {
                foreach ((array) $transition['to'] as $to) {
                    $builder->addTransition(new Transition($name, [$from], [$to]));
                }
            }
        }

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

    /**
     * The attendance workflow exactly as the bundle prepends it.
     */
    private function attendanceConfig(): array
    {
        $container = new ContainerBuilder();
        new FerienpassCoreExtension()->prepend($container);

        foreach ($container->getExtensionConfig('framework') as $config) {
            if (isset($config['workflows']['attendance'])) {
                return $config['workflows']['attendance'];
            }
        }

        self::fail('The bundle does not prepend an attendance workflow');
    }
}
