<?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 Doctrine\Common\Collections\ArrayCollection;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\Offer\Base;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Entity\OfferDate;
use Ferienpass\CoreBundle\Entity\Participant\BaseParticipant;
use Ferienpass\CoreBundle\Entity\ParticipantLog;
use Ferienpass\CoreBundle\Enum\AttendanceStatus;
use Ferienpass\CoreBundle\Enum\AttendanceTransition;
use Ferienpass\CoreBundle\EventListener\Workflow\AttendanceWithdrawTransactionListener;
use Ferienpass\CoreBundle\Message\WithdrawAttendance;
use Ferienpass\CoreBundle\Settings\SystemSettingsManager;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Workflow\Event\EnteredEvent;
use Symfony\Component\Workflow\Marking;
use Symfony\Component\Workflow\Transition;

class AttendanceWithdrawTransactionListenerTest extends TestCase
{
    private MessageBusInterface&MockObject $commandBus;

    protected function setUp(): void
    {
        $this->commandBus = $this->createMock(MessageBusInterface::class);
    }

    public function testGracePeriodNullAlwaysInGracePeriod(): void
    {
        $listener = $this->listener(null);

        $attendance = $this->createAttendanceWithDate(new \DateTime('+1 day'));

        $this->commandBus->method('dispatch')->willReturnCallback(static fn ($msg) => new Envelope($msg));

        $attendance->expects($this->once())->method('setPayable')->with(false);

        $listener($this->createEnteredEvent($attendance));
    }

    public function testOfferWithNoDatesIsInGracePeriod(): void
    {
        $listener = $this->listener('2026-06-01');

        $attendance = $this->createAttendanceWithoutDates();

        $this->commandBus->method('dispatch')->willReturnCallback(static fn ($msg) => new Envelope($msg));

        $attendance->expects($this->once())->method('setPayable')->with(false);

        $listener($this->createEnteredEvent($attendance));
    }

    public function testOfferDateAfterGracePeriodIsInGracePeriod(): void
    {
        $listener = $this->listener('2026-06-01');

        // Offer date is 2026-07-15, which is AFTER the grace period 2026-06-01
        $attendance = $this->createAttendanceWithDate(new \DateTime('2026-07-15'));

        $this->commandBus->method('dispatch')->willReturnCallback(static fn ($msg) => new Envelope($msg));

        $attendance->expects($this->once())->method('setPayable')->with(false);

        $listener($this->createEnteredEvent($attendance));
    }

    public function testOfferDateBeforeGracePeriodIsNotInGracePeriod(): void
    {
        $listener = $this->listener('2026-06-01');

        // Offer date is 2026-04-15, which is BEFORE the grace period 2026-06-01
        $attendance = $this->createAttendanceWithDate(new \DateTime('2026-04-15'));

        $this->commandBus->method('dispatch')->willReturnCallback(static fn ($msg) => new Envelope($msg));

        $attendance->expects($this->never())->method('setPayable');

        $listener($this->createEnteredEvent($attendance));
    }

    /**
     * "Sofortstornierung": giving the spot back within minutes of getting it is
     * free of charge, even for an offer that starts before the grace period —
     * here for someone who applied weeks ago and just moved up.
     */
    public function testInstantWithdrawalClearsThePayableFlagOutsideTheGracePeriod(): void
    {
        $this->commandBus->method('dispatch')->willReturnCallback(static fn ($msg) => new Envelope($msg));

        $attendance = $this->billedAttendance(confirmedAt: new \DateTimeImmutable('-5 minutes'));

        $this->listener('+3 days')($this->createEnteredEvent($attendance));

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

    public function testWithdrawalAfterTheInstantWindowKeepsThePayableFlag(): void
    {
        $this->commandBus->method('dispatch')->willReturnCallback(static fn ($msg) => new Envelope($msg));

        $attendance = $this->billedAttendance(confirmedAt: new \DateTimeImmutable('-31 minutes'));

        $this->listener('+3 days')($this->createEnteredEvent($attendance));

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

    public function testContextReorderAndNotifyFlagsPassedToCommand(): void
    {
        $listener = $this->listener(null);

        $attendance = $this->createAttendanceWithoutDates();

        $this->commandBus
            ->expects($this->once())
            ->method('dispatch')
            ->with($this->callback(static function (WithdrawAttendance $command): bool {
                return false === $command->shallReorder() && false === $command->shallNotify();
            }))
            ->willReturnCallback(static fn ($msg) => new Envelope($msg));

        $listener($this->createEnteredEvent($attendance, ['reorder' => false, 'notify' => false]));
    }

    public function testDefaultContextValuesReorderTrueNotifyTrue(): void
    {
        $listener = $this->listener(null);

        $attendance = $this->createAttendanceWithoutDates();

        $this->commandBus
            ->expects($this->once())
            ->method('dispatch')
            ->with($this->callback(static function (WithdrawAttendance $command): bool {
                return true === $command->shallReorder() && true === $command->shallNotify();
            }))
            ->willReturnCallback(static fn ($msg) => new Envelope($msg));

        // No context provided, defaults apply
        $listener($this->createEnteredEvent($attendance));
    }

    public function testThrowsRuntimeExceptionForNonAttendanceSubject(): void
    {
        $listener = $this->listener(null);

        $this->expectException(\RuntimeException::class);
        $this->expectExceptionMessage('Unexpected event subject');

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

    private function listener(?string $gracePeriod): AttendanceWithdrawTransactionListener
    {
        $systemSettingsManager = $this->createStub(SystemSettingsManager::class);
        $systemSettingsManager->method('getWithdrawGracePeriod')->willReturn($gracePeriod);

        return new AttendanceWithdrawTransactionListener($this->commandBus, $systemSettingsManager);
    }

    /**
     * A real (unmocked) attendance carrying a fee, for an offer that starts
     * tomorrow — i.e. after any sane grace period has run out. Applied for three
     * weeks ago, confirmed at the given time.
     */
    private function billedAttendance(\DateTimeImmutable $confirmedAt): Attendance
    {
        $offer = new Base();
        $offer->setFee(500);

        $offerDate = new OfferDate($offer);
        $offerDate->setBegin(new \DateTime('+1 day'));
        $offer->addDate($offerDate);

        $attendance = new Attendance($offer, new BaseParticipant());
        $attendance->setStatus(AttendanceStatus::CONFIRMED);
        $attendance->setPayable(true);
        new \ReflectionProperty(Attendance::class, 'createdAt')->setValue($attendance, new \DateTimeImmutable('-3 weeks'));

        $log = new ParticipantLog(
            $attendance->getParticipant(),
            null,
            $attendance,
            transition: new Transition(AttendanceTransition::CONFIRM, [], [AttendanceStatus::CONFIRMED]),
        );
        new \ReflectionProperty(ParticipantLog::class, 'createdAt')->setValue($log, $confirmedAt);

        $attendance->getActivity()->add($log);

        return $attendance;
    }

    private function createAttendanceWithDate(\DateTimeInterface $begin): Attendance&MockObject
    {
        $offerDate = $this->createMock(OfferDate::class);
        $offerDate->method('getBegin')->willReturn($begin);

        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getDates')->willReturn(new ArrayCollection([$offerDate]));

        $attendance = $this->createMock(Attendance::class);
        $attendance->method('getOffer')->willReturn($offer);
        $attendance->method('getUuid')->willReturn(Uuid::v4());

        return $attendance;
    }

    private function createAttendanceWithoutDates(): Attendance&MockObject
    {
        $offer = $this->createMock(OfferInterface::class);
        $offer->method('getDates')->willReturn(new ArrayCollection());

        $attendance = $this->createMock(Attendance::class);
        $attendance->method('getOffer')->willReturn($offer);
        $attendance->method('getUuid')->willReturn(Uuid::v4());

        return $attendance;
    }

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