<?php
/**
 * PostGate Stripe Webhooks Class
 *
 * This file contains the Stripe webhook handler that processes incoming
 * webhook events from Stripe for payment and subscription management.
 * Handles webhook verification and event processing for PostGate.
 *
 * @package PostGate
 * @subpackage Payments
 * @since 1.0.0
 * @author QodeSphere
 * @license GPL-2.0+
 */
defined( 'ABSPATH' ) || exit;

/**
 * PostGate Stripe Webhooks Handler
 *
 * This class manages all incoming Stripe webhook events and coordinates the appropriate
 * responses for payment lifecycle events. It serves as the critical integration point
 * between Stripe's event system and PostGate's order and access management.
 *
 * Key responsibilities:
 * - Webhook endpoint registration and request handling
 * - Signature verification for webhook authenticity
 * - Event deduplication to prevent duplicate processing
 * - Critical real-time event processing (checkout, payment success)
 * - Background task scheduling for non-critical events
 * - Order status updates based on payment events
 * - Access grant/revoke coordination via Access Manager
 * - Fee validation for security compliance
 * - Webhook event logging and cleanup
 *
 * Event Processing Strategy:
 * - Critical events (checkout.session.completed, payment_intent.succeeded) are processed
 *   immediately in the webhook request to grant access as quickly as possible
 * - Non-critical events (subscription updates, refunds, invoice failures) are scheduled
 *   as WordPress cron tasks to avoid blocking the webhook response
 * - All events are logged to the postgate_webhooks table for audit trail
 * - Events are cached and deduplicated to handle Stripe retry behavior
 *
 * Supported Webhook Events:
 * - checkout.session.completed: Checkout completion and order fulfillment
 * - payment_intent.succeeded: Payment success confirmation
 * - customer.subscription.deleted: Subscription cancellation
 * - customer.subscription.updated: Subscription changes
 * - invoice.paid: Recurring payment success
 * - invoice.payment_failed: Payment failure handling
 * - charge.refunded: Refund processing and access revocation
 *
 * Security:
 * - All webhook requests must include valid Stripe signature headers
 * - Signature verification using Stripe SDK and merchant webhook secret
 * - Platform fee validation on all payments to prevent fee manipulation
 * - Failed validation marks orders as 'failed' or 'review' status
 *
 * @since 1.0.0
 */
class PostGate_Stripe_Webhooks {

	/**
	 * REST API namespace for webhook endpoints.
	 *
	 * @since 1.0.0
	 */
	private const REST_NS = 'postgate/v1';

	/**
	 * Webhook endpoint route path.
	 *
	 * @since 1.0.0
	 */
	private const WEBHOOK_ROUTE = '/stripe/webhook';

	/**
	 * Number of days to retain webhook logs.
	 *
	 * @since 1.0.0
	 */
	private const LOG_RETENTION_DAYS = 14;
	/**
	 * Cache group for processed webhook events.
	 *
	 * @since 1.0.0
	 */
	private const CACHE_GROUP = 'postgate_webhooks';
	/**
	 * HTTP status code for successful responses.
	 *
	 * @since 1.0.0
	 */
	private const HTTP_OK = 200;
	/**
	 * HTTP status code for bad request responses.
	 *
	 * @since 1.0.0
	 */
	private const HTTP_BAD_REQUEST = 400;

	/**
	 * Initialize the webhook handler.
	 *
	 * Registers REST API endpoint for receiving Stripe webhooks and sets up
	 * the WordPress cron hook for background event processing.
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	public static function init(): void {
		add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) );
		add_action( 'postgate_process_stripe_event', array( __CLASS__, 'process_webhook_event' ), 10, 1 );
	}

	/**
	 * Register REST API route for Stripe webhooks.
	 *
	 * Creates a public POST endpoint at /wp-json/postgate/v1/stripe/webhook that
	 * Stripe can call to deliver event notifications. The endpoint is public because
	 * webhook authentication is handled via signature verification.
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	public static function register_routes(): void {
		register_rest_route(
			self::REST_NS,
			self::WEBHOOK_ROUTE,
			[
				'methods' => 'POST',
				'callback' => array( __CLASS__, 'handle_webhook_request' ),
				'permission_callback' => array( __CLASS__, 'validate_webhook_signature_exists' ),
			]
		);
	}

	/**
	 * Validate that the webhook request contains a Stripe signature header.
	 *
	 * This permission callback performs early validation before the main webhook
	 * handler executes. It ensures that the request includes the required
	 * Stripe-Signature header, which is necessary for webhook verification.
	 *
	 * The actual signature verification (cryptographic validation) happens in
	 * handle_webhook_request() using Stripe's SDK. This method only checks for
	 * the header's presence to fail fast on obviously invalid requests.
	 *
	 * @since 1.0.0
	 *
	 * @param WP_REST_Request $request The REST request object.
	 *
	 * @return true|WP_Error True if signature header exists, WP_Error otherwise.
	 */
	public static function validate_webhook_signature_exists( WP_REST_Request $request ) {
		$signature = $request->get_header( 'stripe-signature' );

		if ( empty( $signature ) ) {
			return new WP_Error(
				'missing_signature',
				__( 'Webhook Rejected: Missing signature header. This request did not come from Stripe or the signature was removed in transit. Check your webhook configuration in the Stripe Dashboard.', 'postgate' ),
				[ 'status' => 403 ]
			);
		}

		return true;
	}

	/**
	 * Handle incoming Stripe webhook HTTP requests.
	 *
	 * This is the main entry point for all Stripe webhook events. It performs:
	 * 1. Signature verification to ensure the request is from Stripe
	 * 2. Event deduplication to prevent reprocessing
	 * 3. Event logging for audit trail
	 * 4. Critical access logic processing (synchronous)
	 * 5. Non-critical task scheduling (asynchronous via cron)
	 * 6. Webhook log cleanup
	 *
	 * Returns HTTP 200 for successful processing or already-processed events,
	 * HTTP 400 for invalid payloads, missing secrets, or signature failures.
	 *
	 * @since 1.0.0
	 *
	 * @param WP_REST_Request $request The webhook request from Stripe.
	 *
	 * @return WP_REST_Response Response indicating success or failure.
	 */
	public static function handle_webhook_request( WP_REST_Request $request ): WP_REST_Response {
		// Extract raw payload and Stripe signature from request
		$webhook_payload = $request->get_body();

		// Read + sanitize Stripe signature header (unslash before sanitize).
		$stripe_signature = '';
		if ( isset( $_SERVER['HTTP_STRIPE_SIGNATURE'] ) ) {
			// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized immediately.
			$stripe_signature = sanitize_text_field( wp_unslash( (string) $_SERVER['HTTP_STRIPE_SIGNATURE'] ) );
		}

		$webhook_secret = PostGate_Merchant_Auth::webhook_secret();
		if ( empty( $webhook_secret ) ) {
			return new WP_REST_Response( __( 'Webhook secret not configured.', 'postgate' ), self::HTTP_BAD_REQUEST );
		}

		try {
			// Set API key using stored access token for Stripe API calls
			$stripe_access_token = PostGate_Merchant_Auth::token();
			if ( empty( $stripe_access_token ) ) {
				return new WP_REST_Response( __( 'Stripe not connected.', 'postgate' ), self::HTTP_BAD_REQUEST );
			}

			// Configure Stripe SDK with merchant's access token
			\Stripe\Stripe::setApiKey( $stripe_access_token );
			// Verify webhook signature and construct event object
			$verified_event = \Stripe\Webhook::constructEvent(
				$webhook_payload,
				$stripe_signature,
				$webhook_secret
			);
		} catch (\UnexpectedValueException $payload_exception) {
			// Malformed JSON payload or other payload issues
			return new WP_REST_Response( __( 'Webhook Error: Invalid payload format. The webhook data could not be parsed. This usually indicates a malformed JSON payload or corrupted transmission.', 'postgate' ), self::HTTP_BAD_REQUEST );
		} catch (\Stripe\Exception\SignatureVerificationException $signature_exception) {
			// Invalid or missing signature - potential security issue
			return new WP_REST_Response( __( 'Webhook Authentication Failed: Signature verification failed. This means either: (1) the webhook secret key in settings is incorrect, (2) this request did not come from Stripe, or (3) the payload was modified in transit. Verify your webhook secret in PostGate Settings → Payment.', 'postgate' ), self::HTTP_BAD_REQUEST );
		}

		// Check idempotency - prevent processing the same event multiple times
		// Stripe may send the same event multiple times for reliability
		if ( self::is_event_processed( (string) $verified_event->id ) ) {
			return new WP_REST_Response( __( 'Already processed.', 'postgate' ), self::HTTP_OK );
		}

		// Mark event as processed (stores full event data for audit trail)
		self::mark_event_processed( $verified_event );

		// Process critical logic immediately (user access grants)
		// These operations must complete synchronously to ensure immediate access
		self::process_critical_access_logic( $verified_event );

		// Schedule non-critical tasks for background processing
		// These can be processed asynchronously without blocking the webhook response
		self::schedule_non_critical_tasks( $verified_event );

		// Clean up old webhook events to prevent database bloat
		self::clean_old_webhook_events();

		// Return success response to Stripe
		return new WP_REST_Response( __( 'OK', 'postgate' ), self::HTTP_OK );
	}

	/**
	 * Process critical events that affect user access immediately.
	 *
	 * Handles time-sensitive webhook events that must complete during the webhook
	 * request to provide immediate access to purchased content. These events include:
	 * - checkout.session.completed: Mark order as paid and grant access
	 * - payment_intent.succeeded: Confirm payment and grant access
	 *
	 * This method runs synchronously in the webhook handler to minimize latency
	 * between payment and content access.
	 *
	 * @since 1.0.0
	 *
	 * @param object $webhook_event The verified Stripe event object.
	 *
	 * @return void
	 */
	public static function process_critical_access_logic( object $webhook_event ): void {
		try {
			$stripe_account_id = self::resolve_account_id_from_event( $webhook_event );

			switch ( $webhook_event->type ) {
				case 'checkout.session.completed':
					self::on_checkout_session_completed( $webhook_event->data->object, $stripe_account_id );
					break;

				case 'payment_intent.succeeded':
					self::on_payment_intent_succeeded( $webhook_event->data->object, $stripe_account_id );
					break;
			}
		} catch (\Throwable $e) {
			return;
		}
	}

	/**
	 * Schedule non-critical webhook events for background processing.
	 *
	 * Defers processing of events that don't require immediate action to a WordPress
	 * cron job, preventing webhook timeout and improving response times. Non-critical
	 * events include subscriptions updates, refunds, and invoice failures.
	 *
	 * If already running in a cron context, processes the event immediately to avoid
	 * unnecessary scheduling overhead.
	 *
	 * @since 1.0.0
	 *
	 * @param object $webhook_event The verified Stripe event object.
	 *
	 * @return void
	 */
	public static function schedule_non_critical_tasks( object $webhook_event ): void {
		$webhook_event_data = [
			'id' => (string) $webhook_event->id,
			'type' => (string) $webhook_event->type,
			'account' => self::resolve_account_id_from_event( $webhook_event ),
		];

		if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
			self::process_webhook_event( $webhook_event_data );
			return;
		}

		$already_scheduled = wp_next_scheduled( 'postgate_process_stripe_event', [ $webhook_event_data ] );

		if ( ! $already_scheduled ) {
			wp_schedule_single_event( time(), 'postgate_process_stripe_event', [ $webhook_event_data ] );
		}
	}

	/**
	 * Process webhook events in background context.
	 *
	 * Handles non-critical webhook events that were scheduled for background
	 * processing. Retrieves the full event from Stripe and processes it
	 * according to the event type.
	 *
	 * Background events include:
	 * - Subscription cancellations and updates
	 * - Payment failures and refunds
	 * - Fee validation and mismatch detection
	 *
	 * @since 1.0.0
	 * @param array $webhook_event_data {
	 *     Event processing arguments.
	 *
	 *     @type string $id      Stripe event ID.
	 *     @type string $type    Event type (e.g., 'invoice.paid').
	 *     @type string $account Stripe account ID (for Connect).
	 * }
	 * @return void
	 */
	public static function process_webhook_event( array $webhook_event_data ): void {
		$event_id = isset( $webhook_event_data['id'] ) ? (string) $webhook_event_data['id'] : '';
		$event_type = isset( $webhook_event_data['type'] ) ? (string) $webhook_event_data['type'] : '';
		$stripe_account_id = isset( $webhook_event_data['account'] ) ? (string) $webhook_event_data['account'] : '';

		if ( '' === $event_id || '' === $event_type ) {
			return;
		}

		if ( '' === $stripe_account_id ) {
			$stripe_account_id = (string) PostGate_Merchant_Auth::account();
		}

		try {
			// Set API key using stored access token for Stripe API calls
			$stripe_access_token = PostGate_Merchant_Auth::token();
			if ( empty( $stripe_access_token ) ) {
				return;
			}

			\Stripe\Stripe::setApiKey( $stripe_access_token );

			// Retrieve full event object from Stripe API
			// Use account parameter for Stripe Connect if available
			$retrieve_options = $stripe_account_id ? array( 'stripe_account' => $stripe_account_id ) : array();
			$webhook_event = \Stripe\Event::retrieve( $event_id, $retrieve_options );

			// Process event based on type
			switch ( $webhook_event->type ) {
				case 'customer.subscription.deleted':
					// Subscription cancelled - revoke access
					self::on_subscription_deleted( $webhook_event->data->object );
					break;

				case 'invoice.payment_failed':
					// Subscription payment failed - handle gracefully
					self::on_invoice_payment_failed( $webhook_event->data->object );
					break;

				case 'customer.subscription.updated':
					// Subscription status changed - update accordingly
					self::on_subscription_updated( $webhook_event->data->object );
					break;

				case 'invoice.paid':
					// Subscription payment succeeded - validate fees
					self::on_invoice_paid( $webhook_event->data->object, $webhook_event->account ?? null );
					break;

				case 'charge.refunded':
				case 'charge.refund.updated':
					// Payment refunded - revoke access and update order status
					self::on_charge_refunded( $webhook_event->data->object );
					break;
			}
		} catch (\Throwable $background_exception) {
			PostGate_Logger::error( 'Background webhook processing failed', [
				'error' => $background_exception->getMessage(),
				'event_id' => $event_id,
				'event_type' => $event_type,
				'trace' => $background_exception->getTraceAsString(),
			] );
			return;
		}
	}

	/**
	 * Handle checkout.session.completed webhook event.
	 *
	 * This is one of the most critical events - it marks the transition from pending
	 * to paid status and triggers immediate access grants. The method:
	 * 1. Finds the local order by session ID, OR creates it from metadata if not found
	 * 2. Associates payment intent if not already set
	 * 3. Validates platform fees to prevent fee manipulation
	 * 4. Updates subscription ID for recurring purchases
	 * 5. Retrieves customer ID from payment intent if needed
	 * 6. Marks order as paid in database
	 * 7. Grants content access via Access Manager
	 *
	 * IMPORTANT: Since Issue #24 fix, orders are created here during webhook processing
	 * rather than during checkout initiation. This prevents orphaned pending orders when
	 * users abandon checkout by clicking browser back button.
	 *
	 * Fee validation failures result in marking payments/subscriptions as failed or
	 * under review, preventing access grants for tampered checkouts.
	 *
	 * @since 1.0.0
	 *
	 * @param object      $checkout_session   The Stripe Checkout Session object.
	 * @param string|null $stripe_account_id  The connected Stripe account ID.
	 *
	 * @return void
	 */
	protected static function on_checkout_session_completed( object $checkout_session, ?string $stripe_account_id = null ): void {
		// Extract payment identifiers from session object
		$payment_intent_id = $checkout_session->payment_intent ?? null;
		$subscription_id = $checkout_session->subscription ?? null;
		$stripe_customer_id = $checkout_session->customer ?? null;

		// Find corresponding order in our database
		// Try by session ID first, then by payment intent ID as fallback
		$postgate_order = PostGate_Orders_Repo::get_by_session_id( (string) $checkout_session->id );
		if ( ! $postgate_order && $payment_intent_id ) {
			$postgate_order = PostGate_Orders_Repo::get_by_intent_id( (string) $payment_intent_id );
		}

		// If no order found, create it from session metadata (delayed order creation)
		// This is the expected flow after Issue #24 fix for abandoned checkout prevention
		if ( ! $postgate_order ) {
			// Check if session has metadata with order creation data
			if ( isset( $checkout_session->metadata ) && is_object( $checkout_session->metadata ) ) {
				$order_id = PostGate_Stripe_Redirector::create_order_from_metadata(
					$checkout_session->metadata,
					(string) $checkout_session->id
				);

				// If order creation succeeded, fetch the created order
				if ( $order_id > 0 ) {
					$postgate_order = PostGate_Orders_Repo::get_by_id( $order_id );
				}
			}

			// If still no order (metadata missing or creation failed), skip processing
			if ( ! $postgate_order ) {
				return;
			}
		}

		if ( $payment_intent_id ) {
			// Persist PI on the order if it wasn’t set yet
			PostGate_Orders_Repo::insert_intent_by_session( (string) $checkout_session->id, (string) $payment_intent_id );
		}
		// Use stored account ID as fallback when webhook lacks it
		if ( ! $stripe_account_id && ! empty( $postgate_order['stripe_account_id'] ) ) {
			$stripe_account_id = (string) $postgate_order['stripe_account_id'];
		}
		// For one-time purchases: validate platform fee amount
		if ( $payment_intent_id && $stripe_account_id ) {
			$fee_validation = PostGate_Fee_Manager::validate_payment_intent_fee( (string) $payment_intent_id, (string) $stripe_account_id );
			if ( is_wp_error( $fee_validation ) ) {
				// Flag order for manual review and log the error
				self::mark_payment_intent_as_failed( (string) $payment_intent_id );
			}
		}
		// For subscriptions: validate platform fee percentage
		if ( $subscription_id && $stripe_account_id ) {
			$subscription_validation = PostGate_Fee_Manager::validate_subscription_fee( (string) $subscription_id, (string) $stripe_account_id );
			if ( is_wp_error( $subscription_validation ) ) {
				// Flag subscription for manual review and log the error
				self::mark_subscription_for_review( (string) $subscription_id );
			}
		}
		// Ensure we persist the subscription id on the order row (subscriptions only)
		if ( $subscription_id && empty( $postgate_order['stripe_subscription_id'] ) ) {
			global $wpdb;
			$orders_table = $wpdb->prefix . 'postgate_orders';

			// Write query; caching applies to reads, and related caches are invalidated in the orders repo.
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->update(
				$orders_table,
				[ 'stripe_subscription_id' => sanitize_text_field( (string) $subscription_id ) ],
				[ 'id' => (int) $postgate_order['id'] ],
				[ '%s' ],
				[ '%d' ]
			);
		}

		if ( empty( $stripe_customer_id ) && $payment_intent_id ) {
			try {
				// Retrieve PI to grab its customer
				$retrieve_opts = [];
				if ( ! empty( $stripe_account_id ) ) {
					$retrieve_opts['stripe_account'] = $stripe_account_id;
				}

				$pi = \Stripe\PaymentIntent::retrieve( (string) $payment_intent_id, $retrieve_opts );
				if ( ! empty( $pi->customer ) ) {
					$stripe_customer_id = (string) $pi->customer;
				}
			} catch (\Throwable $e) {
				return;
			}
		}
		// Mark order as paid in our database
		PostGate_Orders_Repo::mark_paid_by_session_or_intent(
			(string) $checkout_session->id,
			$payment_intent_id ? (string) $payment_intent_id : null,
			$subscription_id ? (string) $subscription_id : null,
			$stripe_customer_id ? (string) $stripe_customer_id : null
		);

		// Grant user access to purchased content
		$updated_order = PostGate_Orders_Repo::get_by_session_id( (string) $checkout_session->id );
		if ( $updated_order ) {
			PostGate_Access_Manager::grant_access_from_order( $updated_order );
		}
	}

	/**
	 * Handle payment_intent.succeeded webhook event.
	 *
	 * Alternative path to mark orders as paid when payment intent succeeds without
	 * going through the checkout.session.completed event. This can occur for:
	 * - Direct payment intent API usage
	 * - Payment methods that bypass checkout sessions
	 * - Asynchronous payment confirmations
	 *
	 * Performs fee validation and grants access similar to checkout completion.
	 *
	 * @since 1.0.0
	 *
	 * @param object      $payment_intent     The Stripe PaymentIntent object.
	 * @param string|null $stripe_account_id  The connected Stripe account ID.
	 *
	 * @return void
	 */
	protected static function on_payment_intent_succeeded( object $payment_intent, ?string $stripe_account_id = null ): void {
		$payment_intent_id = (string) $payment_intent->id;
		$stripe_customer_id = $payment_intent->customer ?? null;

		// Find order by payment intent ID
		$postgate_order = PostGate_Orders_Repo::get_by_intent_id( $payment_intent_id );
		if ( ! $postgate_order ) {
			return;
		}
		// Use stored account ID as fallback when webhook lacks it
		if ( ! $stripe_account_id && ! empty( $postgate_order['stripe_account_id'] ) ) {
			$stripe_account_id = (string) $postgate_order['stripe_account_id'];
		}
		// Validate platform fee amount (non-fatal)
		if ( $stripe_account_id ) {
			$fee_validation = PostGate_Fee_Manager::validate_payment_intent_fee( $payment_intent_id, (string) $stripe_account_id );
			if ( is_wp_error( $fee_validation ) ) {
				self::mark_payment_intent_as_failed( $payment_intent_id );
			}
		}
		// Mark order as paid and grant access
		PostGate_Orders_Repo::mark_paid_by_session_or_intent(
			null,
			$payment_intent_id,
			null,
			$stripe_customer_id ? (string) $stripe_customer_id : null
		);

		$updated_order = PostGate_Orders_Repo::get_by_intent_id( $payment_intent_id );
		if ( $updated_order ) {
			PostGate_Access_Manager::grant_access_from_order( $updated_order );
		}
	}

	/**
	 * Handle invoice.paid webhook event for subscription renewals.
	 *
	 * Called when a subscription invoice is successfully paid. Currently validates
	 * that the subscription order exists. Future implementation may extend access
	 * periods or send renewal notifications.
	 *
	 * @since 1.0.0
	 *
	 * @param object      $stripe_invoice     The Stripe Invoice object.
	 * @param string|null $stripe_account_id  The connected Stripe account ID.
	 *
	 * @return void
	 */
	protected static function on_invoice_paid( object $stripe_invoice, ?string $stripe_account_id = null ): void {
		$subscription_id = $stripe_invoice->subscription ?? null;
		if ( ! $subscription_id ) {
			return;
		}
		// Find order by subscription ID
		$postgate_order = PostGate_Orders_Repo::get_by_subscription_id( (string) $subscription_id );
		if ( ! $postgate_order ) {
			return;
		}

		// Note: Fee validation is handled at subscription creation, not per-invoice
		// This ensures fees are validated when they can still be corrected
	}

	/**
	 * Handle charge.refunded webhook event.
	 *
	 * Processes refunds by marking the associated order as refunded and revoking
	 * the user's access to the previously purchased content. This ensures users
	 * cannot retain access after a refund is processed.
	 *
	 * @since 1.0.0
	 *
	 * @param object $stripe_charge The Stripe Charge object with refund information.
	 *
	 * @return void
	 */
	protected static function on_charge_refunded( object $stripe_charge ): void {
		$payment_intent_id = $stripe_charge->payment_intent ?? null;
		if ( ! $payment_intent_id ) {
			return;
		}
		// Mark order as refunded in our database
		PostGate_Orders_Repo::mark_refunded_by_intent( (string) $payment_intent_id );
		// Revoke user access for refunded purchase
		$postgate_order = PostGate_Orders_Repo::get_by_intent_id( (string) $payment_intent_id );
		if ( $postgate_order ) {
			PostGate_Access_Manager::revoke_access_from_order( $postgate_order );
		}
	}

	/**
	 * Handle customer.subscription.deleted webhook event.
	 *
	 * Processes subscription cancellations by marking the order as canceled and
	 * revoking user access to subscription-gated content. Called when a subscription
	 * is canceled either immediately or at period end.
	 *
	 * @since 1.0.0
	 *
	 * @param object $stripe_subscription The Stripe Subscription object.
	 *
	 * @return void
	 */
	protected static function on_subscription_deleted( object $stripe_subscription ): void {
		$subscription_id = (string) $stripe_subscription->id;

		// Mark subscription as cancelled in our database
		PostGate_Orders_Repo::mark_canceled_subscription( $subscription_id );

		// Revoke user access for cancelled subscription
		$postgate_order = PostGate_Orders_Repo::get_by_subscription_id( $subscription_id );
		if ( $postgate_order ) {
			PostGate_Access_Manager::revoke_access_from_order( $postgate_order );
		}
	}

	/**
	 * Handle invoice.payment_failed webhook event.
	 *
	 * Placeholder for handling failed subscription payments. Future implementation
	 * may notify users, trigger retry logic, or implement grace periods.
	 *
	 * @since 1.0.0
	 *
	 * @param object $stripe_invoice The Stripe Invoice object with failure details.
	 *
	 * @return void
	 */
	protected static function on_invoice_payment_failed( object $stripe_invoice ): void {
		// Optional: mark as past_due; keep access until subscription.deleted
		// This provides a grace period for customers to update payment methods
	}

	/**
	 * Handle customer.subscription.updated webhook event.
	 *
	 * Placeholder for handling subscription changes (plan upgrades, downgrades,
	 * quantity changes). Future implementation may adjust access levels accordingly.
	 *
	 * @since 1.0.0
	 *
	 * @param object $stripe_subscription The updated Stripe Subscription object.
	 *
	 * @return void
	 */
	protected static function on_subscription_updated( object $stripe_subscription ): void {
		// Optional: react to status transitions, plan changes, etc.
		// Future enhancement: handle subscription plan upgrades/downgrades
	}

	/**
	 * Check if a webhook event has already been processed.
	 *
	 * Queries the webhooks log table and cache to determine if an event ID has been
	 * seen before. This prevents duplicate processing when Stripe retries webhook
	 * delivery due to timeouts or failures.
	 *
	 * Results are cached for the retention period to improve performance.
	 *
	 * @since 1.0.0
	 *
	 * @param string $event_id The Stripe event ID to check.
	 *
	 * @return bool True if event was already processed, false otherwise.
	 */
	protected static function is_event_processed( string $event_id ): bool {
		global $wpdb;

		$webhooks_table = $wpdb->prefix . 'postgate_webhooks';
		$event_id = sanitize_text_field( $event_id );

		$cache_key = self::event_cache_key( $event_id );
		$cached = wp_cache_get( $cache_key, self::CACHE_GROUP );
		if ( false !== $cached ) {
			return (bool) $cached;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
		$count = $wpdb->get_var(
			$wpdb->prepare(
				'SELECT COUNT(*) FROM %i WHERE event_id = %s',
				$webhooks_table,
				$event_id
			)
		);

		$processed = (int) $count > 0;
		wp_cache_set( $cache_key, $processed, self::CACHE_GROUP, self::event_cache_ttl() );

		return $processed;
	}

	/**
	 * Mark a webhook event as processed in the database.
	 *
	 * Logs the event to the postgate_webhooks table and caches the processed status
	 * to enable deduplication on retry attempts. Stores event ID, type, account ID,
	 * timestamp, and full event data for audit purposes.
	 *
	 * @since 1.0.0
	 *
	 * @param object $webhook_event The Stripe event object to log.
	 *
	 * @return void
	 */
	protected static function mark_event_processed( object $webhook_event ): void {
		global $wpdb;

		$webhooks_table = $wpdb->prefix . 'postgate_webhooks';
		$stripe_account_id = self::resolve_account_id_from_event( $webhook_event );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
		$inserted = $wpdb->insert(
			$webhooks_table,
			[
				'event_id' => sanitize_text_field( (string) $webhook_event->id ),
				'event_type' => sanitize_text_field( (string) $webhook_event->type ),
				'stripe_account_id' => sanitize_text_field( (string) $stripe_account_id ),
				'received_at' => current_time( 'mysql' ),
				'data' => wp_json_encode( $webhook_event->data->object ),
			],
			[ '%s', '%s', '%s', '%s', '%s' ]
		);

		if ( false !== $inserted ) {
			$cache_key = self::event_cache_key( (string) $webhook_event->id );
			wp_cache_set( $cache_key, true, self::CACHE_GROUP, self::event_cache_ttl() );
		}
	}

	/**
	 * Generate a cache key for webhook event deduplication.
	 *
	 * Creates a unique cache key by hashing the event ID for consistent
	 * cache lookups during event processing checks.
	 *
	 * @since 1.0.0
	 *
	 * @param string $event_id The Stripe event ID.
	 *
	 * @return string The generated cache key.
	 */
	private static function event_cache_key( string $event_id ): string {
		return 'postgate_webhook_' . md5( $event_id );
	}

	/**
	 * Get the cache TTL for webhook event tracking.
	 *
	 * Returns the number of seconds to cache event processing status, based on
	 * the log retention period (14 days by default). Ensures minimum of 60 seconds.
	 *
	 * @since 1.0.0
	 *
	 * @return int Cache TTL in seconds.
	 */
	private static function event_cache_ttl(): int {
		return max( 60, (int) self::LOG_RETENTION_DAYS * DAY_IN_SECONDS );
	}

	/**
	 * Resolve the Stripe account ID from a webhook event.
	 *
	 * Attempts to extract the connected account ID from multiple sources in priority order:
	 * 1. Event's account property (for Connect accounts)
	 * 2. Stored merchant account from PostGate settings
	 * 3. Metadata in the event's data object
	 *
	 * @since 1.0.0
	 *
	 * @param object $webhook_event The Stripe event object.
	 *
	 * @return string The Stripe account ID, or empty string if not found.
	 */
	private static function resolve_account_id_from_event( object $webhook_event ): string {
		// 1) If Stripe included it (platform endpoint), use it.
		if ( ! empty( $webhook_event->account ) ) {
			return (string) $webhook_event->account;
		}

		// 2) Fallback: the connected account stored in our settings (same used at checkout).
		$stored = (string) PostGate_Merchant_Auth::account();
		if ( '' !== $stored ) {
			return $stored;
		}

		// 3) Last resort: metadata sent by our Redirector.
		$obj = $webhook_event->data->object ?? null;
		if ( $obj && ! empty( $obj->metadata['stripe_account_id'] ) ) {
			return (string) $obj->metadata['stripe_account_id'];
		}

		return '';
	}

	/**
	 * Mark a payment intent as failed due to validation errors.
	 *
	 * Updates the order status to 'failed' when platform fee validation fails,
	 * indicating potential fee manipulation or configuration issues. Failed orders
	 * do not grant access to content.
	 *
	 * @since 1.0.0
	 *
	 * @param string $payment_intent_id The Stripe payment intent ID.
	 *
	 * @return void
	 */
	private static function mark_payment_intent_as_failed( string $payment_intent_id ): void {
		global $wpdb;

		$orders_table = $wpdb->prefix . 'postgate_orders';

		// Write query; caching applies to reads, not updates.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->update(
			$orders_table,
			array( 'status' => 'failed' ),
			array( 'stripe_payment_intent_id' => sanitize_text_field( $payment_intent_id ) ),
			array( '%s' ),
			array( '%s' )
		);
	}

	/**
	 * Mark a subscription order for manual review.
	 *
	 * Updates the subscription order status to 'review' when platform fee validation
	 * fails, flagging it for administrator investigation. Review status prevents
	 * automatic access grants while allowing manual resolution.
	 *
	 * @since 1.0.0
	 *
	 * @param string $subscription_id The Stripe subscription ID.
	 *
	 * @return void
	 */
	private static function mark_subscription_for_review( string $subscription_id ): void {
		if ( ! $subscription_id ) {
			return;
		}

		global $wpdb;

		$orders_table = $wpdb->prefix . 'postgate_orders';

		// Write query; caching applies to reads, not updates.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->update(
			$orders_table,
			array( 'status' => 'review' ),
			array( 'stripe_subscription_id' => sanitize_text_field( $subscription_id ) ),
			array( '%s' ),
			array( '%s' )
		);
	}

	/**
	 * Clean up old webhook event logs from the database.
	 *
	 * Removes webhook event records older than the retention period (14 days by default)
	 * to prevent the table from growing indefinitely. Called after each webhook is
	 * processed to maintain a manageable log size.
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	public static function clean_old_webhook_events(): void {
		global $wpdb;

		$webhooks_table = $wpdb->prefix . 'postgate_webhooks';

		// Write query; caching applies to reads, not deletes.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$wpdb->query(
			$wpdb->prepare(
				'DELETE FROM %i WHERE received_at < DATE_SUB(NOW(), INTERVAL %d DAY)',
				$webhooks_table,
				self::LOG_RETENTION_DAYS
			)
		);
	}
}
