<?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\AdminBundle\Controller\Page;

use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Ferienpass\AdminBundle\Breadcrumb\Breadcrumb;
use Ferienpass\AdminBundle\Controller\TagsResponses;
use Ferienpass\AdminBundle\Export\ExportQueryBuilderInterface;
use Ferienpass\AdminBundle\Form\Filter\OffersFilter;
use Ferienpass\AdminBundle\List\ListCriteriaApplier;
use Ferienpass\AdminBundle\Routing\Base58;
use Ferienpass\AdminBundle\WorkflowSurvey\OfferCompleteSurveyInterface;
use Ferienpass\CoreBundle\Entity\Edition;
use Ferienpass\CoreBundle\Entity\Notification;
use Ferienpass\CoreBundle\Entity\Offer\OfferInterface;
use Ferienpass\CoreBundle\Entity\Offer\VariantsTrait;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\Entity\WorkflowSurvey;
use Ferienpass\CoreBundle\Enum\OfferStatus;
use Ferienpass\CoreBundle\Enum\OfferTransition;
use Ferienpass\CoreBundle\Export\Offer\OffersExportInterface;
use Ferienpass\CoreBundle\HttpCache\CacheTag;
use Ferienpass\CoreBundle\Message\ReorderParticipantList;
use Ferienpass\CoreBundle\Message\SyncOfferVariants;
use Ferienpass\CoreBundle\Repository\EditionRepository;
use Ferienpass\CoreBundle\Repository\HostRepository;
use Ferienpass\CoreBundle\Repository\NotificationRepository;
use Ferienpass\CoreBundle\Repository\OfferRepositoryInterface;
use Ferienpass\CoreBundle\Repository\WorkflowSurveyRepository;
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\Attribute\TaggedLocator;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Workflow\Exception\NotEnabledTransitionException;
use Symfony\Component\Workflow\WorkflowInterface;
use Symfony\UX\Turbo\TurboBundle;

#[Route('/angebote')]
final class OffersController extends AbstractController
{
    use TagsResponses;

    public function __construct(#[TaggedLocator(ExportQueryBuilderInterface::class, defaultIndexMethod: 'getFormat')] private readonly ServiceLocator $exporters, #[Autowire(param: 'ferienpass.model.offer.class')] private readonly string $offerEntityClass, private readonly MessageBusInterface $commandBus)
    {
    }

    #[Route('/{edition?}', name: 'admin_offers_index', priority: -2, requirements: ['edition' => '[a-zA-Z0-9_\-+]+'])]
    public function index(?string $edition, #[CurrentUser] User $user, OfferRepositoryInterface $repository, HostRepository $hosts, Breadcrumb $breadcrumb, EditionRepository $editions, Request $request): Response
    {
        // The `edition` path segment is either empty, a single slug, or several
        // slugs joined by "+". Empty = all editions. Single = the existing
        // contract. Multiple = filter the table over a set of editions.
        $aliases = $edition ? array_values(array_filter(explode('+', $edition))) : [];
        $selectedEditions = $aliases ? $editions->findBy(['alias' => $aliases]) : [];
        if ($aliases && \count($selectedEditions) !== \count($aliases)) {
            throw $this->createNotFoundException();
        }

        // Editions list shown in the headline switcher. Newest first, both
        // active and archived. For non-admins, restrict to editions where the
        // user is one of the configured hosts (mirrors the access rule that
        // used to live in MenuBuilder::secondaryNavigation).
        $allEditions = $editions->findBy([], ['createdAt' => 'DESC']);
        $allEditions = $this->visibleEditions($allEditions, $user);

        $qb = $this->createOffersQueryBuilder($repository);
        if ($selectedEditions) {
            $qb->andWhere('i.edition IN (:selectedEditions)')->setParameter('selectedEditions', $selectedEditions);
        }

        $draft = OfferStatus::DRAFT;

        $singleEdition = 1 === \count($selectedEditions) ? $selectedEditions[0] : null;
        $isAdmin = $this->isGranted('ROLE_ADMIN');

        // Editions the current user is allowed to create offers in. Feeds the
        // "Neu erstellen" split-button dropdown — the user picks the target
        // edition, the menu only lists what's actually possible.
        $creatableEditions = array_values(array_filter(
            $allEditions,
            fn (Edition $e) => $isAdmin || $this->isGranted('offer.create', $e),
        ));

        return $this->render('@FerienpassAdmin/page/offers/index.html.twig', [
            'qb' => $qb,
            'filterType' => OffersFilter::class,
            'creatableEditions' => $creatableEditions,
            'exports' => array_keys($this->exporters->getProvidedServices()),
            'searchable' => ['name'],
            'headline' => 'offers.title',
            'edition' => $singleEdition,
            'editions' => $allEditions,
            'selectedEditions' => $selectedEditions,
            'hostCount' => \count($hosts->findByUser($user)),
            'breadcrumb' => $breadcrumb->generate(['offers.title', ['route' => 'admin_offers_index']], $singleEdition?->getName()),
            'config' => $this->getOfferListConfig($user, $isAdmin, $hosts, 1 !== \count($selectedEditions)),
        ]);
    }

    #[Route('.{format}', name: 'admin_offers_export', requirements: ['format' => '\w+'])]
    #[Route('/{edition}.{format}', name: 'admin_offers_export_in_edition', requirements: ['edition' => '[a-zA-Z0-9_\-+]+', 'format' => '\w+'])]
    public function export(?string $edition, OfferRepositoryInterface $repository, string $format, Request $request, ListCriteriaApplier $filterer, EditionRepository $editions)
    {
        $qb = $this->createOffersQueryBuilder($repository);

        if (null !== $edition) {
            $aliases = array_values(array_filter(explode('+', $edition)));
            $selectedEditions = $aliases ? $editions->findBy(['alias' => $aliases]) : [];
            if ($aliases && \count($selectedEditions) !== \count($aliases)) {
                throw $this->createNotFoundException();
            }
            if ([] !== $selectedEditions) {
                $qb->andWhere('i.edition IN (:selectedEditions)')
                   ->setParameter('selectedEditions', $selectedEditions);
            }
        }

        $filterer->apply(
            $qb,
            OffersFilter::class,
            ['name'],
            $request->query->get('q'),
            $request->query->get('sort'),
            (array) $request->query->all('filter'),
            config: ['groupBy' => 'edition']
        );

        if (!$this->exporters->has($format)) {
            throw $this->createNotFoundException();
        }

        $exporter = $this->exporters->get($format);

        return $this->file($exporter->generate($qb), "angebote.$format");
    }

    #[Route('/{uuid}', name: 'admin_offers_show', requirements: ['uuid' => Base58::UUID])]
    #[Route('/{uuid}', name: 'admin_offers_show_legacy', requirements: ['uuid' => Requirement::UUID])]
    public function show(Uuid $uuid, #[MapQueryParameter(name: 'kommentare')] ?bool $commentsOnly, OfferRepositoryInterface $repository, Breadcrumb $breadcrumb): Response
    {
        if (null === $entity = $repository->findOneBy(['uuid' => $uuid])) {
            throw $this->createNotFoundException();
        }

        $this->denyAccessUnlessGranted('view', $entity);

        $this->tagResponse(CacheTag::offer($entity));

        return $this->render('@FerienpassAdmin/page/offers/show.html.twig', [
            'item' => $entity,
            'commentsOnly' => (bool) $commentsOnly,
            'breadcrumb' => $breadcrumb->generate(
                ['offers.title', ['route' => 'admin_offers_index', 'routeParameters' => array_filter(['edition' => $entity->getEdition()?->getAlias()])]],
                $entity->getEdition() ? [$entity->getEdition()->getName(), ['route' => 'admin_offers_index', 'routeParameters' => ['edition' => $entity->getEdition()->getAlias()]]] : null,
                $entity->getName()
            ),
        ]);
    }

    #[Route('/neu', name: 'admin_offers_new')]
    #[Route('/{edition}/neu', name: 'admin_offers_new_in_edition')]
    public function newWizard(#[MapEntity(mapping: ['edition' => 'alias'])] ?Edition $edition, Breadcrumb $breadcrumb, Request $request): Response
    {
        if ($edition instanceof Edition) {
            $this->denyAccessUnlessGranted('offer.create', $edition);
        }

        if ('admin_offers_new_in_edition' === $request->attributes->get('_canonical_route', $request->get('_route')) && !$edition instanceof Edition) {
            throw $this->createNotFoundException();
        }

        return $this->render('@FerienpassAdmin/page/offers/new.html.twig', [
            'edition' => $edition,
            'breadcrumb' => $breadcrumb->generate(
                ['offers.title', ['route' => 'admin_offers_index', 'routeParameters' => array_filter(['edition' => $edition?->getAlias()])]],
                $edition instanceof Edition ? [$edition->getName(), ['route' => 'admin_offers_index', 'routeParameters' => ['edition' => $edition->getAlias()]]] : null,
                'Neue Veranstaltung'
            ),
        ]);
    }

    #[Route('/{uuid}/korrekturabzug', name: 'admin_offers_proof', requirements: ['uuid' => Base58::UUID])]
    #[Route('/{uuid}/korrekturabzug', name: 'admin_offers_proof_legacy', requirements: ['uuid' => Requirement::UUID])]
    public function proof(Uuid $uuid, OfferRepositoryInterface $repository, #[TaggedLocator(OffersExportInterface::class, indexAttribute: 'name', defaultIndexMethod: 'getName')] ServiceLocator $exporters, Request $request, WorkflowInterface $offerWorkflow, Breadcrumb $breadcrumb, EntityManagerInterface $entityManager): Response
    {
        if (!($offer = $repository->findByUuid($uuid)) instanceof OfferInterface) {
            throw $this->createNotFoundException();
        }

        $this->denyAccessUnlessGranted('view', $offer);

        if ($request->isMethod('POST')) {
            $offerWorkflow->apply($offer, OfferTransition::FINALIZE);

            $entityManager->flush();

            return $this->redirectToRoute('admin_offers_proof', ['uuid' => $offer->getUuid()->toBase58()]);
        }

        $this->tagResponse(CacheTag::offer($offer));

        return $this->render('@FerienpassAdmin/page/offers/proof.html.twig', [
            'offer' => $offer,
            'pdfs' => array_map(static fn ($v) => substr($v, 0, -4), array_filter(array_keys($exporters->getProvidedServices()), static fn (string $k) => str_ends_with($k, '.pdf'))),
            'breadcrumb' => $breadcrumb->generate(
                ...array_filter([
                    ['offers.title', ['route' => 'admin_offers_index']],
                    [$offer->getEdition()->getName(), ['route' => 'admin_offers_index', 'routeParameters' => ['edition' => $offer->getEdition()->getAlias()]]],
                    $offer->getName(),
                ])
            ),
        ]);
    }

    #[Route('/{uuid}/abschließen', name: 'admin_offers_complete', requirements: ['uuid' => Base58::UUID])]
    #[Route('/{uuid}/abschließen', name: 'admin_offers_complete_legacy', requirements: ['uuid' => Requirement::UUID])]
    public function complete(Uuid $uuid, OfferRepositoryInterface $repository, WorkflowSurveyRepository $surveyData, Request $request, Breadcrumb $breadcrumb, #[TaggedLocator(OfferCompleteSurveyInterface::class, defaultIndexMethod: 'getName')] ServiceLocator $surveys, EntityManagerInterface $entityManager, WorkflowInterface $offerWorkflow): Response
    {
        /** @var OfferInterface $offer */
        if (!($offer = $repository->findByUuid($uuid)) instanceof OfferInterface) {
            throw $this->createNotFoundException();
        }

        if (!$offerWorkflow->can($offer, OfferTransition::COMPLETE)) {
            throw $this->createAccessDeniedException();
        }

        $edition = $offer->getEdition();
        $key = $offer->isOnlineApplication() ? $edition->getSurveyWithApplication() : $edition->getSurveyWithoutApplication();
        if (null === $key) {
            throw $this->createNotFoundException();
        }

        if (!$surveys->has($key)) {
            throw $this->createNotFoundException();
        }

        /** @var OfferCompleteSurveyInterface $survey */
        $survey = $surveys->get($key);

        /** @var WorkflowSurvey|null $data */
        $data = $surveyData->findOneBy(['offer' => $offer, 'survey' => $key]);

        // TODO real DTO
        $dto = $data?->getData() ?? [];
        $form = $this->createForm($survey::class, $dto);
        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            $data ??= new WorkflowSurvey($offer, $key);
            $data->setData($form->getData());

            if (!$data->getId()) {
                $entityManager->persist($data);
            }

            $offerWorkflow->apply($offer, OfferTransition::COMPLETE, ['survey_data' => $data->getData()]);
            $entityManager->flush();

            return $this->redirectToRoute('admin_offers_index', ['edition' => $offer->getEdition()->getAlias()]);
        }

        $this->tagResponse(CacheTag::offer($offer));

        return $this->render('@FerienpassAdmin/page/offers/complete.html.twig', [
            'offer' => $offer,
            'action' => $survey,
            'form' => $form,
            'breadcrumb' => $breadcrumb->generate(['offers.title', ['route' => 'admin_offers_index', 'routeParameters' => ['edition' => $offer->getEdition()->getAlias()]]], [$offer->getEdition()->getName(), ['route' => 'admin_offers_index', 'routeParameters' => ['edition' => $offer->getEdition()->getAlias()]]], $offer->getName()),
        ]);
    }

    #[Route('/{uuid}/neu', name: 'admin_offers_duplicate', requirements: ['uuid' => Base58::UUID], priority: 1)]
    #[Route('/{uuid}/neu', name: 'admin_offers_duplicate_legacy', requirements: ['uuid' => Requirement::UUID], priority: 1)]
    public function duplicate(Uuid $uuid, OfferRepositoryInterface $repository, EditionRepository $editionRepository): Response
    {
        if (!($entity = $repository->findByUuid($uuid)) instanceof OfferInterface) {
            throw $this->createNotFoundException();
        }

        $this->denyAccessUnlessGranted('view', $entity);

        $editions = $editionRepository->findWithActiveTask('host_editing_stage');

        $this->tagResponse(CacheTag::offer($entity));

        return $this->render('@FerienpassAdmin/page/offers/duplicate.html.twig', [
            'item' => $entity,
            'editions' => $editions,
        ]);
    }

    #[Route('/{uuid}/auffüllen', name: 'admin_offers_reorder', requirements: ['uuid' => Base58::UUID], methods: ['POST'])]
    #[Route('/{uuid}/auffüllen', name: 'admin_offers_reorder_legacy', requirements: ['uuid' => Requirement::UUID], methods: ['POST'])]
    public function reorder(Uuid $uuid, OfferRepositoryInterface $repository, Request $request, MessageBusInterface $commandBus): Response
    {
        if (!($offer = $repository->findByUuid($uuid)) instanceof OfferInterface) {
            throw $this->createNotFoundException();
        }

        // TODO permission
        $this->denyAccessUnlessGranted('edit', $offer);

        $notificationUuid = (string) Uuid::v7();
        $commandBus->dispatch(new ReorderParticipantList((string) $offer->getUuid(), notify: true, commit: true, forceReorder: true, group: $notificationUuid));

        if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
            $request->setRequestFormat(TurboBundle::STREAM_FORMAT);

            return $this->renderBlock('@FerienpassAdmin/page/offers/edit.html.twig', 'reorder_success_stream', [
                'notificationUuid' => $notificationUuid,
            ]);
        }

        return $this->redirectToRoute('admin_offers_edit', ['edition' => $offer->getEdition()->getAlias(), 'uuid' => $offer->getUuid()->toBase58()]);
    }

    #[Route('/{uuid}/plätze-erhöhen', name: 'admin_offers_increase_seats', requirements: ['uuid' => Base58::UUID], methods: ['GET'])]
    #[Route('/{uuid}/plätze-erhöhen', name: 'admin_offers_increase_seats_legacy', requirements: ['uuid' => Requirement::UUID], methods: ['GET'])]
    public function increaseSeats(Uuid $uuid, OfferRepositoryInterface $repository): Response
    {
        if (!($offer = $repository->findByUuid($uuid)) instanceof OfferInterface) {
            throw $this->createNotFoundException();
        }

        $this->denyAccessUnlessGranted('edit', $offer);

        return $this->render('@FerienpassAdmin/page/offers/reorder.html.twig', [
            'offer' => $offer,
            'increase' => true,
        ]);
    }

    #[Route('/{uuid}/teilnehmende-übertragen', name: 'admin_offers_transfer_participants', requirements: ['uuid' => Base58::UUID], methods: ['GET'])]
    #[Route('/{uuid}/teilnehmende-übertragen', name: 'admin_offers_transfer_participants_legacy', requirements: ['uuid' => Requirement::UUID], methods: ['GET'])]
    public function transferParticipants(Uuid $uuid, OfferRepositoryInterface $repository): Response
    {
        if (!($offer = $repository->findByUuid($uuid)) instanceof OfferInterface) {
            throw $this->createNotFoundException();
        }

        $this->denyAccessUnlessGranted('participants.view', $offer);

        return $this->render('@FerienpassAdmin/page/offers/transfer_participants.html.twig', [
            'offer' => $offer,
        ]);
    }

    #[Route('/{uuid}/varianten', name: 'admin_offers_variants', requirements: ['uuid' => Base58::UUID])]
    #[Route('/{uuid}/varianten', name: 'admin_offers_variants_legacy', requirements: ['uuid' => Requirement::UUID])]
    public function variants(Uuid $uuid, OfferRepositoryInterface $repository, Request $request, EntityManagerInterface $entityManager, #[CurrentUser] User $user, FormFactoryInterface $formFactory): Response
    {
        /** @var OfferInterface $item */
        if (!($item = $repository->findByUuid($uuid)) instanceof OfferInterface) {
            throw $this->createNotFoundException();
        }

        $this->denyAccessUnlessGranted('edit', $item);

        /** @var VariantsTrait $item */
        if ($item->isVariant() || $item->hasVariants()) {
            $detach = $formFactory->createNamed('detach', options: [
                'action' => $this->generateUrl('admin_offers_variants', ['uuid' => $uuid->toBase58()]),
            ]);

            $detach->handleRequest($request);
            if ($detach->isSubmitted() && $detach->isValid()) {
                if ($item->hasVariants()) {
                    $variants = $item->getVariants()->toArray();
                    $variants[0]->setVariantBase(null);
                    $counter = \count($variants);

                    for ($i = 1; $i < $counter; ++$i) {
                        $variants[$i]->setVariantBase($variants[0]);
                    }
                } elseif (!$item->isVariantBase()) {
                    $item->setVariantBase(null);
                }

                $entityManager->flush();

                return $this->redirectToRoute('admin_offers_variants', ['uuid' => $uuid->toBase58()]);
            }
        } else {
            $link = $formFactory->createNamedBuilder('link', options: [
                'action' => $this->generateUrl('admin_offers_variants', ['uuid' => $uuid->toBase58()]),
            ])
                ->add('base', EntityType::class, [
                    'class' => $this->offerEntityClass,
                    'query_builder' => function (EntityRepository $er) use ($item, $user): QueryBuilder {
                        $qb = $er->createQueryBuilder('o')
                            ->andWhere('o.edition = :edition')
                            ->setParameter('edition', $item->getEdition())
                            ->andwhere('o.variantBase IS NULL')
                            ->andWhere('o.id <> :id')
                            ->setParameter('id', $item->getId());

                        if (!$this->isGranted('ROLE_ADMIN')) {
                            $qb->innerJoin('o.hosts', 'h')
                                ->andWhere('h IN (:hosts)')
                                ->setParameter('hosts', $user->getHosts())
                            ;
                        }

                        return $qb->orderBy('o.name', 'ASC');
                    },
                    'choice_label' => 'name',
                    'label' => 'Haupttermin auswählen',
                    'placeholder' => '-',
                ])
                ->getForm()
            ;

            $link->handleRequest($request);
            if ($link->isSubmitted() && $link->isValid()) {
                /** @var OfferInterface $base */
                $base = $link->get('base')->getData();

                foreach ($item->getAttendances() as $attendance) {
                    $attendance->setOffer($base);
                }

                $item->setVariantBase($base);

                $entityManager->flush();

                $this->commandBus->dispatch(new SyncOfferVariants((string) $base->getUuid()));

                return $this->redirectToRoute('admin_offers_variants', ['uuid' => $uuid->toBase58()]);
            }
        }

        $this->tagResponse(CacheTag::offer($item));

        return $this->render('@FerienpassAdmin/page/offers/variants.html.twig', [
            'item' => $item,
            'variants' => $item->getVariants(true),
            'detach' => $detach ?? null,
            'link' => $link ?? null,
        ]);
    }

    #[Route('/{uuid}/löschen', name: 'admin_offers_delete', requirements: ['uuid' => Base58::UUID])]
    #[Route('/{uuid}/löschen', name: 'admin_offers_delete_legacy', requirements: ['uuid' => Requirement::UUID])]
    public function delete(Uuid $uuid, OfferRepositoryInterface $repository, Request $request, EntityManagerInterface $entityManager): Response
    {
        if (!($item = $repository->findByUuid($uuid)) instanceof OfferInterface) {
            throw $this->createNotFoundException();
        }

        $this->denyAccessUnlessGranted('delete', $item);

        $form = $this->createForm(FormType::class, options: [
            'action' => $this->generateUrl('admin_offers_delete', ['uuid' => $uuid->toBase58()]),
        ]);
        $form->handleRequest($request);

        // TODO Can use a facade
        if ($form->isSubmitted() && $form->isValid()) {
            /** @var $attendance */
            foreach ($item->getAttendances() as $attendance) {
                foreach ($attendance->getPaymentItems() as $paymentItem) {
                    $paymentItem->removeAttendanceAssociation();
                }
            }

            if ($item->hasVariants()) {
                $variants = $item->getVariants()->toArray();
                $variants[0]->setVariantBase(null);
                $counter = \count($variants);

                for ($i = 1; $i < $counter; ++$i) {
                    $variants[$i]->setVariantBase($variants[0]);
                }
            } elseif (!$item->isVariantBase()) {
                $item->setVariantBase(null);
            }

            $deletedId = $item->getId();
            $edition = $item->getEdition()->getAlias();

            $entityManager->remove($item);
            $entityManager->flush();

            if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
                // If the request comes from Turbo, set the content type as text/vnd.turbo-stream.html and only send the HTML to update
                $request->setRequestFormat(TurboBundle::STREAM_FORMAT);

                return $this->renderBlock('@FerienpassAdmin/page/offers/index.html.twig', 'deleted_stream', ['id' => $deletedId]);
            }

            return $this->redirectToRoute('admin_offers_index', ['edition' => $edition]);
        }

        return $this->render('@FerienpassAdmin/page/offers/delete.html.twig', [
            'item' => $item,
            'form' => $form,
        ]);
    }

    #[Route('/{uuid}/apply/{transition}', name: 'admin_offers_apply', requirements: ['uuid' => Base58::UUID], methods: ['POST'])]
    #[Route('/{uuid}/apply/{transition}', name: 'admin_offers_apply_legacy', requirements: ['uuid' => Requirement::UUID], methods: ['POST'])]
    public function apply(Uuid $uuid, string $transition, OfferRepositoryInterface $repository, WorkflowInterface $offerWorkflow, EntityManagerInterface $entityManager, Request $request, HostRepository $hosts, #[CurrentUser] User $user): Response
    {
        if (!($offer = $repository->findByUuid($uuid)) instanceof OfferInterface) {
            throw $this->createNotFoundException();
        }

        if (!$offerWorkflow->can($offer, $transition)) {
            throw $this->createNotFoundException('Transition not enabled');
        }

        $this->denyAccessUnlessGranted($transition, $offer);

        $offerWorkflow->apply($offer, $transition);
        $entityManager->flush();

        if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
            $request->setRequestFormat(TurboBundle::STREAM_FORMAT);

            return $this->renderBlock('@FerienpassAdmin/page/offers/index.html.twig', 'row_stream', [
                'item' => $offer,
                'hostCount' => \count($hosts->findByUser($user)),
                'config' => $this->getOfferListConfig($user, $this->isGranted('ROLE_ADMIN'), $hosts),
            ]);
        }

        return $this->redirectToRoute('admin_offers_index', array_filter(['edition' => $offer->getEdition()?->getAlias()]));
    }

    #[Route('/{uuid}/absagen', name: 'admin_offers_cancel', requirements: ['uuid' => Base58::UUID])]
    #[Route('/{uuid}/absagen', name: 'admin_offers_cancel_legacy', requirements: ['uuid' => Requirement::UUID])]
    public function cancel(Uuid $uuid, OfferRepositoryInterface $repository, Request $request, EntityManagerInterface $entityManager, WorkflowInterface $offerWorkflow, Breadcrumb $breadcrumb, NotificationRepository $notificationRepository): Response
    {
        if (!($offer = $repository->findByUuid($uuid)) instanceof OfferInterface) {
            throw $this->createNotFoundException();
        }

        $this->denyAccessUnlessGranted(OfferTransition::CANCEL, $offer);

        if (!$offerWorkflow->can($offer, OfferTransition::CANCEL)) {
            $this->tagResponse(CacheTag::offer($offer));

            return $this->render('@FerienpassAdmin/page/offers/cancel.html.twig', [
                'offer' => $offer,
                'notAllowed' => true,
                'breadcrumb' => $breadcrumb->generate(
                    ['offers.title', ['route' => 'admin_offers_index', 'routeParameters' => ['edition' => $offer->getEdition()->getAlias()]]],
                    [$offer->getEdition()->getName(), ['route' => 'admin_offers_index', 'routeParameters' => ['edition' => $offer->getEdition()->getAlias()]]],
                    $offer->getName()
                ),
            ]);
        }

        $form = $this->createFormBuilder(options: [
            'action' => $this->generateUrl('admin_offers_cancel', ['uuid' => $uuid->toBase58()]),
        ])
            ->add('notifyText', TextareaType::class, [
                'label' => 'E-Mail-Text',
                'required' => false,
                'data' => $this->notificationText('offer_cancelled', $offer, $notificationRepository),
            ])
            ->getForm()
        ;
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $transitionUuid = Uuid::v7();

            try {
                $offerWorkflow->apply($offer, OfferTransition::CANCEL, [
                    'uuid' => (string) $transitionUuid,
                    'notifyText' => $form->get('notifyText')->getData(),
                ]);
            } catch (NotEnabledTransitionException) {
                throw $this->createNotFoundException('Transition not enabled');
            }

            $entityManager->flush();

            if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
                $request->setRequestFormat(TurboBundle::STREAM_FORMAT);

                return $this->renderBlock('@FerienpassAdmin/page/offers/cancel.html.twig', 'success_stream', [
                    'offer' => $offer,
                    // base58 for the admin_tools_dispatch link (canonical route).
                    'transitionUuid' => $transitionUuid->toBase58(),
                ]);
            }

            return $this->redirectToRoute('admin_offers_participants', ['uuid' => $offer->getUuid()->toBase58()]);
        }

        $this->tagResponse(CacheTag::offer($offer));

        return $this->render('@FerienpassAdmin/page/offers/cancel.html.twig', [
            'offer' => $offer,
            'cancel' => $form,
            'breadcrumb' => $breadcrumb->generate(['offers.title', ['route' => 'admin_offers_index', 'routeParameters' => ['edition' => $offer->getEdition()->getAlias()]]], [$offer->getEdition()->getName(), ['route' => 'admin_offers_index', 'routeParameters' => ['edition' => $offer->getEdition()->getAlias()]]], $offer->getName()),
        ]);
    }

    #[Route('/{uuid}/wiederherstellen', name: 'admin_offers_relaunch', requirements: ['uuid' => Base58::UUID])]
    #[Route('/{uuid}/wiederherstellen', name: 'admin_offers_relaunch_legacy', requirements: ['uuid' => Requirement::UUID])]
    public function relaunch(Uuid $uuid, OfferRepositoryInterface $repository, Request $request, EntityManagerInterface $entityManager, WorkflowInterface $offerWorkflow, Breadcrumb $breadcrumb, NotificationRepository $notificationRepository): Response
    {
        /** @var OfferInterface $offer */
        if (!($offer = $repository->findByUuid($uuid)) instanceof OfferInterface) {
            throw $this->createNotFoundException();
        }

        $this->denyAccessUnlessGranted(OfferTransition::RELAUNCH, $offer);

        if (!$offerWorkflow->can($offer, OfferTransition::RELAUNCH)) {
            $this->tagResponse(CacheTag::offer($offer));

            return $this->render('@FerienpassAdmin/page/offers/relaunch.html.twig', [
                'offer' => $offer,
                'notAllowed' => true,
                'breadcrumb' => $breadcrumb->generate(
                    ['offers.title', ['route' => 'admin_offers_index', 'routeParameters' => ['edition' => $offer->getEdition()->getAlias()]]],
                    [$offer->getEdition()->getName(), ['route' => 'admin_offers_index', 'routeParameters' => ['edition' => $offer->getEdition()->getAlias()]]],
                    $offer->getName()
                ),
            ]);
        }

        $form = $this->createFormBuilder(options: [
            'action' => $this->generateUrl('admin_offers_relaunch', ['uuid' => $uuid->toBase58()]),
        ])
            ->add('notifyText', TextareaType::class, [
                'label' => 'E-Mail-Text',
                'required' => false,
                'data' => $this->notificationText('offer_relaunched', $offer, $notificationRepository),
            ])
            ->getForm()
        ;
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $transitionUuid = Uuid::v7();

            try {
                $offerWorkflow->apply($offer, OfferTransition::RELAUNCH, [
                    'uuid' => (string) $transitionUuid,
                    'notifyText' => $form->get('notifyText')->getData(),
                ]);
            } catch (NotEnabledTransitionException) {
                throw $this->createNotFoundException('Transition not enabled');
            }

            $entityManager->flush();

            if (TurboBundle::STREAM_FORMAT === $request->getPreferredFormat()) {
                $request->setRequestFormat(TurboBundle::STREAM_FORMAT);

                return $this->renderBlock('@FerienpassAdmin/page/offers/relaunch.html.twig', 'success_stream', [
                    'offer' => $offer,
                    // base58 for the admin_tools_dispatch link (canonical route).
                    'transitionUuid' => $transitionUuid->toBase58(),
                ]);
            }

            return $this->redirectToRoute('admin_offers_participants', ['uuid' => $offer->getUuid()->toBase58()]);
        }

        $this->tagResponse(CacheTag::offer($offer));

        return $this->render('@FerienpassAdmin/page/offers/relaunch.html.twig', [
            'offer' => $offer,
            'relaunch' => $form,
            'breadcrumb' => $breadcrumb->generate(
                ['offers.title', ['route' => 'admin_offers_index', 'routeParameters' => ['edition' => $offer->getEdition()->getAlias()]]],
                [$offer->getEdition()->getName(), ['route' => 'admin_offers_index', 'routeParameters' => ['edition' => $offer->getEdition()->getAlias()]]],
                $offer->getName()
            ),
        ]);
    }

    private function notificationText(string $notification, OfferInterface $offer, NotificationRepository $notificationRepository): ?string
    {
        $entity = $notificationRepository
            ->createQueryBuilder('n')
            ->where('n.type = :type')
            ->andWhere('n.disable = 0')
            ->setParameter('type', $notification)
        ;

        $entity
            ->addSelect('(CASE WHEN n.edition = :edition THEN 1 ELSE 0 END) AS HIDDEN mainSort')
            ->addOrderBy('mainSort', 'DESC')
            ->setParameter('edition', $offer->getEdition())
        ;

        $entity->addSelect('(CASE WHEN n.edition IS NULL THEN 1 ELSE 0 END) AS HIDDEN defaultFirst')->addOrderBy('defaultFirst', 'DESC');

        $entity = $entity->setMaxResults(1)->getQuery()->getOneOrNullResult();

        if (!$entity instanceof Notification) {
            return null;
        }

        return $entity->getEmailText();
    }

    private function createOffersQueryBuilder(OfferRepositoryInterface $repository): QueryBuilder
    {
        $qb = $repository->createQueryBuilder('i')
            ->leftJoin('i.dates', 'd')
        ;

        // Solve N+1
        $qb
            ->addSelect('d')
            ->leftJoin('i.hosts', 'h')
            ->addSelect('h')
            ->leftJoin('i.edition', 'e')
            ->addSelect('e')
            ->leftJoin('i.variants', 'v')
            ->addSelect('v')
            ->leftJoin('i.activity', 'l')
            ->addSelect('l')
            ->leftJoin('i.attendances', 'a')
            ->addSelect('a')
        ;

        return $qb;
    }

    /**
     * Filter an edition list down to those the user is allowed to see. Admins
     * always see everything. Non-admins only see editions that have no host
     * restriction or include one of the user's hosts.
     *
     * @param Edition[] $editions
     *
     * @return Edition[]
     */
    private function visibleEditions(array $editions, User $user): array
    {
        if ($this->isGranted('ROLE_ADMIN')) {
            return $editions;
        }
        $userHost = $user->getHosts()->first() ?: null;

        return array_values(array_filter($editions, static fn (Edition $e) => $e->getHosts()->isEmpty() || ($userHost && $e->getHosts()->contains($userHost))));
    }

    private function getOfferListConfig(User $user, bool $isAdmin, HostRepository $hosts, bool $showEditionColumn = false): array
    {
        $hostCount = \count($hosts->findByUser($user));
        $showHosts = $isAdmin || $hostCount > 1;

        return [
            'showUrl' => '{{ path("admin_offers_show", { uuid: item.uuid.toBase58 }) }}',
            'squeezedColumns' => [
                'host',
            ],
            // The list / grid view's per-row title.
            //
            // The date + name + variant badge live inside a single
            // hover-chip `<a>` linking to the offer's show page in the
            // slideover — same chip pattern the table view's `name`
            // column already uses below. The dropdown chips that follow
            // (edition, status badges) sit OUTSIDE the link so they can
            // own their own click behaviour without nested-`<a>` issues.
            //
            // When the user is looking across multiple editions, an
            // edition chip drops in between the title link and the
            // status badges. Single-edition path-param visits skip it —
            // every row already belongs to the same edition, so the
            // chip would be redundant noise.
            'title' => '{% trans_default_domain "admin" %}

                <a href="{{ path("admin_offers_show", { uuid: item.uuid.toBase58 }) }}" data-turbo-frame="slideover"
                   class="inline-flex items-baseline hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100 rounded-md px-1.5 py-1 -mx-1.5 -my-1 transition-colors">
                    {% if item.dates is not empty and item.dates|first.begin is not null %}
                        <span class="text-gray-700 dark:text-gray-400 font-normal mr-1">{{ item.dates|first.begin|date("d.m.Y") }}</span>
                    {% endif %}
                    <span>{{ item.name|default("") }}</span>
                    {% if (item.isVariant|default or item.hasVariants) and item.variantNumber is defined %}
                        {{ component("Admin:Badge", { color: item.isVariantBase ? "yellow" : "gray", class: "ml-1", content: item.variantNumber|default("") }) }}
                    {% endif %}
                </a>'
                .($showEditionColumn ? '
                {% if item.edition %}
                    <span class="ml-2 inline-flex items-center">
                        {{ include("@FerienpassAdmin/page/offers/_edition_chip.html.twig", { edition: item.edition }) }}
                    </span>
                {% endif %}' : '')
                .'
                {% for status in item.status|keys|default([]) %}
                {{ component("Admin:Badge", { offer: status, class: "ml-2" }) }}
                {% endfor %}
                ',
            'info' => [
                <<<'TWIG'
                    {% if item.dates is not empty %}
                        {% set firstDate = item.dates|first %}
                        {% if firstDate.begin is not null and firstDate.end is not null %}
                            <time datetime="{{ firstDate.begin|date('c') }}">{{ firstDate.begin|date("H:i") }} - {{ firstDate.end|date("H:i") }}</time>
                            {% if item.dates|length > 1 %}
                                <span class="ml-1">+ {{ item.dates|length - 1 }} weitere Zeiten</span>
                            {% endif %}
                        {% else %}
                            <span class="inline-flex items-center text-orange-600">
                                {{ ux_icon('ph:warning', {class: 'mr-1 size-3.5'}) }}
                                <span>Termin fehlt</span>
                            </span>
                        {% endif %}
                    {% else %}
                        <span>Dauerangebot</span>
                    {% endif %}
                    TWIG,
                null,
                // Host chips. Each host name is its own dropdown chip
                // (view host slideover · filter by host) — same partial
                // the table view's `hosts` column uses below, so list and
                // table view stay in sync on host interactions.
                $showHosts ? <<<'TWIG'
                    {% for host in item.hosts %}
                        {{ include("@FerienpassAdmin/page/offers/_host_chip.html.twig", { host: host }) }}{% if not loop.last %},{% endif %}
                    {% endfor %}
                    TWIG : null,
                // The legacy "x / y" application-count info line was
                // replaced by an `aside` capacity bar that ships with the
                // same scope (confirmed + waiting + waitlisted + unfulfilled)
                // but visualises it instead of stringifying it.
                //
                // The slot is now reused for the read-only lock indicator
                // — same icon as the edit screen's section navigation
                // (`page/offers/edit.html.twig:45-46`). It surfaces for the
                // same reason there too: the offer exists and the current
                // user can't edit it. Per `OfferVoter::canEdit()`, admins
                // can always edit, so this is effectively a host-side
                // signal: "this offer is locked because the editing phase
                // closed or the offer is already published/reviewed."
                //
                // Empty-string fallback keeps the info loop's |trim filter
                // happy (the entry drops out for editable offers instead
                // of leaving a stray separator dot).
                <<<'TWIG'
                    {% if item.id and not is_granted('edit', item) %}
                        <span class="inline-flex items-center text-gray-500" title="Schreibgeschützt">
                            {{ ux_icon('ph:lock', { class: 'size-3.5' }) }}
                        </span>
                    {% endif %}
                    TWIG,
            ],
            // Right-side aside widget — capacity indicator linking to the
            // participants slideover. Inspired by the participants list's
            // bar (`page/participants/index.html.twig:28-54`), but the
            // scale is different here: instead of growing the bar with
            // attendance counts (which produced cartoon-wide bars on
            // outliers like "9 spots, 62 unfulfilled"), the bar stays a
            // fixed `w-24` and we encode state in COLOUR.
            //
            //   demand <  maxParticipants → green  (room left)
            //   demand == maxParticipants → orange (at capacity)
            //   demand >  maxParticipants → red    (over capacity)
            //
            // where `demand = confirmed + unfulfilled` — the count of
            // applications that DID or SHOULD have got a spot. Folding
            // unfulfilled into the threshold is what makes "9 spots, 62
            // unfulfilled" render red even when `confirmed` itself sits
            // exactly at the cap: 71 people wanted in, 9 made it, the
            // bar should scream.
            //
            // Bar fill within the fixed width is `(confirmed + waiting) /
            // capacity` capped at 100%, so a half-full offer reads as a
            // half-filled green bar; a full offer reads as a fully filled
            // orange bar; an over-demand offer reads as a fully filled
            // red bar.
            //
            // Unlimited offers (no `maxParticipants`) have no capacity to
            // compare against — they stay green, with `inAttendance`
            // shown on the left and `∞` on the right.
            //
            // Number colouring matches the user mental model:
            //  - left  = "people in good standing" → always green when > 0
            //  - right = "the cap" → coloured by bar state (it's the same
            //    threshold being crossed)
            //
            // Gated on `requiresApplication and onlineApplication` because
            // offers that don't accept applications have no attendance
            // counts worth showing.
            'aside' => <<<'TWIG'
                {% if item.requiresApplication and item.onlineApplication %}
                    {% set confirmed = item.attendancesConfirmed|length %}
                    {% set waiting = item.attendancesWaiting|length %}
                    {% set waitlisted = item.attendancesWaitlisted|length %}
                    {% set unfulfilled = item.attendancesWithStatuses(["unfulfilled"])|length %}
                    {% set inAttendance = confirmed + waiting %}
                    {% set capacity = item.maxParticipants %}
                    {# Tri-state colour: green = room, orange = at cap, red = over.
                       Compared against `confirmed + unfulfilled` so a flood of
                       rejected applicants paints the bar red even when the
                       confirmed count itself fits the cap exactly. #}
                    {% set demand = confirmed + unfulfilled %}
                    {% set state = 'green' %}
                    {% if capacity %}
                        {% if demand > capacity %}
                            {% set state = 'red' %}
                        {% elseif demand == capacity %}
                            {% set state = 'orange' %}
                        {% endif %}
                    {% endif %}
                    {% set barClass = {
                        'green': 'bg-green-600 dark:bg-green-600/70',
                        'orange': 'bg-orange-500 dark:bg-orange-500/70',
                        'red': 'bg-red-600 dark:bg-red-600/70',
                    }[state] %}
                    {% set capClass = {
                        'green': 'text-green-600',
                        'orange': 'text-orange-500',
                        'red': 'text-red-600',
                    }[state] %}
                    {# Bar fill: (confirmed + waiting) / capacity, capped at 100%.
                       Unlimited offers fall back to a full bar — nothing
                       to compare against, but the green colour reads as
                       "all good" which matches the semantics. #}
                    {% set fillPct = capacity ? min(100, inAttendance / capacity * 100) : (inAttendance ? 100 : 0) %}
                    <a href="{{ path("admin_offers_participants", { uuid: item.uuid.toBase58 }) }}"
                       data-turbo-frame="slideover"
                       title="zugesagt: {{ confirmed }}, wartend: {{ waiting }}, Warteliste: {{ waitlisted }}{% if unfulfilled %}, ohne Platz: {{ unfulfilled }}{% endif %} · Kapazität: {{ item.maxParticipants|default('∞') }}"
                       class="flex items-center space-x-2 shrink-0 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md px-1.5 py-1 -mx-1.5 -my-1 transition-colors">
                        {% if inAttendance %}
                            <span class="text-green-600 font-medium text-base">{{ inAttendance }}</span>
                        {% else %}
                            <span class="text-gray-500 font-medium text-base">0</span>
                        {% endif %}
                        <div class="w-24 shrink-0 h-1.5 rounded-full overflow-hidden bg-gray-200 dark:bg-gray-200/50">
                            {% if fillPct > 0 %}
                                <div class="{{ barClass }} h-1.5" style="width: {{ fillPct }}%"></div>
                            {% endif %}
                        </div>
                        <span class="{{ capClass }} font-medium text-base">{{ item.maxParticipants|default('∞') }}</span>
                    </a>
                {% endif %}
                TWIG,
            'columns' => [
                'name' => <<<'TWIG'
                    <a href="{{ path("admin_offers_show", { uuid: item.uuid.toBase58 }) }}" class="inline-flex items-center gap-1 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100 rounded-md px-1.5 py-1 -mx-1.5 -my-1 transition-colors" data-turbo-frame="slideover">
                        {{ include("@FerienpassAdmin/page/offers/_status_icon.html.twig", { offer: item }) }}
                        <span class="{{ item.cancelled ? "line-through" : "" }} font-medium text-gray-900 dark:text-gray-100">{{ item.name }}</span>

                        {% if (item.isVariant|default or item.hasVariants) and item.variantNumber is defined %}
                            {{ component("Admin:Badge", { color: item.isVariantBase ? "yellow" : "gray", class: "ml-1", content: item.variantNumber|default("") }) }}
                        {% endif %}

                        {% if item.collectDateOfBirth %}
                            <span title="Geburtsdatum wird für Veranstalter miterhoben" class="ml-1 text-orange-600 w-4 h-4">{{ ux_icon('ph:shield-warning') }}</span>
                        {% endif %}
                    </a>
                    TWIG,
                // Host + edition columns share their chip partials with
                // the list view's info-row hosts and title-strip edition,
                // so a host-name click does the exact same thing in both
                // layouts (view in slideover · filter by host) — same for
                // edition.
                'hosts' => $showHosts ? <<<'TWIG'
                    {% for host in item.hosts %}
                        {{ include("@FerienpassAdmin/page/offers/_host_chip.html.twig", { host: host }) }}{% if not loop.last %},{% endif %}
                    {% endfor %}
                    TWIG : null,
                // Table view's edition column — the column header already
                // labels the cell as "Edition", so we render the chip in
                // the plain hover variant (matches the host column's
                // styling). The badge variant is for the list view's
                // title strip where the chip needs to stand out.
                'edition' => $showEditionColumn ? <<<'TWIG'
                    {% if item.edition %}
                        {{ include("@FerienpassAdmin/page/offers/_edition_chip.html.twig", { edition: item.edition, variant: 'plain' }) }}
                    {% endif %}
                    TWIG : null,
                'date' => <<<'TWIG'
                    {% if item.dates is not empty %}
                        {% set startDate = item.dates|first.begin %}
                        {% if startDate %}
                            <time datetime="{{ startDate|date('c') }}">{{ startDate|format_datetime(pattern="E, dd.MM.yyyy HH:mm") }}</time>
                        {% else %}
                            <span class="inline-flex items-center text-orange-600">
                                {{ ux_icon('ph:warning', {class: 'mr-1 size-3.5'}) }}
                                <span>Termin fehlt</span>
                            </span>
                        {% endif %}
                    {% else %}
                        <span>Dauerangebot</span>
                    {% endif %}
                    TWIG,
                'attendances' => <<<'TWIG'
                    {% if item.requiresApplication and item.onlineApplication %}
                        <a href="{{ path("admin_offers_participants", { uuid: item.uuid.toBase58 }) }}" data-turbo-frame="slideover" class="hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100 rounded-md px-1.5 py-1 -mx-1.5 -my-1 transition-colors flex items-center space-x-1" title="aktuelle Anmeldungen">
                            <span class="{{ (item.maxParticipants and item.attendancesConfirmed|length >= item.maxParticipants) ? 'text-red-700' : 'text-green-700' }}">
                                <svg viewBox="0 0 2 2" class="h-2 w-2 fill-current">
                                    <circle cx="1" cy="1" r="1"/>
                                </svg>
                            </span>

                            <span>
                                {{ item.attendancesWithStatuses(["confirmed", "waiting", "waitlisted"])|length ~ ' / ' ~ (item.maxParticipants|default('∞')) ~ '' }}
                            </span>

                            {% if not item.cancelled and (item.attendancesWaiting|length or (item.maxParticipants and item.attendancesConfirmed|length < item.maxParticipants and item.attendancesWaitlisted|length)) %}
                                <span title="nicht-zugeteilte Teilnehmende" class="mt-0.5 text-orange-600 w-4 h-4">{{ ux_icon('ph:warning') }}</span>
                            {% endif %}
                        </a>
                    {% endif %}
                    TWIG,
            ],
        ];
    }
}
