<?php
/**
 * PostGate Stripe Redirector Class
 *
 * Creates Stripe Checkout sessions (one-time and subscription) in the
 * authenticated merchant context. Keeps implementation intentionally minimal
 * while performing basic error handling.
 *
 * @package PostGate
 * @subpackage Payments
 * @since 1.0.0
 * @author QodeSphere
 * @license GPL-2.0+
 */
defined( 'ABSPATH' ) || exit;

/**
 * PostGate Stripe Redirector Class
 *
 * Low-level helper used by REST endpoints to build Stripe Checkout session
 * payloads with comprehensive metadata for delayed order creation.
 *
 * This class handles:
 * - Creating one-time and subscription Stripe Checkout sessions
 * - Validating users and Stripe account connections
 * - Applying platform fees in the merchant context
 * - Managing order creation via webhooks after payment completion
 *
 * Key architectural decisions:
 * - Uses merchant's API key (not Connect header) for fee collection
 * - Stores all order data in session metadata for delayed order creation
 * - Applies different fee structures for one-time vs subscription payments
 * - Orders created only after checkout.session.completed webhook (Issue #24)
 * - unlocked_content recalculated in webhook to avoid Stripe metadata limits
 *
 * @since 1.0.0
 */
class PostGate_Stripe_Redirector {

	/**
	 * HTTP 400 - Bad request (validation errors).
	 *
	 * @since 1.0.0
	 */
	private const HTTP_BAD_REQUEST = 400;

	/**
	 * HTTP 403 - Forbidden (permission/authentication issues).
	 *
	 * @since 1.0.0
	 */
	private const HTTP_FORBIDDEN = 403;

	/**
	 * HTTP 500 - Internal server error (unexpected failures).
	 *
	 * @since 1.0.0
	 */
	private const HTTP_SERVER_ERROR = 500;

	/**
	 * Create a Stripe Checkout session for a one-time post purchase.
	 *
	 * Orchestrates the complete checkout flow for one-time purchases by:
	 * 1. Validate user exists and is active
	 * 2. Validate post exists and is published
	 * 3. Ensure merchant has connected Stripe account and set API context
	 * 4. Retrieve and validate price from Stripe
	 * 5. Extract product name for line item
	 * 6. Validate and extract price data (currency, amount)
	 * 7. Calculate platform fee (5% of purchase amount by default)
	 * 8. Validate calculated fee meets platform requirements
	 * 9. Build comprehensive metadata for webhook-side order creation
	 * 10. Build Stripe Checkout session configuration
	 * 11. Create the actual Stripe Checkout session
	 * 12. Return secure checkout URL for customer redirection
	 *
	 * IMPORTANT: Order creation is deferred until checkout.session.completed webhook
	 * This prevents orphaned pending orders when users abandon checkout
	 *
	 * Platform fees are applied as fixed cent amounts via application_fee_amount
	 * in the payment intent. The session URL redirects customer to Stripe Checkout.
	 *
	 * @since 1.0.0
	 *
	 * @param int    $user_id    Buyer (WordPress) user ID.
	 * @param int    $post_id    Post ID being purchased.
	 * @param string $price_id   Stripe Price ID (server authoritative).
	 * @param string $product_id Optional. Stripe Product ID for product-based architecture. Default ''.
	 *
	 * @return string|WP_Error Checkout session URL on success, or WP_Error on failure.
	 */
	public static function create_one_time_checkout_session_url( int $user_id, int $post_id, string $price_id, string $product_id = '' ) {
		// Step 1: Validate user exists and is active
		$user = self::validate_user( $user_id );
		if ( is_wp_error( $user ) ) {
			return $user;
		}

		// Step 2: Validate post exists and is published
		$post = get_post( $post_id );
		if ( ! $post ) {
			return new WP_Error( 'postgate_invalid_post', __( 'Checkout Error: The content you\'re trying to purchase could not be found. It may have been deleted or is temporarily unavailable.', 'postgate' ), [ 'status' => self::HTTP_BAD_REQUEST ] );
		}

		// Step 3: Ensure merchant has connected Stripe account and set API context
		// This switches the Stripe API key to the merchant's token, enabling fee collection
		$destination_account = self::ensure_merchant_context();
		if ( is_wp_error( $destination_account ) ) {
			return $destination_account;
		}

		try {
			// Step 4: Retrieve price object from Stripe to get currency and amount details
			// This ensures the price_id is valid and gets current pricing information
			$price = self::retrieve_stripe_price( $price_id );
			if ( is_wp_error( $price ) ) {
				return $price;
			}

			// Step 5: Extract product name with post title as fallback
			$product_name = self::extract_product_name( $price->product, get_the_title( $post_id ) );

			// Step 6: Validate and extract price data (currency, amount)
			$price_data = self::validate_and_extract_price_data( $price );
			if ( is_wp_error( $price_data ) ) {
				return $price_data;
			}

			$currency = $price_data['currency'];
			$unit_amount_cents = $price_data['unit_amount_cents'];

			// Step 7: Calculate platform fee (5% of purchase amount by default)
			$fee_cents = PostGate_Fee_Manager::calculate_fee_amount_cents( $unit_amount_cents );

			// Step 8: Validate calculated fee meets platform requirements
			// This prevents fee manipulation and ensures minimum fee thresholds
			$fee_validation_result = PostGate_Fee_Manager::validate_fee_match( $fee_cents, $fee_cents, 'amount' );
			if ( is_wp_error( $fee_validation_result ) ) {
				return $fee_validation_result;
			}

			// Step 9: Build comprehensive metadata for webhook-side order creation
			// All data needed to create the order is stored in metadata since order
			// creation now happens in checkout.session.completed webhook
			// NOTE: unlocked_content is NOT included here due to Stripe's 500-char limit
			// It will be recalculated in the webhook using price_id/product_id + origin_post_id
			$metadata_array = [
				// Order creation data
				'user_id' => $user_id,
				'buyer_id' => $user_id,
				'stripe_account_id' => $destination_account,
				'type' => 'one_time',
				'currency' => $currency,
				'amount_cents' => $unit_amount_cents,
				'fee_cents' => $fee_cents,
				'fee_rate' => PostGate_Fee_Manager::get_platform_fee_rate(),
				'price_id' => $price_id,
				'product_name' => $product_name,
				'origin_post_id' => $post_id,
				// unlocked_content omitted - recalculated in webhook
			];
			// Add product_id if provided (product-based architecture)
			if ( '' !== $product_id ) {
				$metadata_array['product_id'] = $product_id;
			}
			$metadata = self::build_metadata( $metadata_array );

			// Step 10: Build Stripe Checkout session configuration
			$stripe_session_data = [
				'mode' => 'payment',
				'line_items' => [
					[
						'price_data' => [
							'currency' => $currency,
							'product_data' => [ 'name' => $product_name ],
							'unit_amount' => $unit_amount_cents,
						],
						'quantity' => 1,
					],
				],
				'success_url' => self::get_success_redirect_url( $post_id ),
				'cancel_url' => get_permalink( $post_id ),
				'payment_intent_data' => array_filter(
					[
						'application_fee_amount' => $fee_cents ?: null,
						'metadata' => $metadata,
					]
				),
				'metadata' => $metadata,
				'locale' => 'auto',
				'customer_email' => sanitize_email( $user->user_email ),
				'customer_creation' => 'always',
				'expand' => [ 'payment_intent' ],
			];

			// Step 11: Create the actual Stripe Checkout session
			$session = self::create_stripe_session( $stripe_session_data );
			if ( is_wp_error( $session ) ) {
				return $session;
			}

			// Track pending session for return URL verification (Issue #84)
			// This allows us to create orders if webhooks fail
			PostGate_Stripe_Return_Handler::track_pending_session(
				$session->id,
				$user_id,
				$metadata_array
			);

			// Step 12: Return secure checkout URL for customer redirection
			return esc_url_raw( $session->url );

		} catch (\Stripe\Exception\ApiErrorException $stripe_exception) {
			return new WP_Error( 'postgate_stripe_error', $stripe_exception->getMessage(), [ 'status' => self::HTTP_SERVER_ERROR ] );
		}
	}

	/**
	 * Create a Stripe Checkout session for a subscription.
	 *
	 * Orchestrates the complete checkout flow for recurring subscriptions by:
	 * 1. Validate user exists and is active
	 * 2. Get platform fee percentage for subscriptions (5% default)
	 * 3. Ensure merchant has connected Stripe account and set API context
	 * 4. Retrieve and validate the Stripe price object
	 * 5. Extract product name for line item
	 * 6. Validate and extract price data (currency, amount)
	 * 7. Extract subscription interval data (interval, interval_count)
	 * 8. Validate percentage-based fee for subscriptions
	 * 9. Build comprehensive metadata for webhook-side order creation
	 * 10. Build Stripe Checkout session for subscription
	 * 11. Create Stripe Checkout session
	 * 12. Return checkout URL
	 *
	 * IMPORTANT: Order creation is deferred until checkout.session.completed webhook
	 * This prevents orphaned pending orders when users abandon checkout
	 *
	 * Unlike one-time payments, subscription fees are applied as percentages via
	 * application_fee_percent in the subscription data. The post_id is optional
	 * for subscriptions that grant global access.
	 *
	 * @since 1.0.0
	 *
	 * @param int    $user_id    Buyer (WordPress) user ID.
	 * @param string $price_id   Stripe recurring Price ID.
	 * @param int    $post_id    Optional origin post context (0 if none).
	 * @param string $product_id Optional. Stripe Product ID for product-based architecture. Default ''.
	 *
	 * @return string|WP_Error Checkout session URL on success, or WP_Error on failure.
	 */
	public static function create_subscription_checkout_session_url( int $user_id, string $price_id, int $post_id = 0, string $product_id = '' ) {
		// Step 1: Validate user exists and is active
		$user = self::validate_user( $user_id );
		if ( is_wp_error( $user ) ) {
			return $user;
		}

		// Step 2: Get platform fee percentage for subscriptions (5% default)
		$fee_percent = PostGate_Fee_Manager::calculate_subscription_fee_percentage();

		// Step 3: Ensure merchant has connected Stripe account and set API context
		$destination_account = self::ensure_merchant_context();
		if ( is_wp_error( $destination_account ) ) {
			return $destination_account;
		}

		try {
			// Step 4: Retrieve and validate the Stripe price object for subscription
			$price = self::retrieve_stripe_price( $price_id );
			if ( is_wp_error( $price ) ) {
				return $price;
			}

			// Step 5: Extract product name with home URL as fallback
			$product_name = self::extract_product_name( $price->product, '' );

			// Step 6: Validate and extract price data (currency, amount)
			$price_data = self::validate_and_extract_price_data( $price );
			if ( is_wp_error( $price_data ) ) {
				return $price_data;
			}
			$currency = $price_data['currency'];
			$unit_amount_cents = $price_data['unit_amount_cents'];

			// Step 7: Extract subscription interval data
			$interval_data = self::extract_subscription_interval( $price );
			if ( is_wp_error( $interval_data ) ) {
				return $interval_data;
			}
			$interval = $interval_data['interval'];
			$interval_count = $interval_data['interval_count'];

			// Step 8: Validate percentage-based fee for subscriptions
			// For subscriptions, we use percentage fees instead of fixed amounts
			$fee_validation_result = PostGate_Fee_Manager::validate_fee_match( $fee_percent, $fee_percent, 'percent' );
			if ( is_wp_error( $fee_validation_result ) ) {
				return $fee_validation_result;
			}

			// Step 9: Build comprehensive metadata for webhook-side order creation
			// All data needed to create the order is stored in metadata since order
			// creation now happens in checkout.session.completed webhook
			// NOTE: unlocked_content is NOT included here due to Stripe's 500-char limit
			// It will be recalculated in the webhook using price_id/product_id + origin_post_id
			$metadata_array = [
				// Order creation data
				'user_id' => $user_id,
				'buyer_id' => $user_id,
				'stripe_account_id' => $destination_account,
				'type' => 'subscription',
				'currency' => $currency,
				'amount_cents' => $unit_amount_cents,
				'fee_percent' => $fee_percent,
				'price_id' => $price_id,
				'product_name' => $product_name,
				'interval' => $interval,
				'interval_count' => $interval_count,
				'origin_post_id' => $post_id,
				// unlocked_content omitted - recalculated in webhook
			];
			// Add product_id if provided (product-based architecture)
			if ( '' !== $product_id ) {
				$metadata_array['product_id'] = $product_id;
			}
			$metadata = self::build_metadata( $metadata_array );

			// Step 10: Build Stripe Checkout session for subscription
			$stripe_session_data = [
				'mode' => 'subscription',
				'line_items' => [
					[
						'price' => $price_id,
						'quantity' => 1,
					],
				],
				'success_url' => self::get_success_redirect_url( $post_id ),
				'cancel_url' => home_url(),
				'subscription_data' => array_filter(
					[
						'application_fee_percent' => $fee_percent ?: null,
						'metadata' => $metadata,
					]
				),
				'metadata' => $metadata,
				'locale' => 'auto',
				'customer_email' => sanitize_email( $user->user_email ),
			];

			// Step 11: Create Stripe Checkout session
			$session = self::create_stripe_session( $stripe_session_data );
			if ( is_wp_error( $session ) ) {
				return $session;
			}

			// Track pending session for return URL verification (Issue #84)
			// This allows us to create orders if webhooks fail
			PostGate_Stripe_Return_Handler::track_pending_session(
				$session->id,
				$user_id,
				$metadata_array
			);

			// Step 12: Return checkout URL
			return esc_url_raw( $session->url );

		} catch (\Stripe\Exception\ApiErrorException $stripe_exception) {
			return new WP_Error( 'postgate_stripe_error', $stripe_exception->getMessage(), [ 'status' => self::HTTP_SERVER_ERROR ] );
		}
	}

	/**
	 * Ensure we have a connected account and set the merchant token as the API key.
	 *
	 * Validates that a Stripe account is connected via Stripe Connect and sets the
	 * merchant's access token as the active API key for subsequent Stripe API calls.
	 * This ensures all operations occur in the merchant's context for fee collection.
	 *
	 * This is a critical security check - without a connected account and valid token,
	 * no payment operations can proceed.
	 *
	 * @since 1.0.0
	 *
	 * @return string|WP_Error Stripe account ID on success, or WP_Error when account/token missing.
	 */
	private static function ensure_merchant_context() {
		// Retrieve stored Stripe credentials for the connected merchant account
		$stripe_access_token = PostGate_Merchant_Auth::token();
		$stripe_account_id = PostGate_Merchant_Auth::account();

		// Validate that merchant has completed Stripe account connection
		// Account ID is set during the OAuth flow completion
		if ( empty( $stripe_account_id ) ) {
			return new WP_Error(
				'postgate_no_stripe_account',
				__( 'No Stripe account connected. Please connect Stripe in PostGate settings.', 'postgate' ),
				[ 'status' => self::HTTP_FORBIDDEN ]
			);
		}

		// Validate that we have a valid access token for API calls
		// Token is obtained during Stripe Connect OAuth process
		if ( empty( $stripe_access_token ) ) {
			return new WP_Error(
				'postgate_stripe_key_missing',
				__( 'Stripe access token is not set. Please reconnect Stripe.', 'postgate' ),
				[ 'status' => self::HTTP_SERVER_ERROR ]
			);
		}

		// Set the merchant's access token as the active Stripe API key
		// This allows us to create sessions in their account and apply platform fees
		// All subsequent Stripe API calls will be made as this merchant
		\Stripe\Stripe::setApiKey( $stripe_access_token );

		return sanitize_text_field( (string) $stripe_account_id );
	}

	/**
	 * Validate user by ID.
	 *
	 * Verifies that the provided user ID corresponds to a valid WordPress user.
	 * This prevents unauthorized or non-existent users from initiating checkout.
	 *
	 * @since 1.0.0
	 *
	 * @param int $user_id WordPress user ID to validate.
	 *
	 * @return WP_User|WP_Error User object on success, WP_Error if user not found.
	 */
	private static function validate_user( int $user_id ) {
		$user = get_userdata( $user_id );
		if ( ! $user ) {
			return new WP_Error( 'postgate_invalid_user', __( 'Authentication Error: Your user account could not be verified. Please log in again and try your purchase.', 'postgate' ), [ 'status' => self::HTTP_BAD_REQUEST ] );
		}
		return $user;
	}

	/**
	 * Retrieve and expand a Stripe Price object.
	 *
	 * Fetches a price from Stripe with the product expanded to minimize API calls.
	 * This is a centralized wrapper for consistent error handling.
	 *
	 * @since 1.0.0
	 *
	 * @param string $price_id Stripe Price ID to retrieve.
	 *
	 * @return \Stripe\Price|WP_Error Price object on success, WP_Error on failure.
	 */
	private static function retrieve_stripe_price( string $price_id ) {
		try {
			$price = \Stripe\Price::retrieve( $price_id, [ 'expand' => [ 'product' ] ] );
			if ( empty( $price ) || ! isset( $price->currency ) ) {
				return new WP_Error( 'postgate_invalid_price', __( 'Payment Option Error: The selected pricing option is no longer valid. Please refresh the page and select a different option, or contact support.', 'postgate' ), [ 'status' => self::HTTP_BAD_REQUEST ] );
			}
			return $price;
		} catch (\Stripe\Exception\ApiErrorException $e) {
			PostGate_Logger::error( 'Failed to retrieve Stripe price', [
				'price_id' => $price_id,
				'error' => $e->getMessage(),
			] );
			// translators: %s: Error message from Stripe API.
			return new WP_Error( 'postgate_stripe_error', sprintf( __( 'Stripe API Error: %s. Please try again or contact support if the problem persists.', 'postgate' ), $e->getMessage() ), [ 'status' => self::HTTP_SERVER_ERROR ] );
		}
	}

	/**
	 * Extract product name from Stripe Price object.
	 *
	 * Handles both expanded product objects and product ID strings.
	 * Falls back gracefully if product data is unavailable.
	 *
	 * @since 1.0.0
	 *
	 * @param mixed  $price_product Price product field (object or string ID).
	 * @param string $fallback      Optional fallback name if extraction fails.
	 *
	 * @return string Sanitized product name or fallback.
	 */
	private static function extract_product_name( $price_product, string $fallback = '' ): string {
		if ( ! isset( $price_product ) ) {
			return $fallback;
		}

		// Product was successfully expanded as an object
		if ( is_object( $price_product ) && isset( $price_product->name ) ) {
			return sanitize_text_field( (string) $price_product->name );
		}

		// Product came as ID string, need to retrieve it
		if ( is_string( $price_product ) ) {
			try {
				$product = \Stripe\Product::retrieve( (string) $price_product );
				if ( isset( $product->name ) ) {
					return sanitize_text_field( (string) $product->name );
				}
			} catch (\Exception $e) {
				PostGate_Logger::warning( 'Failed to extract product name from Stripe', [
					'product_id' => $price_product,
					'error' => $e->getMessage(),
				] );
				// Non-fatal; return fallback.
			}
		}

		return $fallback;
	}

	/**
	 * Validate and extract basic price data.
	 *
	 * Validates that the price has required fields and extracts currency and amount.
	 * This ensures consistent validation across one-time and subscription flows.
	 *
	 * @since 1.0.0
	 *
	 * @param \Stripe\Price $price Stripe Price object to validate.
	 *
	 * @return array|WP_Error Array with 'currency' and 'unit_amount_cents' on success, WP_Error on failure.
	 */
	private static function validate_and_extract_price_data( $price ) {
		// Validate price has fixed amount
		if ( ! isset( $price->unit_amount ) || ! is_numeric( $price->unit_amount ) ) {
			return new WP_Error( 'postgate_price_amount_missing', __( 'Price is missing a fixed amount; cannot compute fee.', 'postgate' ), [ 'status' => self::HTTP_BAD_REQUEST ] );
		}

		return [
			'currency' => strtolower( (string) $price->currency ),
			'unit_amount_cents' => (int) $price->unit_amount,
		];
	}

	/**
	 * Extract subscription interval data from Stripe Price.
	 *
	 * Validates and extracts recurring interval information required for subscriptions.
	 * Returns structured data for metadata and order creation.
	 *
	 * @since 1.0.0
	 *
	 * @param \Stripe\Price $price Stripe Price object with recurring data.
	 *
	 * @return array|WP_Error Array with 'interval' and 'interval_count' on success, WP_Error on failure.
	 */
	private static function extract_subscription_interval( $price ) {
		if ( ! isset( $price->recurring ) || ! isset( $price->recurring->interval ) ) {
			return new WP_Error( 'postgate_price_interval_missing', __( 'Subscription price is missing interval information.', 'postgate' ), [ 'status' => self::HTTP_BAD_REQUEST ] );
		}

		$interval = (string) $price->recurring->interval;
		$interval_count = ( isset( $price->recurring->interval_count ) && is_numeric( $price->recurring->interval_count ) )
			? (int) $price->recurring->interval_count
			: 1;

		return [
			'interval' => $interval,
			'interval_count' => $interval_count,
		];
	}

	/**
	 * Create an order record in the database (webhook context).
	 *
	 * Delegates to the orders repository to create a new order during webhook
	 * processing after successful checkout completion. The order is initially
	 * created with 'pending' status and immediately updated to 'paid' by the
	 * webhook handler.
	 *
	 * This method is called exclusively from create_order_from_metadata() during
	 * the checkout.session.completed webhook event (Issue #24 delayed order creation).
	 *
	 * @since 1.0.0
	 *
	 * @param array $order_data Order arguments including user_id, amounts, Stripe IDs, etc.
	 *
	 * @return int Inserted order ID from postgate_orders table.
	 */
	private static function create_order( array $order_data ): int {
		return PostGate_Orders_Repo::insert_pending_order( $order_data );
	}

	/**
	 * Build metadata array for Stripe sessions and webhook processing.
	 *
	 * Constructs sanitized metadata that will be attached to Stripe Checkout sessions,
	 * payment intents, and subscriptions. This metadata is critical for webhook processing
	 * as it allows us to match Stripe events back to our local orders.
	 *
	 * Common metadata fields:
	 * - postgate_order_id: Internal order ID for webhook matching (legacy, now empty)
	 * - buyer_id / user_id: WordPress user ID
	 * - stripe_account_id: Connected account ID
	 * - type: 'one_time' or 'subscription'
	 * - fee_cents/fee_rate/fee_percent: Platform fee information
	 * - origin_post_id: Post that initiated purchase
	 * - price_id: Stripe Price ID (used to recalculate unlocked_content)
	 * - currency, amount_cents, product_name, interval: Order details
	 *
	 * Note: unlocked_content is NOT included in metadata due to Stripe's 500-character
	 * limit per field. It is recalculated in the webhook using price_id + origin_post_id.
	 *
	 * @since 1.0.0
	 *
	 * @param array $metadata_source Metadata source values to sanitize and structure.
	 *
	 * @return array Sanitized metadata array ready for Stripe API.
	 */
	private static function build_metadata( array $metadata_source ): array {
		$metadata = [
			'postgate_order_id' => isset( $metadata_source['order_id'] ) ? (int) $metadata_source['order_id'] : '',
			'buyer_id' => isset( $metadata_source['buyer_id'] ) ? (int) $metadata_source['buyer_id'] : '',
			'stripe_account_id' => isset( $metadata_source['stripe_account_id'] ) ? sanitize_text_field( (string) $metadata_source['stripe_account_id'] ) : '',
			'type' => isset( $metadata_source['type'] ) ? sanitize_text_field( (string) $metadata_source['type'] ) : '',
		];

		if ( isset( $metadata_source['origin_post_id'] ) ) {
			$metadata['origin_post_id'] = (int) $metadata_source['origin_post_id'];
		}
		if ( isset( $metadata_source['fee_cents'] ) ) {
			$metadata['postgate_fee_cents'] = (int) $metadata_source['fee_cents'];
		}
		if ( isset( $metadata_source['fee_rate'] ) ) {
			$metadata['postgate_fee_rate'] = sanitize_text_field( (string) $metadata_source['fee_rate'] );
		}
		if ( isset( $metadata_source['fee_percent'] ) ) {
			$metadata['postgate_fee_percent'] = sanitize_text_field( (string) $metadata_source['fee_percent'] );
		}

		if ( isset( $metadata_source['amount_cents'] ) ) {
			$metadata['amount_cents'] = (int) $metadata_source['amount_cents'];
		}
		if ( isset( $metadata_source['price_id'] ) ) {
			$metadata['price_id'] = sanitize_text_field( (string) $metadata_source['price_id'] );
		}
		if ( isset( $metadata_source['interval'] ) ) {
			$metadata['interval'] = sanitize_text_field( (string) $metadata_source['interval'] );
		}
		if ( isset( $metadata_source['interval_count'] ) ) {
			$metadata['interval_count'] = (int) $metadata_source['interval_count'];
		}
		if ( isset( $metadata_source['currency'] ) ) {
			$metadata['currency'] = sanitize_text_field( (string) $metadata_source['currency'] );
		}
		if ( isset( $metadata_source['product_name'] ) ) {
			$metadata['product_name'] = sanitize_text_field( (string) $metadata_source['product_name'] );
		}
		if ( isset( $metadata_source['user_id'] ) ) {
			$metadata['user_id'] = (int) $metadata_source['user_id'];
		}
		if ( isset( $metadata_source['product_id'] ) ) {
			$metadata['product_id'] = sanitize_text_field( (string) $metadata_source['product_id'] );
		}
		// Note: unlocked_content removed from metadata due to Stripe 500-char limit
		// It is recalculated in webhook using price_id + origin_post_id

		return $metadata;
	}

	/**
	 * Create a Stripe Checkout session in merchant context.
	 *
	 * Makes the actual API call to Stripe to create a Checkout session using the
	 * merchant's API key (set via ensure_merchant_context). Wraps Stripe exceptions
	 * into WP_Error for consistent error handling throughout the plugin.
	 *
	 * @since 1.0.0
	 *
	 * @param array $stripe_session_data Complete Stripe session payload with mode, line_items, etc.
	 *
	 * @return \Stripe\Checkout\Session|WP_Error Session object on success, WP_Error on failure.
	 */
	private static function create_stripe_session( array $stripe_session_data ) {
		try {
			return \Stripe\Checkout\Session::create( $stripe_session_data );
		} catch (\Exception $exception) {
			PostGate_Logger::error( 'Failed to create Stripe Checkout session', [
				'error' => $exception->getMessage(),
				'session_mode' => $stripe_session_data['mode'] ?? 'unknown',
			] );
			return new WP_Error( 'postgate_stripe_error', $exception->getMessage(), [ 'status' => self::HTTP_SERVER_ERROR ] );
		}
	}

	/**
	 * Get the success redirect URL based on after-purchase behavior setting.
	 *
	 * Determines where to send the customer after successful payment completion.
	 * Respects the 'postgate_after_purchase_behavior' setting which can be:
	 * - 'return-to-post': Redirect back to the purchased post (default)
	 * - 'redirect-page': Redirect to a custom thank-you page
	 *
	 * Includes query parameters for purchase confirmation and session verification.
	 *
	 * IMPORTANT: Uses Stripe's {CHECKOUT_SESSION_ID} placeholder which is automatically
	 * replaced with the actual session ID after the session is created. This allows
	 * the return handler to verify the session and create orders if webhooks fail.
	 *
	 * Note: Order creation happens during webhook processing (after redirect),
	 * so order_id is not available at this stage.
	 *
	 * @since 1.0.0
	 *
	 * @param int $post_id Origin post ID (0 for subscriptions with no specific post).
	 *
	 * @return string Success redirect URL with query parameters and session_id placeholder.
	 */
	private static function get_success_redirect_url( int $post_id ): string {
		$behavior = get_option( 'postgate_after_purchase_behavior', 'return-to-post' );

		if ( 'redirect-page' === $behavior ) {
			$page_slug = get_option( 'postgate_after_purchase_page', '' );
			if ( ! empty( $page_slug ) ) {
				return add_query_arg(
					[
						'postgate_purchase_success' => 1,
						'postgate_purchase_success_nonce' => wp_create_nonce( 'postgate_purchase_success' ),
						'session_id' => '{CHECKOUT_SESSION_ID}',
					],
					home_url( "/{$page_slug}/" )
				);
			}
		}

		$redirect_url = $post_id > 0 ? get_permalink( $post_id ) : home_url();

		return add_query_arg(
			[
				'postgate_purchase_success' => 1,
				'postgate_purchase_success_nonce' => wp_create_nonce( 'postgate_purchase_success' ),
				'session_id' => '{CHECKOUT_SESSION_ID}',
			],
			$redirect_url
		);
	}

	/**
	 * Create order from Stripe session metadata (webhook context).
	 *
	 * Creates a PostGate order record from the metadata attached to a Stripe
	 * Checkout session. This is called from the checkout.session.completed webhook
	 * handler after payment succeeds, implementing delayed order creation to prevent
	 * orphaned pending orders from abandoned checkouts.
	 *
	 * IMPORTANT: unlocked_content is RECALCULATED here instead of using metadata
	 * because Stripe has a 500-character limit per metadata field, which can cause
	 * truncation of the unlocked_content JSON. We recalculate using price_id and
	 * origin_post_id to ensure accurate content access grants.
	 *
	 * The metadata must contain these required fields:
	 * - user_id / buyer_id: WordPress user ID
	 * - stripe_account_id: Connected Stripe account
	 * - type: 'one_time' or 'subscription'
	 * - currency: Three-letter currency code
	 * - amount_cents: Price amount in cents
	 * - price_id: Stripe Price ID (used to recalculate unlocked_content)
	 * - product_name: Product/post title
	 * - origin_post_id: Original post context (used to recalculate unlocked_content)
	 * - fee_cents (one-time) or fee_percent (subscription): Platform fees
	 * - interval / interval_count (subscription only): Billing cycle
	 * 
	 * Optional fields for product-based architecture:
	 * - product_id: Stripe Product ID (if provided, uses Product_Content_Resolver)
	 *
	 * Returns the created order ID or 0 if creation fails due to missing metadata.
	 *
	 * @since 1.0.0
	 *
	 * @param object $metadata Stripe session metadata object with order data.
	 * @param string $session_id Stripe Checkout Session ID to associate with order.
	 *
	 * @return int Created order ID, or 0 on failure.
	 */
	public static function create_order_from_metadata( object $metadata, string $session_id ): int {
		// Extract required fields from metadata
		$user_id = isset( $metadata->user_id ) ? (int) $metadata->user_id : ( isset( $metadata->buyer_id ) ? (int) $metadata->buyer_id : 0 );
		$stripe_account_id = isset( $metadata->stripe_account_id ) ? (string) $metadata->stripe_account_id : '';
		$type = isset( $metadata->type ) ? (string) $metadata->type : '';
		$currency = isset( $metadata->currency ) ? (string) $metadata->currency : '';
		$amount_cents = isset( $metadata->amount_cents ) ? (int) $metadata->amount_cents : 0;
		$price_id = isset( $metadata->price_id ) ? (string) $metadata->price_id : '';
		$product_id = isset( $metadata->product_id ) ? (string) $metadata->product_id : '';
		$product_name = isset( $metadata->product_name ) ? (string) $metadata->product_name : '';
		$origin_post_id = isset( $metadata->origin_post_id ) ? (int) $metadata->origin_post_id : 0;

		// Validate we have minimum required data
		if ( ! $user_id || ! $stripe_account_id || ! $type || ! $currency || ! $amount_cents || ! $price_id ) {
			return 0;
		}

		// CRITICAL: Recalculate unlocked_content instead of using metadata
		// Stripe metadata has 500-char limit per field which can truncate JSON
		// Recalculating ensures accurate content access grants
		$unlocked_content_array = PostGate_Product_Content_Resolver::resolve_content_for_product(
			$product_id,
			$origin_post_id
		);
		$unlocked_content = wp_json_encode( $unlocked_content_array );

		// Build order data array based on type
		$order_data = [
			'user_id' => $user_id,
			'stripe_account_id' => $stripe_account_id,
			'type' => $type,
			'status' => 'pending', // Will be updated to 'paid' immediately by webhook handler
			'currency' => $currency,
			'amount_cents' => $amount_cents,
			'purchased_price_id' => $price_id,
			'purchased_product_id' => $product_id,
			'product_name' => $product_name,
			'unlocked_content' => $unlocked_content,
			'origin_post_id' => $origin_post_id > 0 ? $origin_post_id : null,
			'stripe_checkout_session_id' => $session_id,
		];

		// Add type-specific fields
		if ( $type === 'one_time' ) {
			$order_data['fee_cents'] = isset( $metadata->postgate_fee_cents ) ? (int) $metadata->postgate_fee_cents : 0;
			$order_data['fee_percent'] = null;
		} elseif ( $type === 'subscription' ) {
			$order_data['fee_cents'] = 0;
			$order_data['fee_percent'] = isset( $metadata->postgate_fee_percent ) ? (string) $metadata->postgate_fee_percent : null;
			$order_data['recurring_interval'] = isset( $metadata->interval ) ? (string) $metadata->interval : '';
		}

		// Create the order
		$order_id = self::create_order( $order_data );

		return $order_id;
	}
}