<?php
declare(strict_types=1);

namespace PayPo\Payment\Controller\Checkout;

use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\CsrfAwareActionInterface;
use Magento\Framework\App\Request\Http as HttpRequest;
use Magento\Framework\App\Request\InvalidRequestException;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Controller\Result\Json as JsonResult;
use Magento\Framework\Controller\Result\JsonFactory;
use Magento\Framework\DB\TransactionFactory;
use Magento\Framework\Serialize\Serializer\Json;
use Magento\Sales\Api\OrderRepositoryInterface;
use Magento\Sales\Model\Order;
use Magento\Sales\Model\Order\Invoice;
use Magento\Sales\Model\Order\Payment\Transaction;
use Magento\Sales\Model\Order\Payment\Transaction\BuilderInterface;
use Magento\Sales\Model\OrderFactory;
use Magento\Sales\Model\Service\InvoiceService;
use PayPo\Payment\Helper\Data;
use PayPo\Payment\Logger\Logger;
use PayPo\Payment\Model\Payment\PayPo;

/**
 * Server to server notification endpoint called by PayPo (notifyUrl).
 *
 * The endpoint is exempt from CSRF validation on purpose - it is called by
 * PayPo, not by the browser. Every notification is verified against the
 * merchant id, the order total and the authorization transaction id before it
 * is allowed to change anything.
 */
class Process implements HttpPostActionInterface, CsrfAwareActionInterface
{
    /**
     * PayPo transaction statuses handled by this endpoint.
     */
    private const STATUS_ACCEPTED = 'ACCEPTED';
    private const STATUS_COMPLETED = 'COMPLETED';
    private const STATUS_PENDING = 'PENDING';
    private const STATUS_REJECTED = 'REJECTED';
    private const STATUS_CANCELED = 'CANCELED';
    private const STATUS_FAILED = 'FAILED';

    /**
     * Response code returned for every payload that fails verification.
     */
    private const HTTP_BAD_REQUEST = 400;

    /**
     * @param RequestInterface $request
     * @param JsonFactory $jsonResultFactory
     * @param Json $json
     * @param OrderFactory $orderFactory
     * @param OrderRepositoryInterface $orderRepository
     * @param InvoiceService $invoiceService
     * @param TransactionFactory $transactionFactory
     * @param BuilderInterface $transactionBuilder
     * @param Logger $logger
     * @param Data $payPoHelper
     */
    public function __construct(
        private readonly RequestInterface $request,
        private readonly JsonFactory $jsonResultFactory,
        private readonly Json $json,
        private readonly OrderFactory $orderFactory,
        private readonly OrderRepositoryInterface $orderRepository,
        private readonly InvoiceService $invoiceService,
        private readonly TransactionFactory $transactionFactory,
        private readonly BuilderInterface $transactionBuilder,
        private readonly Logger $logger,
        private readonly Data $payPoHelper
    ) {
    }

    /**
     * Handle a PayPo status notification.
     *
     * @return JsonResult
     */
    public function execute()
    {
        $result = $this->jsonResultFactory->create();
        $data = $this->getRequestData();

        if (!isset($data['transactionStatus'], $data['referenceId'], $data['transactionId'], $data['amount'])) {
            return $this->badRequest($result, $data);
        }

        $order = $this->orderFactory->create()->loadByIncrementId((string)$data['referenceId']);
        $payment = $order->getId() ? $order->getPayment() : null;

        if ($payment === null || $payment->getMethod() !== PayPo::METHOD_CODE) {
            return $this->badRequest($result, $data);
        }

        if ($this->payPoHelper->getMerchantId($order->getStoreId()) !== (string)($data['merchantId'] ?? '')) {
            return $this->badRequest($result, $data);
        }

        if ((int)round((float)$order->getGrandTotal() * 100) !== (int)$data['amount']) {
            return $this->badRequest($result, $data);
        }

        $method = $payment->getMethodInstance();
        $orderTransaction = $method instanceof PayPo ? $method->getOrderTransaction($payment) : false;
        $status = (string)$data['transactionStatus'];

        // A notification is trusted only when we have a stored transaction and the
        // id matches exactly. A missing transaction is also rejected — it means we
        // never issued one for this order and the callback is not authenticated.
        if (!$orderTransaction || $orderTransaction->getTxnId() !== $data['transactionId']) {
            $status = self::STATUS_FAILED;
        }

        try {
            return match ($status) {
                self::STATUS_ACCEPTED, self::STATUS_COMPLETED => $this->approve(
                    $result,
                    $order,
                    (string)$data['transactionId']
                ),
                self::STATUS_PENDING => $this->accepted($result),
                self::STATUS_REJECTED, self::STATUS_CANCELED => $this->cancel($result, $order),
                default => $this->badRequest($result, $data),
            };
        } catch (\Exception $e) {
            $this->logger->addError(
                sprintf('Notify, order %s: %s', $order->getIncrementId(), $e->getMessage())
            );

            return $this->badRequest($result, $data);
        }
    }

    /**
     * Mark the payment as captured and invoice the order when configured to.
     *
     * @param JsonResult $result
     * @param Order $order
     * @param string $transactionId
     * @return JsonResult
     * @throws \Exception
     */
    private function approve(JsonResult $result, Order $order, string $transactionId): JsonResult
    {
        if ($this->payPoHelper->autoApprove($order->getStoreId()) && $order->canInvoice()) {
            $payment = $order->getPayment();
            $payment->setIsTransactionClosed(1);
            $payment->setParentTransactionId($transactionId);
            $payment->setIsTransactionPending(false);
            $payment->setIsTransactionApproved(true);

            $transaction = $this->transactionBuilder->setPayment($payment)
                ->setOrder($order)
                ->setTransactionId($payment->getTransactionId())
                ->build(Transaction::TYPE_CAPTURE);
            $payment->addTransactionCommentsToOrder($transaction, '');

            $invoice = $this->invoiceService->prepareInvoice($order);
            $invoice->setTransactionId($transactionId)
                ->setRequestedCaptureCase(Invoice::CAPTURE_ONLINE);
            $invoice->register()->pay();

            $this->transactionFactory->create()
                ->addObject($invoice)
                ->addObject($invoice->getOrder())
                ->save();

            $order->addCommentToStatusHistory(__('Invoice #%1.', $invoice->getIncrementId()))
                ->setIsCustomerNotified(false);
            $this->orderRepository->save($order);
        }

        return $this->accepted($result);
    }

    /**
     * Cancel the order after a rejected or cancelled payment.
     *
     * @param JsonResult $result
     * @param Order $order
     * @return JsonResult
     * @throws \Exception
     */
    private function cancel(JsonResult $result, Order $order): JsonResult
    {
        if ($this->payPoHelper->autoCancel($order->getStoreId()) && $order->getState() !== Order::STATE_CANCELED) {
            $order->registerCancellation(__('Transaction canceled successfully')->render());
            $this->orderRepository->save($order);
        }

        return $this->accepted($result);
    }

    /**
     * Standard success payload expected by PayPo.
     *
     * @param JsonResult $result
     * @return JsonResult
     */
    private function accepted(JsonResult $result): JsonResult
    {
        return $result->setData([
            'code' => '200',
            'statusDescription' => __('Transaction updated successfully'),
        ]);
    }

    /**
     * Log the rejected payload and answer with HTTP 400.
     *
     * @param JsonResult $result
     * @param array $data
     * @return JsonResult
     */
    private function badRequest(JsonResult $result, array $data): JsonResult
    {
        $this->logger->addInfo(sprintf('Notify, Payload: %s', $this->json->serialize($data)));
        $result->setHttpResponseCode(self::HTTP_BAD_REQUEST);
        $result->setData(['error_message' => __('HTTP_BAD_REQUEST')]);

        return $result;
    }

    /**
     * Read the notification payload, accepting both JSON and form encoded bodies.
     *
     * @return array
     */
    private function getRequestData(): array
    {
        if ($this->request instanceof HttpRequest) {
            $content = trim((string)$this->request->getContent());

            if ($content !== '') {
                try {
                    $decoded = $this->json->unserialize($content);

                    if (is_array($decoded)) {
                        return $decoded;
                    }
                } catch (\InvalidArgumentException $e) {
                    $this->logger->addInfo('Notify: request body is not valid JSON');
                }
            }
        }

        return (array)$this->request->getParams();
    }

    /**
     * @param RequestInterface $request
     * @return InvalidRequestException|null
     */
    public function createCsrfValidationException(RequestInterface $request): ?InvalidRequestException
    {
        return null;
    }

    /**
     * PayPo signs nothing with a form key, the payload itself is validated instead.
     *
     * @param RequestInterface $request
     * @return bool|null
     */
    public function validateForCsrf(RequestInterface $request): ?bool
    {
        return true;
    }
}
