<?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\Components;

use Doctrine\ORM\EntityManagerInterface;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\Participant\ParticipantInterface;
use Ferienpass\CoreBundle\Entity\Payment as PaymentEntity;
use Ferienpass\CoreBundle\Entity\PaymentItem;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\Enum\PaymentStatus;
use Ferienpass\CoreBundle\Facade\PaymentsFacade;
use Ferienpass\CoreBundle\Payments\Provider\PaymentProviderInterface;
use Ferienpass\CoreBundle\Repository\AttendanceRepository;
use Ferienpass\CoreBundle\Repository\PaymentRepository;
use Ferienpass\CoreBundle\Settings\SystemSettingsManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Session\Session;
use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Workflow\WorkflowInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\Attribute\LiveListener;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\ComponentToolsTrait;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\LiveComponent\Metadata\UrlMapping;
use Symfony\UX\LiveComponent\ValidatableComponentTrait;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;

#[AsLiveComponent(template: '@Contao/components/payment.html.twig', route: 'live_component_cms')]
class Payment extends AbstractController
{
    use ComponentToolsTrait;
    use DefaultActionTrait;
    use ValidatableComponentTrait;

    #[LiveProp(writable: true, url: new UrlMapping(as: 'step'))]
    public ?string $step = null;

    #[LiveProp(writable: true)]
    public array $selectedItems = [];

    public function __construct(private readonly AttendanceRepository $attendances, private readonly PaymentRepository $payments, private readonly PaymentProviderInterface $paymentProvider, private readonly SystemSettingsManager $systemSettingsManager, private readonly EventDispatcherInterface $dispatcher, private readonly PaymentsFacade $paymentsFacade, private readonly LockFactory $lockFactory)
    {
    }

    #[ExposeInTemplate]
    public function attendancesDue(): array
    {
        $user = $this->getUser();
        if (!$user instanceof User) {
            return [];
        }

        return $this->paymentsFacade->attendancesDue($user);
    }

    #[ExposeInTemplate]
    public function debtorBalance(): int
    {
        $user = $this->getUser();
        if (!$user instanceof User) {
            return 0;
        }

        return $user->getDebtor()->getBalance() ?? 0;
    }

    #[ExposeInTemplate]
    public function requiresPayment(): bool
    {
        return [] === $this->attendancesNonPayable();
    }

    #[ExposeInTemplate]
    public function isConfigured(): bool
    {
        return $this->paymentProvider->isConfigured();
    }

    #[ExposeInTemplate]
    public function isSelectable(): bool
    {
        return $this->systemSettingsManager->isPaymentAllowSelect();
    }

    #[ExposeInTemplate]
    public function dispatcher(): EventDispatcherInterface
    {
        return $this->dispatcher;
    }

    public function participants(): array
    {
        $participants = array_map(static fn (Attendance $a) => $a->getParticipant(), $this->attendancesDue());

        return array_values(array_intersect_key($participants, array_unique(array_map(static fn (ParticipantInterface $p) => $p->getId(), $participants))));
    }

    #[ExposeInTemplate]
    public function attendancesForPayment(): array
    {
        if (!$this->isSelectable()) {
            return $this->attendancesDue();
        }

        $user = $this->getUser();

        if ([] === $this->selectedItems) {
            return [];
        }

        $qb = $this->attendances->createQueryBuilder('attendance')
            ->select('attendance', 'participant')
            ->innerJoin('attendance.participant', 'participant')
            ->where('participant.user = :user')
            ->setParameter('user', $user->getId())
            ->andWhere('attendance.id IN (:ids)')
            ->setParameter('ids', $this->selectedItems)
        ;

        // Only attendances that are genuinely due (unpaid AND payable) may be
        // paid; mirror PaymentsFacade::addWhereLogic so the client cannot inject
        // already-paid or non-payable attendance ids into the payable set.
        PaymentsFacade::addWhereLogic($qb);

        return $qb
            ->getQuery()
            ->getResult()
        ;
    }

    /**
     * The part of the debtor's credit that this bill actually consumes. Clamped
     * to the bill so an over-balance cannot drive the amount below zero — the
     * same invariant {@see redirectToPay()} and the admin-side SettleAttendances
     * enforce when they add the discount line.
     */
    #[ExposeInTemplate]
    public function creditApplied(): int
    {
        return min(max(0, $this->debtorBalance()), $this->feeSum());
    }

    #[ExposeInTemplate]
    public function totalAmount(): int
    {
        return $this->feeSum() - $this->creditApplied();
    }

    #[LiveListener('proceedTo')]
    public function proceedTo(#[LiveArg] string $step): void
    {
        $this->step = $step;
    }

    #[LiveAction]
    public function redirectToPay(#[CurrentUser] User $user, EntityManagerInterface $entityManager, Session $session): RedirectResponse
    {
        // $this->validate();

        $payableAttendances = $this->attendancesForPayment();

        $user->createDebtor();
        $debtor = $user->getDebtor();

        // Serialize payment creation per debtor so concurrent requests cannot
        // create duplicate Payment rows (and double-charge) for the same
        // attendances. Blocking acquire mirrors the lottery lock pattern.
        $lock = $this->lockFactory->createLock('payment.debtor.'.$user->getId(), ttl: 30, autoRelease: false);
        $lock->acquire(true);

        try {
            // Inside the lock: refuse to create a new payment when a still-open
            // (non-finalized) payment already exists for the same attendances.
            $existing = $this->payments->findPaymentByAttendances($payableAttendances, onlyUnpaid: true);
            if (null !== $existing && !$existing->isFinalized()) {
                $payment = $existing;
            } else {
                $payment = PaymentEntity::fromAttendances($debtor, $payableAttendances, $this->dispatcher, user: $user);

                if ($debtor->getBalance() > 0) {
                    $balance = min($debtor->getBalance(), $payment->getTotalAmount());
                    $payment->addItem(new PaymentItem($payment, (-1) * $balance, type: 'discount'));
                }

                $entityManager->persist($payment);
                $entityManager->flush();
            }

            $redirect = $this->paymentProvider->dispatchPayment($payment);

            $session->set('fp.processPayment', $payment->getId());
        } finally {
            $lock->release();
        }

        return $this->redirect($redirect);
    }

    /**
     * Settle the selection without sending the user to the payment gateway.
     * Two cases end up here: offers that are free of charge (nothing to
     * collect), and offers whose fee is fully covered by the debtor's credit.
     * The latter must still be billed — the receipt, the book entries that
     * consume the credit and the paid flag all hang off the payment's "pay"
     * transition — or the user is shown a confirmation while the attendance
     * stays unpaid and the credit untouched.
     */
    #[LiveAction]
    public function settleWithoutPayment(#[CurrentUser] User $user, EntityManagerInterface $entityManager, WorkflowInterface $paymentStateMachine): void
    {
        $attendances = $this->attendancesForPayment();
        if ([] === $attendances) {
            return;
        }

        $payable = array_values(array_filter($attendances, fn (Attendance $a) => $this->feeOf($a) > 0));
        $feeSum = array_sum(array_map(fn (Attendance $a) => $this->feeOf($a), $payable));

        // Nothing but the credit can clear a fee here, and this action is
        // reachable with any selection the client sends. Refuse to confirm a
        // bill the balance does not cover (a stale page whose credit has been
        // spent in the meantime lands here too) and let the component re-render
        // with the amount that is really due.
        if ([] !== $payable && $feeSum > $user->getDebtor()->getBalance()) {
            return;
        }

        // Serialize against redirectToPay() so a credit settlement and a gateway
        // payment cannot bill the same attendances concurrently.
        $lock = $this->lockFactory->createLock('payment.debtor.'.$user->getId(), ttl: 30, autoRelease: false);
        $lock->acquire(true);

        try {
            foreach ($attendances as $attendance) {
                if ($this->feeOf($attendance) <= 0) {
                    // Nothing to bill: confirming the attendance is all it takes.
                    $attendance->setPaid();
                }
            }

            if ([] !== $payable) {
                // A still-open payment for exactly these attendances is only
                // reusable when it already carries the full discount; anything
                // else would book money that never arrived.
                $payment = $this->payments->findPaymentByAttendances($payable, totalAmount: 0, onlyUnpaid: true);
                if (null === $payment || $payment->isFinalized()) {
                    $payment = PaymentEntity::fromAttendances($user->getDebtor(), $payable, $this->dispatcher, user: $user);
                    $payment->addItem(new PaymentItem($payment, (-1) * $payment->getTotalAmount(), type: 'discount'));

                    $entityManager->persist($payment);
                }

                $paymentStateMachine->apply($payment, PaymentStatus::TRANSITION_PAY);
            }

            $entityManager->flush();
        } finally {
            $lock->release();
        }

        $this->step = 'confirmed';
    }

    private function feeSum(): int
    {
        return array_sum(array_map(fn (Attendance $a) => $this->feeOf($a), $this->attendancesForPayment()));
    }

    private function feeOf(Attendance $attendance): int
    {
        return $attendance->getOffer()->getFeePayable($attendance->getParticipant(), $this->dispatcher);
    }

    private function attendancesNonPayable(): array
    {
        return array_filter($this->attendancesForPayment(), fn (Attendance $attendance) => 0 === $this->feeOf($attendance));
    }
}
