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

use Doctrine\ORM\EntityManagerInterface;
use Ferienpass\CoreBundle\Entity\Attendance;
use Ferienpass\CoreBundle\Entity\Debtor;
use Ferienpass\CoreBundle\Entity\Payment;
use Ferienpass\CoreBundle\Entity\PaymentItem;
use Ferienpass\CoreBundle\Entity\User;
use Ferienpass\CoreBundle\Enum\PaymentStatus;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Workflow\WorkflowInterface;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveArg;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\LiveComponent\ValidatableComponentTrait;

/**
 * Retroactively correct a cash receipt. A finalized receipt is immutable, so
 * "adjusting" it means: cancel the original in full (a Stornobeleg, exactly the
 * storno path) and issue a fresh, corrected receipt from the edited posten — the
 * same operation the "abrechnen" slideover performs. The net cash-book effect is
 * therefore (new − original), which is the correction the admin actually made.
 */
#[AsLiveComponent(route: 'live_component_admin')]
#[IsGranted('ROLE_ADMIN')]
class AdjustPayment extends AbstractController
{
    use DefaultActionTrait;
    use ValidatableComponentTrait;

    #[LiveProp]
    public Payment $payment;

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

    #[LiveProp(writable: true)]
    public string $address = '';

    #[LiveProp(writable: true)]
    #[Assert\Email(message: 'Bitte geben Sie eine gültige E-Mail-Adresse ein.')]
    public string $email = '';

    #[LiveProp(writable: true)]
    public bool $paid = true;

    #[LiveProp(writable: true)]
    #[Assert\Expression(
        'value == false or this.paid == true',
        message: 'Eine Benachrichtigung kann nur gesendet werden, wenn die Zahlung erfolgt ist.'
    )]
    #[Assert\Expression(
        'value == false or this.email != ""',
        message: 'Eine Benachrichtigung kann nur gesendet werden, wenn eine E-Mail-Adresse hinterlegt ist.'
    )]
    public bool $notify = false;

    public ?Payment $newPayment = null;
    public ?string $error = null;

    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        private readonly WorkflowInterface $paymentStateMachine,
    ) {
    }

    public function mount(Payment $payment): void
    {
        $this->payment = $payment;
        $this->address = (string) $payment->getBillingAddress();
        $this->email = (string) $payment->getBillingEmail();

        // Only attendance posten can be reversed and re-billed (a discount/Guthaben
        // item can't be negated — a positive "discount" is rejected — so the storno
        // path only ever touches attendance items; we mirror that here).
        foreach ($payment->getItems() as $item) {
            if (!$item->isAttendance() || null === $item->getAttendance()) {
                continue;
            }

            $this->items[] = [
                'amount' => $item->getAmount() / 100,
                'participant' => $item->getAttendance()->getParticipant()->getName(),
                'offer' => $item->getAttendance()->getOffer()->getName(),
                'attendanceId' => $item->getAttendance()->getId(),
                'type' => 'attendance',
            ];
        }
    }

    public function getTotalAmount(): float
    {
        // Amounts come back from the editable number inputs as strings (and an
        // emptied field as ''), so cast each before summing.
        return array_sum(array_map(static fn ($item) => (float) ($item['amount'] ?? 0), $this->items));
    }

    #[LiveAction]
    public function removeItem(#[LiveArg] int $index): void
    {
        unset($this->items[$index]);
        $this->items = array_values($this->items);
    }

    #[LiveAction]
    public function submit(): void
    {
        $this->validate();

        $original = $this->payment;

        // Re-check the invariants the button/route guard on, in case the receipt
        // changed since the slideover was opened (e.g. reversed in another tab).
        if (!$original->canBeAdjusted()) {
            $this->error = 'Dieser Beleg kann nicht mehr angepasst werden.';

            return;
        }

        if ([] === $this->items) {
            $this->error = 'Ein Beleg muss mindestens einen Posten enthalten. Um alle Posten zu entfernen, stornieren Sie den Beleg.';

            return;
        }

        $user = $this->getUser();
        if (!$user instanceof User) {
            throw $this->createAccessDeniedException();
        }

        $debtor = $original->getDebtor();

        // Reuse the same Attendance instances the original references, so the
        // storno (mark unpaid) and the new receipt (mark paid) act on one managed
        // entity each.
        $attendances = [];
        foreach ($original->getItems() as $item) {
            if (null !== $item->getAttendance()) {
                $attendances[$item->getAttendance()->getId()] = $item->getAttendance();
            }
        }

        // Storno + re-bill must be atomic. Flushing the reversal before creating
        // the new receipt is also what lets ReceiptNumberGenerator hand out two
        // distinct numbers (it reads the highest number from the DB); within the
        // transaction the second read already sees the first, still-uncommitted
        // INSERT.
        $this->entityManager->wrapInTransaction(function () use ($original, $user, $debtor, $attendances): void {
            // 1. Cancel the original in full.
            $reversal = $this->buildReversal($original, $debtor, $user);

            if ($reversal->getItems()->isEmpty()) {
                $this->error = 'Es wurde nichts storniert. Die Buchungen waren bereits storniert.';

                return;
            }

            // Link to the original only now that we know the reversal has posten —
            // linking earlier would expose an empty reversal to Doctrine's
            // persist-by-reachability through the original's reversals collection.
            $reversal->setReversalOf($original);

            $this->paymentStateMachine->apply($reversal, PaymentStatus::TRANSITION_PAY, [
                'reverse' => true,
                'payout' => $debtor instanceof Debtor ? 'payout' : null,
                'notify' => false,
            ]);

            $this->entityManager->persist($reversal);
            $this->entityManager->flush();

            // 2. Issue the corrected receipt from the edited posten.
            $new = $this->buildNewReceipt($attendances, $debtor, $user);

            if ($this->paid) {
                $this->paymentStateMachine->apply($new, PaymentStatus::TRANSITION_PAY, ['notify' => $this->notify]);
            }

            $this->entityManager->persist($new);
            $this->entityManager->flush();

            $this->newPayment = $new;
        });
    }

    /**
     * Build the Stornobeleg that cancels the original receipt in full: one negated
     * posten for every *paid* attendance item on the original. Discount (Guthaben)
     * items are skipped — a positive discount would be rejected by PaymentItem, so,
     * exactly like the storno flow, only attendance posten are reversed. Note this
     * always reverses the whole original regardless of which rows the admin edited
     * or removed; the correction is the difference to the re-billed receipt. The
     * returned payment is not yet linked, transitioned or persisted — an empty item
     * collection means there was nothing left to cancel.
     */
    public function buildReversal(Payment $original, ?Debtor $debtor, User $user): Payment
    {
        $reversal = new Payment($debtor, user: $user);
        $reversal->setBillingAddress($original->getBillingAddress());
        $reversal->setBillingEmail($original->getBillingEmail());

        foreach ($original->getItems() as $item) {
            if (!$item->isAttendance() || false === $item->getAttendance()?->isPaid()) {
                continue;
            }

            $reversal->addItem(new PaymentItem($reversal, (-1) * $item->getAmount(), $item->getAttendance()));
        }

        return $reversal;
    }

    /**
     * Build the corrected receipt from the edited posten ({@see $items}). Only rows
     * whose attendance is among the original's attendances are billed, so removing a
     * row simply drops it from the new receipt (and its attendance is left reversed,
     * i.e. unpaid); the amounts are the admin-edited euro values converted back to
     * cents. The returned payment is not yet transitioned or persisted.
     *
     * @param array<int, Attendance> $attendances the original's attendances, keyed by id
     */
    public function buildNewReceipt(array $attendances, ?Debtor $debtor, User $user): Payment
    {
        $new = new Payment($debtor, user: $user);
        $new->setBillingAddress($this->address);
        $new->setBillingEmail($this->email);

        foreach ($this->items as $itemData) {
            $attendance = isset($itemData['attendanceId']) ? ($attendances[$itemData['attendanceId']] ?? null) : null;
            if (!$attendance instanceof Attendance) {
                continue;
            }

            $new->addItem(new PaymentItem($new, (int) round((float) ($itemData['amount'] ?? 0) * 100), $attendance));
        }

        return $new;
    }
}
