<?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 Ferienpass\AdminBundle\Breadcrumb\Breadcrumb;
use Ferienpass\AdminBundle\Controller\TagsResponses;
use Ferienpass\AdminBundle\Export\ExportLink;
use Ferienpass\AdminBundle\Export\ExportQueryBuilderInterface;
use Ferienpass\AdminBundle\Form\Filter\DebtorFilter;
use Ferienpass\AdminBundle\Form\Filter\OverpaidPaymentsFilter;
use Ferienpass\AdminBundle\Form\Filter\PaymentsFilter;
use Ferienpass\AdminBundle\Form\Filter\UnpaidPaymentsFilter;
use Ferienpass\AdminBundle\List\DebtorsList;
use Ferienpass\AdminBundle\List\ListCriteriaApplier;
use Ferienpass\AdminBundle\LiveComponent\MultiSelect;
use Ferienpass\AdminBundle\LiveComponent\MultiSelectAction;
use Ferienpass\AdminBundle\LiveComponent\MultiSelectHandlerInterface;
use Ferienpass\AdminBundle\Routing\Base58;
use Ferienpass\CoreBundle\Entity\Edition;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\Enum\PaymentStatus;
use Ferienpass\CoreBundle\Export\Payments\CashReportPdfExport;
use Ferienpass\CoreBundle\Export\Payments\ReceiptPdfExport;
use Ferienpass\CoreBundle\Facade\PaymentsFacade;
use Ferienpass\CoreBundle\HttpCache\CacheTag;
use Ferienpass\CoreBundle\Repository\AttendanceRepository;
use Ferienpass\CoreBundle\Repository\BookEntryRepository;
use Ferienpass\CoreBundle\Repository\DebtorRepository;
use Ferienpass\CoreBundle\Repository\EditionRepository;
use Ferienpass\CoreBundle\Repository\PaymentRepository;
use Psr\EventDispatcher\EventDispatcherInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\AutowireLocator;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;

#[IsGranted('ROLE_ADMIN')]
#[Route('/zahlungen', defaults: ['_requires_payments' => true])]
final class PaymentsController extends AbstractController implements MultiSelectHandlerInterface
{
    use TagsResponses;

    public function __construct(private readonly ReceiptPdfExport $receiptExport, #[AutowireLocator(ExportQueryBuilderInterface::class, defaultIndexMethod: 'getFormat')] private readonly ServiceLocator $exporters)
    {
    }

    #[Route('/belege/{edition?}', name: 'admin_payments_receipts', priority: -2, requirements: ['edition' => '[a-zA-Z0-9_\-+]+'])]
    public function index(?string $edition, PaymentRepository $repository, Breadcrumb $breadcrumb, EditionRepository $editions): Response
    {
        [$selectedEditions, $allEditions] = $this->resolveEditions($edition, $editions);

        $qb = $repository->createQueryBuilder('i')
            // Solve N+1: the row template renders the payer, the actions menu
            // resolves the debtor, and an account drags its inverse one-to-ones
            // (which Doctrine cannot proxy) along with it.
            ->leftJoin('i.user', 'u')
            ->addSelect('u')
            ->leftJoin('u.blockedContact', 'ubc')
            ->addSelect('ubc')
            ->leftJoin('u.debtor', 'ud')
            ->addSelect('ud')
            ->leftJoin('i.debtor', 'd')
            ->addSelect('d')
            ->where('i.status <> :status_abandoned')
            ->setParameter('status_abandoned', PaymentStatus::ABANDONED)
            ->orderBy('i.createdAt', 'DESC')
        ;

        if ($selectedEditions) {
            $qb
                ->innerJoin('i.items', 'pi_edition')
                ->innerJoin('pi_edition.attendance', 'a_edition')
                ->innerJoin('a_edition.offer', 'o_edition')
                ->andWhere('o_edition.edition IN (:selectedEditions)')
                ->setParameter('selectedEditions', $selectedEditions)
            ;
        }

        return $this->render('@FerienpassAdmin/page/payments/index.html.twig', [
            'qb' => $qb,
            'filterType' => PaymentsFilter::class,
            'exports' => [
                ...ExportLink::formats(array_keys($this->exporters->getProvidedServices()), 'admin_payments_export'),
                ExportLink::to('payments.cashReport', 'admin_payments_kassenbericht'),
            ],
            'searchable' => ['billingAddress', 'billingEmail', 'receiptNumber'],
            'editions' => $allEditions,
            'selectedEditions' => $selectedEditions,
            'config' => $this->receiptsListConfig(),
            'breadcrumb' => $breadcrumb->generate(['payments.title', ['route' => 'admin_payments_receipts']]),
        ]);
    }

    #[Route('/debitoren/{edition?}', name: 'admin_payments_debtors', priority: -2, requirements: ['edition' => '[a-zA-Z0-9_\-+]+'])]
    public function debtors(?string $edition, BookEntryRepository $bookEntryRepository, DebtorRepository $debtorsRepository, Breadcrumb $breadcrumb, EventDispatcherInterface $dispatcher, EditionRepository $editions, AttendanceRepository $attendanceRepository, DebtorsList $debtorsList): Response
    {
        [$selectedEditions, $allEditions] = $this->resolveEditions($edition, $editions);

        $sumByDay = $bookEntryRepository->createQueryBuilder('entry')
            ->select('SUM(entry.value) as sum')
            ->addSelect("DATE_FORMAT(entry.bookingDate, '%Y-%m-%d') AS date")
            ->where('entry.type = :type')
            ->setParameter('type', 'payment')
            ->groupBy('date')
            ->orderBy('date', 'ASC')
            ->getQuery()
            ->getResult()
        ;

        $chart = [];
        foreach (new \DatePeriod(new \DateTime('-1 year'), new \DateInterval('P1D'), new \DateTime('tomorrow')) as $date) {
            $chart['labels'][] = $date->format('d.m.Y');
            $entry = array_find($sumByDay, static fn (array $v) => $v['date'] === $date->format('Y-m-d'));
            $chart['values'][] = $entry ? (int) ($entry['sum']) / 100 : 0;
        }

        $qb = $debtorsRepository->createQueryBuilder('i')
            ->leftJoin('i.user', 'user')
        ;

        // Solve N+1
        $qb
            ->addSelect('user')
            // Inverse one-to-one — Doctrine cannot proxy it, so it fetches one
            // per hydrated account otherwise.
            ->leftJoin('user.blockedContact', 'blockedContact')
            ->addSelect('blockedContact')
            ->leftJoin('user.participants', 'participant')
            ->addSelect('participant')
            ->leftJoin('participant.attendances', 'attendance')
            ->addSelect('attendance')
            ->leftJoin('i.bookEntries', 'entry')
            ->addSelect('entry')
        ;

        if ($selectedEditions) {
            // Restrict *which* debtors are listed to those with an attendance in
            // the selected edition(s) — but via an EXISTS subquery, NOT by
            // joining+filtering the fetch-joined `attendance` collection. The
            // latter would partially hydrate `participant.attendances`, so the
            // entity's getDueSum()/getBalance() could no longer report the full
            // (unscoped) figure shown below the scoped value in the columns.
            $exists = $attendanceRepository->createQueryBuilder('att_e')
                ->select('1')
                ->innerJoin('att_e.participant', 'p_e')
                ->innerJoin('att_e.offer', 'o_e')
                ->andWhere('p_e.user = user')
                ->andWhere('o_e.edition IN (:selectedEditions)')
                ->getDQL()
            ;

            $qb
                ->andWhere($qb->expr()->exists($exists))
                ->setParameter('selectedEditions', $selectedEditions)
            ;
        }

        return $this->render('@FerienpassAdmin/page/payments/debtors.html.twig', [
            'qb' => $qb,
            'filterType' => DebtorFilter::class,
            'searchable' => ['user.firstname', 'user.lastname'],
            'chart' => $chart,
            'dispatcher' => $dispatcher,
            'editions' => $allEditions,
            'selectedEditions' => $selectedEditions,
            'config' => $debtorsList->config(),
            'breadcrumb' => $breadcrumb->generate(['payments.title', ['route' => 'admin_payments_receipts']], 'payments.debtors'),
        ]);
    }

    #[Route('/ausstehend/{edition?}', name: 'admin_payments_unpaid', priority: -2, requirements: ['edition' => '[a-zA-Z0-9_\-+]+'])]
    public function unpaid(?string $edition, AttendanceRepository $attendances, Breadcrumb $breadcrumb, EventDispatcherInterface $dispatcher, EditionRepository $editions): Response
    {
        [$selectedEditions, $allEditions] = $this->resolveEditions($edition, $editions);

        $qb = $attendances->createQueryBuilder('attendance')
            ->innerJoin('attendance.offer', 'o')
            ->innerJoin('attendance.participant', 'participant')
            ->leftJoin('attendance.activity', 'activity')
            ->leftJoin('activity.createdBy', 'activityUser')
            ->addSelect('participant')
            ->addSelect('o')
            // The status column renders the activity trail and its author, so
            // both have to be selected — joining alone still lazy-loads them
            // once per row.
            ->addSelect('activity')
            ->addSelect('activityUser')
            // Inverse one-to-ones on the author account: Doctrine cannot proxy
            // those, so every hydrated author would fetch them separately.
            ->leftJoin('activityUser.blockedContact', 'activityUserBlockedContact')
            ->addSelect('activityUserBlockedContact')
            ->leftJoin('activityUser.debtor', 'activityUserDebtor')
            ->addSelect('activityUserDebtor')
            // Offer::$dates is EAGER, so the date column costs one query per
            // offer without this.
            ->leftJoin('o.dates', 'od')
            ->addSelect('od')
            ->where('o.fee > 0')
        ;

        PaymentsFacade::addWhereLogic($qb);

        if ($selectedEditions) {
            $qb->andWhere('o.edition IN (:selectedEditions)')->setParameter('selectedEditions', $selectedEditions);
        }

        return $this->render('@FerienpassAdmin/page/payments/unpaid.html.twig', [
            'qb' => $qb,
            'filterType' => UnpaidPaymentsFilter::class,
            'searchable' => ['o.name', 'participant.firstname', 'participant.lastname'],
            'dispatcher' => $dispatcher,
            'editions' => $allEditions,
            'selectedEditions' => $selectedEditions,
            'config' => $this->unpaidListConfig(),
            'breadcrumb' => $breadcrumb->generate(['payments.title', ['route' => 'admin_payments_receipts']], 'payments.unpaid'),
        ]);
    }

    #[Route('/überzahlt/{edition?}', name: 'admin_payments_overpaid', priority: -2, requirements: ['edition' => '[a-zA-Z0-9_\-+]+'])]
    public function overpaid(?string $edition, AttendanceRepository $attendances, Breadcrumb $breadcrumb, EventDispatcherInterface $dispatcher, EditionRepository $editions): Response
    {
        [$selectedEditions, $allEditions] = $this->resolveEditions($edition, $editions);

        $qb = $attendances->createQueryBuilder('attendance')
            ->innerJoin('attendance.paymentItems', 'pi')
            ->innerJoin('pi.payment', 'p')
            ->leftJoin('attendance.offer', 'o')
            ->leftJoin('attendance.participant', 'participant')
            ->groupBy('attendance.id')
            // Solve N+1 for the offer and participant columns. `pi`/`p` stay
            // select-less on purpose: they only carry the HAVING aggregate, and
            // GROUP BY collapses them to one row per attendance — selecting
            // them would hydrate a paymentItems collection holding a single
            // item, which is exactly what the amount column reads.
            ->addSelect('o')
            ->addSelect('participant')
            ->where('p.status = :paid')
            ->setParameter('paid', PaymentStatus::PAID)
            // Two kinds of "money to hand back", discriminated in the list by
            // `attendance.payable`:
            //  - payable = 1 (fee still owed): a genuine over-payment — more
            //    positive than negative payment entries on an active attendance.
            //  - payable = 0 (fee no longer owed, e.g. the offer was cancelled):
            //    any net positive amount actually collected is refundable.
            // The `payable` field is folded into each aggregate rather than
            // referenced bare in HAVING, so this stays valid under
            // ONLY_FULL_GROUP_BY regardless of the SQL mode.
            ->having('(SUM(CASE WHEN attendance.payable = 1 AND pi.amount > 0 THEN 1 ELSE 0 END) - SUM(CASE WHEN attendance.payable = 1 AND pi.amount < 0 THEN 1 ELSE 0 END) > 2) OR SUM(CASE WHEN attendance.payable = 0 THEN pi.amount ELSE 0 END) > 0')
        ;

        PaymentsFacade::addUsePaymentsLogic($qb);

        if ($selectedEditions) {
            $qb->andWhere('o.edition IN (:selectedEditions)')->setParameter('selectedEditions', $selectedEditions);
        }

        return $this->render('@FerienpassAdmin/page/payments/overpaid.html.twig', [
            'qb' => $qb,
            'filterType' => OverpaidPaymentsFilter::class,
            'searchable' => ['o.name', 'participant.firstname', 'participant.lastname'],
            'dispatcher' => $dispatcher,
            'editions' => $allEditions,
            'selectedEditions' => $selectedEditions,
            'config' => $this->overpaidListConfig(),
            'breadcrumb' => $breadcrumb->generate(['payments.title', ['route' => 'admin_payments_receipts']], 'payments.overpaid'),
        ]);
    }

    #[Route('/debitoren/{uuid}', name: 'admin_payments_debtor', requirements: ['uuid' => Base58::UUID])]
    #[Route('/debitoren/{uuid}', name: 'admin_payments_debtor_legacy', requirements: ['uuid' => Requirement::UUID])]
    public function debtor(Uuid $uuid, DebtorRepository $debtorRepository, Breadcrumb $breadcrumb, EventDispatcherInterface $dispatcher, AttendanceRepository $attendanceRepository): Response
    {
        $debtor = $debtorRepository->findOneBy(['uuid' => $uuid]);
        if (null === $debtor) {
            throw $this->createNotFoundException();
        }

        $qb = $attendanceRepository->createQueryBuilder('attendance');
        $qb
            ->innerJoin('attendance.participant', 'participant')
            ->innerJoin('attendance.offer', 'offer')
            ->where('participant.user = :user')
            ->setParameter('user', $debtor->getUser())
        ;
        PaymentsFacade::addWhereLogic($qb);

        $ms = new MultiSelect([
            new MultiSelectAction('settle', 'admin_attendances_settle', method: 'GET', turboFrame: 'slideover'),
        ], self::class);

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

        return $this->render('@FerienpassAdmin/page/payments/debtor.html.twig', [
            'debtor' => $debtor,
            'qb' => $qb,
            'ms' => $ms,
            'dispatcher' => $dispatcher,
            'breadcrumb' => $breadcrumb->generate(['payments.title', ['route' => 'admin_payments_receipts']], ['payments.debtors', ['route' => 'admin_payments_debtors']], $debtor->getUser()?->getName() ?: 'payments.debtor_unknown'),
        ]);
    }

    public function handleMultiSelect(string $action, array $selected, Request $request): Response
    {
        throw new \RuntimeException('Code should not be reached');
    }

    #[Route('/debitoren/{uuid}/freie-buchung', name: 'admin_payments_create_booking', requirements: ['uuid' => Base58::UUID])]
    #[Route('/debitoren/{uuid}/freie-buchung', name: 'admin_payments_create_booking_legacy', requirements: ['uuid' => Requirement::UUID])]
    public function createBooking(Uuid $uuid, DebtorRepository $debtorRepository, Breadcrumb $breadcrumb): Response
    {
        $debtor = $debtorRepository->findOneBy(['uuid' => $uuid]);
        if (null === $debtor) {
            throw $this->createNotFoundException();
        }

        return $this->render('@FerienpassAdmin/page/payments/createBooking.html.twig', [
            'debtor' => $debtor,
            'breadcrumb' => $breadcrumb->generate(
                ['payments.title', ['route' => 'admin_payments_receipts']],
                ['payments.debtors', ['route' => 'admin_payments_debtors']],
                $debtor->getUser()?->getName() ?: 'payments.debtor_unknown',
                //    [$debtor->getUser()?->getName() ?: 'payments.debtor_unknown', ['route' => 'admin_payments_debtor', 'id' => $debtor->getId()]],
                'Freie Buchung'
            ),
        ]);
    }

    #[Route('/export.{format}', name: 'admin_payments_export', requirements: ['format' => '\w+'])]
    public function export(PaymentRepository $repository, string $format, Request $request, ListCriteriaApplier $filterer)
    {
        $qb = $repository->createQueryBuilder('i')
            ->where('i.status <> :status_abandoned')
            ->setParameter('status_abandoned', PaymentStatus::ABANDONED)
        ;

        $filterer->apply(
            $qb,
            PaymentsFilter::class,
            ['billingAddress', 'billingEmail', 'receiptNumber'],
            $request->query->get('q'),
            $request->query->get('sort'),
            (array) $request->query->all('filter')
        );

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

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

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

    #[Route('/kassenbericht.pdf', name: 'admin_payments_kassenbericht')]
    public function cashReport(PaymentRepository $repository, Request $request, ListCriteriaApplier $filterer, CashReportPdfExport $export): Response
    {
        // Only money actually collected: paid receipts. The same filters as the
        // receipts list apply (date range, search, …) so the report mirrors what
        // the user sees on the page.
        $qb = $repository->createQueryBuilder('i')
            ->where('i.status = :status_paid')
            ->setParameter('status_paid', PaymentStatus::PAID)
            ->orderBy('i.createdAt', 'ASC')
        ;

        $filterer->apply(
            $qb,
            PaymentsFilter::class,
            ['billingAddress', 'billingEmail', 'receiptNumber'],
            $request->query->get('q'),
            $request->query->get('sort'),
            (array) $request->query->all('filter')
        );

        $path = $export->generate($qb->getQuery()->getResult(), $this->cashReportFilterInfo($request));

        return $this->file($path, 'kassenbericht.pdf');
    }

    #[Route('/belege/{uuid}', name: 'admin_payments_receipt', requirements: ['uuid' => Base58::UUID])]
    #[Route('/belege/{uuid}', name: 'admin_payments_receipt_legacy', requirements: ['uuid' => Requirement::UUID])]
    public function show(Uuid $uuid, PaymentRepository $paymentRepository, Breadcrumb $breadcrumb): Response
    {
        $payment = $paymentRepository->findOneBy(['uuid' => $uuid]);
        if (null === $payment) {
            throw $this->createNotFoundException();
        }

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

        $response = $this->render('@FerienpassAdmin/page/payments/receipt.html.twig', [
            'payment' => $payment,
            'breadcrumb' => $breadcrumb->generate(['payments.title', ['route' => 'admin_payments_receipts']], 'Beleg #'.$payment->getReceiptNumber()),
        ]);

        // A not-yet-finalized payment's pmPayment transaction status/description
        // can still change on the daily pmpayment:fetch-status fetch WITHOUT a
        // Payment transition — so the `payment.<uuid>` tag isn't purged (see
        // CacheInvalidationListener). Cap the shared TTL to bound that staleness;
        // once finalized the receipt is immutable and keeps the default /admin
        // s-maxage (1 year). The /admin fos_http_cache rule is `overwrite: false`,
        // so this lower value is preserved rather than re-stamped (same pattern as
        // FollowInvitationController). List pages are unaffected.
        if (!$payment->isFinalized()) {
            $response->setPublic();
            $response->setSharedMaxAge(900);
        }

        return $response;
    }

    #[Route('/{uuid}/storno', name: 'admin_payments_reverse', requirements: ['uuid' => Base58::UUID])]
    #[Route('/{uuid}/storno', name: 'admin_payments_reverse_legacy', requirements: ['uuid' => Requirement::UUID])]
    public function reverse(Uuid $uuid, PaymentRepository $paymentRepository, Request $request, Breadcrumb $breadcrumb): Response
    {
        $payment = $paymentRepository->findOneBy(['uuid' => $uuid]);
        if (null === $payment) {
            throw $this->createNotFoundException();
        }

        if ($payment->isReversal()) {
            throw new NotFoundHttpException('Payment exists, but is a reversal payment that cannot be reversed.');
        }

        return $this->render('@FerienpassAdmin/page/payments/reverse.html.twig', [
            'payment' => $payment,
            'items' => (array) $request->query->all('items'),
            'breadcrumb' => $breadcrumb->generate(['payments.title', ['route' => 'admin_payments_receipts']], 'Beleg #'.$payment->getReceiptNumber(), 'Storno'),
        ]);
    }

    #[Route('/{uuid}/anpassen', name: 'admin_payments_adjust', requirements: ['uuid' => Base58::UUID])]
    #[Route('/{uuid}/anpassen', name: 'admin_payments_adjust_legacy', requirements: ['uuid' => Requirement::UUID])]
    public function adjust(Uuid $uuid, PaymentRepository $paymentRepository, Breadcrumb $breadcrumb): Response
    {
        $payment = $paymentRepository->findOneBy(['uuid' => $uuid]);
        if (null === $payment) {
            throw $this->createNotFoundException();
        }

        // Adjusting means "storno + re-bill", so it only applies to a paid cash
        // receipt that isn't itself a reversal and hasn't been reversed yet.
        if (!$payment->canBeAdjusted()) {
            throw new NotFoundHttpException('Payment exists, but cannot be adjusted.');
        }

        return $this->render('@FerienpassAdmin/page/payments/adjust.html.twig', [
            'payment' => $payment,
            'breadcrumb' => $breadcrumb->generate(['payments.title', ['route' => 'admin_payments_receipts']], 'Beleg #'.$payment->getReceiptNumber(), 'Anpassen'),
        ]);
    }

    #[Route('/belege/{uuid}.pdf', name: 'admin_payments_receipt_pdf', requirements: ['uuid' => Base58::UUID])]
    #[Route('/belege/{uuid}.pdf', name: 'admin_payments_receipt_pdf_legacy', requirements: ['uuid' => Requirement::UUID])]
    public function pdf(Uuid $uuid, PaymentRepository $paymentRepository): Response
    {
        $payment = $paymentRepository->findOneBy(['uuid' => $uuid]);
        if (null === $payment) {
            throw $this->createNotFoundException();
        }

        $path = $this->receiptExport->generate($payment);

        return $this->file($path, \sprintf('beleg-%s.pdf', $payment->getId()));
    }

    /**
     * Build the ordered "label => value" lines printed at the top of the
     * Kassenbericht so the document records which filters produced it.
     *
     * @return array<string, string>
     */
    private function cashReportFilterInfo(Request $request): array
    {
        $filter = (array) $request->query->all('filter');

        $format = static fn (?string $date): ?string => $date ? (date_create($date)?->format('d.m.Y') ?: $date) : null;
        $from = $format($filter['from'] ?? null);
        $until = $format($filter['until'] ?? null);

        $info = [];
        $info['Berichtszeitraum'] = match (true) {
            null !== $from && null !== $until => \sprintf('%s – %s', $from, $until),
            null !== $from => \sprintf('ab %s', $from),
            null !== $until => \sprintf('bis %s', $until),
            default => 'gesamter Zeitraum',
        };

        if ('' !== ($q = (string) $request->query->get('q', ''))) {
            $info['Suche'] = $q;
        }

        $info['Belege'] = 'nur bezahlte Belege';

        return $info;
    }

    private function receiptsListConfig(): array
    {
        return [
            'squeezedColumns' => ['user'],
            'title' => <<<'TWIG'
                <span class="text-gray-700 dark:text-gray-400 font-normal mr-1">{{ item.createdAt|default ? item.createdAt|date("d.m.Y") : "" }}</span>
                {{ item.billingAddress|split("\n")|first }}
                {{ component("Admin:Badge", { payment: item.status, class: "ml-2", dot: true, boxed: true }) }}
                TWIG,
            'info' => [
                '{{ item.receiptNumber|default("") }}',
            ],
            'aside' => '<div class="text-teal-700 dark:text-teal-400 font-semibold leading-6 shrink-0">{{ ((item.totalAmount|default(0))/100)|format_currency("EUR", locale="de") }}</div>',
            'columns' => [
                'receiptNumber' => '<a href="{{ path("admin_payments_receipt", { uuid: item.uuid.toBase58 }) }}" data-turbo-frame="slideover" class="hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md px-1 py-0.5 -mx-1 -my-0.5 transition-colors">{{ item.receiptNumber|default("") }}</a>',
                'billingName' => '{{ item.billingAddress|default("")|split("\n")|first }}',
                'date' => '{{ item.createdAt|default ? item.createdAt|date("d.m.Y H:i") : "" }}',
                'amount' => '{{ ((item.totalAmount|default(0))/100)|format_currency("EUR", locale="de") }}',
                'user' => <<<'TWIG'
                    {% if item.user|default %}
                    {% set avatarBg = item.user.isAdmin ? 'bg-red-500' : (item.user.isHost ? 'bg-blue-500' : 'bg-green-500') %}
                    <span class="inline-flex items-center justify-center h-6 w-6 rounded-full {{ avatarBg }}">
                        <span class="text-xs font-medium leading-none text-white">{{ item.user.firstname|first|upper ~ item.user.lastname|first|upper }}</span>
                    </span>
                    <span>{{ item.user.firstname ~ " " ~ item.user.lastname }}</span>
                    {% else %}-{% endif %}
                    TWIG,
                'status' => '{{ component("Admin:Badge", { payment: item.status, size: "sm", class: "-ml-2", dot: true }) }}',
            ],
        ];
    }

    /**
     * Marks an attendance whose offer has been cancelled. Both money views can
     * list such an attendance — the fee stays owed (or was already collected)
     * until someone settles or refunds it — so the cancellation has to be
     * readable here and not only on the offer itself. Mirrors the house
     * presentation elsewhere: struck-through name plus a red badge.
     */
    private function cancelledOfferMarkup(): array
    {
        return [
            'class' => '{{ item.offer.cancelled|default(false) ? "line-through" : "" }}',
            'badge' => <<<'TWIG'
                {% if item.offer.cancelled|default(false) %}{{ component("Admin:Badge", { offer: "cancelled", size: "xs", dot: true, boxed: true }) }}{% endif %}
                TWIG,
        ];
    }

    private function unpaidListConfig(): array
    {
        ['class' => $cancelledClass, 'badge' => $cancelledBadge] = $this->cancelledOfferMarkup();

        return [
            'title' => <<<'TWIG'
                <div class="flex items-center space-x-2">
                {% if item.participant|default %}
                <span class="inline-flex items-center justify-center h-6 w-6 rounded-full bg-gray-400 shrink-0">
                    <span class="text-xs font-medium leading-none text-white">{{ item.participant.firstname|first|upper ~ item.participant.lastname|first|upper }}</span>
                </span>
                {% endif %}
                <span>{{ item.participant.name|default("") }}</span>
                </div>
                TWIG,
            'info' => [
                <<<TWIG
                    <span class="$cancelledClass">{{ item.offer.name }}</span>
                    TWIG,
                // Own chip rather than appended to the name: each info entry is
                // truncated on its own, and an empty one is dropped.
                $cancelledBadge,
                '{{ (item.offer.getFeePayable(item.participant, dispatcher)/100)|format_currency("EUR", locale="de") }}',
            ],
            'columns' => [
                'participant' => <<<'TWIG'
                    <div class="flex items-center space-x-2">
                    {% if item.participant|default %}
                    <span class="inline-flex items-center justify-center h-6 w-6 rounded-full bg-gray-400 shrink-0">
                        <span class="text-xs font-medium leading-none text-white">{{ item.participant.firstname|first|upper ~ item.participant.lastname|first|upper }}</span>
                    </span>
                    {% endif %}
                    {% if item.participant|default %}<a href="{{ path('admin_participants_show', { uuid: item.participant.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">{{ item.participant.name }}</a>{% endif %}
                    </div>
                    TWIG,
                'offer' => <<<TWIG
                    <p class="font-medium text-gray-900 dark:text-gray-100">
                        <a href="{{ path('admin_offers_show', { uuid: item.offer.uuid.toBase58 }) }}" data-turbo-frame="slideover" class="$cancelledClass 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">{{ item.offer.name|default("") }}</a>
                        {{ component("Admin:Badge", { attendance: item.status, size: "xs", class: "ml-1", dot: true, boxed: true }) }}
                        $cancelledBadge
                    </p>
                    <p>
                        {% if item.offer.dates|first.begin %}
                            {{ item.offer.dates|first.begin|format_datetime(pattern="E, dd.MM.yyyy HH:mm") }}
                        {% else %}
                            <span class="inline-flex items-center text-orange-600">
                                {{ ux_icon('ph:warning', {class: 'mr-1 size-4'}) }}
                                <span>Termin fehlt</span>
                            </span>
                        {% endif %}
                    </p>
                    {% if item.confirmed %}
                        {% set log = item.activity|filter(a => a.transitionName == "create")|last %}
                        {% if log is not null %}
                            <p class="text-xs text-gray-500">angemeldet am {{ log.createdAt|default ? log.createdAt|format_datetime(pattern="dd.MM HH:mm") : "" }}{% if log.createdBy.name|default %} von {{ log.createdBy.name }}{% endif %}</p>
                        {% endif %}
                        {% set log = item.activity|filter(a => a.transitionName == "confirm")|last %}
                        {% if log is not null %}
                            <p class="text-xs text-gray-500">bestätigt am {{ log.createdAt|default ? log.createdAt|format_datetime(pattern="dd.MM HH:mm") : "" }}{% if log.createdBy.name|default %} von {{ log.createdBy.name }}{% endif %}</p>
                        {% endif %}
                    {% endif %}
                    {% if item.withdrawn %}
                        {% set log = item.activity|filter(a => a.transitionName == "withdraw")|last %}
                        {% if log is not null %}
                            <p class="text-xs text-gray-500">abgemeldet am {{ log.createdAt|default ? log.createdAt|format_datetime(pattern="dd.MM HH:mm") : "" }}{% if log.createdBy.name|default %} von {{ log.createdBy.name }}{% endif %}</p>
                        {% endif %}
                    {% endif %}
                    TWIG,
                'amount' => '<span class="font-medium block text-red-600">{{ (item.offer.getFeePayable(item.participant, dispatcher)/100)|format_currency("EUR", locale="de") }}</span>',
            ],
        ];
    }

    private function overpaidListConfig(): array
    {
        // Discriminator badge: payable=1 → genuine over-payment, payable=0 →
        // fee no longer owed (e.g. cancelled offer) so the collected money is
        // refundable. Reused in the title and in the dedicated `type` column.
        $typeBadge = <<<'TWIG'
            {{ component("Admin:Badge", { color: item.payable ? "orange" : "red", content: (item.payable ? "payments.overpaid.type.overpaid" : "payments.overpaid.type.refundable")|trans({}, "admin"), size: "xs", boxed: true, dot: true }) }}
            TWIG;

        ['class' => $cancelledClass, 'badge' => $cancelledBadge] = $this->cancelledOfferMarkup();

        return [
            'title' => <<<TWIG
                <div class="flex items-center space-x-2">
                {% if item.participant|default %}
                <span class="inline-flex items-center justify-center h-6 w-6 rounded-full bg-gray-400 shrink-0">
                    <span class="text-xs font-medium leading-none text-white">{{ item.participant.firstname|first|upper ~ item.participant.lastname|first|upper }}</span>
                </span>
                {% endif %}
                <span>{{ item.participant.name|default("") }}</span>
                $typeBadge
                </div>
                TWIG,
            'info' => [
                <<<TWIG
                    <span class="$cancelledClass">{{ item.offer.name }}</span>
                    TWIG,
                $cancelledBadge,
                '{{ item.offer.dates|first.begin ? (item.offer.dates|first.begin)|format_datetime(pattern="E, dd.MM.yyyy HH:mm") : ("<span class=\"inline-flex items-center text-orange-600\">" ~ ux_icon("ph:warning", {class: "mr-1 size-4"}) ~ "<span>Termin fehlt</span></span>")|raw }}',
            ],
            'columns' => [
                'participant' => <<<'TWIG'
                    <div class="flex items-center space-x-2">
                    {% if item.participant|default %}
                    <span class="inline-flex items-center justify-center h-6 w-6 rounded-full bg-gray-400 shrink-0">
                        <span class="text-xs font-medium leading-none text-white">{{ item.participant.firstname|first|upper ~ item.participant.lastname|first|upper }}</span>
                    </span>
                    {% endif %}
                    <span>{{ item.participant.name|default("") }}</span>
                    </div>
                    TWIG,
                'type' => $typeBadge,
                'offer' => <<<TWIG
                    <p class="font-medium text-gray-900">
                        <span class="$cancelledClass">{{ item.offer.name|default("") }}</span>
                        {{ component("Admin:Badge", { attendance: item.status, size: "xs", class: "ml-1" }) }}
                        $cancelledBadge
                    </p>
                    <p>
                        {% if item.offer.dates|first.begin %}
                            {{ item.offer.dates|first.begin|format_datetime(pattern="E, dd.MM.yyyy HH:mm") }}
                        {% else %}
                            <span class="inline-flex items-center text-orange-600">
                                {{ ux_icon('ph:warning', {class: 'mr-1 size-4'}) }}
                                <span>Termin fehlt</span>
                            </span>
                        {% endif %}
                    </p>
                    TWIG,
                'payments' => <<<'TWIG'
                    {% for pi in item.paymentItems|filter(i => i.payment.paid) %}
                        <p class="whitespace-nowrap">
                            <a target="_blank" href="{{ path('admin_payments_receipt', { uuid: pi.payment.uuid.toBase58 }) }}" class="text-gray-600 hover:text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100 rounded-md px-1 py-0.5 -mx-1 -my-0.5 transition-colors">#{{ pi.payment.receiptNumber }}</a>
                            <span class="{{ pi.amount >= 0 ? 'text-green-600' : 'text-red-600' }} font-medium">{{ (pi.amount/100)|format_currency("EUR", locale="de") }}</span>
                        </p>
                    {% endfor %}
                    TWIG,
            ],
        ];
    }

    /**
     * Parse the optional `{edition?}` route segment into a list of selected
     * Edition entities, and return the full list of editions visible to the
     * current user. 404 if any of the requested aliases doesn't exist.
     *
     * @return array{0: Edition[], 1: Edition[]}
     */
    private function resolveEditions(?string $edition, EditionRepository $editions): array
    {
        $aliases = $edition ? array_values(array_filter(explode('+', $edition))) : [];
        $selectedEditions = $aliases ? $editions->findBy(['alias' => $aliases]) : [];
        if ($aliases && \count($selectedEditions) !== \count($aliases)) {
            throw $this->createNotFoundException();
        }

        /** @var User $user */
        $user = $this->getUser();
        $allEditions = $editions->findBy([], ['createdAt' => 'DESC']);
        if (!$this->isGranted('ROLE_ADMIN')) {
            $userHost = $user->getHosts()->first() ?: null;
            $allEditions = array_values(array_filter($allEditions, static fn (Edition $e) => $e->getHosts()->isEmpty() || ($userHost && $e->getHosts()->contains($userHost))));
        }

        return [$selectedEditions, $allEditions];
    }
}
