<?php
/**
 * A class that handles integration with the PayPal API for payments.
 *
 * @since 3.0.0
 */

declare( strict_types=1 );
namespace FeData\FastEvents\Payment\Provider;

use DateTimeImmutable;
use DateTimeZone;
use Exception;
use FeData\FastEvents\Helpers\EventsHelper;
use FeData\FastEvents\Payment\AbstractPayment;
use FeData\FastEvents\Utilities\PluginUtil;
use FeData\FastEvents\Utilities\TimeUtil;
use stdClass;
use WP_Error;
use WP_REST_Request;
use WP_User;

defined( 'ABSPATH' ) || exit;

/**
 * A class that handles integration with the PayPal API for payments.
 */
final class PaypalProvider extends AbstractPayment {

	/**
	 * Provider name.
	 *
	 * @since 3.0.0
	 * @var string
	 */
	protected static string $provider_name = 'PayPal';

	/**
	 * Name of the body encoder function.
	 *
	 * @since 3.0.0
	 * @var string
	 */
	protected static string $encoder = 'wp_json_encode';

	/**
	 * Provider base URL for API calls.
	 *
	 * @since 3.0.0
	 * @var string
	 */
	private const BASE_URL = 'https://api-m.paypal.com/';

	/**
	 * Provider base URL for sandbox API calls.
	 *
	 * @since 3.0.0
	 * @var string
	 */
	private const BASE_SANDBOX_URL = 'https://api-m.sandbox.paypal.com/';

	/**
	 * Array of webhook events we are subscribed to.
	 *
	 * @since 3.0.0
	 * @var array
	 */
	private const ALLOWED_EVENTS = array(
		'CHECKOUT.ORDER.APPROVED', // Authorized.
		'CUSTOMER.DISPUTE.CREATED', // Backcharge.
		'PAYMENT.CAPTURE.COMPLETED', // Paid.
		'PAYMENT.CAPTURE.DECLINED', // Failed.
		'PAYMENT.CAPTURE.DENIED', // Failed.
		'PAYMENT.CAPTURE.REFUNDED', // Refunded.
	);

	/**
	 * Array of mandatory webhook headers.
	 *
	 * @since 3.0.0
	 * @var array
	 */
	private const WEBHOOK_HEADERS = array(
		'paypal_transmission_time',
		'paypal_transmission_sig',
		'paypal_transmission_id',
		'paypal_cert_url',
		'paypal_auth_version',
		'paypal_auth_algo',
	);


	/**
	 * Installation activities of the payment provider. It can be called multiple times!
	 * This is the payload used for the webhook creation.
	 *
	 *  ```json
	 *  {
	 *    "self": {
	 *       "webhook_test_id": "some_string",
	 *       "webhook_live_id": "some_string
	 *    },
	 *    "saas": {
	 *      "webhook_test_id": "some_string",
	 *      "webhook_live_id": "some_string"
	 *    }
	 *  }
	 *  ```
	 *
	 * @return bool|WP_Error Returns true if the installation was successful.
	 */
	public function install(): bool|WP_Error {

		// Set the payload for the webhook creation (live and test).
		$this->payload = array(
			'url'         => home_url() . '/wp-json/fast-events/v1/payment/webhook/',
			'event_types' => array_map( fn( $e ) => array( 'name' => $e ), self::ALLOWED_EVENTS ),
		);
		$this->set_headers( 0, true );
		$info       = $this->options->get_provider_data( self::$provider_name ); // A new access token may have been generated via 'set_headers'.
		$test_token = $this->headers['Authorization'];

		// Set the test webhook events.
		if ( empty( $info['webhooks']['webhook_test_id'] ) ) {
			$test_hook = $this->api_call( $this->base_url( true ) . 'v1/notifications/webhooks', 'POST' );
			if ( ! is_wp_error( $test_hook ) ) {
				$info['webhooks']['webhook_test_id'] = $test_hook['id'];
				$this->options->set_provider_data( self::$provider_name, $info );
			}
		}

		// Check if the keys are the same.
		$this->set_headers( 0, false );
		$info = $this->options->get_provider_data( self::$provider_name ); // A new access token may have been generated via 'set_headers'.
		if ( $test_token === $this->headers['Authorization'] ) {
			return new WP_Error( 'invalid_request', __( 'The PayPal test and live tokens are the same', 'fast-events' ), array( 'status' => 400 ) );
		}

		// Set the live webhook events.
		if ( empty( $info['webhooks']['webhook_live_id'] ) ) {
			$this->payload['connect'] = 'false';
			$live_hook                = $this->api_call( $this->base_url( false ) . 'v1/notifications/webhooks', 'POST' );
			if ( ! is_wp_error( $live_hook ) ) {
				$info['webhooks']['webhook_live_id'] = $live_hook['id'];
				$this->options->set_provider_data( self::$provider_name, $info );
			}
		}

		return true;
	}

	/**
	 * Uninstallation activities of the payment provider.
	 *
	 * @since 3.0.0
	 */
	public function uninstall(): void {
		$info = $this->options->get_provider_data( self::$provider_name );

		// Delete the webhooks of the live environment.
		$this->set_headers( 0, false );
		if ( ! empty( $info['webhooks']['webhook_live_id'] ) ) {
			$this->api_call( $this->base_url( false ) . 'v1/notifications/webhooks/' . $info['webhooks']['webhook_live_id'], 'DELETE' );
		}

		// Delete the webhooks of the test environment.
		$this->set_headers( 0, true );
		if ( ! empty( $info['webhooks']['webhook_test_id'] ) ) {
			$this->api_call( $this->base_url( true ) . 'v1/notifications/webhooks/' . $info['webhooks']['webhook_test_id'], 'DELETE' );
		}

		// Delete the options.
		$this->options->delete_provider_data( self::$provider_name );
	}

	/**
	 * Create a payment.
	 * We use the 'AUTHORIZE' intent to create a payment that can be captured later.
	 * Fast Events will authorize it, and if the order is not expired, it will capture the payment, else the payment is voided.
	 *
	 * @since 3.0.0
	 *
	 * @param EventsHelper $events_helper The events helper.
	 *                                    It contains all the methods to set up a payment.
	 * @param bool         $test          Use test mode or not (default: false).
	 * @return array|WP_Error Returns an array with the payment details ('id' and 'url').
	 */
	public function create_payment( EventsHelper $events_helper, bool $test = false ): array|WP_Error {
		$this->payload = array(
			'intent'         => 'CAPTURE',
			'purchase_units' => array(
				array(
					'description' => count( $events_helper->get_order_ids() ) > 1
						? 'Order #' . implode( ',#', $events_helper->get_order_ids() ) . ' (' . $events_helper->get_event_group() . ')'
						: 'Order #' . $events_helper->get_order_ids()[0] . ' (' . $events_helper->get_event_name() . ')',
					'amount'      => array(
						'currency_code' => $this->settings->currency(),
						'value'         => number_format( $events_helper->get_amount(), 2, '.', '' ),
					),
					'invoice_id'  => $events_helper->get_order_uid(),
					'custom_id'   => wp_json_encode(
						array(
							'order_ids'    => $events_helper->get_order_ids(),
							'event_ids'    => $events_helper->get_event_ids(),
							'fe_order_uid' => $events_helper->get_order_uid(),
							'test'         => $test,
							'fe_version'   => $GLOBALS['fast_events']::FAST_EVENTS_VERSION,
						)
					),
				),
			),
			'payment_source' => array(
				'paypal' => array(
					'email_address'      => $events_helper->get_email(),
					'experience_context' => array(
						'landing_page'              => 'GUEST_CHECKOUT',
						'shipping_preference'       => 'NO_SHIPPING',
						'payment_method_preference' => 'IMMEDIATE_PAYMENT_REQUIRED',
						'user_action'               => 'PAY_NOW',
						'return_url'                => home_url() . '/wp-json/fast-events/v1/payment/redirect/' . $events_helper->get_order_uid(),
						'cancel_url'                => home_url() . '/wp-json/fast-events/v1/payment/cancel/' . $events_helper->get_order_uid(),
					),
				),
			),
		);
		$this->set_headers( $events_helper->get_saas_user_id(), $test );
		if ( $this->settings->saas_mode() && 0 !== $events_helper->get_saas_user_id() ) {
			$this->set_saas_fields( $events_helper, $test );
		}
		$response = $this->api_call( $this->base_url( $test ) . 'v2/checkout/orders', 'POST' );
		if ( is_wp_error( $response ) ) {
			return $response;
		}

		// Find the approval link.
		$approval_url = null;
		if ( ! empty( $response['links'] ) && is_array( $response['links'] ) ) {
			foreach ( $response['links'] as $link ) {
				if ( isset( $link['rel'] ) && 'payer-action' === $link['rel'] ) {
					$approval_url = $link['href'];
					break;
				}
			}
		}

		if ( ! $approval_url ) {
			$this->logger->warning(
				'PayPal approval URL not found in response',
				array(
					'type'     => 'PayPal create order',
					'ip'       => PluginUtil::get_ip(),
					'provider' => self::$provider_name,
					'body'     => $response,
				)
			);
			return new WP_Error( 'invalid_request', __( 'PayPal approval URL not found in response', 'fast-events' ) );
		}

		// Look up the payment ID and redirect URL from the response.
		return array(
			'id'  => $response['id'],
			'url' => $approval_url,
		);
	}

	/**
	 * Retrieve the unique order uid and id from the webhook request parameters.
	 *
	 * @since 3.0.0
	 *
	 * @param WP_REST_Request $data The webhook request.
	 * @return string|WP_Error Returns an array with the payment details ('id' and 'uid').
	 */
	public function get_uid( WP_REST_Request $data ): array|WP_Error {
		global $wpdb;

		$params = $data->get_params();

		// Check if all mandatory headers are present.
		foreach ( self::WEBHOOK_HEADERS as $header ) {
			if ( empty( $data->get_header( $header ) ) ) {
				$this->logger->warning(
					'Mandatory header is missing from the webhook',
					array(
						'type'     => 'PayPal webhook',
						'ip'       => PluginUtil::get_ip(),
						'provider' => self::$provider_name,
						'header'   => $header,
						'event'    => $params['type'] ?? '',
					)
				);
				return new WP_Error( 'invalid_request', __( 'Mandatory header is missing from the webhook', 'fast-events' ) );
			}
		}

		// If the cert url did change, we assume that the public key did change as well.
		$info = $this->options->get_provider_data( self::$provider_name );
		if ( empty( $info['cert_url'] ) || $info['cert_url'] !== $data->get_header( 'PayPal-Cert-Url' ) ) {
			$info['cert_url']   = $data->get_header( 'PayPal-Cert-Url' );
			$info['public_key'] = wp_remote_retrieve_body( wp_remote_get( $info['cert_url'] ) );
			$this->options->set_provider_data( self::$provider_name, $info );
			$this->logger->debug(
				'New Cert URL detected. Public key updated.',
				array(
					'type'     => 'PayPal webhook',
					'ip'       => PluginUtil::get_ip(),
					'provider' => self::$provider_name,
					'cert_url' => $info['cert_url'],
					'event'    => $params['type'] ?? '',
				)
			);
		}

		// Verify the signature. As we don't know if we are in test or live mode, we first try to verify the signature in live mode.
		// If that fails, try to verify it in test mode.
		$result = openssl_verify(
			implode(
				'|',
				array(
					$data->get_header( 'PayPal-Transmission-Id' ),
					$data->get_header( 'PayPal-Transmission-Time' ),
					$info['webhooks']['webhook_live_id'],
					crc32( $data->get_body() ),
				)
			),
			base64_decode( $data->get_header( 'PayPal-Transmission-Sig' ) ), // phpcs:ignore
			openssl_pkey_get_public( $info['public_key'] ),
			'sha256WithRSAEncryption'
		);
		if ( 1 !== $result ) {
			$result = openssl_verify(
				implode(
					'|',
					array(
						$data->get_header( 'PayPal-Transmission-Id' ),
						$data->get_header( 'PayPal-Transmission-Time' ),
						$info['webhooks']['webhook_test_id'],
						crc32( $data->get_body() ),
					)
				),
				base64_decode( $data->get_header( 'PayPal-Transmission-Sig' ) ), // phpcs:ignore
				openssl_pkey_get_public( $info['public_key'] ),
				'sha256WithRSAEncryption'
			);
		}
		if ( 1 !== $result ) {
			$this->logger->warning(
				'Webhook signature verification failed',
				array(
					'type'     => 'PayPal webhook',
					'ip'       => PluginUtil::get_ip(),
					'provider' => self::$provider_name,
					'event'    => $params['type'] ?? '',
					'body'     => $data->get_body(),
				)
			);
			return new WP_Error( 'invalid_signature', __( 'Webhook signature verification failed', 'fast-events' ) );
		}

		// Check if this is an event we are subscribed to.
		if ( ! in_array( $params['event_type'], self::ALLOWED_EVENTS, true ) ) {
			$this->logger->warning(
				'This event is ignored: ' . $params['event_type'],
				array(
					'type'      => 'PayPal webhook',
					'ip'        => PluginUtil::get_ip(),
					'provider'  => self::$provider_name,
					'order_uid' => $params['resource']['invoice_id'] ?? '',
					'event'     => $params['event_type'],
				)
			);
			return new WP_Error( 'invalid_event', __( 'Invalid webhook event', 'fast-events' ) );
		}

		$uid    = '';
		$status = '';
		switch ( $params['event_type'] ) {
			case 'CHECKOUT.ORDER.APPROVED':
				$status        = 'authorized';
				$uid           = $params['resource']['purchase_units'][0]['invoice_id'];
				$approved_date = wp_date( 'Y-m-d H:i:s', strtotime( $params['create_time'] ) );
				$created_date  = wp_date( 'Y-m-d H:i:s', strtotime( $params['resource']['create_time'] ) );
				$metadata      = json_decode( $params['resource']['purchase_units'][0]['custom_id'] );
				$ids           = empty( $metadata->order_ids ) ? array( 0 ) : array_map( 'intval', $metadata->order_ids ); // Sanitize the order IDs.
				$wpdb->query(
					"UPDATE {$wpdb->prefix}fe_event_orders SET input_fields = " .
					"JSON_INSERT(input_fields, '$.metadata', JSON_OBJECT(), '$.metadata.created_at', '$created_date', '$.metadata.authorized_at', '$approved_date' ) " .  // phpcs:ignore
					'WHERE id IN (' . implode( ',', $ids ) . ')' // phpcs:ignore
				);
				break;
			case 'CUSTOMER.DISPUTE.CREATED':
				$status = 'chargeback';

				// Find the first disputed order that meets our criterium (JSON content in 'custom').
				foreach ( $params['resource']['disputed_transactions'] as $dispute ) {
					$metadata = json_decode( $dispute['custom'] );
					$ids      = empty( $metadata->order_ids ) ? array( 0 ) : array_map( 'intval', $metadata->order_ids ); // Sanitize the order IDs.
					if ( JSON_ERROR_NONE !== json_last_error() ) {
						continue;
					}
					$uid  = $metadata->fe_order_uid;
					$date = wp_date( 'Y-m-d H:i:s', strtotime( $params['create_time'] ) );
					$wpdb->query(
						"UPDATE {$wpdb->prefix}fe_event_orders SET input_fields = " .
						"JSON_INSERT(input_fields, '$.metadata', JSON_OBJECT(), '$.metadata.dispute_created_at', '$date', '$.metadata.has_chargebacks', '1' ) " .  // phpcs:ignore
						'WHERE id IN (' . implode( ',', $ids ) . ')' // phpcs:ignore
					);
				}
				break;
			case 'PAYMENT.CAPTURE.COMPLETED':
				$status   = 'paid';
				$uid      = $params['resource']['invoice_id'];
				$date     = wp_date( 'Y-m-d H:i:s', strtotime( $params['create_time'] ) );
				$metadata = json_decode( $params['resource']['custom_id'] );
				$ids      = empty( $metadata->order_ids ) ? array( 0 ) : array_map( 'intval', $metadata->order_ids ); // Sanitize the order IDs.
				$wpdb->query(
					"UPDATE {$wpdb->prefix}fe_event_orders SET input_fields = " .
					"JSON_INSERT(input_fields, '$.metadata', JSON_OBJECT(), '$.metadata.paid_at', '$date' ) " .  // phpcs:ignore
					'WHERE id IN (' . implode( ',', $ids ) . ')' // phpcs:ignore
				);
				break;
			case 'PAYMENT.CAPTURE.DECLINED':
			case 'PAYMENT.CAPTURE.DENIED':
				$status   = 'failed';
				$uid      = $params['resource']['invoice_id'];
				$date     = wp_date( 'Y-m-d H:i:s', strtotime( $params['create_time'] ) );
				$metadata = json_decode( $params['resource']['custom_id'] );
				$ids      = empty( $metadata->order_ids ) ? array( 0 ) : array_map( 'intval', $metadata->order_ids ); // Sanitize the order IDs.
				$wpdb->query(
					"UPDATE {$wpdb->prefix}fe_event_orders SET input_fields = " .
					"JSON_INSERT(input_fields, '$.metadata', JSON_OBJECT(), '$.metadata.failed_at', '$date' ) " .  // phpcs:ignore
					'WHERE id IN (' . implode( ',', $ids ) . ')' // phpcs:ignore
				);
				break;
			case 'PAYMENT.CAPTURE.REFUNDED':
				$status   = 'refunded';
				$metadata = json_decode( $params['resource']['custom_id'] );
				$ids      = empty( $metadata->order_ids ) ? array( 0 ) : array_map( 'intval', $metadata->order_ids ); // Sanitize the order IDs.
				$uid      = $metadata->fe_order_uid;
				$wpdb->query(
					"UPDATE {$wpdb->prefix}fe_event_orders SET input_fields = " .
					"JSON_INSERT(input_fields, '$.metadata', JSON_OBJECT(), '$.metadata.has_refunds', '1' ) " .  // phpcs:ignore
					'WHERE id IN (' . implode( ',', $ids ) . ')' // phpcs:ignore
				);
				break;
		}

		return array(
			'id'     => '',
			'uid'    => $uid,
			'status' => $status,
		);
	}

	/**
	 * Retrieve a single payment object by its payment ID.
	 *
	 * @since 3.0.0
	 *
	 * @param string   $payment_id The unique ID of the payment.
	 * @param int      $user_id    The ID of the user.
	 * @param stdClass $order      The order object.
	 * @param bool     $test       Use test mode or not (default: false).
	 * @return array|WP_Error      Returns an array with the payment details.
	 * @throws Exception Exception from 'convert_date'.
	 */
	public function get_payment( string $payment_id, int $user_id, stdClass $order, bool $test = false ): array|WP_Error {
		global $wpdb;

		$endpoint = $this->base_url( $test ) . 'v2/checkout/orders/' . $payment_id;
		$this->set_headers( $user_id, $test );
		$response = $this->api_call( $endpoint, 'GET' );
		if ( is_wp_error( $response ) ) {
			return $response;
		}

		// Fill the array with the payment details.
		$json                               = json_decode( $order->input_fields, true );
		$purchase_units                     = $response['purchase_units'][0];
		$result                             = array();
		$result['status']                   = 'open';
		$result['description']              = $purchase_units['description'];
		$result['amount']                   = (float) $purchase_units['amount']['value'];
		$result['currency']                 = $purchase_units['amount']['currency_code'];
		$result['created']                  = $order->created_at;
		$result['metadata']                 = json_decode( $purchase_units['custom_id'], true );
		$result['metadata']['fe_order_uid'] = $purchase_units['invoice_id'];

		// Are there any customer details?
		if ( ! empty( $response['payer']['name'] ) ) {
			$name = $response['payer']['name'];
			$result['customer'][ __( 'Customer', 'fast-events' ) ] = ( $name['given_name'] ?? '' ) . ' ' . ( $name['surname'] );
		}
		$result['customer'][ __( 'Transaction ID', 'fast-events' ) ] = $response['id'];

		if ( ! empty( $json['metadata']['created_at'] ) ) {
			$result['actions'][ __( 'Created', 'fast-events' ) ] = $json['metadata']['created_at'];
		}

		// Is it expired?
		if ( ! empty( $json['metadata']['expired_at'] ) ) {
			$more = '';
			if ( ! empty( $json['metadata']['expired_msg'] ) ) {
				$more = ' (' . $json['metadata']['expired_msg'] . ')';
			}
			$result['status']                                    = 'expired';
			$result['actions'][ __( 'Expired', 'fast-events' ) ] = $json['metadata']['expired_at'] . $more;
		}

		// Is it authorized?
		if ( ! empty( $json['metadata']['authorized_at'] ) ) {
			$result['status']                                       = 'authorized';
			$result['actions'][ __( 'Authorized', 'fast-events' ) ] = $json['metadata']['authorized_at'];
		}

		if ( ! empty( $json['metadata']['paid_at'] ) ) {
			$result['actions'][ __( 'Paid', 'fast-events' ) ] = $json['metadata']['paid_at'];
		}

		// Is it cancelled?
		if ( ! empty( $json['metadata']['canceled_at'] ) ) {
			$result['status']                                     = 'canceled';
			$result['actions'][ __( 'Canceled', 'fast-events' ) ] = $json['metadata']['canceled_at'];
		}

		// Has it failed?
		if ( ! empty( $json['metadata']['failed_at'] ) ) {
			$more = '';
			if ( ! empty( $json['metadata']['failed_msg'] ) ) {
				$more = ' (' . $json['metadata']['failed_msg'] . ')';
			}
			$result['status']                                   = 'failed';
			$result['actions'][ __( 'Failed', 'fast-events' ) ] = $json['metadata']['failed_at'] . $more;
		}

		// Is there more info?
		if ( ! empty( $json['metadata']['extra_msg'] ) ) {
			$result['actions'][ __( 'More info', 'fast-events' ) ] = $json['metadata']['extra_msg'];
		}

		if ( ! empty( $json['metadata']['has_chargebacks'] ) ) {
			// Is there a dispute creation date?
			if ( ! empty( $json['metadata']['dispute_created_at'] ) ) {
				$result['actions'][ __( 'Dispute', 'fast-events' ) ] = $json['metadata']['dispute_created_at'];
			}
			$result['status'] = 'chargeback';
		}

		// Are there any refunds?
		$result['has_refunds']   = false;
		$result['total_refunds'] = 0.0;
		if ( ! empty( $response['purchase_units'][0]['payments']['refunds'] ) ) {
			foreach ( $response['purchase_units'][0]['payments']['refunds'] as $refund ) {
				if ( 'COMPLETED' !== $refund['status'] ) {
					continue;
				}
				$result['has_refunds']    = true;
				$result['status']         = 'refunded';
				$result['refunds'][]      = array(
					'amount'   => $refund['amount']['value'],
					'currency' => $refund['amount']['currency_code'],
					'date'     => wp_date( 'Y-m-d H:i:s', strtotime( $refund['create_time'] ) ),
					'order_id' => ! ctype_digit( $refund['invoice_id'] ) ? '' : (int) $refund['invoice_id'],
				);
				$result['total_refunds'] += (float) $refund['amount']['value'];
			}
		}
		// Is the dashboard link already stored?
		if ( empty( $json['metadata']['dashboard_link'] ) && ! empty( $json['metadata']['capture_id'] ) ) {
			$environment = $test ? 'sandbox.' : '';
			$link        = 'https://www.' . $environment . 'paypal.com/unifiedtransactions//details/payment/' . $json['metadata']['capture_id'];
			$ids         = empty( $json['orders'] ) ? array( (int) $order->id ) : array_map( 'intval', $json['orders'] );
			$wpdb->query(
				$wpdb->prepare(
					"UPDATE {$wpdb->prefix}fe_event_orders SET input_fields = " .
					"JSON_INSERT(input_fields, '$.metadata', JSON_OBJECT(), '$.metadata.dashboard_link', %s ) " .
					'WHERE id IN (' . implode( ',', $ids ) . ')', // phpcs:ignore
					$link
				)
			);
			$json['metadata']['dashboard_link'] = $link;
		}
		$result['dashboard_link'] = ! empty( $json['metadata']['dashboard_link'] ) ? $json['metadata']['dashboard_link'] : '';

		return $result;
	}

	/**
	 * Capture the payment.
	 *
	 * @since 3.0.0
	 *
	 * @param string   $payment_id The unique ID of the payment.
	 * @param int      $user_id    The ID of the user.
	 * @param stdClass $order      The order object.
	 * @param bool     $test       Use test mode or not (default: false).
	 * @return array|WP_Error      Returns an array with the payment details.
	 */
	public function capture_payment( string $payment_id, int $user_id, stdClass $order, bool $test = false ): array|WP_Error {
		global $wpdb;

		// Capture the order if it is authorized and not expired/canceled/failed.
		// If it did expire, cancel the order instead.
		$json          = json_decode( $order->input_fields, true );
		$ids           = empty( $json['orders'] ) ? array( (int) $order->id ) : array_map( 'intval', $json['orders'] );
		$this->payload = array();
		$this->set_headers( $user_id, $test );
		if ( 'authorized' === $order->payment_status && empty( $json['metadata']['expired_at'] ) && empty( $json['metadata']['canceled_at'] ) && empty( $json['metadata']['failed_at'] ) ) {
			$response = $this->api_call( $this->base_url( $test ) . 'v2/checkout/orders/' . $payment_id . '/capture', 'POST' );
			if ( ! is_wp_error( $response ) ) {
				$capture_id = $response['purchase_units'][0]['payments']['captures'][0]['id'];
				$wpdb->query(
					"UPDATE {$wpdb->prefix}fe_event_orders SET " .
					"input_fields = JSON_INSERT(input_fields, '$.metadata', JSON_OBJECT(), '$.metadata.capture_id', '$capture_id' ) " . // phpcs:ignore
					'WHERE id IN (' . implode( ',', $ids ) . ')' // phpcs:ignore
				);
			} else {
				// It failed-> log the error.
				$wpdb->query(
					$wpdb->prepare(
						"UPDATE {$wpdb->prefix}fe_event_orders SET " .
						"input_fields = JSON_INSERT(input_fields, '$.metadata', JSON_OBJECT(), '$.metadata.failed_msg', %s ) " .
						'WHERE id IN (' . implode( ',', $ids ) . ')', // phpcs:ignore
						$response->get_error_message()
					)
				);

				// Log the error for each order.
				foreach ( $ids as $key => $order_id ) {
					$this->logger->warning(
						$response->get_error_message(),
						array(
							'type'     => 'PayPal webhook',
							'order_id' => (int) $order_id,
							'event_id' => (int) $json['events'][ $key ],
							'provider' => self::$provider_name,
						)
					);
				}
			}
		} elseif ( ! empty( $json['metadata']['expired_at'] ) ) {
			// The user approved the order, but it expired. Show in payment details that it was expired and no charge was made.
			$wpdb->query(
				$wpdb->prepare(
					"UPDATE {$wpdb->prefix}fe_event_orders SET payment_status ='expired', " .
					"input_fields = JSON_INSERT(input_fields, '$.metadata', JSON_OBJECT(), '$.metadata.extra_msg', %s ) " .
					'WHERE id IN (' . implode( ',', $ids ) . ')', // phpcs:ignore
					__( 'The user tried, after the order had expired, to approve it → the payment was cancelled and no charge was made', 'fast-events' )
				)
			);

			// Log the error for each order.
			foreach ( $ids as $key => $order_id ) {
				$this->logger->warning(
					esc_html__( 'The user tried, after the order had expired, to approve it → the payment was cancelled and no charge was made', 'fast-events' ),
					array(
						'type'     => 'PayPal webhook',
						'order_id' => (int) $order_id,
						'event_id' => (int) $json['events'][ $key ],
						'provider' => self::$provider_name,
					)
				);
			}
		} elseif ( ! empty( $json['metadata']['canceled_at'] ) ) {
			// The user approved the order, but it has been canceled. Show in payment details that it was canceled and no charge was made.
			$wpdb->query(
				$wpdb->prepare(
					"UPDATE {$wpdb->prefix}fe_event_orders SET payment_status ='canceled', " .
					"input_fields = JSON_INSERT(input_fields, '$.metadata', JSON_OBJECT(), '$.metadata.extra_msg', %s ) " .
					'WHERE id IN (' . implode( ',', $ids ) . ')', // phpcs:ignore
					__( 'The user tried, after the order has been canceled, to approve it → the payment was terminated and no charge was made', 'fast-events' )
				)
			);

			// Log the error for each order.
			foreach ( $ids as $key => $order_id ) {
				$this->logger->warning(
					esc_html__( 'The user tried, after the order has been canceled, to approve it → the payment was terminated and no charge was made', 'fast-events' ),
					array(
						'type'     => 'PayPal webhook',
						'order_id' => (int) $order_id,
						'event_id' => (int) $json['events'][ $key ],
						'provider' => self::$provider_name,
					)
				);
			}
		} elseif ( ! empty( $json['metadata']['failed_at'] ) ) {
			// The user approved the order, but it did earlier fail. Show in payment details that it failed and no charge was made.
			$wpdb->query(
				$wpdb->prepare(
					"UPDATE {$wpdb->prefix}fe_event_orders SET payment_status ='failed', " .
					"input_fields = JSON_INSERT(input_fields, '$.metadata', JSON_OBJECT(), '$.metadata.extra_msg', %s ) " .
					'WHERE id IN (' . implode( ',', $ids ) . ')', // phpcs:ignore
					__( 'After the order failed, the user tried to approve it. The payment was terminated and no charge was made', 'fast-events' )
				)
			);

			// Log the error for each order.
			foreach ( $ids as $key => $order_id ) {
				$this->logger->warning(
					esc_html__( 'After the order failed, the user tried to approve it. → the payment was terminated and no charge was made', 'fast-events' ),
					array(
						'type'     => 'PayPal webhook',
						'order_id' => (int) $order_id,
						'event_id' => (int) $json['events'][ $key ],
						'provider' => self::$provider_name,
					)
				);
			}
		}

		return array();
	}

	/**
	 * Cancel a payment.
	 * Not used at the moment.
	 *
	 * @since 3.0.0
	 *
	 * @param string $payment_id The unique ID of the payment.
	 * @param int    $user_id    The ID of the user.
	 * @param bool   $test       Use test mode or not (default: false).
	 * @return array|WP_Error Returns an array with the payment details ('id' and 'url').
	 */
	public function cancel_payment( string $payment_id, int $user_id, bool $test = false ): array|WP_Error {
		return array();
	}

	/**
	 * Refund a payment.
	 *
	 * @since 3.0.0
	 *
	 * @param stdClass $order       The order object.
	 * @param string   $description The description of the payment.
	 * @param float    $amount      The amount of the payment.
	 * @param array    $metadata    Any metadata you want to store with the payment.
	 * @param int      $user_id     The ID of the user.
	 * @param bool     $test        Use test mode or not (default: false).
	 * @return array|WP_Error       Returns an array with the payment details ('id' and 'url').
	 */
	public function refund( stdClass $order, string $description, float $amount, array $metadata, int $user_id, bool $test = false ): array|WP_Error {
		$this->payload = array(
			'invoice_id'    => $metadata['order_id'],
			'note_to_payer' => $description,
			'amount'        => array(
				'currency_code' => $this->settings->currency(),
				'value'         => number_format( $amount, 2, '.', '' ),
			),
		);
		$this->set_headers( $user_id, $test );
		$json       = json_decode( $order->input_fields, true );
		$capture_id = $json['metadata']['capture_id'] ?? '';
		$response   = $this->api_call( $this->base_url( $test ) . 'v2/payments/captures/' . $capture_id . '/refund', 'POST' );
		if ( is_wp_error( $response ) ) {
			return $response;
		}

		// Return the refund id.
		return array(
			'id' => $response['id'],
		);
	}

	/**
	 * Generate the authorization URL (OAuth url) for the payment provider.
	 *
	 * @since 3.0.0
	 *
	 * @param WP_User $user The user object.
	 * @return string The authorization URL.
	 */
	public function get_oauth_url( WP_User $user ): string {
		return 'ok';
	}

	/**
	 * Revoke SaaS authorization.
	 *
	 * @since 3.0.0
	 *
	 * @param int $user_id The ID of the user.
	 * @return WP_Error|string
	 */
	public function revoke_authorization( int $user_id ): WP_Error|string {
		return '';
	}

	/**
	 * Get an access token from auth code.
	 *
	 * @param int    $user_id The ID of the user.
	 * @param string $state   The state (nonce) parameter from the webhook request.
	 * @param array  $params  The parameters in the webhook request.
	 * @return WP_Error|string The authorization URL.
	 *
	 * @since 3.0.0
	 */
	public function get_access_token_from_auth_token( int $user_id, string $state, array $params ): WP_Error|string {

		return 'ok';
	}

	/**
	 * Get an access token from auth code.
	 *
	 * @param int $user_id The ID of the user.
	 * @return string The access token.
	 *
	 * @since 3.0.0
	 */
	public function get_access_token( int $user_id ): string {
		return 'ok';
	}

	/**
	 * The provider-specific default headers.
	 *
	 * @since 3.0.0
	 *
	 * @return array Returns the default headers.
	 */
	protected function default_headers(): array {
		return array(
			'Accept'       => 'application/json',
			'Content-Type' => 'application/json',
		);
	}

	/**
	 * The provider-specific error analysis.
	 *
	 * @since 3.0.0
	 *
	 * @param array $error  The error body payload.
	 * @return string Returns the proper error message.
	 */
	protected function analyse_error( array $error ): string {
		return ! empty( $error['details'][0]['issue'] )
			? $error['details'][0]['issue'] . ': ' . ( $error['details'][0]['description'] ?? '' )
			: ( $error['name'] ?? '' ) . ': ' . ( $error['message'] ?? '' );
	}

	/**
	 * If we are operating in SaaS mode, we need to set additional fields for the Mollie API call.
	 *
	 * @since 3.0.0
	 *
	 * @param EventsHelper $events_helper The events helper.
	 * @param bool         $test Use test mode or not (default: false).
	 */
	private function set_saas_fields( EventsHelper $events_helper, bool $test ): void {}

	/**
	 * Create the headers for the Mollie API call.
	 *
	 * @since 3.0.0
	 *
	 * @param int  $user_id The ID of the user.
	 * @param bool $test    Use test mode or not (default: false).
	 */
	private function set_headers( int $user_id, bool $test ): void {
		$this->headers['Prefer']        = 'return=representation';
		$this->headers['Authorization'] = 'Bearer ' . $this->get_account_access_token( $test );
	}

	/**
	 * Get an access token for API calls.
	 *
	 * The payload (there are more properties!) is a JSON object with the following structure for this method:
	 *
	 * ```json
	 * {
	 *   "oauth_live": {
	 *      "access_token": "some_string",
	 *      "expires_in": 3600,
	 *      "local_time": 1606582800
	 *   },
	 *   "oauth_test": {
	 *     "access_token": "some_string",
	 *     "expires_in": 3600,
	 *     "local_time": 1606582800
	 *   }
	 * }
	 * ```
	 *
	 * @since 3.0.0
	 *
	 * @param bool $test  Is this the test environment?.
	 *
	 * @return string Returns the proper error message.
	 */
	private function get_account_access_token( bool $test ): string {
		$renew = false;
		$info  = $this->options->get_provider_data( self::$provider_name );
		$key   = $test ? 'oauth_test' : 'oauth_live';
		if ( empty( $info[ $key ]['access_token'] ) || ! isset( $info[ $key ]['local_time'] ) || ! isset( $info[ $key ]['expires_in'] ) ) {
			$this->logger->warning(
				'Empty token information -> creating new access token for ' . ( $test ? 'test' : 'live' ) . ' environment',
				array(
					'type'     => 'Paypal OAuth',
					'provider' => self::$provider_name,
				)
			);
			$renew = true;
		}

		// Is the access token still valid? We use a 60-second safety margin.
		if ( ! $renew ) {
			if ( ( $info[ $key ]['local_time'] + $info[ $key ]['expires_in'] - 60 ) > TimeUtil::local_time() ) {
				return $info[ $key ]['access_token'];
			}
		}

		// Get the new access token.
		$this->logger->debug(
			'Renewing access token for ' . ( $test ? 'test' : 'live' ) . ' environment',
			array(
				'type'     => 'Paypal OAuth',
				'provider' => self::$provider_name,
			)
		);
		$save_payload  = $this->payload;
		$save_headers  = $this->headers;
		$save_encoder  = self::$encoder;
		$client_id     = $test ? 'test_client_id' : 'live_client_id';
		$client_secret = $test ? 'test_secret' : 'live_secret';
		$this->payload = array( 'grant_type' => 'client_credentials' );
		self::$encoder = 'http_build_query';
		$this->headers = array(
			'Authorization' => 'Basic ' . base64_encode( $this->settings->payment_property( $client_id ) . ':' . $this->settings->payment_property( $client_secret ) ), // phpcs:ignore
		);
		$response      = $this->api_call( $this->base_url( $test ) . 'v1/oauth2/token', 'POST' );
		$this->payload = $save_payload;
		$this->headers = $save_headers;
		self::$encoder = $save_encoder;
		if ( is_wp_error( $response ) ) {
			$this->logger->warning(
				'Malformed access token information',
				array(
					'type'     => 'Paypal OAuth',
					'provider' => self::$provider_name,
					'body'     => $response,
				)
			);
			return '';
		}
		$info[ $key ]['access_token'] = $response['access_token'];
		$info[ $key ]['expires_in']   = $response['expires_in'];
		$info[ $key ]['local_time']   = TimeUtil::local_time();
		$this->options->set_provider_data( self::$provider_name, $info );
		return $response['access_token'];
	}

	/**
	 * Get the PayPal base URL.
	 *
	 * @since 3.0.0
	 *
	 * @param bool $test  Is this the test environment?.
	 * @return string Returns the base URL.
	 */
	private function base_url( bool $test ): string {
		return $test ? self::BASE_SANDBOX_URL : self::BASE_URL;
	}
}
