<?php
/**
 * Stripe Checkout payment provider.
 *
 * @package WPDudeProducts
 */

declare(strict_types=1);

namespace WPDude\Products\Providers\Stripe;

use WPDude\Products\Logging\Logger;
use WPDude\Products\REST\REST_Loader;
use WPDude\Products\Settings\Settings;
use Stripe\Checkout\Session;
use Stripe\Exception\SignatureVerificationException;
use Stripe\File;
use Stripe\FileLink;
use Stripe\Refund;
use Stripe\Stripe;
use Stripe\StripeClient;
use Stripe\Webhook;

/**
 * Stripe-hosted Checkout implementation.
 */
final class Stripe_Provider {

	/**
	 * Configure Stripe SDK API key.
	 *
	 * @return true|\WP_Error
	 */
	private function configure() {
		$secret = Settings::get_secret_key();
		if ( '' === $secret ) {
			return new \WP_Error( 'stripe_not_configured', __( 'Stripe is not configured.', 'dude-commerce' ) );
		}
		Stripe::setApiKey( $secret );
		Stripe::setAppInfo( 'Dude Commerce', WPDUDE_DC_VERSION, home_url( '/' ) );
		return true;
	}

	/**
	 * Whether Stripe's servers can fetch this image URL (not Local / loopback).
	 */
	public static function url_is_publicly_fetchable( string $url ): bool {
		$host = wp_parse_url( $url, PHP_URL_HOST );
		if ( ! is_string( $host ) || '' === $host ) {
			return false;
		}
		$host = strtolower( $host );
		if ( in_array( $host, array( 'localhost', '127.0.0.1', '::1' ), true ) ) {
			return false;
		}
		if ( preg_match( '/\.(local|localhost|test|invalid)$/', $host ) ) {
			return false;
		}
		$scheme = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) );
		return in_array( $scheme, array( 'http', 'https' ), true );
	}

	/**
	 * Return a URL Stripe Checkout can fetch for an attachment.
	 *
	 * Public site URLs are used as-is. On Local / private hosts, uploads a Stripe
	 * File + FileLink so Checkout can display the image.
	 */
	public function ensure_public_image_url( int $attachment_id, string $site_url = '' ): string {
		$attachment_id = absint( $attachment_id );
		if ( $attachment_id <= 0 ) {
			return '';
		}

		if ( '' !== $site_url && self::url_is_publicly_fetchable( $site_url ) ) {
			return $site_url;
		}

		$meta_key = '_wpdude_dc_stripe_file_link';
		$cached   = get_post_meta( $attachment_id, $meta_key, true );
		if ( is_array( $cached ) ) {
			$url     = (string) ( $cached['url'] ?? '' );
			$expires = (int) ( $cached['expires'] ?? 0 );
			if ( '' !== $url && $expires > ( time() + DAY_IN_SECONDS ) ) {
				return $url;
			}
		}

		$configured = $this->configure();
		if ( is_wp_error( $configured ) ) {
			return $site_url;
		}

		$path = get_attached_file( $attachment_id );
		if ( ! is_string( $path ) || '' === $path || ! is_readable( $path ) ) {
			return $site_url;
		}

		$handle = fopen( $path, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen
		if ( false === $handle ) {
			return $site_url;
		}

		try {
			$file = File::create(
				array(
					'file'    => $handle,
					'purpose' => 'business_logo',
				)
			);
			$expires_at = time() + ( 2 * YEAR_IN_SECONDS );
			$link       = FileLink::create(
				array(
					'file'       => $file->id,
					'expires_at' => $expires_at,
				)
			);
			$url = (string) ( $link->url ?? '' );
			if ( '' !== $url ) {
				update_post_meta(
					$attachment_id,
					$meta_key,
					array(
						'url'     => $url,
						'expires' => $expires_at,
						'file'    => (string) $file->id,
					)
				);
				return $url;
			}
		} catch ( \Throwable $e ) {
			Logger::instance()->warning(
				'Could not upload product image to Stripe for Checkout',
				array(
					'attachment_id' => $attachment_id,
					'error'         => substr( $e->getMessage(), 0, 300 ),
				)
			);
		} finally {
			if ( is_resource( $handle ) ) {
				fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
			}
		}

		return $site_url;
	}

	/**
	 * Create a Checkout Session.
	 *
	 * @param array<string, mixed> $args Session args (+ optional idempotency_key).
	 * @return array<string, mixed>|\WP_Error
	 */
	public function create_checkout_session( array $args ) {
		$configured = $this->configure();
		if ( is_wp_error( $configured ) ) {
			return $configured;
		}

		$idempotency_key = (string) ( $args['idempotency_key'] ?? '' );
		unset( $args['idempotency_key'] );

		try {
			$params = array();
			if ( $idempotency_key ) {
				$params['idempotency_key'] = $idempotency_key;
			}
			$session = Session::create( $args, $params );
			return $session->toArray();
		} catch ( \Throwable $e ) {
			$message = $e->getMessage();
			Logger::instance()->error(
				'Stripe Checkout Session creation failed',
				array(
					'error_class'   => get_class( $e ),
					'error_message' => substr( $message, 0, 500 ),
				)
			);

			$user_message = __( 'We could not start the checkout. Please try again.', 'dude-commerce' );
			if ( Settings::is_test_mode() && '' !== $message ) {
				$user_message = sprintf(
					/* translators: %s: Stripe API error message (test mode only) */
					__( 'We could not start the checkout: %s', 'dude-commerce' ),
					$message
				);
			}

			return new \WP_Error( 'stripe_error', $user_message, array( 'status' => 400 ) );
		}
	}

	/**
	 * Verify webhook signature.
	 *
	 * @return array<string, mixed>|\WP_Error
	 */
	public function verify_webhook( string $payload, string $signature ) {
		$secret = Settings::get_webhook_secret();
		if ( '' === $secret ) {
			return new \WP_Error( 'webhook_not_configured', __( 'Webhook secret is not configured.', 'dude-commerce' ), array( 'status' => 500 ) );
		}

		try {
			$event = Webhook::constructEvent( $payload, $signature, $secret );
			return $event->toArray();
		} catch ( SignatureVerificationException $e ) {
			Logger::instance()->warning( 'Invalid Stripe webhook signature' );
			return new \WP_Error( 'invalid_signature', __( 'Invalid signature.', 'dude-commerce' ), array( 'status' => 400 ) );
		} catch ( \Throwable $e ) {
			Logger::instance()->error( 'Stripe webhook verification failed', array( 'error_class' => get_class( $e ) ) );
			return new \WP_Error( 'webhook_error', __( 'Webhook error.', 'dude-commerce' ), array( 'status' => 400 ) );
		}
	}

	/**
	 * Build a StripeClient with the configured secret key.
	 *
	 * @return StripeClient|\WP_Error
	 */
	private function client() {
		$configured = $this->configure();
		if ( is_wp_error( $configured ) ) {
			return $configured;
		}
		$secret = Settings::get_secret_key();
		if ( '' === $secret ) {
			return new \WP_Error( 'stripe_not_configured', __( 'Stripe is not configured.', 'dude-commerce' ) );
		}
		return new StripeClient( $secret );
	}

	/**
	 * Retrieve a Checkout Session.
	 *
	 * @return array<string, mixed>|\WP_Error
	 */
	public function retrieve_session( string $session_id ) {
		$client = $this->client();
		if ( is_wp_error( $client ) ) {
			return $client;
		}

		try {
			$session = $client->checkout->sessions->retrieve(
				$session_id,
				array(
					'expand' => array( 'payment_intent.latest_charge.balance_transaction' ),
				)
			);
			return $session->toArray();
		} catch ( \Throwable $e ) {
			Logger::instance()->error( 'Stripe session retrieve failed', array( 'error_class' => get_class( $e ) ) );
			return new \WP_Error( 'stripe_error', __( 'Unable to retrieve checkout session.', 'dude-commerce' ) );
		}
	}

	/**
	 * Radar risk + processing fee from a Payment Intent (via latest charge).
	 *
	 * @return array{level: string, score: int|null, fee: int|null, net: int|null, currency: string}
	 */
	public function get_payment_charge_details( string $payment_intent_id ): array {
		$empty = self::empty_charge_details();

		$payment_intent_id = sanitize_text_field( $payment_intent_id );
		if ( '' === $payment_intent_id || ! preg_match( '/^pi_[a-zA-Z0-9]+$/', $payment_intent_id ) ) {
			return $empty;
		}

		$client = $this->client();
		if ( is_wp_error( $client ) ) {
			return $empty;
		}

		try {
			// Service API: expand belongs in params (not RequestOptions).
			$intent  = $client->paymentIntents->retrieve(
				$payment_intent_id,
				array(
					'expand' => array( 'latest_charge.balance_transaction' ),
				)
			);
			$payload = $intent->toArray();
			$details = self::charge_details_from_payment_intent( $payload );

			if ( '' === $details['level'] || null === $details['fee'] ) {
				$details = $this->enrich_charge_details( $client, $details, $payload, $payment_intent_id );
			}

			return $details;
		} catch ( \Throwable $e ) {
			Logger::instance()->warning(
				'Stripe payment charge details retrieve failed',
				array(
					'error_class' => get_class( $e ),
					'pi'          => $payment_intent_id,
				)
			);
			return $empty;
		}
	}

	/**
	 * Fetch risk/fee when latest_charge is missing, unexpanded, or incomplete.
	 *
	 * @param StripeClient                                                                           $client Stripe client.
	 * @param array{level: string, score: int|null, fee: int|null, net: int|null, currency: string} $details Existing.
	 * @param array<string, mixed>                                                                   $payment_intent PI array.
	 * @param string                                                                                 $payment_intent_id PI id.
	 * @return array{level: string, score: int|null, fee: int|null, net: int|null, currency: string}
	 */
	private function enrich_charge_details( StripeClient $client, array $details, array $payment_intent, string $payment_intent_id ): array {
		$charge_id = self::resolve_charge_id_from_payment_intent( $payment_intent );

		if ( '' === $charge_id ) {
			try {
				$list = $client->charges->all(
					array(
						'payment_intent' => $payment_intent_id,
						'limit'          => 1,
					)
				);
				$rows = $list->toArray();
				$data = is_array( $rows['data'] ?? null ) ? $rows['data'] : array();
				if ( isset( $data[0]['id'] ) ) {
					$charge_id = sanitize_text_field( (string) $data[0]['id'] );
					// Prefer details from the listed charge immediately when expand isn't needed.
					if ( is_array( $data[0] ) ) {
						$details = self::merge_charge_detail_arrays( $details, self::charge_details_from_charge( $data[0] ) );
					}
				}
			} catch ( \Throwable $e ) {
				Logger::instance()->warning(
					'Stripe charges list for PI failed',
					array(
						'error_class' => get_class( $e ),
						'pi'          => $payment_intent_id,
					)
				);
			}
		}

		if ( '' !== $charge_id && preg_match( '/^ch_[a-zA-Z0-9]+$/', $charge_id ) ) {
			try {
				$charge_obj  = $client->charges->retrieve(
					$charge_id,
					array(
						'expand' => array( 'balance_transaction' ),
					)
				);
				$from_charge = self::charge_details_from_charge( $charge_obj->toArray() );
				$details     = self::merge_charge_detail_arrays( $details, $from_charge );
			} catch ( \Throwable $e ) {
				Logger::instance()->warning(
					'Stripe charge details retrieve failed',
					array(
						'error_class' => get_class( $e ),
						'charge'      => $charge_id,
					)
				);
			}
		} elseif ( null === $details['fee'] ) {
			$charge = self::resolve_charge_from_payment_intent( $payment_intent );
			if ( is_array( $charge ) ) {
				$balance_id = $charge['balance_transaction'] ?? null;
				if ( is_string( $balance_id ) && preg_match( '/^txn_[a-zA-Z0-9]+$/', $balance_id ) ) {
					try {
						$txn                           = $client->balanceTransactions->retrieve( $balance_id )->toArray();
						$charge['balance_transaction'] = $txn;
						$details                       = self::merge_charge_detail_arrays( $details, self::charge_details_from_charge( $charge ) );
					} catch ( \Throwable $e ) {
						Logger::instance()->warning(
							'Stripe balance transaction retrieve failed',
							array(
								'error_class' => get_class( $e ),
								'txn'         => $balance_id,
							)
						);
					}
				}
			}
		}

		// Successful charge without Radar risk_level → still record a level so UI isn't blank.
		if ( '' === $details['level'] ) {
			$charge = self::resolve_charge_from_payment_intent( $payment_intent );
			$status = is_array( $charge ) ? (string) ( $charge['status'] ?? '' ) : '';
			if ( 'succeeded' === $status || '' !== $charge_id ) {
				$details['level'] = 'not_assessed';
			}
		}

		return $details;
	}

	/**
	 * Charge id string from a Payment Intent payload.
	 *
	 * @param array<string, mixed> $payment_intent Payment Intent.
	 */
	public static function resolve_charge_id_from_payment_intent( array $payment_intent ): string {
		$charge = self::resolve_charge_from_payment_intent( $payment_intent );
		if ( is_string( $charge ) && preg_match( '/^ch_[a-zA-Z0-9]+$/', $charge ) ) {
			return $charge;
		}
		if ( is_array( $charge ) ) {
			$id = sanitize_text_field( (string) ( $charge['id'] ?? '' ) );
			if ( preg_match( '/^ch_[a-zA-Z0-9]+$/', $id ) ) {
				return $id;
			}
		}
		return '';
	}

	/**
	 * @param array{level: string, score: int|null, fee: int|null, net: int|null, currency: string} $base
	 * @param array{level: string, score: int|null, fee: int|null, net: int|null, currency: string} $overlay
	 * @return array{level: string, score: int|null, fee: int|null, net: int|null, currency: string}
	 */
	private static function merge_charge_detail_arrays( array $base, array $overlay ): array {
		if ( '' === $base['level'] && '' !== $overlay['level'] ) {
			$base['level'] = $overlay['level'];
		}
		if ( null === $base['score'] && null !== $overlay['score'] ) {
			$base['score'] = $overlay['score'];
		}
		if ( null === $base['fee'] && null !== $overlay['fee'] ) {
			$base['fee'] = $overlay['fee'];
		}
		if ( null === $base['net'] && null !== $overlay['net'] ) {
			$base['net'] = $overlay['net'];
		}
		if ( '' === $base['currency'] && '' !== $overlay['currency'] ) {
			$base['currency'] = $overlay['currency'];
		}
		return $base;
	}

	/**
	 * @deprecated Use get_payment_charge_details().
	 *
	 * @return array{level: string, score: int|null}
	 */
	public function get_payment_risk( string $payment_intent_id ): array {
		$details = $this->get_payment_charge_details( $payment_intent_id );
		return array(
			'level' => $details['level'],
			'score' => $details['score'],
		);
	}

	/**
	 * @return array{level: string, score: int|null, fee: int|null, net: int|null, currency: string}
	 */
	public static function empty_charge_details(): array {
		return array(
			'level'    => '',
			'score'    => null,
			'fee'      => null,
			'net'      => null,
			'currency' => '',
		);
	}

	/**
	 * Extract charge details from a Payment Intent array.
	 *
	 * @param array<string, mixed> $payment_intent Payment Intent.
	 * @return array{level: string, score: int|null, fee: int|null, net: int|null, currency: string}
	 */
	public static function charge_details_from_payment_intent( array $payment_intent ): array {
		$charge = self::resolve_charge_from_payment_intent( $payment_intent );
		if ( is_array( $charge ) ) {
			return self::charge_details_from_charge( $charge );
		}
		return self::empty_charge_details();
	}

	/**
	 * latest_charge object/id, or first item from charges.data (older API shapes).
	 *
	 * @param array<string, mixed> $payment_intent Payment Intent.
	 * @return array<string, mixed>|string|null
	 */
	public static function resolve_charge_from_payment_intent( array $payment_intent ) {
		$charge = $payment_intent['latest_charge'] ?? null;
		if ( is_array( $charge ) || ( is_string( $charge ) && '' !== $charge ) ) {
			return $charge;
		}

		$charges = $payment_intent['charges']['data'] ?? null;
		if ( is_array( $charges ) && isset( $charges[0] ) && is_array( $charges[0] ) ) {
			return $charges[0];
		}

		return null;
	}

	/**
	 * Extract Radar risk and processing fee from a Charge array.
	 *
	 * @param array<string, mixed> $charge Charge.
	 * @return array{level: string, score: int|null, fee: int|null, net: int|null, currency: string}
	 */
	public static function charge_details_from_charge( array $charge ): array {
		$details = self::empty_charge_details();
		$risk    = self::risk_from_charge( $charge );
		$details['level'] = $risk['level'];
		$details['score'] = $risk['score'];

		$balance = $charge['balance_transaction'] ?? null;
		if ( ! is_array( $balance ) ) {
			return $details;
		}

		if ( isset( $balance['fee'] ) && is_numeric( $balance['fee'] ) ) {
			$details['fee'] = max( 0, (int) $balance['fee'] );
		}
		if ( isset( $balance['net'] ) && is_numeric( $balance['net'] ) ) {
			$details['net'] = (int) $balance['net'];
		}
		$details['currency'] = strtolower( sanitize_text_field( (string) ( $balance['currency'] ?? '' ) ) );

		return $details;
	}

	/**
	 * Extract Radar risk_level / risk_score from a Payment Intent array.
	 *
	 * @param array<string, mixed> $payment_intent Payment Intent.
	 * @return array{level: string, score: int|null}
	 */
	public static function risk_from_payment_intent( array $payment_intent ): array {
		$details = self::charge_details_from_payment_intent( $payment_intent );
		return array(
			'level' => $details['level'],
			'score' => $details['score'],
		);
	}

	/**
	 * Extract Radar risk from a Charge array.
	 *
	 * @param array<string, mixed> $charge Charge.
	 * @return array{level: string, score: int|null}
	 */
	public static function risk_from_charge( array $charge ): array {
		$outcome = is_array( $charge['outcome'] ?? null ) ? $charge['outcome'] : array();
		$level   = sanitize_key( (string) ( $outcome['risk_level'] ?? '' ) );
		$allowed = array( 'normal', 'elevated', 'highest', 'not_assessed', 'unknown' );
		if ( ! in_array( $level, $allowed, true ) ) {
			$level = '';
		}

		$score = null;
		if ( isset( $outcome['risk_score'] ) && is_numeric( $outcome['risk_score'] ) ) {
			$score = max( 0, min( 100, (int) $outcome['risk_score'] ) );
		}

		return array(
			'level' => $level,
			'score' => $score,
		);
	}

	/**
	 * Full refund for a Payment Intent.
	 *
	 * @return array<string, mixed>|\WP_Error
	 */
	public function refund_payment_intent( string $payment_intent_id ) {
		$payment_intent_id = sanitize_text_field( $payment_intent_id );
		if ( '' === $payment_intent_id || ! preg_match( '/^pi_[a-zA-Z0-9]+$/', $payment_intent_id ) ) {
			return new \WP_Error( 'invalid_payment_intent', __( 'Missing or invalid Payment Intent.', 'dude-commerce' ) );
		}

		$configured = $this->configure();
		if ( is_wp_error( $configured ) ) {
			return $configured;
		}

		try {
			$refund = Refund::create(
				array(
					'payment_intent' => $payment_intent_id,
				)
			);
			return $refund->toArray();
		} catch ( \Throwable $e ) {
			Logger::instance()->error(
				'Stripe refund failed',
				array(
					'error_class' => get_class( $e ),
					'message'     => $e->getMessage(),
				)
			);
			$message = $e->getMessage();
			if ( '' === $message ) {
				$message = __( 'Unable to refund this payment in Stripe.', 'dude-commerce' );
			}
			return new \WP_Error( 'stripe_refund_failed', $message );
		}
	}

	/**
	 * Stripe Dashboard URL for a Payment Intent (test or live).
	 */
	public static function dashboard_payment_url( string $payment_intent_id ): string {
		$payment_intent_id = sanitize_text_field( $payment_intent_id );
		if ( '' === $payment_intent_id || ! preg_match( '/^pi_[a-zA-Z0-9]+$/', $payment_intent_id ) ) {
			return '';
		}
		$base = Settings::is_test_mode()
			? 'https://dashboard.stripe.com/test/payments/'
			: 'https://dashboard.stripe.com/payments/';
		return $base . rawurlencode( $payment_intent_id );
	}

	/**
	 * Lightweight connectivity check.
	 *
	 * @return true|\WP_Error
	 */
	public function ping() {
		$configured = $this->configure();
		if ( is_wp_error( $configured ) ) {
			return $configured;
		}
		try {
			\Stripe\Balance::retrieve();
			return true;
		} catch ( \Throwable $e ) {
			return new \WP_Error( 'stripe_error', __( 'Unable to connect to Stripe with the configured keys.', 'dude-commerce' ) );
		}
	}

	/**
	 * Test-mode probe: create and expire a Checkout Session so Stripe sends
	 * checkout.session.expired to the configured webhook, then wait for receipt.
	 *
	 * @return true|\WP_Error
	 */
	public function probe_webhook() {
		if ( ! Settings::is_test_mode() ) {
			return new \WP_Error(
				'probe_test_only',
				__( 'Webhook probe runs in test mode only.', 'dude-commerce' )
			);
		}

		if ( '' === Settings::get_webhook_secret() ) {
			return new \WP_Error(
				'webhook_not_configured',
				__( 'Add a test webhook signing secret before probing.', 'dude-commerce' )
			);
		}

		$configured = $this->configure();
		if ( is_wp_error( $configured ) ) {
			return $configured;
		}

		$endpoint_hint = $this->describe_matching_webhook_endpoint();
		if ( is_wp_error( $endpoint_hint ) ) {
			return $endpoint_hint;
		}

		$currency   = Settings::get_currency();
		$home       = home_url( '/' );
		$probe_at   = gmdate( 'Y-m-d H:i:s' );
		$session_id = '';

		try {
			$session = Session::create(
				array(
					'mode'        => 'payment',
					'success_url' => $home,
					'cancel_url'  => $home,
					'line_items'  => array(
						array(
							'quantity'   => 1,
							'price_data' => array(
								'currency'     => $currency,
								'unit_amount'  => 100,
								'product_data' => array(
									'name' => 'Dude Commerce webhook probe',
								),
							),
						),
					),
					'metadata'    => array(
						'wpdude_dc_probe' => '1',
					),
					'expires_at'  => time() + 1800,
				),
				array(
					'idempotency_key' => 'wpdude_dc_probe_' . wp_generate_uuid4(),
				)
			);

			$session_id = (string) $session->id;
			$session->expire();
		} catch ( \Throwable $e ) {
			Logger::instance()->error(
				'Stripe webhook probe failed',
				array(
					'error_class'   => get_class( $e ),
					'error_message' => substr( $e->getMessage(), 0, 500 ),
				)
			);
			return new \WP_Error(
				'probe_failed',
				__( 'Could not create a Stripe test Checkout Session for the webhook probe.', 'dude-commerce' )
			);
		}

		$deadline = time() + 20;
		while ( time() <= $deadline ) {
			if ( $this->has_stripe_event_since( $probe_at ) ) {
				return true;
			}
			sleep( 1 );
		}

		$local = $this->self_test_webhook_endpoint();
		$parts = array(
			sprintf(
				/* translators: %s: Checkout Session id */
				__( 'Stripe created and expired session %s, but no webhook was recorded by Dude Commerce within 20 seconds.', 'dude-commerce' ),
				$session_id ? $session_id : '(unknown)'
			),
		);

		if ( is_wp_error( $local ) ) {
			$parts[] = sprintf(
				/* translators: %s: error detail */
				__( 'Internal webhook handler check failed: %s', 'dude-commerce' ),
				$local->get_error_message()
			);
		} else {
			$parts[] = __( 'Internal webhook handler check passed (signature + route OK). Stripe is not delivering to this site, or deliveries are failing before WordPress — check that endpoint’s Recent deliveries in Stripe.', 'dude-commerce' );
		}

		if ( is_string( $endpoint_hint ) && '' !== trim( $endpoint_hint ) ) {
			$parts[] = trim( $endpoint_hint );
		}

		return new \WP_Error( 'webhook_not_received', implode( ' ', $parts ) );
	}

	/**
	 * Match our REST webhook URL against Stripe Test-mode webhook endpoints.
	 *
	 * @return string|\WP_Error Empty/hint string when OK enough to continue; WP_Error when misconfigured.
	 */
	private function describe_matching_webhook_endpoint() {
		$expected = untrailingslashit( rest_url( REST_Loader::NAMESPACE . '/stripe/webhook' ) );

		try {
			$endpoints = \Stripe\WebhookEndpoint::all( array( 'limit' => 100 ) );
		} catch ( \Throwable $e ) {
			Logger::instance()->warning(
				'Could not list Stripe webhook endpoints',
				array( 'error_message' => substr( $e->getMessage(), 0, 300 ) )
			);
			return '';
		}

		$match = null;
		foreach ( $endpoints->data as $endpoint ) {
			$url = untrailingslashit( (string) ( $endpoint->url ?? '' ) );
			if ( $url === $expected ) {
				$match = $endpoint;
				break;
			}
		}

		if ( ! $match ) {
			return new \WP_Error(
				'webhook_endpoint_missing',
				sprintf(
					/* translators: %s: webhook URL */
					__( 'No Stripe Test-mode webhook endpoint matches %s. Create one in Test mode (toggle in the Stripe Dashboard) with that exact URL.', 'dude-commerce' ),
					$expected
				)
			);
		}

		$status = (string) ( $match->status ?? '' );
		if ( 'enabled' !== $status ) {
			return new \WP_Error(
				'webhook_endpoint_disabled',
				__( 'Your matching Stripe webhook endpoint is disabled. Enable it in the Stripe Dashboard (Test mode).', 'dude-commerce' )
			);
		}

		$events = $match->enabled_events;
		if ( ! is_array( $events ) ) {
			$events = array();
		}
		$has_expired = in_array( '*', $events, true ) || in_array( 'checkout.session.expired', $events, true );
		if ( ! $has_expired ) {
			return new \WP_Error(
				'webhook_event_missing',
				__( 'Your Stripe webhook endpoint does not include checkout.session.expired (required for this probe and for abandoned checkouts).', 'dude-commerce' )
			);
		}

		return sprintf(
			/* translators: %s: Stripe webhook endpoint id */
			__( 'Matched Stripe endpoint %s.', 'dude-commerce' ),
			(string) ( $match->id ?? '' )
		);
	}

	/**
	 * Run the webhook handler in-process (no public HTTP / Cloudflare).
	 *
	 * @return true|\WP_Error
	 */
	private function self_test_webhook_endpoint() {
		$secret   = Settings::get_webhook_secret();
		$event_id = 'evt_wpdude_dc_probe_' . str_replace( '-', '', wp_generate_uuid4() );
		$payload  = wp_json_encode(
			array(
				'id'       => $event_id,
				'object'   => 'event',
				'type'     => 'checkout.session.expired',
				'livemode' => false,
				'data'     => array(
					'object' => array(
						'id'       => 'cs_test_wpdude_dc_probe',
						'object'   => 'checkout.session',
						'metadata' => array(
							'wpdude_dc_probe' => '1',
						),
					),
				),
			)
		);

		if ( ! is_string( $payload ) || '' === $payload ) {
			return new \WP_Error( 'probe_encode', __( 'Could not build probe payload.', 'dude-commerce' ) );
		}

		$timestamp = time();
		$signature = hash_hmac( 'sha256', $timestamp . '.' . $payload, $secret );
		$header    = 't=' . $timestamp . ',v1=' . $signature;

		$request = new \WP_REST_Request( 'POST', '/' . REST_Loader::NAMESPACE . '/stripe/webhook' );
		$request->set_header( 'Content-Type', 'application/json' );
		$request->set_header( 'Stripe-Signature', $header );
		$request->set_body( $payload );

		$response = rest_do_request( $request );
		if ( $response->is_error() ) {
			$error = $response->as_error();
			return new \WP_Error(
				$error->get_error_code(),
				$error->get_error_message()
			);
		}

		$status = (int) $response->get_status();
		if ( $status < 200 || $status >= 300 ) {
			$data = $response->get_data();
			$msg  = is_array( $data ) && isset( $data['message'] ) ? (string) $data['message'] : __( 'Unexpected REST response.', 'dude-commerce' );
			return new \WP_Error(
				'webhook_http_' . $status,
				sprintf(
					/* translators: 1: HTTP status, 2: error detail */
					__( 'HTTP %1$d: %2$s', 'dude-commerce' ),
					$status,
					sanitize_text_field( $msg )
				)
			);
		}

		return true;
	}

	/**
	 * Whether any Stripe event was recorded at or after the given UTC MySQL datetime.
	 */
	private function has_stripe_event_since( string $utc_mysql ): bool {
		global $wpdb;
		$table = esc_sql( $wpdb->prefix . 'wpdude_dc_stripe_events' );
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is a fixed escaped prefixed identifier.
		$found = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM `{$table}` WHERE processed_at >= %s ORDER BY id DESC LIMIT 1", $utc_mysql ) );
		return null !== $found && '' !== (string) $found;
	}
}
