<?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\Common\Collections\ArrayCollection;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Entity\OfferDate;
use Ferienpass\CoreBundle\Workflow\AttendanceGuard;
use Ferienpass\CoreBundle\Workflow\CompulsoryWithdrawalContext;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Workflow\Event\GuardEvent;
use Symfony\Component\Workflow\Marking;
use Symfony\Component\Workflow\Transition;
use Symfony\Component\Workflow\WorkflowInterface;

final class AttendanceGuardTest extends TestCase
{
    public function testAdminIsAlwaysAllowed(): void
    {
        $guard = $this->makeGuard(isAdmin: true);
        $attendance = $this->makeAttendance(['cancelled' => true]);
        $event = $this->makeEvent($attendance);

        $guard->withdraw($event);

        self::assertFalse($event->isBlocked());
    }

    public function testBlockedWhenOfferIsCancelled(): void
    {
        $guard = $this->makeGuard();
        $attendance = $this->makeAttendance(['cancelled' => true]);
        $event = $this->makeEvent($attendance);

        $guard->withdraw($event);

        self::assertTrue($event->isBlocked());
    }

    public function testBlockedWhenOfferDateHasPassed(): void
    {
        $guard = $this->makeGuard();

        $date = $this->createMock(OfferDate::class);
        $date->method('getBegin')->willReturn(new \DateTime('-1 day'));

        $attendance = $this->makeAttendance(['dates' => [$date]]);
        $event = $this->makeEvent($attendance);

        $guard->withdraw($event);

        self::assertTrue($event->isBlocked());
    }

    public function testAllowedWhenOfferDateIsInFuture(): void
    {
        $guard = $this->makeGuard();

        $date = $this->createMock(OfferDate::class);
        $date->method('getBegin')->willReturn(new \DateTime('+1 day'));

        $attendance = $this->makeAttendance(['dates' => [$date]]);
        $event = $this->makeEvent($attendance);

        $guard->withdraw($event);

        self::assertFalse($event->isBlocked());
    }

    public function testBlockedWhenAfterApplicationDeadline(): void
    {
        $guard = $this->makeGuard(noWithdrawAfterDeadline: true);

        $attendance = $this->makeAttendance(['deadline' => new \DateTimeImmutable('-1 hour')]);
        $event = $this->makeEvent($attendance);

        $guard->withdraw($event);

        self::assertTrue($event->isBlocked());
    }

    public function testAllowedWhenApplicationDeadlineNotReached(): void
    {
        $guard = $this->makeGuard(noWithdrawAfterDeadline: true);

        $attendance = $this->makeAttendance(['deadline' => new \DateTimeImmutable('+1 hour')]);
        $event = $this->makeEvent($attendance);

        $guard->withdraw($event);

        self::assertFalse($event->isBlocked());
    }

    public function testBlockedWhenAfterGracePeriodAndNoDates(): void
    {
        // Grace period is 'yesterday' so we're past grace period
        $guard = $this->makeGuard(gracePeriod: '-1 day', noWithdrawAfterGrace: true);

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

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

        $event = $this->makeEvent($attendance);

        $guard->withdraw($event);

        // Grace period with no dates: inGracePeriod() returns true (dates is empty), so NOT blocked
        self::assertFalse($event->isBlocked());
    }

    public function testCompulsoryWithdrawalWaivesTheApplicationDeadlineGuard(): void
    {
        $context = new CompulsoryWithdrawalContext();
        $guard = $this->makeGuard(noWithdrawAfterDeadline: true, compulsoryWithdrawal: $context);
        $attendance = $this->makeAttendance(['deadline' => new \DateTimeImmutable('-1 hour')]);
        $event = $this->makeEvent($attendance);

        $context->run(static fn () => $guard->withdraw($event));

        self::assertFalse($event->isBlocked(), 'A compulsory-consent revocation overrides the no-withdraw-after-deadline rule.');
    }

    public function testCompulsoryWithdrawalWaivesTheGracePeriodGuard(): void
    {
        $context = new CompulsoryWithdrawalContext();
        $guard = $this->makeGuard(gracePeriod: 'last month', noWithdrawAfterGrace: true, compulsoryWithdrawal: $context);

        $offerDate = $this->createMock(OfferDate::class);
        // Offer starts in the future, but began before the grace period window.
        $offerDate->method('getBegin')->willReturn(new \DateTime('+2 days'));

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

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

        $event = $this->makeEvent($attendance);

        $context->run(static fn () => $guard->withdraw($event));

        self::assertFalse($event->isBlocked(), 'A compulsory-consent revocation overrides the no-withdraw-after-grace rule.');
    }

    public function testCompulsoryWithdrawalStillBlocksAStartedOffer(): void
    {
        $context = new CompulsoryWithdrawalContext();
        $guard = $this->makeGuard(compulsoryWithdrawal: $context);

        $date = $this->createMock(OfferDate::class);
        $date->method('getBegin')->willReturn(new \DateTime('-1 day'));
        $attendance = $this->makeAttendance(['dates' => [$date]]);
        $event = $this->makeEvent($attendance);

        $context->run(static fn () => $guard->withdraw($event));

        self::assertTrue($event->isBlocked(), 'Already-started offers stay blocked even for a compulsory revocation (future-effect).');
    }

    public function testCompulsoryWithdrawalStillBlocksACancelledOffer(): void
    {
        $context = new CompulsoryWithdrawalContext();
        $guard = $this->makeGuard(compulsoryWithdrawal: $context);
        $attendance = $this->makeAttendance(['cancelled' => true]);
        $event = $this->makeEvent($attendance);

        $context->run(static fn () => $guard->withdraw($event));

        self::assertTrue($event->isBlocked(), 'Cancelled offers stay blocked even for a compulsory revocation.');
    }

    public function testInstantWithdrawalWaivesTheGracePeriodGuard(): void
    {
        $guard = $this->makeGuard(gracePeriod: '+3 days', noWithdrawAfterGrace: true);

        $offerDate = $this->createMock(OfferDate::class);
        // The offer starts tomorrow, so the withdrawal is past the grace period …
        $offerDate->method('getBegin')->willReturn(new \DateTime('+1 day'));

        $attendance = $this->makeAttendance(['dates' => [$offerDate]]);
        // … but the registration happened minutes ago.
        $attendance->method('isInstantWithdrawal')->willReturn(true);

        $event = $this->makeEvent($attendance);

        $guard->withdraw($event);

        self::assertFalse($event->isBlocked(), 'A withdrawal right after registering ("Sofortstornierung") overrides the no-withdraw-after-grace rule.');
    }

    public function testInstantWithdrawalWaivesTheApplicationDeadlineGuard(): void
    {
        $guard = $this->makeGuard(noWithdrawAfterDeadline: true);

        $attendance = $this->makeAttendance(['deadline' => new \DateTimeImmutable('-1 hour')]);
        $attendance->method('isInstantWithdrawal')->willReturn(true);

        $event = $this->makeEvent($attendance);

        $guard->withdraw($event);

        self::assertFalse($event->isBlocked());
    }

    public function testInstantWithdrawalStillBlocksAStartedOffer(): void
    {
        $guard = $this->makeGuard();

        $date = $this->createMock(OfferDate::class);
        $date->method('getBegin')->willReturn(new \DateTime('-1 day'));

        $attendance = $this->makeAttendance(['dates' => [$date]]);
        $attendance->method('isInstantWithdrawal')->willReturn(true);

        $event = $this->makeEvent($attendance);

        $guard->withdraw($event);

        self::assertTrue($event->isBlocked(), 'Registering for an already-started offer cannot be undone by the instant window.');
    }

    public function testThrowsForNonAttendance(): void
    {
        $guard = $this->makeGuard();
        $event = $this->makeEvent(new \stdClass());

        $this->expectException(\RuntimeException::class);
        $guard->withdraw($event);
    }

    public function testNoGracePeriodDoesNotBlock(): void
    {
        $guard = $this->makeGuard(gracePeriod: null, noWithdrawAfterGrace: true);
        $attendance = $this->makeAttendance();
        $event = $this->makeEvent($attendance);

        $guard->withdraw($event);

        self::assertFalse($event->isBlocked());
    }

    public function testBlockedWhenAfterGracePeriodAndDatesArePast(): void
    {
        // Grace period is set to 'last month' (so we're past it)
        $guard = $this->makeGuard(gracePeriod: 'last month', noWithdrawAfterGrace: true);

        $offerDate = $this->createMock(OfferDate::class);
        // Offer start was 2 months ago → before the grace period → not in grace period
        $offerDate->method('getBegin')->willReturn(new \DateTime('-2 months'));

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

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

        $event = $this->makeEvent($attendance);

        $guard->withdraw($event);

        self::assertTrue($event->isBlocked());
    }

    private function makeGuard(bool $isAdmin = false, ?string $gracePeriod = null, bool $noWithdrawAfterGrace = false, bool $noWithdrawAfterDeadline = false, ?CompulsoryWithdrawalContext $compulsoryWithdrawal = null): AttendanceGuard
    {
        $security = $this->createMock(Security::class);
        $security->method('isGranted')->with('ROLE_ADMIN')->willReturn($isAdmin);

        $systemSettingsManager = $this->createStub(\Ferienpass\CoreBundle\Settings\SystemSettingsManager::class);
        $systemSettingsManager->method('getWithdrawGracePeriod')->willReturn($gracePeriod);
        $systemSettingsManager->method('isNoWithdrawAfterGracePeriod')->willReturn($noWithdrawAfterGrace);
        $systemSettingsManager->method('isNoWithdrawAfterApplicationDeadline')->willReturn($noWithdrawAfterDeadline);

        return new AttendanceGuard($security, $systemSettingsManager, $compulsoryWithdrawal ?? new CompulsoryWithdrawalContext());
    }

    private function makeEvent(object $subject): GuardEvent
    {
        $workflow = $this->createMock(WorkflowInterface::class);
        $transition = new Transition('withdraw', 'confirmed', 'withdrawn');
        $marking = new Marking();

        return new GuardEvent($subject, $marking, $transition, $workflow);
    }

    private function makeAttendance(array $offerConfig = []): Attendance&MockObject
    {
        $offer = $this->createMock(OfferInterface::class);
        $offer->method('isCancelled')->willReturn($offerConfig['cancelled'] ?? false);
        $offer->method('getApplicationDeadline')->willReturn($offerConfig['deadline'] ?? null);

        $dates = $offerConfig['dates'] ?? [];
        $offer->method('getDates')->willReturn(new ArrayCollection($dates));

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

        return $attendance;
    }
}
