<?php
/**
 * Integration: WooCommerceIntegration.php.
 *
 * @package RewardsWP
 */

namespace Awesomemotive\Rewardswp\Integrations;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

use Automattic\WooCommerce\Utilities\OrderUtil;
use Awesomemotive\Rewardswp\Abstracts\AbstractIntegrationWithCoupon;
use Awesomemotive\Rewardswp\Factories\MailerFactory;
use Awesomemotive\Rewardswp\Factories\RewardTypesFactory;
use Awesomemotive\Rewardswp\Factories\ViewsFactory;
use Awesomemotive\Rewardswp\Helpers\IconHelper;
use Awesomemotive\Rewardswp\Models\Member;
use Awesomemotive\Rewardswp\Models\Point;
use Awesomemotive\Rewardswp\Models\Referral;
use Awesomemotive\Rewardswp\Models\Reward;
use Awesomemotive\Rewardswp\Services\AssetsService;
use Awesomemotive\Rewardswp\Services\GuestMemberService;
use Awesomemotive\Rewardswp\Services\PanelService;
use Awesomemotive\Rewardswp\Services\PointsService;
use Awesomemotive\Rewardswp\Services\RewardsService;
use Awesomemotive\Rewardswp\Services\TrackingService;
use DateTimeImmutable;
use Awesomemotive\Rewardswp\Settings\Options\Points\EarnActionsOption;
use Awesomemotive\Rewardswp\Views\ProductPointsNoticeView;
use Awesomemotive\Rewardswp\Views\PurchaseConfirmationBannerView;
use Awesomemotive\Rewardswp\Settings\Options\Referrals\RewardsOption;
use Exception;
use WC_Coupon;
use function Awesomemotive\Rewardswp\Helpers\comma_separated_array;
use function Awesomemotive\Rewardswp\Helpers\create_member;
use function Awesomemotive\Rewardswp\Helpers\create_referral;
use function Awesomemotive\Rewardswp\Helpers\get_current_member;
use function Awesomemotive\Rewardswp\Helpers\get_member;
use function Awesomemotive\Rewardswp\Helpers\get_member_by;
use function Awesomemotive\Rewardswp\Helpers\get_option_value;
use function Awesomemotive\Rewardswp\Helpers\get_points_entries;
use function Awesomemotive\Rewardswp\Helpers\get_referrals;
use function Awesomemotive\Rewardswp\Helpers\is_guest_participation_enabled;
use function Awesomemotive\Rewardswp\Helpers\sanitize_array_to_int;
use function Awesomemotive\Rewardswp\Helpers\sanitize_float;
use function Awesomemotive\Rewardswp\Helpers\get_default_points_base_amount;
use WC_Cart;
use WC_Order;
use WP_Error;
use WP_Query;
use WP_User;
use WP_User_Query;
use function Awesomemotive\Rewardswp\Helpers\get_customer_reward;
use function Awesomemotive\Rewardswp\Helpers\rewardswp_log_error;

/**
 * Integration with WooCommerce.
 */
class WooCommerceIntegration extends AbstractIntegrationWithCoupon {
	/**
	 * Controls whether the customer must be converted into advocate when a successful purchase occurs.
	 *
	 * @var bool
	 */
	public bool $convert_customer_on_purchase = true;

	/**
	 * Panel service instance.
	 *
	 * @since 1.1.0
	 * @var PanelService
	 */
	protected PanelService $panel_service;

	/**
	 * Icon helper instance.
	 *
	 * @since 1.1.0
	 * @var IconHelper
	 */
	protected IconHelper $icon_helper;

	/**
	 * Views factory instance.
	 *
	 * @since 1.1.0
	 * @var ViewsFactory
	 */
	protected ViewsFactory $views_factory;

	/**
	 * Constructor with DI.
	 *
	 * @since 1.1.0
	 *
	 * @param RewardTypesFactory $reward_types_factory Reward types factory instance.
	 * @param RewardsService     $rewards_service      Rewards service instance.
	 * @param TrackingService    $tracking_service     Tracking service instance.
	 * @param AssetsService      $assets_service       Assets service instance.
	 * @param MailerFactory      $mailer_factory       Mailer factory instance.
	 * @param GuestMemberService $guest_member_service Guest member service instance.
	 * @param PointsService      $points_service       Points service instance.
	 * @param PanelService       $panel_service        Panel service instance.
	 * @param IconHelper         $icon_helper          Icon helper instance.
	 * @param ViewsFactory       $views_factory        Views factory instance.
	 */
	public function __construct(
		RewardTypesFactory $reward_types_factory,
		RewardsService $rewards_service,
		TrackingService $tracking_service,
		AssetsService $assets_service,
		MailerFactory $mailer_factory,
		GuestMemberService $guest_member_service,
		PointsService $points_service,
		PanelService $panel_service,
		IconHelper $icon_helper,
		ViewsFactory $views_factory
	) {
		parent::__construct(
			$reward_types_factory,
			$rewards_service,
			$tracking_service,
			$assets_service,
			$mailer_factory,
			$guest_member_service,
			$points_service
		);

		$this->panel_service = $panel_service;
		$this->icon_helper   = $icon_helper;
		$this->views_factory = $views_factory;
	}

	/**
	 * Returns the source name.
	 *
	 * @since 1.0.0
	 *
	 * @return string woocommerce
	 */
	public function get_source(): string {
		return 'woocommerce';
	}

	/**
	 * Check if WooCommerce HPOS (High-Performance Order Storage) is enabled.
	 *
	 * @since 1.1.0
	 *
	 * @return bool True if HPOS is enabled, false otherwise.
	 */
	public function is_hpos_enabled(): bool {
		return class_exists( OrderUtil::class ) && OrderUtil::custom_orders_table_usage_is_enabled();
	}

	/**
	 * Execute hooks so referrals can be generated when a purchase is done.
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	public function init(): void {
		if ( ! $this->is_active() ) {
			return; // WC is not active, should not run any hooks.
		}

		add_action( 'wp_enqueue_scripts', [ $this, 'action_maybe_show_advocate_panel' ] );
		add_action( 'woocommerce_order_details_after_order_table', [ $this, 'action_display_rewards_welcome_message' ], 10 );

		// Create pending referral when order is created (traditional checkout).
		add_action( 'woocommerce_checkout_update_order_meta', [ $this, 'action_create_pending_referral' ] );

		// Create pending referral for Checkout Blocks (with WC version compatibility).
		if ( defined( 'WC_VERSION' ) && version_compare( WC_VERSION, '6.4.0', '>=' ) ) {
			add_action( 'woocommerce_store_api_checkout_order_processed', [ $this, 'action_create_pending_referral_checkout_block' ] );
		} else {
			add_action( 'woocommerce_blocks_checkout_order_processed', [ $this, 'action_create_pending_referral_checkout_block' ] );
		}

		// Process rewards from visit only when order status changes to completed or processing.
		add_action( 'woocommerce_order_status_completed', [ $this, 'action_process_reward_from_visit' ] );
		add_action( 'woocommerce_order_status_processing', [ $this, 'action_process_reward_from_visit' ] );

		add_action( 'woocommerce_new_order', [ $this, 'action_connect_referral_to_order' ] );
		add_action( 'woocommerce_checkout_order_created', [ $this, 'action_connect_referral_to_order' ] );
		add_action( 'woocommerce_new_order', [ $this, 'action_create_pending_purchase_points' ] );
		add_action( 'woocommerce_checkout_order_created', [ $this, 'action_create_pending_purchase_points' ] );
		add_action( 'woocommerce_order_status_completed', [ $this, 'action_process_reward_from_coupon' ] );
		add_action( 'woocommerce_order_status_processing', [ $this, 'action_process_reward_from_coupon' ] );
		add_action( 'woocommerce_order_status_completed', [ $this, 'action_convert_customer_into_advocate' ], 12 );
		add_action( 'woocommerce_order_status_processing', [ $this, 'action_convert_customer_into_advocate' ], 12 );
		add_action( 'woocommerce_order_status_on-hold', [ $this, 'action_convert_customer_into_advocate' ], 12 );
		add_action( 'woocommerce_order_status_on-hold', [ $this, 'action_create_pending_purchase_points' ], 13 );
		add_action( 'template_redirect', [ $this, 'action_hide_panel_from_cart_and_checkout_pages' ] );
		add_action( 'woocommerce_customer_meta_updated', [ $this, 'action_keep_advocate_in_sync_with_customer_name_on_meta_update' ], 10, 4 );
		add_action( 'woocommerce_new_order', [ $this, 'action_keep_advocate_in_sync_with_orders_billing_name' ] );
		add_action( 'woocommerce_checkout_order_created', [ $this, 'action_keep_advocate_in_sync_with_orders_billing_name' ] );
		add_action( 'profile_update', [ $this, 'action_keep_advocate_in_sync_with_wp_user_profile' ] );
		add_action( 'woocommerce_order_status_completed', [ $this, 'action_maybe_award_purchase_points' ] );
		add_action( 'woocommerce_order_status_processing', [ $this, 'action_maybe_award_purchase_points' ] );
		add_action( 'woocommerce_payment_complete', [ $this, 'action_maybe_award_purchase_points' ] );
		add_action( 'woocommerce_order_status_completed', [ $this, 'action_maybe_award_first_order_points' ] );
		add_action( 'woocommerce_order_status_processing', [ $this, 'action_maybe_award_first_order_points' ] );
		add_action( 'woocommerce_payment_complete', [ $this, 'action_maybe_award_first_order_points' ] );
		add_action( 'woocommerce_single_product_summary', [ $this, 'render_product_points_notice' ], 12 );
		add_action( 'transition_comment_status', [ $this, 'action_maybe_award_review_points' ], 10, 3 );
		add_action( 'comment_post', [ $this, 'action_maybe_award_review_points_on_create' ], 10, 3 );

		// Refunded - multiple hooks to capture all transitions (full refunds only).
		add_action( 'woocommerce_order_status_completed_to_refunded', [ $this, 'action_handle_order_refund' ], 10 );
		add_action( 'woocommerce_order_status_processing_to_refunded', [ $this, 'action_handle_order_refund' ], 10 );
		add_action( 'woocommerce_order_status_pending_to_refunded', [ $this, 'action_handle_order_refund' ], 10 );
		add_action( 'woocommerce_order_status_on-hold_to_refunded', [ $this, 'action_handle_order_refund' ], 10 );

		// Partial refunds - captures any refund event (we filter out full refunds inside the handler).
		add_action( 'woocommerce_order_refunded', [ $this, 'action_handle_partial_refund' ], 10, 2 );
	}

	/**
	 * Render a notice on the single product page with the number of points
	 * the customer will earn for purchasing the product.
	 *
	 * @since 1.1.0
	 *
	 * @return void
	 */
	public function render_product_points_notice(): void {
		// Ensure points program is active and properly configured.
		if ( ! $this->panel_service->is_points_program_active() ) {
			return;
		}

		$earn_actions = get_option_value( EarnActionsOption::class );

		if (
			empty( $earn_actions['purchase']['enabled'] ) ||
			empty( $earn_actions['purchase']['points_per_dollar'] )
		) {
			return;
		}

		global $product;

		if ( ! $product ) {
			return;
		}

		$price = 0.0;
		if ( function_exists( 'wc_get_price_to_display' ) ) {
			$price = (float) wc_get_price_to_display( $product );
		} elseif ( method_exists( $product, 'get_price' ) ) {
			$price = (float) $product->get_price();
		}

		$points_per_dollar = (int) $earn_actions['purchase']['points_per_dollar'];
		$base_amount       = $earn_actions['purchase']['base_amount'] ?? get_default_points_base_amount();
		$points            = (int) floor( ( $price / $base_amount ) * $points_per_dollar );

		if ( $points <= 0 ) {
			return;
		}

		// Determine variant based on user status and program settings.
		$advocate      = get_current_member();
		$is_guest      = is_null( $advocate ) || $advocate->is_guest();
		$guest_enabled = is_guest_participation_enabled();
		$variant       = 3; // Default: members only / guest checkout off.

		if ( ! is_null( $advocate ) && ! $is_guest ) {
			// Variant 1: Logged-in member.
			$variant = 1;
		} elseif ( $guest_enabled ) {
			// Variant 2: Guest participation enabled (for logged-out users or guest members).
			$variant = 2;
		}

		// Use the ProductPointsNoticeView for consistent styling.
		$notice_view = $this->views_factory->get_view( ProductPointsNoticeView::class );

		if ( $notice_view ) {
			$notice_view->render(
				[
					'points'  => $points,
					'variant' => $variant,
				]
			);
		}
	}

	/**
	 * Set the JS global to make the panel open on the page load in the WC Thank You page.
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	public function action_maybe_show_advocate_panel() {

		if ( ! function_exists( 'is_order_received_page' ) || ! is_order_received_page() ) {
			return;
		}

		$advocate = get_current_member();

		if ( is_null( $advocate ) ) {
			return;
		}

		if ( $advocate->get_meta( 'welcome_panel_shown' ) ) {
			return;
		}

		$advocate->update_meta( 'welcome_panel_shown', true );

		if ( ! empty( $advocate->token ) ) {
			$this->guest_member_service->save_token_to_local_storage( $advocate->token );
		}
	}

	/**
	 * Display a compact rewards card on the order confirmation page.
	 *
	 * Shows different states based on member status and participation mode.
	 *
	 * @since 1.1.0
	 *
	 * @param \WC_Order $order The order object.
	 * @return void
	 */
	public function action_display_rewards_welcome_message( $order ) {
		if ( ! $order ) {
			return;
		}

		// Only show on thank you page, not on My Account > View Order.
		if ( ! function_exists( 'is_order_received_page' ) || ! is_order_received_page() ) {
			return;
		}

		// Check if points program is active.
		if ( ! $this->panel_service->is_points_program_active() ) {
			return;
		}

		$order_id = $order->get_id();
		if ( ! $order_id ) {
			return;
		}

		$advocate          = get_current_member();
		$guest_enabled     = is_guest_participation_enabled();

		// Fallback: if no current member found (e.g., guest without cookie), try to find by order email.
		if ( is_null( $advocate ) ) {
			$advocate = get_member_by( 'email', (string) $order->get_billing_email() );
		}

		$is_guest          = is_null( $advocate ) || $advocate->is_guest();
		$points_earned     = 0;
		$current_balance   = 0;
		$currency_singular = $this->points_service->get_currency_singular();
		$currency_plural   = $this->points_service->get_currency_plural();

		// Get net points earned for this order (earned minus refund deductions).
		if ( ! is_null( $advocate ) ) {
			$points_earned   = $this->points_service->get_net_points_earned_for_order( $order_id, $advocate->id );
			$current_balance = absint( $advocate->points_balance ?? 0 );
		}

		// Calculate expected points for pending orders.
		$expected_points = $this->calculate_expected_purchase_points( $order );

		// Determine if this is a pending payment (no points awarded yet, but points would be earned).
		$is_pending = ( 0 === $points_earned && $expected_points > 0 );

		// Use expected points for currency label when pending.
		$display_points = $is_pending ? $expected_points : $points_earned;
		$currency_label = 1 === $display_points ? $currency_singular : $currency_plural;
		$balance_label  = 1 === $current_balance ? $currency_singular : $currency_plural;

		// Determine which variant to show.
		$variant = 3; // Default: members only / guest checkout off.

		if ( ! is_null( $advocate ) && ! $is_guest ) {
			// Logged-in member: variant 1 (paid) or 4 (pending).
			$variant = $is_pending ? 4 : 1;
		} elseif ( $is_guest && $guest_enabled ) {
			// Guest with all-customers mode: variant 2 (paid) or 5 (pending).
			// This covers both cases: advocate found by cookie/email, or no member yet (pending order).
			$variant = $is_pending ? 5 : 2;
		}

		// Prepare data for view.
		$view_args = [
			'variant'         => $variant,
			'points_earned'   => $points_earned,
			'pending_points'  => $expected_points,
			'current_balance' => $current_balance,
			'currency_label'  => $currency_label,
			'balance_label'   => $balance_label,
			'advocate_email'  => $advocate->email ?? (string) $order->get_billing_email(),
			'customer_name'   => $order->get_billing_first_name() . ' ' . $order->get_billing_last_name(),
		];

		// Render view.
		$banner_view = $this->views_factory->get_view( PurchaseConfirmationBannerView::class );
		if ( $banner_view ) {
			$banner_view->render( $view_args );
		}
	}

	/**
	 * Calculate the expected purchase points for an order.
	 *
	 * This is used to show pending points on the thank you page before
	 * payment is confirmed.
	 *
	 * @since 1.1.0
	 *
	 * @param WC_Order $order The order object.
	 * @return int Expected points to be earned.
	 */
	private function calculate_expected_purchase_points( WC_Order $order ): int {
		$earn_actions = get_option_value( EarnActionsOption::class );

		if ( empty( $earn_actions['purchase']['enabled'] ) || empty( $earn_actions['purchase']['points_per_dollar'] ) ) {
			return 0;
		}

		$order_total = $this->calculate_order_total_for_points( $order );

		return $this->calculate_purchase_points( $order_total, $earn_actions );
	}

	/**
	 * Create a pending referral when an order is created (traditional checkout).
	 *
	 * This method creates a referral in pending status. The referral will only be
	 * completed when the order status changes to 'processing' or 'completed'.
	 *
	 * @since 1.1.0
	 *
	 * @param int $order_id The order ID.
	 *
	 * @return void
	 */
	public function action_create_pending_referral( int $order_id ): void {
		// Check if a referral already exists to avoid duplicates.
		if ( $this->has_order_referral( $order_id ) ) {
			return; // Referral already exists.
		}

		// Check if there's an advocate being tracked.
		$advocate_id = $this->tracking_service->get_cookie( $this->tracking_service::ADVOCATE_ID_COOKIE_NAME );

		if ( empty( $advocate_id ) ) {
			return; // Nothing to track.
		}

		$visit_id = $this->tracking_service->get_cookie( $this->tracking_service::VISIT_ID_COOKIE_NAME );

		// Check if there are any referrals generated from the visit.
		$referrals = get_referrals(
			[
				'advocate_id' => $advocate_id,
				'event_id'    => $visit_id,
				'status'      => 'pending',
			]
		);

		// If a pending referral exists, just update it with order info.
		if ( ! empty( $referrals ) ) {
			$order = $this->get_order( $order_id );

			if ( ! $order ) {
				return;
			}

			foreach ( $referrals as $referral ) {
				$referral->source           = $this->get_source();
				$referral->order_id         = $order->get_id();
				$referral->referred_user_id = $order->get_user_id();
				$referral->referred_email   = (string) $order->get_billing_email();

				$referral->update();
			}

			return;
		}

		$order = $this->get_order( $order_id );

		if ( ! $order ) {
			return;
		}

		// Fallback: the visit cookie may have changed since the friend claimed a reward
		// (e.g., friend navigated to another page, creating a new visit event).
		// Search by advocate + billing email to find the claim-created referral.
		$referrals = get_referrals(
			[
				'advocate_id'    => $advocate_id,
				'referred_email' => (string) $order->get_billing_email(),
				'status'         => 'pending',
			]
		);

		if ( ! empty( $referrals ) ) {
			foreach ( $referrals as $referral ) {
				$referral->source           = $this->get_source();
				$referral->order_id         = $order->get_id();
				$referral->referred_user_id = $order->get_user_id();
				$referral->referred_email   = (string) $order->get_billing_email();

				$referral->update();
			}

			return;
		}

		// Create a new pending referral.
		create_referral(
			[
				'advocate_id'      => $advocate_id,
				'event_id'         => $visit_id,
				'source'           => $this->get_source(),
				'referred_user_id' => $order->get_user_id(),
				'referred_email'   => (string) $order->get_billing_email(),
				'order_id'         => $order->get_id(),
				'status'           => 'pending',
			]
		);
	}

	/**
	 * Create a pending referral when an order is created via Checkout Blocks.
	 *
	 * This method handles referral creation for WooCommerce Checkout Blocks.
	 * The referral will only be completed when the order status changes to 'processing' or 'completed'.
	 *
	 * @since 1.1.0
	 *
	 * @param WC_Order $order The order object.
	 *
	 * @return void
	 */
	public function action_create_pending_referral_checkout_block( $order ): void {
		if ( ! is_a( $order, WC_Order::class ) ) {
			return;
		}

		// Only process orders created via store-api (Checkout Blocks).
		if ( 'store-api' !== $order->get_created_via() ) {
			return;
		}

		// Only process orders in checkout-draft or pending status.
		if ( ! in_array( $order->get_status(), [ 'checkout-draft', 'pending' ], true ) ) {
			return;
		}

		// Check if a referral already exists to avoid duplicates.
		if ( $this->has_order_referral( $order->get_id() ) ) {
			return; // Referral already exists.
		}

		// Use the same logic as traditional checkout.
		$this->action_create_pending_referral( $order->get_id() );
	}

	/**
	 * Bound a referral to an order when the order is just created.
	 *
	 * @since 1.0.0
	 *
	 * @param int|WC_Order $order The order ID or object
	 *
	 * @return void
	 *
	 * @throws Exception If persisting the referral association fails.
	 */
	public function action_connect_referral_to_order( $order ): void {
		$advocate_id = $this->tracking_service->get_cookie( $this->tracking_service::ADVOCATE_ID_COOKIE_NAME );

		if ( empty( $advocate_id ) ) {
			return;
		}

		$visit_id = $this->tracking_service->get_cookie( $this->tracking_service::VISIT_ID_COOKIE_NAME );

		$referrals = get_referrals(
			[
				'advocate_id' => $advocate_id,
				'event_id'    => $visit_id,
				'status'      => 'pending',
				'orderby'     => 'created_at',
				'order'       => 'DESC',
			]
		);

		if ( empty( $referrals ) ) {
			return;
		}

		$order = is_int( $order )
			? $this->get_order( $order )
			: $order;

		if ( ! $order ) {
			return; // Bail if it is not a WooCommerce order.
		}

		// Only update the most recent referral (first one when sorted DESC by created_at).
		// This prevents stale referrals from previous claims getting associated with new orders.
		$referral = $referrals[0];

		$referral->source           = $this->get_source();
		$referral->order_id         = $order->get_id();
		$referral->referred_user_id = $order->get_user_id();
		$referral->referred_email   = (string) $order->get_billing_email();

		$referral->update();
	}

	/**
	 * Process reward from visit when order status changes to completed or processing.
	 *
	 * This method is called when the order status changes to 'processing' or 'completed'.
	 * It completes pending referrals and issues rewards to advocates.
	 *
	 * @since 1.0.0
	 * @since 1.1.0 Updated to process rewards only on valid order statuses.
	 *
	 * @param int|WC_Order $order The order ID or object.
	 *
	 * @return void|null
	 *
	 * @throws Exception If the reward issuance fails.
	 */
	public function action_process_reward_from_visit( $order ) {
		// Get order object if ID was passed.
		$order = is_int( $order ) ? $this->get_order( $order ) : $order;

		if ( ! is_a( $order, WC_Order::class ) ) {
			return; // Bail if it is not an order object.
		}

		// Only process rewards for orders with valid payment status.
		$order_status = $order->get_status();

		if ( ! in_array( $order_status, [ 'completed', 'processing' ], true ) ) {
			return; // Bail because order status is not valid for reward processing.
		}

		$reward = $this->get_reward_from_order( $order->get_id() );

		if ( is_a( $reward, Reward::class ) ) {
			return; // Bail because a Reward has been created by the claiming reward form.
		}

		// Get pending referrals for this order.
		$referrals = get_referrals(
			[
				'order_id' => $order->get_id(),
				'status'   => 'pending',
				'source'   => $this->get_source(),
			]
		);

		// If no pending referrals found for this order, try to find by visit.
		if ( empty( $referrals ) ) {
			$advocate_id = $this->tracking_service->get_cookie( $this->tracking_service::ADVOCATE_ID_COOKIE_NAME );

			if ( empty( $advocate_id ) ) {
				return; // Nothing to track.
			}

			$visit_id = $this->tracking_service->get_cookie( $this->tracking_service::VISIT_ID_COOKIE_NAME );

			// Check if there are any pending referrals from the visit.
			$referrals = get_referrals(
				[
					'advocate_id' => $advocate_id,
					'event_id'    => $visit_id,
					'status'      => 'pending',
				]
			);
		}

		// No pending referrals found, nothing to complete.
		if ( empty( $referrals ) ) {
			return;
		}

		// Process each pending referral.
		foreach ( $referrals as $referral ) {
			// Update referral with order info if needed.
			if ( empty( $referral->order_id ) || $referral->order_id !== $order->get_id() ) {
				$referral->referred_user_id = $order->get_user_id();
				$referral->referred_email   = (string) $order->get_billing_email();
				$referral->order_id         = $order->get_id();
				$referral->source           = $this->get_source();
				$referral->update();
			}

			// Check if advocate is a guest member.
			$advocate = get_member( $referral->advocate_id );
			$is_guest = $advocate && 'guest' === $advocate->get_status();

			// Check if reward already exists for this referral.
			$existing_reward = $referral->get_reward( 'advocate' );

			// If advocate is guest and no reward exists yet, create reward with pending status.
			if ( $is_guest && ! $existing_reward ) {
				$reward_options = get_option_value( RewardsOption::class );
				$reward_type    = $this->reward_types_factory->get_reward_type( $reward_options['advocate']['type'] ?? '' );

				if ( is_null( $reward_type ) ) {
					continue; // Skip this referral if reward type is invalid.
				}

				try {
					// Create reward with pending status for guest members.
					// This prevents the reward from appearing in the widget and prevents email from being sent.
					$reward = $reward_type->issue_reward(
						$referral,
						'advocate',
						[
							'order_id' => $order->get_id(),
							'status'   => 'pending',
						]
					);

					// Don't complete referral for guests - it will be completed when they become official.
					continue;
				} catch ( Exception $e ) {
					// Log error but continue processing other referrals.
					rewardswp_log_error(
						'Failed to create pending reward for guest referral {referral_id}: {message}',
						[
							'referral_id' => $referral->id ?? 0,
							'message'     => $e->getMessage(),
							'order_id'    => $order->get_id(),
						]
					);
					continue;
				}
			}

			// For official members, process normally.
			if ( ! $is_guest && ! $existing_reward ) {
				$reward_options = get_option_value( RewardsOption::class );
				$reward_type    = $this->reward_types_factory->get_reward_type( $reward_options['advocate']['type'] ?? '' );

				if ( is_null( $reward_type ) ) {
					continue; // Skip this referral if reward type is invalid.
				}

				try {
					// Issue reward for the advocate.
					// Note: For points rewards, issue_reward() also auto-redeems.
					// For coupon rewards, the reward stays as "issued" for advocate to claim later.
					$reward = $reward_type->issue_reward(
						$referral,
						'advocate',
						[
							'order_id' => $order->get_id(),
						]
					);

					if ( is_null( $reward ) ) {
						continue; // Skip this referral if reward issuance fails.
					}

					// Complete the referral.
					$referral->set_as_completed( $this->get_source(), $order->get_id() );
				} catch ( Exception $e ) {
					// Log error but continue processing other referrals.
					rewardswp_log_error(
						'Failed to process reward for referral {referral_id}: {message}',
						[
							'referral_id' => $referral->id ?? 0,
							'message'     => $e->getMessage(),
							'order_id'    => $order->get_id(),
						]
					);
					continue;
				}
			}
		}
	}

	/**
	 * Issues a reward if the order status is completed or processing.
	 *
	 * This method checks the order status and, if eligible, issues the appropriate reward.
	 * Supports both referral-based rewards and points redemption rewards.
	 *
	 * @see https://woocommerce.com/document/cash-on-delivery/ For details about handling COD (Cash on Delivery) orders.
	 *
	 * @since 1.0.0
	 *
	 * @param int $order_id The ID of the order to check.
	 *
	 * @return void
	 *
	 * @throws Exception If the reward issuance fails.
	 */
	public function action_process_reward_from_coupon( int $order_id ): void {

		$reward = $this->get_reward_from_order( $order_id );

		if ( ! is_a( $reward, Reward::class ) ) {
			return; // No reward to process.
		}

		$order = $this->get_order( $order_id );

		if ( ! $order ) {
			return; // Bail if it is not a WooCommerce order.
		}

		// Leave pending status for COD orders while in processing status.
		if ( $this->is_cod_processing( $order ) ) {
			return;
		}

		$reward_type = $this->reward_types_factory->get_reward_type( $reward->reward_type );

		if ( is_null( $reward_type ) ) {
			return;
		}

		$referral = $reward->get_referral();

		// Check if this is a points redemption reward (no referral).
		$is_points_redemption = is_null( $referral );

		// If this is a referral-based reward, update the referral with source and order ID.
		if ( ! $is_points_redemption ) {
			$referral->source   = $this->get_source();
			$referral->order_id = $order_id;
			$referral->update();
		}

		try {
			// Redeem the reward that was used in this order (coupon was applied).
			// This applies to ALL rewards: advocate, friend, and points redemption.
			if ( ! $reward_type->redeem_reward( $reward ) ) {
				return;
			}

			// Store the order ID on the reward for display purposes.
			$reward->update_meta( 'applied_order_id', $order_id );

			// For points redemption rewards, just redeem and we're done.
			if ( $is_points_redemption ) {
				return;
			}

			// If it is a reward from an advocate, mark referral as completed and stop here.
			// The reward is now redeemed because the advocate used the coupon.
			if ( 'advocate' === $reward->recipient_type ) {
				$referral->set_as_completed();
				return;
			}

			// This is a friend's reward, now create the advocate's reward.
			$reward_options = get_option_value( RewardsOption::class );

			$advocate_reward_type = $this->reward_types_factory->get_reward_type( $reward_options['advocate']['type'] ?? '' );

			if ( is_null( $advocate_reward_type ) ) {
				return;
			}

			// Issue the advocate reward.
			// Note: For points rewards, issue_reward() also auto-redeems.
			// For coupon rewards, the reward stays as "issued" for advocate to claim later.
			$advocate_reward = $advocate_reward_type->issue_reward(
				$referral,
				'advocate',
				[
					'order_id' => $order_id,
				]
			);

			if ( is_null( $advocate_reward ) ) {
				return; // Bail because it failed to issue a reward for the advocate.
			}

			$referral->set_as_completed();
		} catch ( Exception $e ) {
			rewardswp_log_error(
				'Failed to process reward from coupon for order {order_id}: {message}',
				[
					'order_id' => $order_id,
					'message'  => $e->getMessage(),
				]
			);
			return;
		}
	}

	/**
	 * Convert user into advocate when a successful purchase is done.
	 *
	 * @since 1.0.0
	 *
	 * @param int $order_id
	 *
	 * @return void
	 *
	 * @throws Exception
	 */
	public function action_convert_customer_into_advocate( int $order_id ): void {
		/**
		 * Allow plugins to change if customers should be converted after purchases.
		 *
		 * @since 1.0.0
		 *
		 * @param bool $convert_customer_on_purchase Whether to convert or not.
		 * @param int  $order_id                     Woocommerce order ID.
		 */
		$should_convert = apply_filters(
			'rewardswp_auto_convert_to_member_on_purchase',
			$this->convert_customer_on_purchase,
			$order_id
		);

		if ( ! $should_convert ) {
			return;
		}

		$order = $this->get_order( $order_id );

		if ( ! $order ) {
			return; // Bail if it is not a WooCommerce order.
		}

		// Do not convert users from COD orders into members while the order is in processing status.
		if ( $this->is_cod_processing( $order ) ) {
			return;
		}

		$advocate = $this->create_advocate_from_order( $order );

		if ( is_null( $advocate ) ) {
			return;
		}

		// Guest members must register the token cookie.
		if ( ! empty( $advocate->token ) ) {
			$this->guest_member_service->set_cookie( $advocate->token );
		}

		// Only award points if order is not on-hold (payment pending).
		// On-hold orders will have points awarded when status changes to processing/completed.
		if ( 'on-hold' !== $order->get_status() ) {
			// Award purchase points if "Make a Purchase" is enabled and points weren't awarded yet.
			// This ensures new members receive points even if they weren't members when the order was processed.
			$order_total = $this->calculate_order_total_for_points( $order );
			$this->maybe_award_purchase_points_after_conversion( $advocate, $order_id, $order_total );

			// Award first order bonus points if enabled and this is the member's first order.
			// This ensures new members receive first order bonus even if they weren't members when the order was processed.
			$this->maybe_award_first_order_points_after_conversion( $advocate, $order_id );
		}

		// Get the coupons used in this order.
		$used_coupons = $order->get_coupon_codes();

		// Update advocate_id on friend rewards whose coupons were used in this order.
		// These rewards were already redeemed by action_process_reward_from_coupon,
		// but advocate_id wasn't set because the member didn't exist yet.
		// Now that the member exists, we update advocate_id so the reward shows in their widget.
		if ( ! empty( $used_coupons ) ) {
			foreach ( $used_coupons as $coupon_code ) {
				$reward = $this->rewards_service->get_reward_by_coupon_code( $coupon_code );
				if ( $reward && 'friend' === $reward->recipient_type && 0 === $reward->advocate_id ) {
					$reward->advocate_id = $advocate->id;
					$reward->update();
				}
			}
		}

		$visit_id = $this->tracking_service->get_cookie( $this->tracking_service::VISIT_ID_COOKIE_NAME );

		// Bond any unused rewards to the new advocate.
		// Only fetch rewards from the referral that led to this order (has matching order_id).
		$rewards = is_null( $visit_id )
			? []
			: $this->rewards_service->get_unused_friend_rewards( $visit_id, $order_id );

		// Fallback: if event_id-based search failed (cookie changed between claim and purchase),
		// search by billing email to find the unclaimed friend reward.
		if ( empty( $rewards ) ) {
			$email_reward = get_customer_reward( (string) $order->get_billing_email() );

			if ( $email_reward && 0 === $email_reward->advocate_id && 'issued' === $email_reward->status ) {
				$rewards = [ $email_reward ];
			}
		}

		if ( empty( $rewards ) ) {
			return;
		}

		// Transfer only ONE reward (the most recent) to the new advocate.
		// Skip rewards whose coupon is being used in this order - they're already handled above.
		// This prevents old stale rewards from being transferred to the new member.
		$reward_transferred = false;
		foreach ( $rewards as $reward ) {
			// Skip rewards whose coupon is being used in this order.
			$reward_coupon = $reward->get_meta( 'coupon_code' );
			if ( ! empty( $reward_coupon ) && in_array( $reward_coupon, $used_coupons, true ) ) {
				continue; // Already handled above.
			}

			// Only transfer one reward. The query is scoped to this visit+order, so typically only one exists.
			if ( $reward_transferred ) {
				continue;
			}

			$reward->advocate_id    = $advocate->id;
			$reward->recipient_type = 'advocate';
			$reward->update();
			$reward_transferred = true;
		}
	}

	/**
	 * Hide the panel from the checkout and thank-you pages.
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	public function action_hide_panel_from_cart_and_checkout_pages(): void {
		$is_checkout_page  = function_exists( 'is_checkout' ) && is_checkout();
		$is_order_received = function_exists( 'is_order_received_page' ) && is_order_received_page();

		// Checkout: Hide assets AND widget (performance critical).
		// But NOT if it's also the order-received page.
		if ( $is_checkout_page && ! $is_order_received ) {
			add_filter( 'rewardswp_is_rewards_widget_visible', '__return_false' );
			add_filter( 'rewardswp_should_load_main_assets', '__return_false' );
		}
	}

	/**
	 * Keep advocate's name in sync when the meta is updated directly.
	 *
	 * @since 1.0.0
	 *
	 * @param int    $meta_id
	 * @param int    $customer_id
	 * @param string $meta_key
	 *
	 * @param mixed  $meta_value
	 *
	 * @return void
	 *
	 * @throws Exception
	 */
	public function action_keep_advocate_in_sync_with_customer_name_on_meta_update(
		int $meta_id,
		int $customer_id,
		string $meta_key,
		$meta_value
	) {
		$advocate = get_member_by( 'user_id', $customer_id );

		if ( is_null( $advocate ) ) {
			return; // Not an advocate.
		}

		$has_changes = false;

		if ( 'billing_first_name' === $meta_key && ! empty( $meta_value ) ) {
			$advocate->first_name = sanitize_text_field( $meta_value );
			$has_changes          = true;
		}

		if ( 'billing_last_name' === $meta_key ) {
			$advocate->last_name = sanitize_text_field( $meta_value );
			$has_changes         = true;
		}

		if ( $has_changes ) {
			$advocate->update();
		}
	}

	/**
	 * Create pending purchase points entry when an order is created.
	 *
	 * This method creates a point entry with 'pending' status when an order is created.
	 * The entry will be updated to 'approved' when payment is confirmed.
	 *
	 * @since 1.2.0
	 *
	 * @param int|WC_Order $order The order ID or object.
	 *
	 * @return void
	 */
	public function action_create_pending_purchase_points( $order ): void {
		$order = is_int( $order ) ? $this->get_order( $order ) : $order;

		if ( ! $order instanceof WC_Order ) {
			return;
		}

		$earn_actions = get_option_value( EarnActionsOption::class );

		// Check if purchase points are enabled and configured.
		if ( empty( $earn_actions['purchase']['enabled'] ) || empty( $earn_actions['purchase']['points_per_dollar'] ) ) {
			return;
		}

		$order_id = $order->get_id();

		// Try to retrieve the member by its email.
		$billing_email = (string) $order->get_billing_email();
		$member        = get_member_by( 'email', $billing_email );

		if ( is_null( $member ) ) {
			return;
		}

		// Check if points entry already exists for this order (prevent duplicates).
		$existing_points = get_points_entries(
			[
				'member_id'   => $member->id,
				'source_type' => 'purchase',
				'source_id'   => $order_id,
			],
			1,
			1
		);

		if ( ! empty( $existing_points ) ) {
			return;
		}

		// Calculate points based on order subtotal (excludes tax and shipping).
		$order_total = $this->calculate_order_total_for_points( $order );
		$points      = $this->calculate_purchase_points( $order_total, $earn_actions );

		if ( $points <= 0 ) {
			return;
		}

		// Create pending points entry.
		$points_entry = [
			'member_id'   => absint( $member->id ),
			'points'      => $points,
			'type'        => 'earned',
			'source_type' => 'purchase',
			'source_id'   => $order_id,
			'status'      => 'pending',
			'note_public' => sprintf(
				/* translators: %d: order ID */
				__( 'Points pending for order #%d', 'rewardswp' ),
				$order_id
			),
		];

		$point = $this->points_service->create_points_entry( $points_entry, false, false );

		if ( is_a( $point, Point::class ) ) {
			$point->update_meta( 'integration', $this->get_source() );
		}
	}

	/**
	 * Award points for purchase if conditions are met.
	 *
	 * This method updates existing 'pending' point entries to 'approved' when payment is confirmed.
	 * If no pending entry exists, it creates a new one (fallback for compatibility).
	 *
	 * @since 1.0.1
	 *
	 * @param int $order_id The order ID.
	 *
	 * @return void
	 */
	public function action_maybe_award_purchase_points( int $order_id ): void {
		$earn_actions = get_option_value( EarnActionsOption::class );

		// Check if purchase points are enabled and configured.
		if ( empty( $earn_actions['purchase']['enabled'] ) || empty( $earn_actions['purchase']['points_per_dollar'] ) ) {
			return;
		}

		$order = $this->get_order( $order_id );

		if ( ! $order instanceof WC_Order ) {
			return;
		}

		// Leave pending status for COD orders while in processing status.
		if ( $this->is_cod_processing( $order ) ) {
			return;
		}

		// Try to retrieve the member by its email.
		$billing_email = (string) $order->get_billing_email();
		$member        = get_member_by( 'email', $billing_email );

		if ( is_null( $member ) ) {
			return;
		}

		// Recalculate points based on current order subtotal (order may have changed).
		$order_total = $this->calculate_order_total_for_points( $order );
		$points      = $this->calculate_purchase_points( $order_total, $earn_actions );

		if ( $points <= 0 ) {
			return;
		}

		// Check if a pending point entry exists for this order.
		$existing_points = get_points_entries(
			[
				'member_id'   => $member->id,
				'source_type' => 'purchase',
				'source_id'   => $order_id,
				'status'      => 'pending',
			],
			1,
			1
		);

		if ( ! empty( $existing_points ) ) {
			// Update existing pending entry to approved.
			$update_result = $this->points_service->update_points_entry(
				[
					'member_id'   => absint( $member->id ),
					'source_type' => 'purchase',
					'source_id'   => $order_id,
					'points'      => $points,
					'status'      => 'approved',
					'note_public' => sprintf(
						/* translators: %d: order ID */
						__( 'Points earned for order #%d', 'rewardswp' ),
						$order_id
					),
				]
			);

			if ( is_a( $update_result, Point::class ) ) {
				$update_result->update_meta( 'integration', $this->get_source() );
			}

			return;
		}

		// Fallback: Check if any point entry exists (approved or other status).
		$any_existing_points = get_points_entries(
			[
				'member_id'   => $member->id,
				'source_type' => 'purchase',
				'source_id'   => $order_id,
			],
			1,
			1
		);

		if ( ! empty( $any_existing_points ) ) {
			return; // Entry already exists, don't create duplicate.
		}

		// Fallback: Calculate points based on order subtotal for old data compatibility.
		$order_total = $this->calculate_order_total_for_points( $order );
		$points      = $this->calculate_purchase_points( $order_total, $earn_actions );

		if ( $points <= 0 ) {
			return;
		}

		// Create points entry.
		$points_entry = [
			'member_id'   => absint( $member->id ),
			'points'      => $points,
			'type'        => 'earned',
			'source_type' => 'purchase',
			'source_id'   => $order_id,
			'status'      => 'approved',
			'note_public' => sprintf(
				/* translators: %d: order ID */
				__( 'Points earned for order #%d', 'rewardswp' ),
				$order_id
			),
		];

		$point = $this->points_service->create_points_entry( $points_entry );

		if ( is_a( $point, Point::class ) ) {
			$point->update_meta( 'integration', $this->get_source() );
		}
	}

	/**
	 * Get date range for a given period.
	 *
	 * @since 1.1.0
	 *
	 * @param string $period The period type (daily, weekly, monthly, quarterly, yearly).
	 *
	 * @return array Array with 'start' and 'end' keys containing formatted date strings.
	 */
	private function get_period_date_range( string $period ): array {
		$current_time     = current_time( 'timestamp' );
		$timezone         = wp_timezone();
		$current_datetime = ( new DateTimeImmutable( '@' . $current_time ) )->setTimezone( $timezone );

		switch ( $period ) {
			case 'daily':
				$start_dt = $current_datetime->setTime( 0, 0, 0 );
				$end_dt   = $current_datetime->setTime( 23, 59, 59 );
				break;

			case 'weekly':
				$weekday  = (int) $current_datetime->format( 'w' );
				$start_dt = $current_datetime->modify( "-{$weekday} days" )->setTime( 0, 0, 0 );
				$end_dt   = $start_dt->modify( '+6 days' )->setTime( 23, 59, 59 );
				break;

			case 'quarterly':
				$current_month       = (int) $current_datetime->format( 'n' );
				$current_year        = (int) $current_datetime->format( 'Y' );
				$quarter_start_month = ( ceil( $current_month / 3 ) - 1 ) * 3 + 1;
				$start_dt            = $current_datetime->setDate( $current_year, $quarter_start_month, 1 )->setTime( 0, 0, 0 );
				$end_dt              = $start_dt->modify( '+2 months' )->modify( 'last day of this month' )->setTime( 23, 59, 59 );
				break;

			case 'yearly':
				$current_year = (int) $current_datetime->format( 'Y' );
				$start_dt     = $current_datetime->setDate( $current_year, 1, 1 )->setTime( 0, 0, 0 );
				$end_dt       = $current_datetime->setDate( $current_year, 12, 31 )->setTime( 23, 59, 59 );
				break;

			case 'monthly':
			default:
				$start_dt = $current_datetime->modify( 'first day of this month' )->setTime( 0, 0, 0 );
				$end_dt   = $current_datetime->modify( 'last day of this month' )->setTime( 23, 59, 59 );
				break;
		}

		return [
			'start' => $start_dt->format( 'Y-m-d H:i:s' ),
			'end'   => $end_dt->format( 'Y-m-d H:i:s' ),
		];
	}

	/**
	 * Award points for first order if conditions are met.
	 *
	 * @since 1.1.0
	 *
	 * @param int $order_id The order ID.
	 *
	 * @return void
	 */
	public function action_maybe_award_first_order_points( int $order_id ): void {
		$earn_actions = get_option_value( EarnActionsOption::class );

		// Check if first order points are enabled and configured.
		if ( empty( $earn_actions['first_order']['enabled'] ) || empty( $earn_actions['first_order']['points'] ) ) {
			return;
		}

		$order = wc_get_order( $order_id );
		if ( ! $order ) {
			return;
		}

		$customer_email = (string) $order->get_billing_email();
		if ( empty( $customer_email ) ) {
			return;
		}

		$member = get_member_by( 'email', $customer_email );
		if ( is_null( $member ) ) {
			return;
		}

		// Check if member already received first order points.
		if ( $member->get_meta( 'first_order_awarded' ) ) {
			return;
		}

		// Check if this is truly the first order.
		if ( ! $this->is_first_order( $customer_email, $order_id ) ) {
			return;
		}

		$points = absint( $earn_actions['first_order']['points'] );

		$entry = [
			'member_id'   => (int) $member->id,
			'points'      => $points,
			'type'        => 'earned',
			'source_type' => 'first_order',
			'source_id'   => $order_id,
			'status'      => 'approved',
			'note_public' => __( 'Welcome bonus for your first order', 'rewardswp' ),
		];

		$point = $this->points_service->create_points_entry( $entry );

		if ( is_a( $point, Point::class ) ) {
			$point->update_meta( 'integration', $this->get_source() );
		}

		$member->update_meta( 'first_order_awarded', true );
	}

	/**
	 * Check if this is the customer's first completed order.
	 *
	 * @since 1.1.0
	 *
	 * @param string $customer_email Customer email address.
	 * @param int    $current_order_id Current order ID to exclude from count.
	 *
	 * @return bool True if this is the first order, false otherwise.
	 */
	private function is_first_order( string $customer_email, int $current_order_id ): bool {
		$previous_orders = $this->get_customer_orders(
			$customer_email,
			[
				'status' => [ 'wc-processing', 'wc-completed' ],
				'limit'  => 2, // Get 2 to see if there are any besides current.
			]
		);

		// Filter out the current order from results.
		$previous_orders = array_filter(
			$previous_orders,
			function ( $order ) use ( $current_order_id ) {
				return (int) $order['id'] !== $current_order_id;
			}
		);

		// If no previous orders found, this is the first order.
		return empty( $previous_orders );
	}

	/**
	 * Award points for product review if conditions are met.
	 *
	 * This method is called when a comment status transitions to approved.
	 *
	 * @since 1.1.0
	 *
	 * @param string $new_status The new comment status.
	 * @param string $old_status The old comment status.
	 * @param object $comment    The comment object.
	 *
	 * @return void
	 */
	public function action_maybe_award_review_points( string $new_status, string $old_status, $comment ): void {
		// Only process approved reviews.
		if ( 'approved' !== $new_status || empty( $comment ) ) {
			return;
		}

		// Only process if status actually changed (avoid duplicate processing with comment_post hook).
		if ( $new_status === $old_status ) {
			return;
		}

		$this->process_review_points_award( $comment );
	}

	/**
	 * Award points for product review when comment is first created.
	 *
	 * This method catches reviews that are approved immediately upon creation,
	 * which don't trigger the transition_comment_status hook.
	 *
	 * @since 1.1.0
	 *
	 * @param int   $comment_id       The comment ID.
	 * @param int   $comment_approved 1 if the comment is approved, 0 if not, 'spam' if spam.
	 * @param array $comment_data     Comment data array.
	 *
	 * @return void
	 */
	public function action_maybe_award_review_points_on_create( int $comment_id, $comment_approved, array $comment_data ): void {
		// Only process if comment is approved (1 or 'approved').
		if ( 1 !== $comment_approved && 'approved' !== $comment_approved ) {
			return;
		}

		// Get the comment object.
		$comment = get_comment( $comment_id );
		if ( ! $comment ) {
			return;
		}

		$this->process_review_points_award( $comment );
	}

	/**
	 * Process the awarding of points for a product review.
	 *
	 * This is the core logic that checks all conditions and awards points.
	 *
	 * @since 1.1.0
	 *
	 * @param object $comment The comment object.
	 *
	 * @return void
	 */
	private function process_review_points_award( $comment ): void {
		// Only process product reviews.
		$product_id = (int) ( $comment->comment_post_ID ?? 0 );
		if ( $product_id <= 0 || 'product' !== get_post_type( $product_id ) ) {
			return;
		}

		$earn_actions = get_option_value( EarnActionsOption::class );

		// Check if product review points are enabled and configured.
		$action  = $earn_actions['product_review'] ?? [];
		$enabled = ! empty( $action['enabled'] );
		$points  = absint( $action['points'] ?? 0 );

		if ( ! $enabled || $points <= 0 ) {
			return;
		}

		// Check verified buyer requirement.
		$verified_required = ! empty( $action['verified_buyer_only'] );
		if ( $verified_required ) {
			$is_verified = function_exists( 'wc_review_is_from_verified_owner' )
				? (bool) wc_review_is_from_verified_owner( (int) $comment->comment_ID )
				: (bool) get_comment_meta( (int) $comment->comment_ID, 'verified', true );

			if ( ! $is_verified ) {
				return;
			}
		}

		// Get member from comment user.
		$user_id = (int) ( $comment->user_id ?? 0 );
		$member  = $user_id > 0 ? get_member_by( 'user_id', $user_id ) : null;

		if ( is_null( $member ) ) {
			return;
		}

		// Check if points were already awarded for this review (idempotency).
		// Since we only allow one review per product, we check by product_id in meta.
		$awarded_meta_key = 'review_awarded_' . $product_id;
		$already_awarded  = $member->get_meta( $awarded_meta_key );

		if ( ! empty( $already_awarded ) ) {
			return; // Already awarded for this product.
		}

		// Create points entry.
		$points_entry = [
			'member_id'   => absint( $member->id ),
			'points'      => $points,
			'type'        => 'earned',
			'source_type' => 'product_review',
			'source_id'   => (int) $comment->comment_ID,
			'status'      => 'approved',
			'note_public' => __( 'Points earned for a product review', 'rewardswp' ),
		];

		$point = $this->points_service->create_points_entry( $points_entry );

		if ( is_a( $point, Point::class ) ) {
			$point->update_meta( 'integration', $this->get_source() );
		}

		// Mark as awarded for this product.
		$member->update_meta( $awarded_meta_key, true );
	}

	/**
	 * Keep advocate's name in sync with the last order details.
	 *
	 * @param int|WC_Order $order
	 *
	 * @return void
	 *
	 * @throws Exception
	 */
	public function action_keep_advocate_in_sync_with_orders_billing_name( $order ): void {
		$order = is_int( $order )
			? $this->get_order( $order )
			: $order;

		if ( ! $order ) {
			return; // Bail if it is not a WooCommerce order.
		}

		$advocate = get_member_by( 'user_id', $order->get_user_id() );

		if ( is_null( $advocate ) ) {
			return; // Not an advocate.
		}

		$advocate->first_name = (string) $order->get_billing_first_name();
		$advocate->last_name  = (string) $order->get_billing_last_name();

		$advocate->update();
	}

	/**
	 * Keep advocate's name in sync with the WP User.
	 *
	 * @param int $user_id
	 *
	 * @return void
	 *
	 * @throws Exception
	 */
	public function action_keep_advocate_in_sync_with_wp_user_profile( int $user_id ): void {
		$advocate = get_member_by( 'user_id', $user_id );

		if ( is_null( $advocate ) ) {
			return; // Not an advocate.
		}

		$first_name = get_user_meta( $user_id, 'billing_first_name', true );

		// Keep at last the saved name, because the first name should not be empty.
		$advocate->first_name = empty( $first_name )
			? $advocate->first_name
			: $first_name;

		$advocate->last_name = get_user_meta( $user_id, 'billing_last_name', true );

		$advocate->update();
	}

	/**
	 * Convert a customer into advocate.
	 *
	 * @since 1.0.0
	 *
	 * @param WC_Order|null $order The WooCommerce order.
	 *
	 * @return Member|null
	 */
	protected function create_advocate_from_order( ?WC_Order $order ): ?Member {
		if ( ! $order ) {
			return null;
		}

		$email = (string) $order->get_billing_email();

		// Check if the user is already an advocate.
		$advocate = get_member_by( 'email', $email );

		if ( is_a( $advocate, Member::class ) ) {
			return null; // User is already an advocate.
		}

		$user_id = $order->get_user_id();

		// If guest participation is disabled and this is a guest checkout, don't create a member.
		if ( empty( $user_id ) && ! is_guest_participation_enabled() ) {
			return null; // Guest participation is disabled.
		}

		// Register the user as an advocate.
		$advocate = create_member(
			[
				'email'      => $email,
				'first_name' => (string) $order->get_billing_first_name(),
				'last_name'  => (string) $order->get_billing_last_name(),
				'user_id'    => $user_id ?? 0,
			]
		);

		if ( is_wp_error( $advocate ) ) {
			return null; // Failed to create advocate.
		}

		// Set meta flag to trigger welcome flow on first panel load.
		$advocate->update_meta( 'show_welcome_flow', '1' );

		return $advocate;
	}


	/**
	 * Check if an order came from a referral.
	 *
	 * @since 1.0.0
	 *
	 * @param int $order_id The WooCommerce order ID.
	 *
	 * @return bool|null Whether the order was referred or not. Null in case it can't find the order.
	 */
	public function has_order_referral( int $order_id ): ?bool {
		return (bool) $this->get_order_referral( $order_id );
	}

	/**
	 * Retrieve the order referral if exists.
	 *
	 * @since 1.0.0
	 *
	 * @param int $order_id The WooCommerce order ID.
	 *
	 * @return Referral|null referral object.
	 */
	public function get_order_referral( int $order_id ): ?Referral {
		$order = $this->get_order( $order_id );

		if ( ! $order ) {
			return null; // Bail if it is not a WooCommerce order.
		}

		return get_referrals(
			[
				'referred_email' => (string) $order->get_billing_email(),
				'order_id'       => $order_id,
				'source'         => $this->get_source(),
			],
			1
		)[0] ?? null;
	}

	/**
	 * Checks if an email belongs to a WooCommerce customer, whether registered or guest.
	 *
	 * @since 1.0.0
	 *
	 * @param string $email The email to check.
	 *
	 * @return bool Returns true if the email belongs to a WooCommerce customer, false otherwise.
	 */
	public function is_customer( string $email ): bool {
		// Attempt to retrieve the WordPress user by email.
		$user = get_user_by( 'email', $email );

		// If the user exists and has the 'customer' role, return true.
		if ( $user && in_array( 'customer', $user->roles, true ) ) {
			return true;
		}

		// If the user does not exist or is not a 'customer', search WooCommerce orders associated with the email.
		$customer_orders = wc_get_orders(
			[
				'billing_email' => $email,
				'limit'         => 1,  // Limit to 1 order for performance, as we only need to check for existence.
				'status'        => array_keys( wc_get_order_statuses() ), // Include orders of any status.
			]
		);

		// Return true if at least one order is found for the email, indicating it's a customer email.
		return ! empty( $customer_orders );
	}

	/**
	 * Attempt to find the reward based on a coupon code.
	 *
	 * @since 1.0.0
	 *
	 * @param int $order_id The order ID.
	 *
	 * @return Reward|null
	 */
	public function get_reward_from_order( int $order_id ): ?Reward {
		$order = $this->get_order( $order_id );

		if ( ! $order ) {
			return null;
		}

		$used_coupons = $order->get_coupon_codes();

		if ( empty( $used_coupons ) ) {
			return null;
		}

		foreach ( $used_coupons as $code ) {
			$reward = $this->rewards_service->get_reward_by_coupon_code( $code );

			if ( is_a( $reward, Reward::class ) ) {
				return $reward;
			}
		}

		return null;
	}

	/**
	 * Attempt to retrieve the order from WooCommerce.
	 *
	 * @since 1.0.0
	 *
	 * @param int $order_id The order ID.
	 * @return WC_Order|null
	 */
	public function get_order( int $order_id ): ?WC_Order {
		if ( ! function_exists( 'wc_get_order' ) ) {
			return null;
		}

		$order = wc_get_order( $order_id );

		if ( ! is_a( $order, WC_Order::class ) ) {
			return null;
		}

		return $order;
	}

	/**
	 * Get incomplete orders for a member that are linked to referrals or rewards.
	 *
	 * Returns orders with status 'on-hold', 'pending', or 'processing' that are
	 * associated with the member and linked to a referral or reward. These orders
	 * represent pending points that will be earned once the order is completed.
	 *
	 * @since 1.2.0
	 *
	 * @param Member $member Member instance.
	 *
	 * @return array Array of order IDs with their expected points.
	 */
	public function get_incomplete_orders_for_member( Member $member ): array {
		if ( ! function_exists( 'wc_get_orders' ) ) {
			return [];
		}

		$user_id = $member->user_id ?? 0;

		if ( empty( $user_id ) ) {
			return [];
		}

		// Get incomplete orders for this user.
		$incomplete_statuses = [ 'on-hold', 'pending', 'processing' ];
		$orders              = wc_get_orders(
			[
				'customer_id' => $user_id,
				'status'      => $incomplete_statuses,
				'limit'       => -1, // Get all incomplete orders.
				'return'      => 'ids', // Return only order IDs for performance.
			]
		);

		if ( empty( $orders ) ) {
			return [];
		}

		// Filter orders that are linked to referrals or rewards.
		$pending_orders = [];
		foreach ( $orders as $order_id ) {
			// Check if order has a referral linked to this member.
			$referral = $this->get_order_referral( $order_id );
			if ( $referral && $referral->advocate_id === $member->id ) {
				// Calculate expected points for this order.
				$order = $this->get_order( $order_id );
				if ( $order ) {
					$expected_points = $this->calculate_expected_purchase_points( $order );
					if ( $expected_points > 0 ) {
						$pending_orders[] = [
							'order_id'        => $order_id,
							'expected_points' => $expected_points,
							'status'          => $order->get_status(),
						];
					}
				}
			}
		}

		return $pending_orders;
	}

	/**
	 * Map the coupon data from an external source to WooCommerce format.
	 *
	 * @since 1.0.0
	 *
	 * @param array $data The raw coupon data.
	 *
	 * @return array Mapped data for WooCommerce coupon creation.
	 */
	public function map_coupon_data( array $data ): array {
		// Determine discount type.
		// For free_product: if reward_amount (Product Value) is set, use fixed_cart (protection against price increases).
		// Otherwise, use percent (100% discount - no protection).
		$discount_type = 'fixed_cart';
		if ( in_array( $data['type'], [ 'percent', 'percentage', 'percentage_discount' ], true ) ) {
			$discount_type = 'percent';
		} elseif ( 'free_product' === $data['type'] ) {
			// Free product with product_value > 0: use fixed_cart (e.g., $50 max discount).
			// Free product without product_value: use percent (100% discount - product is truly free).
			$discount_type = ( ! empty( $data['reward_amount'] ) && floatval( $data['reward_amount'] ) > 0 )
				? 'fixed_cart'
				: 'percent';
		}

		// Extract product IDs from products_included array.
		// The array can contain objects like [['id' => 123, 'name' => 'Product']] or plain IDs [123, 456].
		$product_ids = [];
		if ( ! empty( $data['products_included'] ) && is_array( $data['products_included'] ) ) {
			foreach ( $data['products_included'] as $item ) {
				if ( is_array( $item ) && isset( $item['id'] ) ) {
					$product_ids[] = (int) $item['id'];
				} elseif ( is_numeric( $item ) ) {
					$product_ids[] = (int) $item;
				}
			}
		}

		// Determine the coupon amount.
		// For free_product with percent discount type, use 100 (100% off).
		// Otherwise, use the reward_amount.
		$amount = $data['reward_amount'] ?? 0;
		if ( 'free_product' === $data['type'] && 'percent' === $discount_type ) {
			$amount = 100; // 100% discount for truly free products.
		}

		return [
			'type'           => $data['type'],
			'code'           => $data['code'] ?? '',
			'discount_type'  => $discount_type,
			'amount'         => $amount,
			'expiry_date'    => $data['expiry_date'] ?? '',
			'minimum_amount' => $data['minimum_order_amount'] ?? 0,
			'free_shipping'  => $data['free_shipping'] ?? '',
			'product_ids'    => $product_ids,
		];
	}

	/**
	 * Create a new WooCommerce coupon.
	 *
	 * @since 1.0.0
	 *
	 * @param array $data {
	 *     @type string $code           The coupon code.
	 *     @type string $discount_type  The discount type, like 'percent', 'fixed_cart', or 'fixed_product'. Default 'fixed_cart'.
	 *     @type float  $amount         The discount amount. Must be greater than zero.
	 *     @type string $expiry_date    Optional expiry date in format YYYY-MM-DD.
	 *     @type float  $minimum_amount A minimum cart amount to enable the coupon. Default 0.
	 *     @type bool   $free_shipping  Whether the coupon should allow free shipping or not. Default false.
	 * }
	 *
	 * @return int|WP_Error The coupon ID if created successfully, or WP_Error in case of failure.
	 */
	public function create_coupon( array $data = [] ) {
		// Validate if the EDD is active.
		if ( ! $this->is_active() ) {
			return new WP_Error(
				'woocommerce_not_installed',
				esc_html__( 'Woocommerce is not installed or active.', 'rewardswp' )
			);
		}

		// Parse the data and set default values.
		$data = wp_parse_args(
			$data,
			[
				'type'           => '',
				'code'           => '',
				'discount_type'  => 'fixed_cart',
				'amount'         => 0,
				'expiry_date'    => null,
				'minimum_amount' => 0,
				'free_shipping'  => false,
				'product_ids'    => [],
			]
		);

		// Ensure the amount is sanitized and greater than zero.
		$data['amount'] = sanitize_float( $data['amount'] );

		if ( empty( $data['free_shipping'] ) && ( empty( $data['amount'] ) || $data['amount'] <= 0 ) ) {
			return new WP_Error(
				'integration_error',
				esc_html__( 'Failed to create coupon, `amount` must be higher than zero.', 'rewardswp' )
			);
		}

		$new_coupon_id = wp_insert_post(
			[
				'post_title'   => sanitize_text_field( $data['code'] ),
				'post_content' => '',
				'post_excerpt' => esc_html__( 'Generated by RewardsWP', 'rewardswp' ),
				'post_status'  => 'publish',
				'post_type'    => 'shop_coupon',
			]
		);

		if ( is_wp_error( $new_coupon_id ) ) {
			return $new_coupon_id; // Bail early if there's an error.
		}

		// Set free shipping if specified.
		if ( $data['free_shipping'] ) {
			update_post_meta( $new_coupon_id, 'free_shipping', 'yes' );
		}

		if ( ! empty( $data['product_ids'] ) ) {
			update_post_meta( $new_coupon_id, 'product_ids', comma_separated_array( sanitize_array_to_int( $data['product_ids'] ) ) );
		}

		// Update coupon metadata.
		update_post_meta( $new_coupon_id, 'discount_type', sanitize_text_field( $data['discount_type'] ) );
		update_post_meta( $new_coupon_id, 'coupon_amount', $data['amount'] );
		update_post_meta( $new_coupon_id, 'individual_use', 'yes' );
		update_post_meta( $new_coupon_id, 'exclude_sale_items', 'no' );
		update_post_meta( $new_coupon_id, 'usage_limit', 1 );
		update_post_meta( $new_coupon_id, 'usage_limit_per_user', 1 );
		update_post_meta( $new_coupon_id, 'expiry_date', is_string( $data['expiry_date'] ) ? strtotime( $data['expiry_date'] ) : $data['expiry_date'] );
		update_post_meta( $new_coupon_id, 'minimum_amount', sanitize_float( $data['minimum_amount'] ) );

		// For free_product coupons, limit the discount to a single item quantity.
		// This prevents customers from changing cart quantity to get unlimited free products.
		if ( 'free_product' === ( $data['type'] ?? '' ) ) {
			update_post_meta( $new_coupon_id, 'limit_usage_to_x_items', 1 );
		}

		return $new_coupon_id;
	}

	/**
	 * Retrieve the WC cart instance.
	 *
	 * @since 1.0.0
	 *
	 * @return WC_Cart|null
	 */
	protected function get_cart(): ?WC_Cart {
		return function_exists( 'WC' ) && property_exists( WC(), 'cart' ) && WC()->cart instanceof WC_Cart
			? WC()->cart
			: null;
	}

	/**
	 * Check if a coupon has been applied to the cart.
	 *
	 * @param string $coupon_code The coupon code to check.
	 *
	 * @return bool Whether the coupon is applied or not.
	 */
	public function is_coupon_applied( string $coupon_code ): bool {
		$cart = $this->get_cart();

		if ( is_null( $cart ) ) {
			return false;
		}

		return $cart->has_discount( $coupon_code );
	}

	/**
	 * Apply a coupon code to cart programmatically.
	 *
	 * @since 1.0.0
	 *
	 * @param string $coupon_code The coupon code.
	 *
	 * @return true|WP_Error|null True in case of success, WP_Error in case the coupon doesn't exist or Woocommerce is not active,
	 *                            null if the discount was already applied.
	 */
	public function apply_coupon( string $coupon_code ) {
		if ( ! $this->is_active() ) {
			return new WP_Error(
				'woocommerce_not_installed',
				esc_html__( 'Failed to apply coupon, WooCommerce must be installed.', 'rewardswp' )
			);
		}

		$cart = $this->get_cart();

		if ( is_null( $cart ) ) {
			return null;
		}

		if ( $cart->has_discount( $coupon_code ) ) {
			return null; // Discount already applied, nothing to do here.
		}

		// Check if the coupon exists.
		$coupon_id = function_exists( 'wc_get_coupon_id_by_code' )
			? wc_get_coupon_id_by_code( $coupon_code )
			: 0;

		if ( ! $coupon_id ) {
			return new WP_Error(
				'invalid_coupon',
				esc_html__( 'The coupon code does not exist.', 'rewardswp' )
			);
		}

		// Creat a new session in case there's nothing in the cart yet.
		if ( ! property_exists( WC(), 'session' ) || ! WC()->session->has_session() ) {
			WC()->session->set_customer_session_cookie( true );
		}

		$cart->apply_coupon( $coupon_code );

		// Verify if the coupon was actually applied (WC may reject it silently).
		if ( ! $cart->has_discount( $coupon_code ) ) {
			// Capture error message from WC notices before clearing.
			$error_message = __( 'Could not apply coupon.', 'rewardswp' );
			if ( function_exists( 'wc_get_notices' ) ) {
				$notices = wc_get_notices( 'error' );
				if ( ! empty( $notices ) && isset( $notices[0]['notice'] ) ) {
					// Strip HTML tags and decode HTML entities for clean display.
					$error_message = html_entity_decode( wp_strip_all_tags( $notices[0]['notice'] ), ENT_QUOTES, 'UTF-8' );
				}
			}
			if ( function_exists( 'wc_clear_notices' ) ) {
				wc_clear_notices();
			}
			return new WP_Error( 'coupon_not_applied', $error_message );
		}

		if ( function_exists( 'wc_clear_notices' ) ) {
			wc_clear_notices();
		}

		return true;
	}

	/**
	 * Remove a coupon from the cart.
	 *
	 * @param string $coupon_code The coupon code to remove.
	 *
	 * @return bool Whether the coupon was removed or not.
	 */
	public function remove_coupon( string $coupon_code = '' ): bool {
		$cart = $this->get_cart();

		if ( is_null( $cart ) ) {
			return false;
		}

		if ( empty( $coupon_code ) ) {
			$cart->remove_coupons();
			$cart->calculate_totals();

			return true;
		}

		return $cart->remove_coupon( $coupon_code );
	}

	/**
	 * Check if the necessary dependency is available.
	 *
	 * @since 1.0.0
	 *
	 * @return bool Whether Woocommerce is active or not.
	 */
	public function is_active(): bool {
		return function_exists( 'WC' );
	}

	/**
	 * Get customer URL.
	 *
	 * @since 1.0.0
	 *
	 * @param int      $user_id  The user ID.
	 * @param int|null $order_id The order ID.
	 *
	 * @return string The customer URL.
	 */
	public function get_customer_url( int $user_id, ?int $order_id = null ): string {
		$user = get_user_by( 'id', $user_id );

		if ( $user ) {
			return admin_url( sprintf( 'user-edit.php?user_id=%s', $user_id ) );
		}

		return '';
	}

	/**
	 * Get customer name from order.
	 *
	 * @since 1.0.0
	 *
	 * @param int|null $order_id The order ID.
	 *
	 * @return string|null The customer name.
	 */
	public function get_customer_name( ?int $order_id = null ): ?string {
		$order = $this->get_order( $order_id );

		if ( ! $order ) {
			return null; // Bail if it is not a WooCommerce order.
		}

		$first_name = (string) $order->get_billing_first_name();
		$last_name  = (string) $order->get_billing_last_name();

		// If we just have the first name, return it.
		if ( empty( $last_name ) ) {
			return $first_name;
		}

		return trim(
			sprintf(
				'%s %s',
				$first_name,
				$last_name
			)
		);
	}

	/**
	 * Get customer email from order.
	 *
	 * @since 1.0.0
	 *
	 * @param int|null $order_id The order ID.
	 *
	 * @return string|null The customer email.
	 */
	public function get_customer_email( ?int $order_id = null ): ?string {
		$order = $this->get_order( $order_id );

		if ( ! $order ) {
			return null; // Bail if it is not a WooCommerce order.
		}

		return (string) $order->get_billing_email();
	}

	/**
	 * Get the product data by ID.
	 *
	 * Retrieves the ID and name of an EDD product.
	 *
	 * @since 1.0.0
	 *
	 * @param int $product_id The product ID.
	 *
	 * @return array An array containing the ID and name, or empty array if the product doesn't exist.
	 */
	public function get_product( $product_id ): array {
		if ( ! function_exists( 'wc_get_product' ) ) {
			return [];
		}

		$product = wc_get_product( $product_id );

		if ( ! $product ) {
			return [];
		}

		$name = $product->get_name();

		// For variations, format as "Parent – Attribute1 / Attribute2".
		if ( $product->is_type( 'variation' ) ) {
			$name = $this->format_variation_name( $product );
		}

		return [
			'id'    => $product->get_id(),
			'name'  => $name,
			'price' => (float) $product->get_price(),
		];
	}

	/**
	 * Search WooCommerce products by name or ID.
	 *
	 * Returns simple products and product variations (not variable product parents).
	 * Supports searching by ID if the query is numeric.
	 *
	 * @since 1.0.0
	 * @since 1.2.0 Added support for variations and ID search.
	 *
	 * @param string $substring The substring to search for in product names, or a numeric ID.
	 *
	 * @return array An array of arrays with 'id', 'name', and 'price' keys.
	 */
	public function search_products( string $substring ): array {
		if ( ! function_exists( 'wc_get_product' ) ) {
			return [];
		}

		$substring = sanitize_text_field( $substring );

		// If query is numeric, search by ID directly.
		if ( is_numeric( $substring ) ) {
			return $this->search_product_by_id( (int) $substring );
		}

		return $this->search_products_by_name( $substring );
	}

	/**
	 * Search for a product or variation by ID.
	 *
	 * @since 1.2.0
	 *
	 * @param int $product_id The product or variation ID.
	 *
	 * @return array An array with a single product, or empty if not found.
	 */
	private function search_product_by_id( int $product_id ): array {
		$product = wc_get_product( $product_id );

		if ( ! $product || 'publish' !== $product->get_status() ) {
			return [];
		}

		// Don't return variable products (parents), only simple or variations.
		if ( $product->is_type( 'variable' ) ) {
			// Return the first few variations of this variable product instead.
			return $this->get_product_variations( $product_id, 10 );
		}

		return [
			$this->format_product_for_search( $product ),
		];
	}

	/**
	 * Search products by name substring.
	 *
	 * @since 1.2.0
	 *
	 * @param string $substring The substring to search for.
	 *
	 * @return array Array of products with id, name, and price.
	 */
	private function search_products_by_name( string $substring ): array {
		$results = [];
		$limit   = 50;

		// Search for purchasable products, excluding types handled separately or not cart-compatible.
		// Variable products are queried below for their variations; grouped/external can't be added to cart.
		$products = wc_get_products(
			[
				'status'    => 'publish',
				's'         => $substring,
				'limit'     => $limit,
				'orderby'   => 'title',
				'order'     => 'ASC',
				'tax_query' => [
					[
						'taxonomy' => 'product_type',
						'field'    => 'slug',
						'terms'    => [ 'variable', 'grouped', 'external' ],
						'operator' => 'NOT IN',
					],
				],
			]
		);

		foreach ( $products as $product ) {
			$results[] = $this->format_product_for_search( $product );
		}

		// If we already have enough results, return early.
		if ( count( $results ) >= $limit ) {
			return array_slice( $results, 0, $limit );
		}

		// Search for variable products to get their variations.
		$variable_products = wc_get_products(
			[
				'status'  => 'publish',
				's'       => $substring,
				'type'    => 'variable',
				'limit'   => 20, // Limit parent products since each can have many variations.
				'orderby' => 'title',
				'order'   => 'ASC',
			]
		);

		foreach ( $variable_products as $variable_product ) {
			$variations = $this->get_product_variations( $variable_product->get_id(), $limit - count( $results ) );
			$results    = array_merge( $results, $variations );

			if ( count( $results ) >= $limit ) {
				break;
			}
		}

		// Also search directly in variations by title.
		$remaining_slots = $limit - count( $results );
		if ( $remaining_slots > 0 ) {
			$variation_results = $this->search_variations_by_name( $substring, $remaining_slots, wp_list_pluck( $results, 'id' ) );
			$results           = array_merge( $results, $variation_results );
		}

		return array_slice( $results, 0, $limit );
	}

	/**
	 * Search product variations by name.
	 *
	 * @since 1.2.0
	 *
	 * @param string $substring   The substring to search for.
	 * @param int    $limit       Maximum number of results.
	 * @param array  $exclude_ids IDs to exclude from results.
	 *
	 * @return array Array of variations with id, name, and price.
	 */
	private function search_variations_by_name( string $substring, int $limit, array $exclude_ids = [] ): array {
		$results = [];

		$query_args = [
			'post_type'      => 'product_variation',
			'post_status'    => 'publish',
			's'              => $substring,
			'posts_per_page' => $limit,
			'orderby'        => 'title',
			'order'          => 'ASC',
			'post__not_in'   => $exclude_ids,
		];

		$query = new WP_Query( $query_args );

		foreach ( $query->posts as $post ) {
			$variation = wc_get_product( $post->ID );

			if ( ! $variation || ! $variation->is_type( 'variation' ) ) {
				continue;
			}

			$results[] = $this->format_product_for_search( $variation );
		}

		return $results;
	}

	/**
	 * Get variations for a variable product.
	 *
	 * @since 1.2.0
	 *
	 * @param int $parent_id The parent variable product ID.
	 * @param int $limit     Maximum number of variations to return.
	 *
	 * @return array Array of variations with id, name, and price.
	 */
	private function get_product_variations( int $parent_id, int $limit = 50 ): array {
		$results = [];
		$product = wc_get_product( $parent_id );

		if ( ! $product || ! $product->is_type( 'variable' ) ) {
			return [];
		}

		$variation_ids = $product->get_children();
		$count         = 0;

		foreach ( $variation_ids as $variation_id ) {
			if ( $count >= $limit ) {
				break;
			}

			$variation = wc_get_product( $variation_id );

			if ( ! $variation || 'publish' !== $variation->get_status() ) {
				continue;
			}

			$results[] = $this->format_product_for_search( $variation );
			++$count;
		}

		return $results;
	}

	/**
	 * Format a product or variation for search results.
	 *
	 * @since 1.2.0
	 *
	 * @param \WC_Product $product The product or variation object.
	 *
	 * @return array Formatted product with id, name, and price.
	 */
	private function format_product_for_search( $product ): array {
		$name = $product->get_name();

		// For variations, format as "Parent – Attribute1 / Attribute2".
		if ( $product->is_type( 'variation' ) ) {
			$name = $this->format_variation_name( $product );
		}

				return [
			'id'    => $product->get_id(),
			'name'  => esc_html( $name ),
			'price' => wp_strip_all_tags( wc_price( $product->get_price() ) ),
		];
	}

	/**
	 * Format a variation name with parent name and attributes.
	 *
	 * @since 1.2.0
	 *
	 * @param \WC_Product_Variation $variation The variation object.
	 *
	 * @return string Formatted name like "T-Shirt – Blue / Large".
	 */
	private function format_variation_name( $variation ): string {
		$parent_id = $variation->get_parent_id();
		$parent    = wc_get_product( $parent_id );

		if ( ! $parent ) {
			return $variation->get_name();
		}

		$parent_name  = $parent->get_name();
		$attributes   = $variation->get_attributes();
		$attr_strings = [];

		foreach ( $attributes as $attribute_name => $attribute_value ) {
			if ( empty( $attribute_value ) ) {
				continue;
			}

			// Get the attribute label if it's a taxonomy attribute.
			$taxonomy = str_replace( 'attribute_', '', $attribute_name );

			if ( taxonomy_exists( $taxonomy ) ) {
				$term = get_term_by( 'slug', $attribute_value, $taxonomy );
				if ( $term && ! is_wp_error( $term ) ) {
					$attr_strings[] = $term->name;
					continue;
				}
			}

			// Fallback to the raw value.
			$attr_strings[] = ucfirst( $attribute_value );
		}

		if ( empty( $attr_strings ) ) {
			return $parent_name;
		}

		return sprintf( '%s – %s', $parent_name, implode( ' / ', $attr_strings ) );
	}

	/**
	 * Retrieve the total amount from a given WooCommerce order as a float.
	 *
	 * @since 1.0.0
	 *
	 * @param int $order_id The WooCommerce order ID.
	 *
	 * @return float The order total as a float.
	 */
	function get_order_total( int $order_id ): float {
		if ( ! class_exists( 'WC_Order' ) ) {
			return 0.0;
		}

		$order = wc_get_order( $order_id );

		return $order instanceof WC_Order ? $order->get_total() : 0.0;
	}

	/**
	 * Get the order total used for calculating purchase points.
	 *
	 * Returns the order subtotal (items after sale prices, minus coupon discounts, plus fees)
	 * excluding tax and shipping. This is the industry standard for loyalty programs.
	 *
	 * @since 1.4.2
	 *
	 * @param mixed $order The WC_Order object.
	 *
	 * @return float
	 */
	protected function get_order_total_for_points( $order ): float {
		return (float) $order->get_subtotal()
			 - (float) $order->get_discount_total()
			 + (float) $order->get_total_fees();
	}

	/**
	 * Retrieve the total amount from a given WooCommerce order formatted with currency.
	 *
	 * @since 1.0.0
	 *
	 * @param int $order_id The WooCommerce order ID.
	 *
	 * @return string The formatted order total with currency.
	 */
	function get_order_total_formatted( int $order_id ): string {
		if ( ! class_exists( 'WC_Order' ) ) {
			return '';
		}

		$order = wc_get_order( $order_id );

		return $order instanceof WC_Order
			? $order->get_formatted_order_total()
			: wc_price( 0.0 );
	}

	/**
	 * Get the link to view the WooCommerce order in the admin panel.
	 *
	 * @since 1.0.0
	 *
	 * @param int $order_id The WooCommerce order ID.
	 *
	 * @return string The link to the WooCommerce order.
	 */
	public function get_order_link( int $order_id ): string {
		if ( ! class_exists( 'WC_Order' ) || ! wc_get_order( $order_id ) ) {
			return '';
		}

		return admin_url( 'post.php?post=' . $order_id . '&action=edit' );
	}

	/**
	 * Get the URL to the WooCommerce shop page.
	 *
	 * @since 1.1.0
	 *
	 * @return string The WooCommerce shop URL.
	 */
	public function get_store_url(): string {
		if ( ! $this->is_active() ) {
			return home_url();
		}

		// Get WooCommerce shop page URL.
		$shop_page_id = function_exists( 'wc_get_page_id' ) ? wc_get_page_id( 'shop' ) : 0;

		if ( $shop_page_id && $shop_page_id > 0 ) {
			return get_permalink( $shop_page_id );
		}

		// Fallback to home URL if shop page is not set.
		return home_url();
	}

	/**
	 * Check if a product is in stock and available for purchase.
	 *
	 * @since 1.2.0
	 *
	 * @param int|string $product_id The product or variation ID.
	 *
	 * @return bool True if the product is in stock and purchasable, false otherwise.
	 */
	public function is_product_in_stock( $product_id ): bool {
		if ( ! $this->is_active() || ! function_exists( 'wc_get_product' ) ) {
			return false;
		}

		$product = wc_get_product( $product_id );

		if ( ! $product ) {
			return false;
		}

		// Check if product is purchasable and in stock.
		return $product->is_purchasable() && $product->is_in_stock();
	}

	/**
	 * Get the number of items in the cart.
	 *
	 * @since 1.1.0
	 *
	 * @return int The number of items in cart.
	 */
	public function get_cart_item_count(): int {
		$cart = $this->get_cart();
		return $cart ? $cart->get_cart_contents_count() : 0;
	}

	/**
	 * Get the cart subtotal (before discounts).
	 *
	 * @since 1.2.0
	 *
	 * @return float The cart subtotal.
	 */
	public function get_cart_total(): float {
		$cart = $this->get_cart();
		return $cart ? (float) $cart->get_subtotal() : 0.0;
	}

	/**
	 * Get all product IDs currently in the cart.
	 *
	 * Includes both parent product IDs and variation IDs to support
	 * checking if a variable product is in cart.
	 *
	 * @since 1.1.0
	 *
	 * @return array Array of product IDs in cart.
	 */
	public function get_cart_product_ids(): array {
		$cart = $this->get_cart();
		if ( ! $cart ) {
			return [];
		}

		$product_ids = [];
		foreach ( $cart->get_cart() as $item ) {
			// Always include the parent product ID.
			$product_ids[] = (int) $item['product_id'];

			// Also include variation ID if it's a variable product.
			if ( ! empty( $item['variation_id'] ) ) {
				$product_ids[] = (int) $item['variation_id'];
			}
		}

		return array_values( array_unique( $product_ids ) );
	}

	/**
	 * Get the cart subtotal (before discounts).
	 *
	 * @since 1.2.0
	 *
	 * @return float The cart subtotal.
	 */
	public function get_cart_subtotal(): float {
		$cart = $this->get_cart();
		if ( ! $cart ) {
			return 0.0;
		}

		// Get subtotal before discounts.
		return (float) $cart->get_subtotal();
	}

	/**
	 * Get all coupons currently applied to the cart.
	 *
	 * @since 1.2.0
	 *
	 * @return array Array of coupon codes.
	 */
	public function get_applied_coupons(): array {
		$cart = $this->get_cart();
		if ( ! $cart ) {
			return [];
		}

		return $cart->get_applied_coupons();
	}

	/**
	 * Get the WooCommerce checkout page URL.
	 *
	 * @since 1.1.0
	 *
	 * @return string The checkout URL.
	 */
	public function get_checkout_url(): string {
		if ( ! $this->is_active() || ! function_exists( 'wc_get_checkout_url' ) ) {
			return home_url();
		}
		return wc_get_checkout_url();
	}

	/**
	 * Get the WooCommerce cart page URL.
	 *
	 * @since 1.1.0
	 *
	 * @return string The cart URL.
	 */
	public function get_cart_url(): string {
		if ( ! $this->is_active() || ! function_exists( 'wc_get_cart_url' ) ) {
			return home_url();
		}
		return wc_get_cart_url();
	}

	/**
	 * Get customers from WooCommerce.
	 *
	 * Returns empty array if no more customers or if WooCommerce is not active.
	 *
	 * @param array $args     Additional arguments for filtering.
	 * @param int   $page     Current page number.
	 * @param int   $per_page Number of customers per page.
	 *
	 * @return array Array of customer data.
	 */
	public function get_customers( array $args = [], int $page = 1, int $per_page = 10 ): array {
		// Make sure WooCommerce is active.
		if ( ! $this->is_active() ) {
			return [];
		}

		// Set up the pagination.
		$page     = max( 1, absint( $page ) );
		$per_page = max( 1, absint( $per_page ) );
		$offset   = ( $page - 1 ) * $per_page;

		// Default arguments for WP_User_Query.
		$default_args = [
			'role'    => 'customer',
			'number'  => $per_page,
			'offset'  => $offset,
			'orderby' => 'registered',
			'order'   => 'DESC',
		];

		// Merge with custom args.
		$query_args = wp_parse_args( $args, $default_args );

		// Get customers.
		$customer_query = new WP_User_Query( $query_args );
		$customers      = $customer_query->get_results();

		// If no results, return empty array.
		if ( empty( $customers ) ) {
			return [];
		}

		// Format the customer data with only essential info.
		$formatted_customers = [];
		foreach ( $customers as $customer ) {
			$customer_id = $customer->ID;
			$first_name  = get_user_meta( $customer_id, 'first_name', true );
			$last_name   = get_user_meta( $customer_id, 'last_name', true );
			$name        = trim( $first_name . ' ' . $last_name );

			$formatted_customers[] = [
				'customer_id' => $customer_id,
				'user_id'     => $customer_id, // Same as customer_id for WooCommerce.
				'name'        => $name,
				'email'       => $customer->user_email,
			];
		}

		return $formatted_customers;
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_customer_orders( string $customer_email, array $args = [] ): array {

		if ( ! $this->is_active() ) {
			return [];
		}

		$args = wp_parse_args(
			$args,
			[
				'status'   => [ 'wc-processing', 'wc-completed' ],
				'limit'    => apply_filters( 'am_rewardswp_unlimited', 99999 ),
				'orderby'  => 'date',
				'order'    => 'DESC',
				'customer' => $customer_email,
				'return'   => 'objects',
			]
		);

		$orders = wc_get_orders( $args );

		if ( empty( $orders ) ) {
			return [];
		}

		$formatted_orders = [];
		foreach ( $orders as $order ) {
			$formatted_orders[] = [
				'id'       => $order->get_id(),
				'date'     => $order->get_date_created()->date( 'Y-m-d H:i:s' ),
				'total'    => $order->get_total(),
				'status'   => $order->get_status(),
				'currency' => $order->get_currency(),
				'items'    => $this->get_order_items( $order ),
			];
		}

		return $formatted_orders;
	}

	/**
	 * Get order items details.
	 *
	 * @param WC_Order $order WooCommerce order object.
	 * @return array
	 * @since 1.0.0
	 */
	private function get_order_items( WC_Order $order ): array {
		$items = [];
		foreach ( $order->get_items() as $item ) {
			$product_id = $item->get_product_id();
			$items[]    = [
				'name'       => $item->get_name(),
				'quantity'   => $item->get_quantity(),
				'total'      => $item->get_total(),
				'product_id' => $product_id,
			];
		}
		return $items;
	}

	/**
	 * Get the URL to the WooCommerce My Account Orders page.
	 *
	 * @since 1.1.0
	 *
	 * @return string The WooCommerce My Account Orders URL.
	 */
	public function get_orders_url(): string {
		if ( ! $this->is_active() ) {
			return home_url();
		}

		if ( function_exists( 'wc_get_account_endpoint_url' ) ) {
			return wc_get_account_endpoint_url( 'orders' );
		}

		// Fallback to My Account page.
		if ( function_exists( 'wc_get_page_id' ) ) {
			$myaccount_page_id = wc_get_page_id( 'myaccount' );
			if ( $myaccount_page_id && $myaccount_page_id > 0 ) {
				return get_permalink( $myaccount_page_id );
			}
		}

		return home_url();
	}

	/**
	 * Get the URL to a WooCommerce product with reviews anchor.
	 *
	 * @since 1.1.0
	 *
	 * @param int $product_id The product ID.
	 * @return string The product URL with reviews anchor.
	 */
	public function get_product_review_url( int $product_id ): string {
		if ( ! $this->is_active() || ! $product_id ) {
			return home_url();
		}

		$product_url = get_permalink( $product_id );
		if ( ! $product_url ) {
			return home_url();
		}

		return $product_url . '#reviews';
	}

	/**
	 * Get unique product IDs from customer orders.
	 *
	 * @since 1.1.0
	 *
	 * @param string $customer_email The customer email address.
	 * @return array Array of unique product IDs.
	 */
	public function get_customer_purchased_product_ids( string $customer_email ): array {
		$orders = $this->get_customer_orders( $customer_email );

		if ( empty( $orders ) ) {
			return [];
		}

		$product_ids = [];
		foreach ( $orders as $order ) {
			if ( ! empty( $order['items'] ) ) {
				foreach ( $order['items'] as $item ) {
					if ( ! empty( $item['product_id'] ) ) {
						$product_ids[] = $item['product_id'];
					}
				}
			}
		}

		// Return unique product IDs.
		return array_unique( $product_ids );
	}

	/**
	 * Checks if the order is a Cash on Delivery order with a 'processing' status.
	 *
	 * We should not process anything while a COD order is in processing status.
	 *
	 * @param WC_Order $order The WooCommerce order object.
	 *
	 * @return bool
	 */
	private function is_cod_processing( WC_Order $order ): bool {
		// Leave pending status for COD orders while in processing status.
		return in_array( $order->get_status(), [ 'wc-processing', 'processing' ], true ) &&
			'cod' === strtolower( $order->get_payment_method() );
	}

	/**
	 * Handle order refund - deduct points and reactivate coupons.
	 *
	 * @since 1.1.0
	 *
	 * @param int $order_id The order ID that was refunded.
	 *
	 * @return void
	 */
	public function action_handle_order_refund( int $order_id ): void {
		$order = $this->get_order( $order_id );

		// Check if order exists.
		if ( ! $order ) {
			return;
		}

		// Check if status is really refunded (extra security).
		if ( 'refunded' !== $order->get_status() ) {
			return;
		}

		// Check if already processed (prevent duplication).
		if ( $order->get_meta( '_rewardswp_refund_processed', true ) ) {
			return;
		}

		// Deduct points earned from the purchase.
		$this->deduct_points_from_refunded_order( $order_id );

		// Reactivate coupons used in the order.
		$this->reactivate_coupons_from_refunded_order( $order_id );

		// Mark as processed.
		$order->update_meta_data( '_rewardswp_refund_processed', true );
		$order->save();
	}

	/**
	 * Deduct points earned from a refunded order.
	 *
	 * @since 1.1.0
	 *
	 * @param int $order_id The refunded order ID.
	 *
	 * @return void
	 */
	private function deduct_points_from_refunded_order( int $order_id ): void {
		// Get all points earned from this purchase.
		$points_earned = $this->get_points_earned_from_order( $order_id );

		if ( empty( $points_earned ) ) {
			return;
		}

		// For each point found, create a deduction entry.
		foreach ( $points_earned as $point ) {
			$this->create_refund_deduction_entry( $point, $order_id );
		}
	}

	/**
	 * Reactivate coupons used in a refunded order.
	 *
	 * @param int $order_id The refunded order ID.
	 *
	 * @return void
	 * @throws Exception
	 * @since 1.1.0
	 */
	private function reactivate_coupons_from_refunded_order( int $order_id ): void {
		$order = $this->get_order( $order_id );

		if ( ! $order ) {
			return;
		}

		$used_coupons = $order->get_coupon_codes();

		if ( empty( $used_coupons ) ) {
			return;
		}

		// Process all RewardsWP coupons used in the order.
		foreach ( $used_coupons as $code ) {
			$reward = $this->rewards_service->get_reward_by_coupon_code( $code );

			if ( ! is_a( $reward, Reward::class ) || 'redeemed' !== $reward->status ) {
				continue;
			}

			// Revert reward status.
			$this->reactivate_reward( $reward );

			// Reset coupon usage in WooCommerce platform.
			$coupon_code = $reward->get_meta( 'coupon_code' );
			if ( ! empty( $coupon_code ) ) {
				$this->reset_coupon_usage( $coupon_code );
			}
		}
	}

	/**
	 * Reset WooCommerce coupon usage count.
	 *
	 * @since 1.1.0
	 *
	 * @param string $coupon_code The coupon code.
	 *
	 * @return void
	 */
	private function reset_coupon_usage( string $coupon_code ): void {
		if ( ! function_exists( 'wc_get_coupon_id_by_code' ) ) {
			return;
		}

		$coupon_id = wc_get_coupon_id_by_code( $coupon_code );

		if ( ! $coupon_id ) {
			return;
		}

		$coupon = new WC_Coupon( $coupon_id );

		if ( ! $coupon ) {
			return;
		}

		// Decrement usage count.
		$usage_count = $coupon->get_usage_count();
		if ( $usage_count > 0 ) {
			$coupon->set_usage_count( $usage_count - 1 );
			$coupon->save();
		}

		// Note: WooCommerce also tracks usage per user via meta '_used_by'.
		// It may be necessary to clear this meta as well, but that requires knowing which user used it.
		// For now, we only decrement the general counter.
	}

	/**
	 * Handle partial order refund - deduct points proportionally.
	 *
	 * This handler is triggered for ANY refund (partial or full), but we filter out
	 * full refunds since they are already handled by the status transition hooks.
	 *
	 * For partial refunds:
	 * - Purchase points are deducted proportionally to the refund amount
	 * - First order bonus points are NOT deducted (the first purchase was still made)
	 * - Coupons are NOT reactivated (they were still used for the remaining purchase)
	 *
	 * @since 1.1.0
	 *
	 * @param int $order_id  The order ID.
	 * @param int $refund_id The refund ID.
	 *
	 * @return void
	 */
	public function action_handle_partial_refund( int $order_id, int $refund_id ): void {
		$order = $this->get_order( $order_id );

		// Check if order exists.
		if ( ! $order ) {
			return;
		}

		// Skip full refunds - they are handled by the status transition hooks.
		// When order status is 'refunded', it means it's a full refund.
		if ( 'refunded' === $order->get_status() ) {
			return;
		}

		// Get the refund object.
		$refund = wc_get_order( $refund_id );

		if ( ! $refund ) {
			return;
		}

		// Check if this specific refund was already processed (prevent duplication).
		if ( $refund->get_meta( '_rewardswp_partial_refund_processed', true ) ) {
			return;
		}

		// Deduct purchase points proportionally (excludes first_order points).
		$this->deduct_points_proportionally( $order, $refund );

		// Mark this refund as processed.
		$refund->update_meta_data( '_rewardswp_partial_refund_processed', true );
		$refund->save();
	}

	/**
	 * Deduct purchase points proportionally based on the refund amount.
	 *
	 * Only 'purchase' points are deducted. 'first_order' points are preserved
	 * because the first purchase was still made, regardless of the partial refund.
	 *
	 * @since 1.1.0
	 *
	 * @param \WC_Abstract_Order $order  The original order.
	 * @param \WC_Abstract_Order $refund The refund object (WC_Order_Refund).
	 *
	 * @return void
	 */
	private function deduct_points_proportionally( \WC_Abstract_Order $order, \WC_Abstract_Order $refund ): void {
		// Get only purchase points (excludes first_order points).
		$purchase_points = $this->get_purchase_points_from_order( $order->get_id() );

		if ( empty( $purchase_points ) ) {
			return;
		}

		// Calculate the refund ratio.
		// The refund total is negative, so we use abs() to get the positive value.
		$refund_amount = abs( (float) $refund->get_total() );

		// Calculate the original order total (current total + all refunds).
		$original_total = $this->calculate_original_order_total( $order );

		if ( $original_total <= 0 ) {
			return;
		}

		$refund_ratio = $refund_amount / $original_total;

		// Ensure ratio is between 0 and 1.
		$refund_ratio = max( 0, min( 1, $refund_ratio ) );

		if ( $refund_ratio <= 0 ) {
			return;
		}

		// For each purchase point entry, deduct proportionally.
		foreach ( $purchase_points as $point ) {
			$points_to_deduct = (int) ceil( (float) $point->points * $refund_ratio );

			if ( $points_to_deduct > 0 ) {
				$this->create_partial_refund_deduction_entry(
					$point,
					$order->get_id(),
					$refund->get_id(),
					$points_to_deduct
				);
			}
		}
	}

	/**
	 * Calculate the original order total before any refunds.
	 *
	 * @since 1.1.0
	 *
	 * @param \WC_Abstract_Order $order The order object.
	 *
	 * @return float The original order total.
	 */
	private function calculate_original_order_total( \WC_Abstract_Order $order ): float {
		$current_total = (float) $order->get_total();

		// Get all refunds for this order and add their amounts back.
		$refunds = $order->get_refunds();

		foreach ( $refunds as $refund ) {
			// Refund totals are negative, so we subtract (which adds).
			$current_total -= (float) $refund->get_total();
		}

		return $current_total;
	}

	/**
	 * Link WooCommerce guest orders to a user account.
	 *
	 * Finds all guest orders (where customer_id = 0) with the given billing email
	 * and updates them to be associated with the new user ID.
	 *
	 * Uses WooCommerce's Order data store API to support both traditional storage
	 * and HPOS (High-Performance Order Storage).
	 *
	 * @since 1.1.0
	 *
	 * @param string $email   The billing email to search for.
	 * @param int    $user_id The WordPress user ID to link orders to.
	 *
	 * @return int Number of orders linked.
	 */
	public function link_guest_orders( string $email, int $user_id ): int {
		// Check if WooCommerce is active.
		if ( ! $this->is_active() ) {
			return 0;
		}

		// Find all guest orders with this email.
		$guest_orders = wc_get_orders(
			[
				'billing_email' => $email,
				'customer_id'   => 0, // Guest orders only.
				'status'        => [ 'completed', 'processing', 'on-hold', 'pending' ],
				'limit'         => -1, // Get all orders.
				'return'        => 'ids',
			]
		);

		if ( empty( $guest_orders ) ) {
			return 0;
		}

		$linked_count = 0;

		foreach ( $guest_orders as $order_id ) {
			// Use WooCommerce's Order API to update customer ID.
			// This works with both traditional storage and HPOS.
			$order = wc_get_order( $order_id );
			if ( $order ) {
				$order->set_customer_id( $user_id );
				$order->save();
				++$linked_count;
			}
		}

		return $linked_count;
	}

	/**
	 * Count the number of guest orders for a given email.
	 *
	 * @since 1.2.0
	 *
	 * @param string $email The billing email to search for.
	 *
	 * @return int Number of guest orders found.
	 */
	public function count_guest_orders( string $email ): int {
		// Check if WooCommerce is active.
		if ( ! $this->is_active() ) {
			return 0;
		}

		$orders = wc_get_orders(
			[
				'billing_email' => $email,
				'customer_id'   => 0, // Only guest orders (no user ID).
				'status'        => [ 'completed', 'processing', 'on-hold', 'pending' ],
				'return'        => 'ids',
				'limit'         => -1,
			]
		);

		return is_array( $orders ) ? count( $orders ) : 0;
	}

	/**
	 * Store a value in the WooCommerce session.
	 *
	 * @since 1.2.0
	 *
	 * @param string $key   The session key.
	 * @param mixed  $value The value to store.
	 *
	 * @return bool True if stored successfully, false otherwise.
	 */
	public function set_session( string $key, $value ): bool {
		if ( ! WC()->session ) {
			return false;
		}

		if ( ! WC()->session->has_session() ) {
			WC()->session->set_customer_session_cookie( true );
		}

		WC()->session->set( $key, $value );

		return true;
	}

	/**
	 * Retrieve a value from the WooCommerce session.
	 *
	 * @since 1.2.0
	 *
	 * @param string $key The session key.
	 *
	 * @return mixed The stored value, or null if not found.
	 */
	public function get_session( string $key ) {
		if ( ! WC()->session ) {
			return null;
		}

		return WC()->session->get( $key );
	}

	/**
	 * Clear a value from the WooCommerce session.
	 *
	 * @since 1.2.0
	 *
	 * @param string $key The session key to clear.
	 *
	 * @return bool True if cleared successfully, false otherwise.
	 */
	public function clear_session( string $key ): bool {
		if ( ! WC()->session ) {
			return false;
		}

		WC()->session->set( $key, null );

		return true;
	}
}
