<?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\CmsBundle\Tests\Filter;

use Ferienpass\CmsBundle\Controller\Fragment\OfferListController;
use Ferienpass\CmsBundle\Filter\Type\Offer\FreeSeatsType;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\Offer;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Enum\AttendanceStatus;
use Ferienpass\CoreBundle\Enum\OfferStatus;
use Ferienpass\CoreBundle\Fixtures\Doctrine\OrmTestCase;
use Symfony\Component\Form\FormInterface;

/**
 * The "Sofort buchbar" filter against real rows: it promises a booking that goes
 * through right now, so every offer it returns must still accept one.
 *
 * Two of these ran against production and reached families as a dead end — an
 * offer past its application deadline, and a sold-out show whose last spots were
 * held by its waiting list rather than by confirmed attendances.
 */
final class FreeSeatsTypeQueryTest extends OrmTestCase
{
    public function testKeepsAnOfferWhoseDeadlineHasNotPassed(): void
    {
        $this->bookableOffer('open', deadline: new \DateTimeImmutable('+1 week'));

        self::assertSame(['open'], $this->filter());
    }

    public function testKeepsAnOfferWithoutADeadline(): void
    {
        $this->bookableOffer('no deadline');

        self::assertSame(['no deadline'], $this->filter());
    }

    public function testDropsAnOfferWhoseDeadlineHasPassed(): void
    {
        $this->bookableOffer('closed', deadline: new \DateTimeImmutable('-1 day'));

        self::assertSame([], $this->filter());
    }

    public function testKeepsAnOfferWithSpotsLeft(): void
    {
        $offer = $this->bookableOffer('half full', max: 3);
        $this->attend($offer, AttendanceStatus::CONFIRMED);

        self::assertSame(['half full'], $this->filter());
    }

    public function testDropsAnOfferFilledByConfirmedAttendances(): void
    {
        $offer = $this->bookableOffer('full', max: 2);
        $this->attend($offer, AttendanceStatus::CONFIRMED);
        $this->attend($offer, AttendanceStatus::CONFIRMED);

        self::assertSame([], $this->filter());
    }

    /**
     * The Zaubershow: sold out, but two of its spots were held by the waiting
     * list. Counting only the confirmed left one seat visible, so the show kept
     * advertising instant booking that the application form then refused.
     */
    public function testDropsAnOfferWhoseRemainingSpotsAreHeldByTheWaitingList(): void
    {
        $offer = $this->bookableOffer('Zaubershow', max: 3);
        $this->attend($offer, AttendanceStatus::CONFIRMED);
        $this->attend($offer, AttendanceStatus::WAITLISTED);
        $this->attend($offer, AttendanceStatus::WAITLISTED);

        self::assertSame([], $this->filter());
    }

    /**
     * Withdrawn and rejected applications free their spot again — the offer is
     * bookable, and the filter must not keep counting them against it.
     */
    public function testWithdrawnAndRejectedAttendancesDoNotOccupySpots(): void
    {
        $offer = $this->bookableOffer('freed up', max: 1);
        $this->attend($offer, AttendanceStatus::WITHDRAWN);
        $this->attend($offer, AttendanceStatus::REJECTED);

        self::assertSame(['freed up'], $this->filter());
    }

    /**
     * A limit of 0 means "no limit" everywhere it is read (see
     * {@see \Ferienpass\CoreBundle\ApplicationSystem\FirstComeApplicationSystem}),
     * so an offer spelling it that way must not read as full from the first
     * booking on.
     */
    public function testTreatsAZeroLimitAsUnlimited(): void
    {
        $offer = $this->bookableOffer('unlimited', max: 0);
        $this->attend($offer, AttendanceStatus::CONFIRMED);

        self::assertSame(['unlimited'], $this->filter());
    }

    /**
     * The aggregate is grouped per offer *and* date, so a second date must not
     * split the attendance count into two half-full groups.
     */
    public function testCountsSpotsOnceAcrossSeveralDates(): void
    {
        $offer = $this->bookableOffer('two dates', max: 2, dates: 2);
        $this->attend($offer, AttendanceStatus::CONFIRMED);
        $this->attend($offer, AttendanceStatus::CONFIRMED);

        self::assertSame([], $this->filter());
    }

    /**
     * A cancelled offer stays published — the card draws a ribbon over it — so
     * it reaches this filter and has to be dropped explicitly.
     */
    public function testDropsACancelledOffer(): void
    {
        $this->bookableOffer('cancelled', status: [OfferStatus::PUBLISHED, OfferStatus::CANCELLED]);

        self::assertSame([], $this->filter());
    }

    public function testDropsAnOfferThatOnlyTakesApplicationsOffline(): void
    {
        $offer = $this->bookableOffer('offline only');
        $offer->setOnlineApplication(false);
        $this->em->flush();

        self::assertSame([], $this->filter());
    }

    public function testKeepsAnOfferThatNeedsNoApplicationAtAll(): void
    {
        $offer = $this->bookableOffer('drop in');
        $offer->setRequiresApplication(false);
        $offer->setOnlineApplication(false);
        $this->em->flush();

        self::assertSame(['drop in'], $this->filter());
    }

    public function testAnUncheckedFilterLeavesTheListAlone(): void
    {
        $this->bookableOffer('closed', deadline: new \DateTimeImmutable('-1 day'));

        self::assertSame(['closed'], $this->filter(active: false));
    }

    /**
     * Runs the offer list query the way the fragment controller composes it.
     *
     * @return list<string> the names of the offers that survive, sorted
     */
    private function filter(bool $active = true): array
    {
        $this->em->flush();
        $this->em->clear();

        $qb = $this->em->createQueryBuilder()->select('o')->from(Offer::class, 'o');
        OfferListController::prepareQueryBuilder($qb);

        new FreeSeatsType()->apply($qb, $this->form($active));

        $names = array_map(static fn (OfferInterface $offer) => $offer->getName(), $qb->getQuery()->getResult());
        sort($names);

        return $names;
    }

    private function form(bool $active): FormInterface
    {
        $form = $this->createMock(FormInterface::class);
        $form->method('isEmpty')->willReturn(!$active);

        return $form;
    }

    /**
     * A published, online-bookable offer in the future — the shape the filter is
     * meant to return, so each test only has to spell out its one deviation.
     *
     * @param list<string> $status
     */
    private function bookableOffer(string $name, ?\DateTimeImmutable $deadline = null, ?int $max = null, int $dates = 1, array $status = [OfferStatus::PUBLISHED]): OfferInterface
    {
        $offer = $this->createOffer($status, array_fill(0, $dates, [new \DateTime('+1 week'), new \DateTime('+1 week 2 hours')]));
        $offer->setName($name);
        $offer->setRequiresApplication(true);
        $offer->setOnlineApplication(true);
        $offer->setApplicationDeadline($deadline);
        $offer->setMaxParticipants($max);

        return $offer;
    }

    private function attend(OfferInterface $offer, string $status): void
    {
        $attendance = new Attendance($offer, null);
        $attendance->setStatus($status);

        $offer->addAttendance($attendance);
        $this->em->persist($attendance);
    }
}
