<?php
/**
 * BookiFlex Payment Extension
 *
 * @package    BookiFlex
 * @subpackage Payment
 * @author     BookiFlex
 * @copyright  Copyright (c) 2025 BookiFlex
 * @license    GPL-2.0-or-later
 *
 * @link       https://bookiflex.com
 */

declare(strict_types=1);

 namespace BookiFlex\Extensions\Payment\Service;

use BookiFlex\Core\Exception\InvalidArgumentException;
use BookiFlex\Core\Exception\LogicException;
use BookiFlex\Core\Exception\NotFoundException;
use BookiFlex\Extensions\Payment\Model\Payment;
use JetBrains\PhpStorm\NoReturn;
use Payum\Core\Bridge\Psr\Log\LogExecutedActionsExtension;
use Payum\Core\GatewayInterface;
use Payum\Core\Payum;
use Payum\Core\Reply\HttpPostRedirect;
use Payum\Core\Reply\HttpRedirect;
use Payum\Core\Reply\HttpResponse;
use Payum\Core\Reply\ReplyInterface;
use Payum\Core\Request\Authorize;
use Payum\Core\Request\Cancel;
use Payum\Core\Request\Capture;
use Payum\Core\Request\GetHumanStatus;
use Payum\Core\Request\Notify;
use Payum\Core\Security\TokenInterface;
use Psr\Log\LoggerInterface;

defined( 'ABSPATH' ) || exit;

class PaymentManager
{
    private const CONTENT_TYPE_HTML = 'text/html; charset=utf-8';

    public function __construct(
        private readonly Payum $payum,
        private readonly SyncPaymentStatusWithGateway $syncPaymentStatusWithGateway,
        private readonly ?LoggerInterface $logger = null,
    ) {
    }

    /**
     * Processes payment capture
     *
     * @param array $request HTTP request data
     * @throws InvalidArgumentException
     * @throws LogicException
     */
    public function captureByToken(array $request): never
    {
        $token   = $this->verifyToken($request);
        $gateway = $this->getGateway($token->getGatewayName());

        $this->checkAndRecreateIfPaymentExpired($gateway, $token);
        $reply = $gateway->execute(new Capture($token), true);

        if ($reply) {
            $this->handleReply($reply);
        }

        $this->invalidateTokenAndRedirect($token);
    }

    /**
     * Processes payment authorization
     *
     * @param array $request HTTP request data
     * @throws InvalidArgumentException
     * @throws LogicException
     */
    public function authorizeByToken(array $request): never
    {
        $token   = $this->verifyToken($request);
        $gateway = $this->getGateway($token->getGatewayName());

        $this->checkAndRecreateIfPaymentExpired($gateway, $token);
        $reply = $gateway->execute(new Authorize($token), true);

        if ($reply) {
            $this->handleReply($reply);
        }

        $this->invalidateTokenAndRedirect($token);
    }

    /**
     * Syncs payment status directly with gateway (no Payum token required).
     * Use when webhook was missed and admin wants to re-check gateway state.
     *
     * @throws NotFoundException
     * @throws InvalidArgumentException
     * @throws LogicException
     */
    public function syncPayment(int $paymentId): void
    {
        $storage = $this->payum->getStorage(Payment::class);
        /** @var Payment|null $payment */
        $payment = $storage->find($paymentId);
        if (!$payment instanceof Payment) {
            throw new NotFoundException('Payment not found');
        }
        if (!$payment->isWaiting()) {
            throw new LogicException(
                'Only payments in WAITING status can be synced',
                0,
                null,
                ['errorCode' => 'invalid_status']
            );
        }

        // External (non-Payum) gateways such as WooCommerce are not registered
        // in Payum, so there is nothing to sync against — their status is driven
        // by their own hooks. Treat a manual sync as a no-op success for now.
        // TODO: real status re-check for external gateways (e.g. re-read WC order).
        if (!array_key_exists($payment->getGatewayName(), $this->payum->getGateways())) {
            return;
        }

        $gateway = $this->getGateway($payment->getGatewayName());
        $this->syncPaymentStatusWithGateway->syncDirect($gateway, $payment);
    }

    /**
     * Manually marks a WAITING payment as paid, bypassing the gateway.
     * Use when the guest paid through an alternative method (e.g. cash, bank transfer).
     *
     * @throws NotFoundException
     * @throws LogicException
     */
    public function markAsPaid(int $paymentId): void
    {
        $storage = $this->payum->getStorage(Payment::class);
        /** @var Payment|null $payment */
        $payment = $storage->find($paymentId);
        if (!$payment instanceof Payment) {
            throw new NotFoundException('Payment not found');
        }
        if (!$payment->isWaiting()) {
            throw new LogicException(
                'Only payments in WAITING status can be manually confirmed',
                0,
                null,
                ['errorCode' => 'invalid_status']
            );
        }
        $payment->markPaid();
        $storage->update($payment);
        $this->syncPaymentStatusWithGateway->confirmReservations($payment);
    }

    /**
     * Marks a payment as cancelled, bypassing the gateway.
     * Use when an external processor (e.g. WooCommerce) reports the order as
     * cancelled/failed/refunded.
     *
     * @throws NotFoundException
     * @throws LogicException
     */
    public function markAsCancelled(int $paymentId): void
    {
        $storage = $this->payum->getStorage(Payment::class);
        /** @var Payment|null $payment */
        $payment = $storage->find($paymentId);
        if (!$payment instanceof Payment) {
            throw new NotFoundException('Payment not found');
        }
        if (!$payment->isWaiting() && !$payment->isAuthorized()) {
            throw new LogicException(
                'Only payments in WAITING or AUTHORIZED status can be cancelled',
                0,
                null,
                ['errorCode' => 'invalid_status']
            );
        }
        $payment->markCancelled();
        $storage->update($payment);
    }

    /**
     * Capture authorized payment
     *
     * @param int $paymentId
     * @return void
     * @throws NotFoundException
     * @throws InvalidArgumentException
     * @throws LogicException
     */
    public function captureAuthorizedPayment(int $paymentId): void
    {
        $storage = $this->payum->getStorage(Payment::class);

        /** @var Payment|null $payment */
        $payment = $storage->find($paymentId);
        if (!$payment instanceof Payment) {
            throw new NotFoundException('Payment object does not exist');
        }

        $gateway = $this->getGateway($payment->getGatewayName());
        $gateway->addExtension(new SyncPaymentStatusExtension($this->syncPaymentStatusWithGateway));

        try {
            $reply = $gateway->execute(new Capture($payment), true);
        } catch (\LogicException $e) {
            throw new LogicException('Token for the payment does not exist', 0, $e, ['errorCode' => 'token_not_exist']);
        }

//        if ($reply) {
//            $this->handleReply($reply);
//        }
    }

    /**
     * Processes payment cancellation
     *
     * @param array $request HTTP request data
     * @throws InvalidArgumentException
     * @throws LogicException
     */
    public function cancel(array $request): never
    {
        $token   = $this->verifyToken($request);
        $gateway = $this->getGateway($token->getGatewayName());
	    $gateway->addExtension(new SyncPaymentStatusExtension($this->syncPaymentStatusWithGateway));

	    $reply = null;
		try {
			$reply = $gateway->execute(new Cancel($token), true);
		} catch (\Throwable $e) {
			unset($e);
		}

        if ($reply) {
            $this->handleReply($reply);
        }

        $this->invalidateTokenAndRedirect($token);
    }

    /**
     * Handles payment completion
     *
     * @param array $request HTTP request data
     * @throws InvalidArgumentException
     */
    public function done(array $request): never
    {
        $token   = $this->verifyToken($request);
        $gateway = $this->getGateway($token->getGatewayName());

        $this->syncPaymentStatusWithGateway->sync($gateway, $token);
        $this->payum->getHttpRequestVerifier()->invalidate($token);

		$this->getSuccessPageHtml();
    }

    /**
     * Handles payment notifications (webhooks)
     *
     * @param array $request HTTP request data
     * @throws InvalidArgumentException
     * @throws LogicException
     */
    public function notify(array $request, ?string $gatewayName = null): never
    {
        [$token, $gateway] = $this->prepareNotificationContext($request, $gatewayName);

        $gateway->addExtension(new SyncPaymentStatusExtension($this->syncPaymentStatusWithGateway));
        $reply = $gateway->execute(new Notify($token), true);

        if ($token) {
            $this->invalidateTokenAndRedirect($token);
        }

        if ($reply) {
            $this->handleReply($reply);
        }

        $this->renderSuccessPage('OK', 204);
    }

    /**
     * Verifies and returns the security token
     * @throws InvalidArgumentException
     */
    private function verifyToken(array $request): TokenInterface
    {
        try {
            return $this->payum->getHttpRequestVerifier()->verify($request);
        } catch (\Exception $e) {
            throw new InvalidArgumentException('Invalid payment token', 400, $e);
        }
    }

    /**
     * Gets gateway instance
     * @throws InvalidArgumentException
     */
    private function getGateway(string $gatewayName): GatewayInterface
    {
        try {
            $gateway = $this->payum->getGateway($gatewayName);
        } catch (\Exception $e) {
            throw new InvalidArgumentException('Invalid gateway name', 400, $e);
        }

        if ($this->logger) {
            $gateway->addExtension(new LogExecutedActionsExtension($this->logger));
        }

        return $gateway;
    }

    /**
     * Handles different types of replies from payment gateway
     *
     * @param ReplyInterface $reply
     * @throws LogicException
     */
    private function handleReply(ReplyInterface $reply): void
    {
        switch (true) {
            case $reply instanceof HttpRedirect:
                $this->redirect($reply->getUrl());

            case $reply instanceof HttpPostRedirect:
                foreach ($reply->getHeaders() as $name => $value) {
                    header("$name: $value");
                }

	            // Payum generates a transactional HTTP response (HTML/redirect payload)
				// that must be output verbatim. Escaping or altering this content would
	            // break the payment flow and invalidate gateway signatures.
                echo $reply->getContent();
                exit;

            case $reply instanceof HttpResponse:
                foreach ($reply->getHeaders() as $name => $value) {
                    header("$name: $value");
                }

                http_response_code($reply->getStatusCode());

	            // Payum generates a transactional HTTP response (HTML/redirect payload)
	            // that must be output verbatim. Escaping or altering this content would
	            // break the payment flow and invalidate gateway signatures.
                echo $reply->getContent();
                exit;

            default:
                throw new LogicException('Unsupported reply', 0, null, ['reply' => $reply]);
        }
    }

    /**
     * Logs cancellation errors
     */
    //    private function logCancelError(\Throwable $exception, TokenInterface $token): void
    //    {
    //        error_log(sprintf(
    //            'Payment cancellation error: %s (Gateway: %s)',
    //            $exception->getMessage(),
    //            $token->getGatewayName()
    //        ));
    //    }

    /**
     * Prepares context for payment notifications
     *
     * @return array [TokenInterface|null, GatewayInterface]
     * @throws InvalidArgumentException
     */
    private function prepareNotificationContext(array $request, ?string $gatewayName = null): array
    {
        if ($gatewayName === null) {
            $token   = $this->verifyToken($request);
            $gateway = $this->getGateway($token->getGatewayName());
        } else {
            $token   = null;
            $gateway = $this->getGateway($gatewayName);
        }

        return [$token, $gateway];
    }

    /**
     * Invalidates token and redirects to after URL
     */
    private function invalidateTokenAndRedirect(TokenInterface $token): never
    {
        $this->payum->getHttpRequestVerifier()->invalidate($token);
        $this->redirect($token->getAfterUrl());
    }

    /**
     * Performs HTTP redirect
     */
    private function redirect(string $url): never
    {
        header("Location: $url");
        exit;
    }

    /**
     * Renders success page with JavaScript for popup communication
     */
    private function renderSuccessPage(string $content, int $statusCode = 200): never
    {
        header('Content-Type: ' . self::CONTENT_TYPE_HTML);
        http_response_code($statusCode);

        echo $content;
        exit;
    }

    /**
     * Returns HTML content for success page
     */
    #[NoReturn]
    private function getSuccessPageHtml(): void
    {
        $redirectText = esc_html__(
                'Redirecting to site...',
                'bookiflex'
        );

        $descriptionText = esc_html__(
                'Please wait while we redirect you back to the main site.',
                'bookiflex'
        );

	    // Transactional payment callback response.
		// Inline JS is required to complete the redirect flow.
		// This is not a WordPress-rendered page.
	    wp_die(
		    "<script>
if (window.opener) {
    window.opener.postMessage({ status: 'success' }, '*');
    window.close();
}
window.location.replace(window.location.origin);
</script>
<h1>{$redirectText}</h1>
<p>{$descriptionText}</p>",
		    $redirectText,
		    [
			    'response' => 200,
			    'exit'     => true,
		    ]
	    );
    }

    /**
     * @description Stripe Checkout Session can be used only once and has a lifetime (usually 24 hours).
     * If the user does not complete the payment on time, the session goes into the expired state.
     * If you try to pay again for the same session, Stripe will return an error, and Payum will not initiate a new process,
     * because the Payment has already been created and linked to the old session_id.
     *
     * @param GatewayInterface $gateway
     * @param TokenInterface $token
     * @return void
     */
    private function checkAndRecreateIfPaymentExpired(GatewayInterface $gateway, TokenInterface $token): void
    {
        $gateway->execute($status = new GetHumanStatus($token));

        // if status of payment system is expired then update it.
        if (!$status->isExpired()) {
            return;
        }

        $identity = $token->getDetails();
        $storage = $this->payum->getStorage($identity->getClass());
        /** @var Payment $payment */
        $payment = $storage->find($identity);

        if (!$payment->isWaiting()) {
            return;
        }

        // check valid reservations
        $hasReservation = false;
        foreach ($payment->getPaymentAllocations() as $allocation) {
            $reservation = $allocation->getReservation();
            if ($reservation->isWaitingPayment()) {
                $hasReservation = true;
                break;
            }
        }

        if ($hasReservation) {
            $payment->setDetails([]);
            $storage->update($payment);
        }
    }
}
