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

namespace Awesomemotive\Rewardswp\Integrations;

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

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\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\Settings\Options\Referrals\RewardsOption;
use Awesomemotive\Rewardswp\Views\ProductPointsNoticeView;
use Awesomemotive\Rewardswp\Views\PurchaseConfirmationBannerView;
use EDD_Customer;
use EDD_Payment;
use Exception;
use WP_Error;
use WP_Query;
use WP_User;
use function Awesomemotive\Rewardswp\Helpers\create_member;
use function Awesomemotive\Rewardswp\Helpers\get_customer_reward;
use function Awesomemotive\Rewardswp\Helpers\create_referral;
use function Awesomemotive\Rewardswp\Helpers\count_points_entries;
use function Awesomemotive\Rewardswp\Helpers\get_points_entries;
use function Awesomemotive\Rewardswp\Helpers\get_member;
use function Awesomemotive\Rewardswp\Helpers\get_member_by;
use function Awesomemotive\Rewardswp\Helpers\get_current_member;
use function Awesomemotive\Rewardswp\Helpers\get_option_value;
use function Awesomemotive\Rewardswp\Helpers\get_referrals;
use function Awesomemotive\Rewardswp\Helpers\is_guest_participation_enabled;
use function Awesomemotive\Rewardswp\Helpers\parse_name;
use function Awesomemotive\Rewardswp\Helpers\rewardswp_log_error;
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;

/**
 * Integration with Easy Digital Downloads.
 */
class EasyDigitalDownloadsIntegration 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 easy_digital_downloads
	 */
	public function get_source(): string {
		return 'easy_digital_downloads';
	}

	/**
	 * Render a notice on the single download page with the number of points
	 * the customer will earn for purchasing the download.
	 *
	 * Displays after the download content using edd_after_download_content hook.
	 *
	 * @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 $post;

		if ( ! $post || 'download' !== $post->post_type ) {
			return;
		}

		// Get the download price using EDD functions.
		$price = 0.0;
		if ( function_exists( 'edd_get_download_price' ) ) {
			$price = (float) edd_get_download_price( $post->ID );
		}

		// Skip if the download is free.
		if ( $price <= 0 ) {
			return;
		}

		$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,
				]
			);
		}
	}
	/**
	 * 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( 'edd_payment_receipt_after', [ $this, 'action_display_rewards_welcome_message' ], 5 );
		add_action( 'edd_complete_purchase', [ $this, 'action_process_reward_from_visit' ], 10 );
		add_action( 'edd_complete_purchase', [ $this, 'action_process_reward_from_coupon' ], 12 );
		add_action( 'edd_complete_purchase', [ $this, 'action_convert_customer_into_advocate' ], 14 );
		add_action( 'template_redirect', [ $this, 'action_hide_panel_from_checkout_pages' ] );
		add_action( 'edd_customer_updated', [ $this, 'action_keep_advocate_in_sync_with_customer_name' ], 10, 2 );
		add_action( 'edd_insert_payment', [ $this, 'action_create_pending_purchase_points' ], 10, 2 );
		add_action( 'edd_complete_purchase', [ $this, 'action_maybe_award_purchase_points' ] );
		add_action( 'edd_complete_purchase', [ $this, 'action_maybe_award_first_order_points' ] );
		add_action( 'edd_after_download_content', [ $this, 'render_product_points_notice' ] );

		// EDD Reviews integration - only if addon is active.
		if ( $this->is_edd_reviews_active() ) {
			add_action( 'edd_approve_review', [ $this, 'action_maybe_award_review_points' ], 10, 1 );
		}

		// Fallback for immediately approved reviews using standard WordPress hooks.
		add_action( 'transition_comment_status', [ $this, 'action_maybe_award_review_points_on_transition' ], 10, 3 );
		add_action( 'comment_post', [ $this, 'action_maybe_award_review_points_on_create' ], 10, 3 );

		// Refunds - EDD 3.0+ hook that fires for any refund (both full and partial).
		// We handle both types based on the $all_refunded parameter.
		add_action( 'edd_refund_order', [ $this, 'action_handle_refund' ], 10, 3 );
	}

	/**
	 * 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( 'edd_is_success_page' ) || ! edd_is_success_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 purchase confirmation page.
	 *
	 * Shows different states based on member status and participation mode.
	 *
	 * @since 1.1.0
	 *
	 * @param object $payment The payment object.
	 * @return void
	 */
	public function action_display_rewards_welcome_message( $payment ) {
		if ( ! $payment ) {
			return;
		}

		// Only show on success page, not on order history.
		if ( ! function_exists( 'edd_is_success_page' ) || ! edd_is_success_page() ) {
			return;
		}

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

		$payment_id = isset( $payment->ID ) ? $payment->ID : 0;
		if ( ! $payment_id ) {
			return;
		}

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

		// Get customer name and email from payment meta.
		$user_info     = $this->get_order_user_info( $payment_id );
		$customer_name = isset( $user_info['first_name'], $user_info['last_name'] )
			? trim( $user_info['first_name'] . ' ' . $user_info['last_name'] )
			: '';
		$customer_email = ! empty( $user_info['email'] ) ? (string) $user_info['email'] : '';

		// Fallback: if no current member found (e.g., guest without cookie), try to find by payment email.
		if ( is_null( $advocate ) && ! empty( $customer_email ) ) {
			$advocate = get_member_by( 'email', $customer_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( $payment_id, $advocate->id );
			$current_balance = absint( $advocate->points_balance ?? 0 );
		}

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

		// 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 ?? $customer_email,
			'customer_name'   => $customer_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 a payment.
	 *
	 * This is used to show pending points on the confirmation page before
	 * payment is confirmed.
	 *
	 * @since 1.1.0
	 *
	 * @param int $payment_id The payment ID.
	 * @return int Expected points to be earned.
	 */
	private function calculate_expected_purchase_points( int $payment_id ): 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->get_payment_total_for_points( $payment_id );

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

	/**
	 * Attempt to create a referral based on a visit.
	 *
	 * The referral will not be created if a friend already generated by using the reward claiming form.
	 *
	 * @since 1.0.0
	 *
	 * @param int $order_id
	 *
	 * @return void|null
	 *
	 * @throws Exception If the reward issuance fails.
	 */
	public function action_process_reward_from_visit( int $order_id ) {
		if ( ! $this->is_order_trackable( $order_id ) ) {
			return; // Bail because the order don't attend the conditions to be tracked.
		}

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

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

		$advocate_id = $this->tracking_service->get_cookie( $this->tracking_service::ADVOCATE_ID_COOKIE_NAME );

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

		$user_info = $this->get_order_user_info( $order_id );

		if ( ! isset( $user_info['email'] ) ) {
			return null;
		}

		$wp_user = get_user_by( 'email', $user_info['email'] );

		$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',
			]
		);

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

		if ( ! empty( $referrals ) ) {
			foreach ( $referrals as $referral ) {
				// 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 ) ) {
						try {
							// Create reward with pending status for guest members.
							$reward = $reward_type->issue_reward(
								$referral,
								'advocate',
								[
									'order_id' => $order_id,
									'status'   => 'pending',
								]
							);
						} catch ( Exception $e ) {
							// Log error but continue.
							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_id,
								]
							);
						}
					}
					// Don't complete referral for guests - it will be completed when they become official.
					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_id,
							]
						);

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

						// Complete the referral.
						$referral->set_as_completed( $this->get_source(), $order_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_id,
							]
						);
						continue;
					}
				}
			}
			return null;
		}

		// 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' => $user_info['email'],
				'status'         => 'pending',
			]
		);

		if ( ! empty( $referrals ) ) {
			foreach ( $referrals as $referral ) {
				$existing_reward = $referral->get_reward( 'advocate' );

				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 ) ) {
						try {
							$reward = $reward_type->issue_reward(
								$referral,
								'advocate',
								[
									'order_id' => $order_id,
								]
							);

							if ( ! is_null( $reward ) ) {
								$referral->set_as_completed( $this->get_source(), $order_id );
							}
						} catch ( Exception $e ) {
							rewardswp_log_error(
								'Failed to process reward for referral {referral_id}: {message}',
								[
									'referral_id' => $referral->id ?? 0,
									'message'     => $e->getMessage(),
									'order_id'    => $order_id,
								]
							);
						}
					}
				}
			}
			return null;
		}

		// If no referrals were created at this point (after a Friend's claim), we should create one.
		$referral = create_referral(
			[
				'advocate_id'      => $advocate_id,
				'event_id'         => $visit_id,
				'source'           => $this->get_source(),
				'referred_user_id' => is_a( $wp_user, WP_User::class )
					? $wp_user->ID
					: 0,
				'referred_email'   => $user_info['email'],
				'order_id'         => $order_id,
			]
		);

		if ( is_wp_error( $referral ) ) {
			return null;
		}

		$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 ) ) {
			return;
		}

		try {
			// If advocate is guest, create reward with pending status.
			if ( $is_guest ) {
				$reward = $reward_type->issue_reward(
					$referral,
					'advocate',
					[
						'order_id' => $order_id,
						'status'   => 'pending',
					]
				);

				// Don't complete referral for guests - it will be completed when they become official.
				return;
			}

			// For official members, process normally.
			// 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_id,
				]
			);

			if ( is_null( $reward ) ) {
				return; // Skip if reward issuance fails.
			}

			$referral->set_as_completed();
		} catch ( Exception $e ) {
			return;
		}
	}

	/**
	 * Issues a reward if the order is completed.
	 *
	 * Supports both referral-based rewards and points redemption rewards.
	 *
	 * @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 {
		if ( ! $this->is_order_trackable( $order_id ) ) {
			return; // Bail because the order don't attend the conditions to be tracked.
		}

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

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

		$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 {
			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.
			if ( 'advocate' === $reward->recipient_type ) {
				$referral->set_as_completed();
				return;
			}

			$reward_options = get_option_value( RewardsOption::class );

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

			// Create the advocate reward.
			$advocate_reward = $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 ) {
			return;
		}
	}

	/**
	 * Convert user into advocate when a successful purchase is done.
	 *
	 * @since 1.0.0
	 *
	 * @param int $order_id
	 *
	 * @return void
	 *
	 * @throws Exception If advocate conversion or reward transfer fails.
	 */
	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                     EDD order ID.
		 */
		$should_convert = apply_filters(
			'rewardswp_auto_convert_to_member_on_purchase',
			$this->convert_customer_on_purchase,
			$order_id
		);

		if ( ! $should_convert ) {
			return;
		}

		$user_info = $this->get_order_user_info( $order_id );

		if ( ! isset( $user_info['id'] ) ) {
			return; // Bail if it can't find the user ID.
		}

		$advocate = $this->convert_customer_into_advocate( $user_info );

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

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

		// 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 payment was processed.
		$payment_total = $this->get_payment_total_for_points( $order_id );
		$this->maybe_award_purchase_points_after_conversion( $advocate, $order_id, $payment_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 payment was processed.
		$this->maybe_award_first_order_points_after_conversion( $advocate, $order_id );

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

		// Bond any unused rewards to the new advocate.
		$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 ) && isset( $user_info['email'] ) ) {
			$email_reward = get_customer_reward( $user_info['email'] );

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

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

		// Transfer the reward ownership to the new advocate.
		foreach ( $rewards as $reward ) {
			$reward->advocate_id    = $advocate->id;
			$reward->recipient_type = 'advocate';
			$reward->update();
		}
	}

	/**
	 * Hide the panel from the checkout and purchase confirmation pages.
	 *
	 * @since 1.0.0
	 *
	 * @return void
	 */
	public function action_hide_panel_from_checkout_pages(): void {
		$is_edd_checkout = function_exists( 'edd_is_checkout' ) && edd_is_checkout();
		$is_edd_success  = function_exists( 'edd_is_success_page' ) && edd_is_success_page();

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

	/**
	 * Update the advocate's name when the related customer is updated.
	 *
	 * @param int   $customer_id
	 *
	 * @param array $data
	 *
	 * @return void
	 *
	 * @throws Exception If updating the advocate record fails.
	 */
	public function action_keep_advocate_in_sync_with_customer_name( int $customer_id, array $data ): void {
		$advocate = null;

		if ( isset( $data['user_id'] ) ) {
			$advocate = get_member_by( 'user_id', $data['user_id'] );
		}

		$advocate = empty( $advocate ) && isset( $data['email'] )
			? get_member_by( 'email', $data['email'] )
			: $advocate;

		if ( is_null( $advocate ) || empty( $data['name'] ) ) {
			return; // Can't find any advocate corresponding to the customer or invalid name.
		}

		$full_name = parse_name( $data['name'] );

		$advocate->first_name = $full_name['first_name'];
		$advocate->last_name  = $full_name['last_name'];

		$advocate->update();
	}

	/**
	 * Create pending purchase points entry when a payment is inserted.
	 *
	 * This method creates a point entry with 'pending' status when a payment is inserted.
	 * The entry will be updated to 'approved' when payment is confirmed.
	 *
	 * @since 1.2.0
	 *
	 * @param int   $payment_id The payment ID.
	 * @param array $payment_data The payment data array.
	 *
	 * @return void
	 */
	public function action_create_pending_purchase_points( int $payment_id, array $payment_data ): void {
		if ( ! $this->is_order_trackable( $payment_id ) ) {
			return; // Bail because the order don't attend the conditions to be tracked.
		}

		$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;
		}

		// Try to retrieve the member by its email.
		$user_info = $this->get_order_user_info( $payment_id );
		$member    = get_member_by( 'email', $user_info['email'] ?? '' );

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

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

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

		// Calculate points based on payment subtotal (excludes tax).
		$payment_total = $this->get_payment_total_for_points( $payment_id );
		$points        = $this->calculate_purchase_points( $payment_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'   => $payment_id,
			'status'      => 'pending',
			'note_public' => sprintf(
				/* translators: %d: payment ID */
				__( 'Points pending for payment #%d', 'rewardswp' ),
				$payment_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 $payment_id The payment ID.
	 *
	 * @throws Exception If awarding points or persisting changes fails.
	 *
	 * @return void
	 */
	public function action_maybe_award_purchase_points( int $payment_id ): void {
		if ( ! $this->is_order_trackable( $payment_id ) ) {
			return; // Bail because the order don't attend the conditions to be tracked.
		}

		$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;
		}

		// Try to retrieve the member by its email.
		$user_info = $this->get_order_user_info( $payment_id );
		$member    = get_member_by( 'email', $user_info['email'] ?? '' );

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

		// Recalculate points based on current payment subtotal (payment may have changed).
		$payment_total = $this->get_payment_total_for_points( $payment_id );
		$points        = $this->calculate_purchase_points( $payment_total, $earn_actions );

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

		// Check if a pending point entry exists for this payment.
		$existing_points = get_points_entries(
			[
				'member_id'   => $member->id,
				'source_type' => 'purchase',
				'source_id'   => $payment_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'   => $payment_id,
					'points'      => $points,
					'status'      => 'approved',
					'note_public' => sprintf(
						/* translators: %d: payment ID */
						__( 'Points earned for payment #%d', 'rewardswp' ),
						$payment_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'   => $payment_id,
			],
			1,
			1
		);

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

		// Create new points entry (fallback for compatibility with old data).
		$points_entry = [
			'member_id'   => absint( $member->id ),
			'points'      => $points,
			'type'        => 'earned',
			'source_type' => 'purchase',
			'source_id'   => $payment_id,
			'status'      => 'approved',
			'note_public' => sprintf(
				/* translators: %d: payment ID */
				__( 'Points earned for payment #%d', 'rewardswp' ),
				$payment_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 $payment_id The payment ID.
	 *
	 * @return void
	 */
	public function action_maybe_award_first_order_points( int $payment_id ): void {
		if ( ! $this->is_order_trackable( $payment_id ) ) {
			return; // Bail because the order don't attend the conditions to be tracked.
		}

		$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;
		}

		$user_info = $this->get_order_user_info( $payment_id );
		if ( empty( $user_info['email'] ) ) {
			return;
		}

		$member = get_member_by( 'email', $user_info['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( $user_info['email'], $payment_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'   => $payment_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_payment_id Current payment 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_payment_id ): bool {
		$previous_orders = $this->get_customer_orders(
			$customer_email,
			[
				'status' => [ 'publish', 'complete' ], // EDD order statuses.
				'limit'  => 2, // Get 2 to see if there are any besides current.
			]
		);

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

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

	/**
	 * Convert a user into advocate.
	 *
	 * @since 1.0.0
	 *
	 * @param array $user_info The EDD user info array.
	 *
	 * @return Member|null
	 */
	public function convert_customer_into_advocate( array $user_info ): ?Member {
		// Check if the user is already an advocate.
		$advocate = get_member_by( 'email', $user_info['email'] ?? '' );

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

		if ( empty( $user_info['email'] ) && empty( $user_info['first_name'] ) ) {
			return null;
		}

		$user_id = $user_info['id'] ?? 0;

		// 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'      => $user_info['email'],
				'first_name' => $user_info['first_name'],
				'last_name'  => $user_info['last_name'] ?? '',
				'user_id'    => $user_id,
			]
		);

		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;
	}

	/**
	 * Returns the order gateway.
	 *
	 * @since 1.0.0
	 *
	 * @param int $order_id
	 *
	 * @return string|null The order gateway
	 */
	public function get_order_gateway( int $order_id ): ?string {
		if ( ! function_exists( 'edd_get_payment' ) || empty( $order_id ) ) {
			return null;
		}

		$order = edd_get_payment( $order_id );

		if ( ! $order ) {
			return null;
		}

		return $order->get_meta( '_edd_payment_gateway' );
	}

	/**
	 * 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_order_reward( int $order_id ): ?Reward {
		$used_coupons = $this->get_order_discount_codes( $order_id );

		// Fallback to legacy method if the new method returns empty.
		// This ensures backward compatibility with EDD 2.x and edge cases.
		if ( empty( $used_coupons ) ) {
			$used_coupons = $this->get_order_discount_codes_legacy( $order_id );
		}

		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;
	}

	/**
	 * Get discount codes from order using legacy user_info method.
	 *
	 * This is kept as a fallback for EDD 2.x compatibility and edge cases
	 * where the order adjustments API may not return expected results.
	 *
	 * @since 1.1.0
	 *
	 * @param int $order_id The order ID.
	 *
	 * @return array Array of discount codes.
	 */
	private function get_order_discount_codes_legacy( int $order_id ): array {
		$user_info = $this->get_order_user_info( $order_id );

		if ( empty( $user_info ) || empty( $user_info['discount'] ) ) {
			return [];
		}

		// Filter out "none" and empty values.
		$discount_string = trim( $user_info['discount'] );
		if ( empty( $discount_string ) || 'none' === strtolower( $discount_string ) ) {
			return [];
		}

		$discount_codes = array_map( 'trim', explode( ',', $discount_string ) );

		// Filter out empty strings and "none" values.
		return array_filter(
			$discount_codes,
			function ( $code ) {
				return ! empty( $code ) && 'none' !== strtolower( $code );
			}
		);
	}

	/**
	 * Check if the order has the conditions to be tracked.
	 *
	 * @since 1.0.0
	 *
	 * @param int $order_id The EDD payment ID.
	 *
	 * @return bool
	 */
	protected function is_order_trackable( int $order_id ): bool {
		// Don't track renewals.
		if ( $this->get_meta( 'order', $order_id, '_edd_sl_is_renewal', true ) ) {
			return false;
		}

		// Don't track upgrades.
		if ( $this->get_meta( 'order', $order_id, '_edd_sl_upgraded_payment_id', true ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Retrieve the meta field for an object based on the object type.
	 * Supports EDD 3.0.
	 *
	 * @since 1.0.0
	 *
	 * @param string $type    Object type.
	 * @param int    $id      Object ID.
	 * @param string $key     Optional. The meta key to retrieve. By default,
	 *                        returns data for all keys. Default empty.
	 * @param bool   $single  Optional. Whether to return a single value.
	 *                        This parameter has no effect if `$key` is not specified.
	 *                        Default false.
	 * @return mixed An array of values if `$single` is false.
	 *               The value of the meta field if `$single` is true.
	 *               False for an invalid `$id` (non-numeric, zero, or negative value).
	 *               An empty string if a valid but non-existing post ID is passed.
	 */
	protected function get_meta( string $type, int $id, string $key, bool $single = false ) {
		if ( ! function_exists( 'edd_get_order' ) ) {
			return get_post_meta( $id, $key, $single );
		}

		switch ( strtolower( $type ) ) {
			case 'discount':
				$value = edd_get_adjustment_meta( $id, $key, $single );
				break;

			case 'order':
				$value = edd_get_order_meta( $id, $key, $single );
				break;

			default:
				$value = get_post_meta( $id, $key, $single );
				break;
		}

		return $value;
	}

	/**
	 * Attempt to retrieve the payment info from EDD.
	 *
	 * @since 1.0.0
	 *
	 * @param int $payment_id The payment ID.
	 *
	 * @return array {
	 *     The payment user info.
	 *
	 *     @type int    $id         The user ID.
	 *     @type string $email      The user's email address.
	 *     @type string $first_name The user's first name.
	 *     @type string $last_name  The user's last name.
	 *     @type string $discount   The discount codes applied to the payment.
	 *     @type array  $address    The user's address information.
	 * }
	 */
	public function get_order_user_info( int $payment_id ): array {
		if ( ! function_exists( 'edd_get_payment_meta_user_info' ) ) {
			return [];
		}

		return edd_get_payment_meta_user_info( $payment_id );
	}

	/**
	 * Checks if an email belongs to an EDD 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 an EDD 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 previously purchased with EDD, return true.
		if ( $user && function_exists( 'edd_has_purchases' ) && edd_has_purchases( $user->ID ) ) {
			return true;
		}

		// If no WordPress user is found, check for a guest customer in EDD using the EDD_Customer class.
		$customer = new EDD_Customer( $email );

		// If the customer exists in EDD (registered or guest), return true.
		return $customer->id !== 0;
	}

	/**
	 * Map the coupon data from an external source to EDD format.
	 *
	 * @since 1.0.0
	 *
	 * @param array $data The raw coupon data.
	 * @return array Mapped data for EDD coupon creation.
	 */
	public function map_coupon_data( array $data ): array {
		// Determine discount type.
		// For free_product: if reward_amount (Product Value) is set, use flat (protection against price increases).
		// Otherwise, use percent (100% discount - no protection).
		$discount_type = 'flat';
		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 flat (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 )
				? 'flat'
				: 'percent';
		}

		// Extract product IDs from products_included array.
		// The array can contain:
		// - Objects like [['id' => 123, 'name' => 'Product']] or [['id' => '814:1', 'name' => 'Variable Product']]
		// - Plain IDs [123, 456]
		// - String IDs for EDD variable pricing ["814:1", "814:2"]
		// For EDD variable pricing (download_id:price_id), we extract just the download_id.
		$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'] ) ) {
					// Object format: {'id': 123} or {'id': '814:1'}
					// (int) casting extracts download_id from "814:1" format.
					$product_ids[] = (int) $item['id'];
				} elseif ( is_numeric( $item ) ) {
					// Plain number: 814.
					$product_ids[] = (int) $item;
				} elseif ( is_string( $item ) ) {
					// String format for EDD variable pricing: "814:1".
					// (int) casting extracts just the download_id (814).
					$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 [
			'name'           => $data['name'] ?? '',
			'code'           => $data['code'] ?? '',
			'amount'         => $amount,
			'type'           => $discount_type,
			'expiry_date'    => $data['expiry_date'] ?? '',
			'minimum_amount' => $data['minimum_order_amount'] ?? 0,
			'product_reqs'   => $product_ids,
		];
	}

	/**
	 * Create a coupon in Easy Digital Downloads (EDD).
	 *
	 * @since 1.0.0
	 *
	 * @param array $data {
	 *     @type string $code           Coupon code.
	 *     @type float  $amount         Discount amount. Must be greater than zero.
	 *     @type string $type           Discount type ('flat' or 'percent'). Default 'flat'.
	 *     @type string $expiry_date    Optional expiration date in 'YY-MM-DD' format.
	 *     @type float  $minimum_amount Minimum purchase amount for the coupon. Default 0.
	 * }
	 *
	 * @return int|WP_Error The coupon ID or WP_Error on failure.
	 */
	public function create_coupon( array $data = [] ) {
		// Validate if the EDD is active.
		if ( ! $this->is_active() ) {
			return new WP_Error(
				'edd_not_installed',
				esc_html__( 'Easy Digital Downloads is not installed or active.', 'rewardswp' )
			);
		}

		$data = wp_parse_args(
			$data,
			[
				'name'           => '',
				'code'           => '',
				'amount'         => 0,
				'type'           => 'flat',
				'expiry_date'    => null,
				'minimum_amount' => 0,
			]
		);

		// Sanitize and validate the amount.
		$data['amount'] = sanitize_float( $data['amount'] );

		if ( 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' )
			);
		}

		// Create the coupon using the EDD function.
		$coupon_id = \edd_add_discount(
			[
				'name'              => sanitize_text_field( $data['name'] ),
				'code'              => sanitize_text_field( $data['code'] ),
				'type'              => $data['type'] === 'percent' ? 'percent' : 'flat',
				'amount'            => edd_sanitize_amount( $data['amount'] ),
				'expiration'        => $data['expiry_date'] ? sanitize_text_field( $data['expiry_date'] ) : null,
				'min_charge_amount' => edd_sanitize_amount( $data['minimum_amount'] ),
				'max_uses'          => 1,
				'once_per_customer' => 1,
				'product_reqs'      => empty( $data['product_reqs'] )
					? []
					: sanitize_array_to_int( $data['product_reqs'] ),
				'product_condition' => empty( $data['product_reqs'] )
					? ''
					: 'all',
				'scope'             => empty( $data['product_reqs'] )
					? ''
					: 'not_global',
				'excluded_products' => [],
				'status'            => 'active',
				'description'       => esc_html__( 'Generated by RewardsWP', 'rewardswp' ),
			]
		);

		// Check for errors during coupon creation.
		if ( ! $coupon_id || is_wp_error( $coupon_id ) ) {
			return new WP_Error(
				'edd_coupon_creation_failed',
				esc_html__( 'Failed to create the coupon in EDD.', 'rewardswp' )
			);
		}

		return $coupon_id;
	}

	/**
	 * 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 {
		return in_array(
			$coupon_code,
			function_exists( 'edd_get_cart_discounts' )
				? edd_get_cart_discounts()
				: [],
			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 {
		return function_exists( 'edd_unset_cart_discount' ) && edd_unset_cart_discount( $coupon_code );
	}

	/**
	 * Apply a coupon to the cart in Easy Digital Downloads with minimum amount validation.
	 *
	 * @param string $coupon_code The coupon code to apply.
	 *
	 * @return true|WP_Error True on success, WP_Error if the coupon doesn't exist, fails, or doesn't meet conditions.
	 */
	public function apply_coupon( string $coupon_code ) {
		// Validate if EDD is active.
		if ( ! $this->is_active() ) {
			return new WP_Error(
				'edd_not_installed',
				esc_html__( 'Easy Digital Downloads is not installed or active.', 'rewardswp' )
			);
		}

		// Check if the coupon exists in the system.
		$coupon = edd_get_discount_by_code( $coupon_code );

		if ( ! $coupon || ! edd_is_discount_active( $coupon->ID ) ) {
			return new WP_Error(
				'edd_invalid_coupon',
				esc_html__( 'The coupon code does not exist or is inactive.', 'rewardswp' )
			);
		}

		// Get the cart total.
		$cart_total = edd_get_cart_total();

		// Check if a minimum amount is set for the discount.
		$minimum_amount = edd_get_discount_min_price( $coupon->ID );

		if ( $minimum_amount && $cart_total < $minimum_amount ) {
			return new WP_Error(
				'edd_minimum_amount_not_met',
				sprintf(
					/* translators: %s: Minimum cart total required to apply the coupon. */
					esc_html__( 'The coupon requires a minimum cart total of %s to be applied.', 'rewardswp' ),
					edd_currency_filter( edd_format_amount( $minimum_amount ) )
				)
			);
		}

		// Check if the discount is already applied.
		$applied_discounts = edd_get_cart_discounts();

		if ( in_array( $coupon_code, $applied_discounts, true ) ) {
			return new WP_Error(
				'edd_discount_applied',
				esc_html__( 'The coupon has already been applied to the cart.', 'rewardswp' )
			);
		}

		// Apply the discount to the cart.
		edd_set_cart_discount( $coupon_code );

		return true;
	}

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

	/**
	 * 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 {
		if ( ! $order_id || ! function_exists( 'edd_get_customer_by' ) ) {
			return '';
		}

		$customer = edd_get_customer_by( 'user_id', $user_id );

		if ( $customer ) {
			$customer = $customer->id;
		} elseif ( empty( $customer ) && function_exists( 'edd_get_payment_meta' ) ) {
			$customer = edd_get_payment_meta( $order_id, '_edd_payment_customer_id' );
		}

		if ( ! $customer ) {
			return '';
		}

		return admin_url( sprintf( 'edit.php?post_type=download&page=edd-customers&view=overview&id=%s', $customer ) );
	}

	/**
	 * 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 {
		$user_info = $this->get_order_user_info( $order_id );

		if ( empty( $user_info['first_name'] ) && empty( $user_info['last_name'] ) ) {
			return null;
		}

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

		return trim(
			sprintf(
				'%s %s',
				$user_info['first_name'],
				$user_info['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 {
		$user_info = $this->get_order_user_info( $order_id );

		return $user_info['email'] ?? '';
	}

	/**
	 * Get the product data by ID.
	 *
	 * Retrieves the ID, name, and price of an EDD product.
	 * Supports both simple download IDs (int) and variable pricing format (string "download_id:price_id").
	 *
	 * @since 1.0.0
	 * @since 1.2.0 Added support for variable pricing format and price field.
	 *
	 * @param int|string $product_id The product ID (int) or "download_id:price_id" (string) for variable pricing.
	 *
	 * @return array An array containing the ID, name, and price, or empty array if the product doesn't exist.
	 */
	public function get_product( $product_id ): array {
		// Parse the product ID to extract download_id and optional price_id.
		$download_id = null;
		$price_id    = null;

		if ( is_string( $product_id ) && preg_match( '/^(\d+):(\d+)$/', $product_id, $matches ) ) {
			// Format: "download_id:price_id" (e.g., "814:1").
			$download_id = (int) $matches[1];
			$price_id    = (int) $matches[2];
		} else {
			// Simple download ID.
			$download_id = absint( $product_id );
		}

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

		$product = get_post( $download_id );

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

		// Handle variable pricing.
		if ( null !== $price_id && function_exists( 'edd_has_variable_prices' ) && edd_has_variable_prices( $download_id ) ) {
			$option = $this->format_download_price_option( $download_id, $price_id );
			if ( $option ) {
				return $option;
			}
		}

		// Single priced download or fallback.
		$price = 0;
		if ( function_exists( 'edd_get_download_price' ) ) {
			$price = (float) edd_get_download_price( $download_id );
		}

		return [
			'id'    => $product->ID,
			'name'  => get_the_title( $product->ID ),
			'price' => $price,
		];
	}

	/**
	 * Search EDD downloads by name or ID.
	 *
	 * Returns downloads with their price options (for variable pricing).
	 * Supports searching by ID if the query is numeric.
	 *
	 * @since 1.0.0
	 * @since 1.2.0 Added support for variable pricing and ID search.
	 *
	 * @param string $substring The substring to search for, 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( 'edd_get_download' ) ) {
			return [];
		}

		$substring = sanitize_text_field( $substring );

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

		// Check for ID:price_id format (e.g., "123:0").
		if ( preg_match( '/^(\d+):(\d+)$/', $substring, $matches ) ) {
			return $this->search_download_by_id( (int) $matches[1], (int) $matches[2] );
		}

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

	/**
	 * Search for a download by ID.
	 *
	 * @since 1.2.0
	 *
	 * @param int      $download_id The download ID.
	 * @param int|null $price_id    Optional. Specific price option ID.
	 *
	 * @return array An array with the download, or empty if not found.
	 */
	private function search_download_by_id( int $download_id, ?int $price_id = null ): array {
		$download = edd_get_download( $download_id );

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

		// If download has variable prices.
		if ( function_exists( 'edd_has_variable_prices' ) && edd_has_variable_prices( $download_id ) ) {
			// If specific price_id requested, return only that.
			if ( null !== $price_id ) {
				$item = $this->format_download_price_option( $download_id, $price_id );
				return $item ? [ $item ] : [];
			}

			// Return all price options.
			return $this->get_download_price_options( $download_id, 10 );
		}

		// Single priced download.
		return [
			$this->format_download_for_search( $download_id ),
		];
	}

	/**
	 * Search downloads by name substring.
	 *
	 * Searches both download titles and price option names.
	 *
	 * @since 1.2.0
	 *
	 * @param string $substring The substring to search for.
	 *
	 * @return array Array of downloads with id, name, and price.
	 */
	private function search_downloads_by_name( string $substring ): array {
		$results        = [];
		$limit          = 50;
		$found_ids      = []; // Track unique IDs to avoid duplicates.
		$substring_lower = strtolower( $substring );

		// First, search downloads by title.
		$query_args = [
			'post_type'      => 'download',
			'post_status'    => 'publish',
			's'              => $substring,
			'posts_per_page' => $limit,
			'orderby'        => 'title',
			'order'          => 'ASC',
		];

		$query = new WP_Query( $query_args );

		if ( $query->have_posts() ) {
			foreach ( $query->posts as $post ) {
				$download_id = $post->ID;

				// Check if download has variable pricing.
				if ( function_exists( 'edd_has_variable_prices' ) && edd_has_variable_prices( $download_id ) ) {
					// Add each price option as a separate item.
					$price_options = $this->get_download_price_options( $download_id, $limit - count( $results ) );

					foreach ( $price_options as $option ) {
						if ( ! isset( $found_ids[ $option['id'] ] ) ) {
							$results[]                  = $option;
							$found_ids[ $option['id'] ] = true;
						}
					}
				} else {
					// Single priced download.
					if ( ! isset( $found_ids[ $download_id ] ) ) {
						$results[]                 = $this->format_download_for_search( $download_id );
						$found_ids[ $download_id ] = true;
					}
				}

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

		// If we have room for more results, also search within price option names.
		if ( count( $results ) < $limit ) {
			$price_option_results = $this->search_in_price_options( $substring_lower, $limit - count( $results ), $found_ids );
			$results              = array_merge( $results, $price_option_results );
		}

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

	/**
	 * Search within price option names for downloads with variable pricing.
	 *
	 * @since 1.2.0
	 *
	 * @param string $substring  The lowercase substring to search for.
	 * @param int    $limit      Maximum number of results to return.
	 * @param array  $exclude_ids IDs already found (to avoid duplicates).
	 *
	 * @return array Array of matching price options.
	 */
	private function search_in_price_options( string $substring, int $limit, array $exclude_ids = [] ): array {
		$results = [];

		if ( ! function_exists( 'edd_has_variable_prices' ) || ! function_exists( 'edd_get_variable_prices' ) ) {
			return [];
		}

		// Get all downloads with variable pricing.
		// We limit to 100 downloads to avoid performance issues.
		$query_args = [
			'post_type'      => 'download',
			'post_status'    => 'publish',
			'posts_per_page' => 100,
			'orderby'        => 'title',
			'order'          => 'ASC',
			'meta_query'     => [
				[
					'key'     => '_variable_pricing',
					'value'   => '1',
					'compare' => '=',
				],
			],
		];

		$query = new WP_Query( $query_args );

		if ( ! $query->have_posts() ) {
			return [];
		}

		foreach ( $query->posts as $post ) {
			$download_id = $post->ID;
			$prices      = edd_get_variable_prices( $download_id );

			if ( empty( $prices ) || ! is_array( $prices ) ) {
				continue;
			}

			foreach ( $prices as $price_id => $price_data ) {
				$option_name = isset( $price_data['name'] ) ? strtolower( $price_data['name'] ) : '';
				$unique_id   = sprintf( '%d:%d', $download_id, $price_id );

				// Skip if already found or doesn't match.
				if ( isset( $exclude_ids[ $unique_id ] ) ) {
					continue;
				}

				// Check if price option name contains the search term.
				if ( false !== strpos( $option_name, $substring ) ) {
					$item = $this->format_download_price_option( $download_id, $price_id );

					if ( $item ) {
						$results[]                 = $item;
						$exclude_ids[ $unique_id ] = true;
					}
				}

				if ( count( $results ) >= $limit ) {
					break 2; // Break out of both loops.
				}
			}
		}

		return $results;
	}

	/**
	 * Get all price options for a variable priced download.
	 *
	 * @since 1.2.0
	 *
	 * @param int $download_id The download ID.
	 * @param int $limit       Maximum number of options to return.
	 *
	 * @return array Array of price options with id, name, and price.
	 */
	private function get_download_price_options( int $download_id, int $limit = 50 ): array {
		$results = [];

		if ( ! function_exists( 'edd_get_variable_prices' ) ) {
			return [];
		}

		$prices = edd_get_variable_prices( $download_id );

		if ( empty( $prices ) || ! is_array( $prices ) ) {
			return [];
		}

		$count = 0;
		foreach ( $prices as $price_id => $price_data ) {
			if ( $count >= $limit ) {
				break;
			}

			$item = $this->format_download_price_option( $download_id, $price_id );
			if ( $item ) {
				$results[] = $item;
				++$count;
			}
		}

		return $results;
	}

	/**
	 * Format a download for search results.
	 *
	 * @since 1.2.0
	 *
	 * @param int $download_id The download ID.
	 *
	 * @return array Formatted download with id, name, and price.
	 */
	private function format_download_for_search( int $download_id ): array {
		$name  = get_the_title( $download_id );
		$price = 0;

		if ( function_exists( 'edd_get_download_price' ) ) {
			$price = edd_get_download_price( $download_id );
		}

				return [
			'id'    => $download_id,
			'name'  => esc_html( $name ),
			'price' => esc_html( edd_currency_filter( edd_format_amount( $price ) ) ),
		];
	}

	/**
	 * Format a download price option for search results.
	 *
	 * @since 1.2.0
	 *
	 * @param int $download_id The download ID.
	 * @param int $price_id    The price option ID.
	 *
	 * @return array|null Formatted price option with id, name, and price, or null if invalid.
	 */
	private function format_download_price_option( int $download_id, int $price_id ): ?array {
		if ( ! function_exists( 'edd_get_price_option_name' ) || ! function_exists( 'edd_get_price_option_amount' ) ) {
			return null;
		}

		$download_name = get_the_title( $download_id );
		$option_name   = edd_get_price_option_name( $download_id, $price_id );
		$option_amount = edd_get_price_option_amount( $download_id, $price_id );

		if ( empty( $option_name ) && 0.0 === (float) $option_amount ) {
			return null;
		}

		// Format name as "Download Name – Option Name".
		$name = $download_name;
		if ( ! empty( $option_name ) ) {
			$name .= ' – ' . $option_name;
		}

		// ID format: download_id:price_id (e.g., "123:0").
		$id = sprintf( '%d:%d', $download_id, $price_id );

		return [
			'id'    => $id,
			'name'  => esc_html( $name ),
			'price' => esc_html( edd_currency_filter( edd_format_amount( $option_amount ) ) ),
		];
	}

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

		$payment = edd_get_payment( $order_id );

		return $payment instanceof EDD_Payment
			? $payment->total
			: 0.0;
	}

	/**
	 * Get the order total used for calculating purchase points.
	 *
	 * Returns the payment subtotal minus discounts, excluding tax.
	 * EDD has no shipping, so this is simpler than WooCommerce.
	 *
	 * @since 1.4.2
	 *
	 * @param mixed $order The EDD_Payment object.
	 *
	 * @return float
	 */
	protected function get_order_total_for_points( $order ): float {
		return (float) $order->subtotal - (float) $order->discount;
	}

	/**
	 * Get the points-eligible total for a payment by ID.
	 *
	 * Convenience wrapper that fetches the EDD_Payment object and delegates
	 * to calculate_order_total_for_points() which applies the filter hook.
	 *
	 * @since 1.4.2
	 *
	 * @param int $payment_id The EDD payment ID.
	 *
	 * @return float
	 */
	private function get_payment_total_for_points( int $payment_id ): float {
		if ( ! function_exists( 'edd_get_payment' ) ) {
			return 0.0;
		}

		$payment = edd_get_payment( $payment_id );

		if ( ! $payment instanceof EDD_Payment ) {
			return 0.0;
		}

		return $this->calculate_order_total_for_points( $payment );
	}

	/**
	 * Retrieve the total amount from a given EDD order formatted with currency.
	 *
	 * @since 1.0.0
	 *
	 * @param int $order_id The EDD payment ID.
	 *
	 * @return string The formatted order total with currency.
	 */
	function get_order_total_formatted( int $order_id ): string {
		if ( ! $this->is_active() ) {
			return 0;
		}

		$payment = edd_get_payment( $order_id );

		return $payment instanceof EDD_Payment
			? edd_currency_filter( edd_format_amount( $payment->total ), $payment->currency )
			: edd_currency_filter( edd_format_amount( 0.0 ) );
	}

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

		return admin_url( 'edit.php?post_type=download&page=edd-payment-history&view=view-order-details&id=' . $order_id );
	}

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

		// Try to find downloads archive first.
		$downloads_link = get_post_type_archive_link( 'download' );
		if ( $downloads_link ) {
			return $downloads_link;
		}

		// Fallback: Get EDD checkout page URL.
		$checkout_page = function_exists( 'edd_get_option' ) ? edd_get_option( 'purchase_page' ) : 0;

		if ( $checkout_page ) {
			$downloads_url = get_permalink( $checkout_page );
			if ( $downloads_url ) {
				return $downloads_url;
			}
		}

		// Final fallback to home URL.
		return home_url();
	}

	/**
	 * Check if a product (download) is available for purchase.
	 *
	 * For EDD, digital products are typically always available unless explicitly disabled.
	 * This checks if the download exists and is published.
	 *
	 * @since 1.2.0
	 *
	 * @param int|string $product_id The download ID or "download_id:price_id" string.
	 *
	 * @return bool True if the download is available, false otherwise.
	 */
	public function is_product_in_stock( $product_id ): bool {
		if ( ! $this->is_active() ) {
			return false;
		}

		// Parse download_id:price_id format if needed.
		$download_id = $product_id;
		if ( is_string( $product_id ) && strpos( $product_id, ':' ) !== false ) {
			$parts       = explode( ':', $product_id );
			$download_id = absint( $parts[0] );
		} else {
			$download_id = absint( $product_id );
		}

		if ( ! $download_id ) {
			return false;
		}

		// Check if download exists and is published.
		$download = get_post( $download_id );

		if ( ! $download || 'download' !== $download->post_type || 'publish' !== $download->post_status ) {
			return false;
		}

		// EDD digital products are typically always "in stock".
		return true;
	}

	/**
	 * 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 {
		if ( ! $this->is_active() || ! function_exists( 'edd_get_cart_quantity' ) ) {
			return 0;
		}
		return edd_get_cart_quantity();
	}

	/**
	 * Get all product IDs currently in the cart.
	 *
	 * Includes both download IDs and price IDs (for variable pricing) to support
	 * checking if a specific product/price combination is in cart.
	 *
	 * @since 1.1.0
	 *
	 * @return array Array of product IDs in cart.
	 */
	public function get_cart_product_ids(): array {
		if ( ! $this->is_active() || ! function_exists( 'edd_get_cart_contents' ) ) {
			return [];
		}

		$cart_contents = edd_get_cart_contents();
		if ( empty( $cart_contents ) ) {
			return [];
		}

		$product_ids = [];
		foreach ( $cart_contents as $item ) {
			// Include the download ID.
			if ( ! empty( $item['id'] ) ) {
				$product_ids[] = (int) $item['id'];
			}

			// For variable pricing, also store the combined format "download_id:price_id".
			if ( ! empty( $item['id'] ) && isset( $item['options']['price_id'] ) ) {
				$product_ids[] = $item['id'] . ':' . $item['options']['price_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 {
		if ( ! $this->is_active() || ! function_exists( 'edd_get_cart_subtotal' ) ) {
			return 0.0;
		}

		return (float) edd_get_cart_subtotal();
	}

	/**
	 * Get all discount codes currently applied to the cart.
	 *
	 * @since 1.2.0
	 *
	 * @return array Array of discount codes.
	 */
	public function get_applied_coupons(): array {
		if ( ! $this->is_active() || ! function_exists( 'edd_get_cart_discounts' ) ) {
			return [];
		}

		$discounts = edd_get_cart_discounts();
		return is_array( $discounts ) ? $discounts : [];
	}

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

	/**
	 * Get the EDD cart page URL.
	 * Note: EDD doesn't have a separate cart page, returns checkout URL.
	 *
	 * @since 1.1.0
	 *
	 * @return string The cart/checkout URL.
	 */
	public function get_cart_url(): string {
		// EDD uses the same page for cart and checkout.
		return $this->get_checkout_url();
	}

	/**
	 * Get customers from Easy Digital Downloads.
	 *
	 * Returns empty array if no more customers or if EDD 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 EDD 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.
		$default_args = [
			'number'  => $per_page,
			'offset'  => $offset,
			'orderby' => 'id',
			'order'   => 'DESC',
		];

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

		// Get customers using Customer class instead of deprecated EDD_Customer_Query.
		$customers_list = edd_get_customers( $query_args );

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

		// Format the customer data with only essential info.
		$formatted_customers = [];
		foreach ( $customers_list as $customer ) {
			$formatted_customers[] = [
				'customer_id' => $customer->id,
				'user_id'     => $customer->user_id ?? 0,
				'name'        => $customer->name,
				'email'       => $customer->email,
			];
		}

		return $formatted_customers;
	}

	/**
	 * Retrieve orders for a given customer.
	 *
	 * @param string $customer_email Customer email.
	 * @param array  $args           Query args: page, limit, status.
	 *
	 * @return array List of formatted orders.
	 */
	public function get_customer_orders( string $customer_email, array $args = [] ): array {
		if ( ! $this->is_active() ) {
			return [];
		}

		$args = wp_parse_args(
			$args,
			[
				'page'   => false,
				'limit'  => apply_filters( 'am_rewardswp_unlimited', 99999 ),
				'status' => 'complete',
			]
		);

		$payments = edd_get_users_purchases( $customer_email, $args['limit'], $args['page'], $args['status'] );

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

		$formatted_orders = [];
		foreach ( $payments as $payment ) {
			$formatted_orders[] = [
				'id'       => $payment->ID,
				'date'     => $payment->post_date,
				'total'    => edd_get_payment_amount( $payment->ID ),
				'status'   => $payment->post_status,
				'currency' => edd_get_payment_currency_code( $payment->ID ),
				'items'    => $this->get_payment_items( $payment ),
			];
		}

		return $formatted_orders;
	}

	/**
	 * Get payment items details.
	 *
	 * @since 1.0.0
	 *
	 * @param EDD_Payment $payment EDD payment object.
	 * @return array
	 */
	private function get_payment_items( EDD_Payment $payment ): array {
		$items = [];
		foreach ( $payment->cart_details as $item ) {
			$download_id = ! empty( $item['id'] ) ? absint( $item['id'] ) : 0;
			$items[]     = [
				'name'        => $item['name'],
				'quantity'    => $item['quantity'],
				'total'       => $item['price'],
				'download_id' => $download_id,
			];
		}
		return $items;
	}

	/**
	 * Get the URL to the EDD purchase history page.
	 *
	 * Searches for a page with the [purchase_history] shortcode.
	 *
	 * @since 1.1.0
	 *
	 * @return string The EDD purchase history URL or home URL if not found.
	 */
	public function get_purchase_history_url(): string {
		if ( ! $this->is_active() ) {
			return home_url();
		}

		// Try to find a page with [purchase_history] shortcode.
		$pages = get_posts(
			[
				'post_type'      => 'page',
				'post_status'    => 'publish',
				's'              => '[purchase_history]',
				'posts_per_page' => 1,
			]
		);

		if ( ! empty( $pages ) ) {
			return get_permalink( $pages[0]->ID );
		}

		// Fallback to downloads archive or home.
		return $this->get_store_url();
	}

	/**
	 * Get the URL to an EDD download with reviews anchor.
	 *
	 * @since 1.1.0
	 *
	 * @param int $download_id The download ID.
	 * @return string The download URL with reviews anchor.
	 */
	public function get_download_review_url( int $download_id ): string {
		if ( ! $this->is_active() || ! $download_id ) {
			return home_url();
		}

		$download_url = get_permalink( $download_id );
		if ( ! $download_url ) {
			return home_url();
		}

		return $download_url . '#reviews';
	}

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

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

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

		// Return unique download IDs.
		return array_unique( $download_ids );
	}

	/**
	 * Check if EDD Reviews addon is active.
	 *
	 * @since 1.1.0
	 *
	 * @return bool Whether EDD Reviews addon is active.
	 */
	private function is_edd_reviews_active(): bool {
		return class_exists( 'EDD_Reviews' ) && function_exists( 'edd_reviews' );
	}

	/**
	 * Get EDD Reviews extension status.
	 *
	 * @since 1.1.0
	 *
	 * @return string Status: 'active', 'installed', or 'not_installed'.
	 */
	public function get_edd_reviews_status(): string {
		// Check if active.
		if ( $this->is_edd_reviews_active() ) {
			return 'active';
		}

		// Check if installed but not active.
		$plugin_file = WP_PLUGIN_DIR . '/edd-reviews/edd-reviews.php';
		if ( file_exists( $plugin_file ) ) {
			return 'installed';
		}

		return 'not_installed';
	}

	/**
	 * Check if user has purchased a specific download (for verified buyer check).
	 *
	 * @since 1.1.0
	 *
	 * @param int $user_id    The user ID.
	 * @param int $download_id The download ID.
	 *
	 * @return bool Whether the user has purchased the download.
	 */
	private function is_verified_edd_buyer( int $user_id, int $download_id ): bool {
		if ( ! function_exists( 'edd_has_user_purchased' ) ) {
			return false;
		}

		return edd_has_user_purchased( $user_id, $download_id );
	}

	/**
	 * Award points for EDD product review if conditions are met.
	 *
	 * This method is called by the EDD Reviews addon when a review is approved.
	 *
	 * @since 1.1.0
	 *
	 * @param int $review_id The review (comment) ID.
	 *
	 * @return void
	 */
	public function action_maybe_award_review_points( int $review_id ): void {
		$comment = get_comment( $review_id );

		if ( ! $comment ) {
			return;
		}

		$this->process_review_points_award( $comment );
	}

	/**
	 * Award points for product review when comment status transitions to approved.
	 *
	 * This method is called when a comment status changes 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_on_transition( 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 download reviews.
		if ( ! $comment || 'download' !== get_post_type( $comment->comment_post_ID ) ) {
			return;
		}

		$download_id  = (int) $comment->comment_post_ID;
		$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 ) {
			// Check if EDD Reviews addon is active and configured for "only buyers".
			if ( $this->is_edd_reviews_active() && function_exists( 'edd_get_option' ) ) {
				$edd_only_buyers = edd_get_option( 'edd_reviews_only_allow_reviews_by_buyer', false );

				// If EDD Reviews is not configured for "only buyers" but our option requires verification.
				if ( ! $edd_only_buyers ) {
					$user_id = (int) $comment->user_id;
					if ( ! $this->is_verified_edd_buyer( $user_id, $download_id ) ) {
						return;
					}
				}
				// If EDD Reviews is configured for "only buyers", verification was already done by the addon.
			} else {
				// Fallback verification if EDD Reviews is not active.
				$user_id = (int) $comment->user_id;
				if ( ! $this->is_verified_edd_buyer( $user_id, $download_id ) ) {
					return;
				}
			}
		}

		// Get member from comment user.
		$user_id = (int) $comment->user_id;
		$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 download, we check by download_id in meta.
		$awarded_meta_key = 'review_awarded_download_' . $download_id;
		$already_awarded  = $member->get_meta( $awarded_meta_key );

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

		// Create points entry.
		$points_entry = [
			'member_id'   => absint( $member->id ),
			'points'      => $points,
			'type'        => 'earned',
			'source_type' => 'edd_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 download.
		$member->update_meta( $awarded_meta_key, true );
	}

	/**
	 * Handle refund - deduct points and optionally reactivate coupons.
	 *
	 * This handler is triggered via EDD 3.0+ `edd_refund_order` hook for any refund.
	 * It handles both full and partial refunds based on the $all_refunded parameter.
	 *
	 * For full refunds:
	 * - All points (purchase and first_order) are deducted
	 * - Coupons are reactivated (reward status changes from 'redeemed' to 'issued')
	 *
	 * 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 original order/payment ID.
	 * @param int  $refund_id    The refund order ID.
	 * @param bool $all_refunded Whether the entire order was refunded.
	 *
	 * @return void
	 */
	public function action_handle_refund( int $order_id, int $refund_id, bool $all_refunded ): void {
		if ( $all_refunded ) {
			$this->handle_full_refund( $order_id, $refund_id );
		} else {
			$this->handle_partial_refund( $order_id, $refund_id );
		}
	}

	/**
	 * Handle full refund - deduct all points and reactivate coupons.
	 *
	 * @since 1.1.0
	 *
	 * @param int $order_id  The original order/payment ID.
	 * @param int $refund_id The refund order ID.
	 *
	 * @return void
	 */
	private function handle_full_refund( int $order_id, int $refund_id ): void {
		// Check if already processed (prevent duplication).
		$processed = function_exists( 'edd_get_order_meta' )
			? edd_get_order_meta( $order_id, '_rewardswp_refund_processed', true )
			: get_post_meta( $order_id, '_rewardswp_refund_processed', true );

		if ( $processed ) {
			return;
		}

		// Deduct ALL points earned from the purchase (including first_order).
		$this->deduct_points_from_refunded_payment( $order_id );

		// Reactivate coupons used in the payment.
		$this->reactivate_coupons_from_refunded_payment( $order_id );

		// Mark as processed.
		if ( function_exists( 'edd_update_order_meta' ) ) {
			edd_update_order_meta( $order_id, '_rewardswp_refund_processed', true );
		} else {
			update_post_meta( $order_id, '_rewardswp_refund_processed', true );
		}
	}

	/**
	 * Handle partial refund - deduct purchase points proportionally.
	 *
	 * @since 1.1.0
	 *
	 * @param int $order_id  The original order/payment ID.
	 * @param int $refund_id The refund order ID.
	 *
	 * @return void
	 */
	private function handle_partial_refund( int $order_id, int $refund_id ): void {
		// Check if this specific refund was already processed (prevent duplication).
		$processed = function_exists( 'edd_get_order_meta' )
			? edd_get_order_meta( $refund_id, '_rewardswp_partial_refund_processed', true )
			: get_post_meta( $refund_id, '_rewardswp_partial_refund_processed', true );

		if ( $processed ) {
			return;
		}

		// Get the refund order to calculate the refund amount.
		$refund_order = function_exists( 'edd_get_order' ) ? edd_get_order( $refund_id ) : null;

		if ( ! $refund_order ) {
			return;
		}

		// Get the original order to calculate the ratio.
		$original_order = function_exists( 'edd_get_order' ) ? edd_get_order( $order_id ) : null;

		if ( ! $original_order ) {
			return;
		}

		// Deduct purchase points proportionally (excludes first_order points).
		$this->deduct_points_proportionally_edd( $order_id, $refund_id, $refund_order, $original_order );

		// Mark this refund as processed.
		if ( function_exists( 'edd_update_order_meta' ) ) {
			edd_update_order_meta( $refund_id, '_rewardswp_partial_refund_processed', true );
		} else {
			update_post_meta( $refund_id, '_rewardswp_partial_refund_processed', true );
		}
	}

	/**
	 * Deduct points earned from a refunded payment.
	 *
	 * @since 1.1.0
	 *
	 * @param int $payment_id The refunded payment ID.
	 *
	 * @return void
	 */
	private function deduct_points_from_refunded_payment( int $payment_id ): void {
		// Get all points earned from this purchase.
		$points_earned = $this->get_points_earned_from_order( $payment_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, $payment_id );
		}
	}

	/**
	 * Reactivate coupons used in a refunded payment.
	 *
	 * @param int $payment_id The refunded payment ID.
	 *
	 * @return void
	 * @throws Exception
	 * @since 1.1.0
	 */
	private function reactivate_coupons_from_refunded_payment( int $payment_id ): void {
		$used_coupons = $this->get_order_discount_codes( $payment_id );

		// Fallback to legacy method if the new method returns empty.
		if ( empty( $used_coupons ) ) {
			$used_coupons = $this->get_order_discount_codes_legacy( $payment_id );
		}

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

		// Process all RewardsWP coupons used in the payment.
		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 discount usage in EDD platform.
			$coupon_code = $reward->get_meta( 'coupon_code' );
			if ( ! empty( $coupon_code ) ) {
				$this->reset_coupon_usage( $coupon_code );
			}
		}
	}

	/**
	 * Get discount codes applied to an order.
	 *
	 * Uses EDD 3.0+ order adjustments API when available,
	 * falls back to legacy user_info discount field.
	 *
	 * @since 1.1.0
	 *
	 * @param int $payment_id The payment/order ID.
	 *
	 * @return array Array of discount codes applied to the order.
	 */
	private function get_order_discount_codes( int $payment_id ): array {
		$discount_codes = [];

		// Try EDD 3.0+ method first - use order adjustments.
		if ( function_exists( 'edd_get_order_adjustments' ) ) {
			$adjustments = edd_get_order_adjustments(
				[
					'object_id'   => $payment_id,
					'object_type' => 'order',
					'type'        => 'discount',
					'number'      => 99,
				]
			);

			if ( ! empty( $adjustments ) ) {
				foreach ( $adjustments as $adjustment ) {
					$code = null;

					// Method 1: Try to get the discount code from the type_id (discount ID).
					// edd_get_discount returns an EDD_Discount object with get_code() method.
					if ( ! empty( $adjustment->type_id ) && function_exists( 'edd_get_discount' ) ) {
						$discount = edd_get_discount( $adjustment->type_id );
						if ( $discount ) {
							// Use get_code() method if available (EDD_Discount object).
							if ( method_exists( $discount, 'get_code' ) ) {
								$code = trim( $discount->get_code() );
							} elseif ( ! empty( $discount->code ) ) {
								// Fallback to direct property access.
								$code = trim( $discount->code );
							}
						}
					}

					// Method 2: Fallback to edd_get_adjustment if edd_get_discount didn't work.
					if ( empty( $code ) && ! empty( $adjustment->type_id ) && function_exists( 'edd_get_adjustment' ) ) {
						$discount = edd_get_adjustment( $adjustment->type_id );
						if ( $discount && ! empty( $discount->code ) ) {
							$code = trim( $discount->code );
						}
					}

					// Method 3: The description field might contain the code.
					if ( empty( $code ) && ! empty( $adjustment->description ) ) {
						$code = trim( $adjustment->description );
					}

					if ( ! empty( $code ) ) {
						$discount_codes[] = $code;
					}
				}
			}

			// If we found discount codes via EDD 3.0+ API, return them.
			if ( ! empty( $discount_codes ) ) {
				return array_unique( array_filter( $discount_codes ) );
			}
		}

		// Fallback to legacy method for EDD 2.x or if no adjustments found.
		$user_info = $this->get_order_user_info( $payment_id );

		if ( empty( $user_info ) || empty( $user_info['discount'] ) ) {
			return [];
		}

		// Filter out "none" and empty values.
		$discount_string = trim( $user_info['discount'] );
		if ( empty( $discount_string ) || 'none' === strtolower( $discount_string ) ) {
			return [];
		}

		$discount_codes = array_map( 'trim', explode( ',', $discount_string ) );

		// Filter out empty strings and "none" values.
		$discount_codes = array_filter(
			$discount_codes,
			function ( $code ) {
				return ! empty( $code ) && 'none' !== strtolower( $code );
			}
		);

		return array_unique( $discount_codes );
	}

	/**
	 * Reset EDD discount usage count and reactivate if needed.
	 *
	 * @since 1.1.0
	 *
	 * @param string $coupon_code The discount code.
	 *
	 * @return void
	 */
	private function reset_coupon_usage( string $coupon_code ): void {
		if ( ! function_exists( 'edd_get_discount_by_code' ) ) {
			return;
		}

		$discount = edd_get_discount_by_code( $coupon_code );

		if ( ! $discount ) {
			return;
		}

		$discount_id = is_object( $discount ) ? $discount->id : $discount;

		// EDD 3.0+ edd_decrease_discount_usage expects the CODE, not the ID.
		if ( function_exists( 'edd_decrease_discount_usage' ) ) {
			edd_decrease_discount_usage( $coupon_code );
		} else {
			// Fallback for EDD 2.x - needs the ID.
			$use_count = edd_get_discount_uses( $discount_id );

			if ( $use_count > 0 ) {
				// Use meta function directly if available.
				if ( function_exists( 'edd_update_adjustment_meta' ) ) {
					// EDD 3.0+.
					edd_update_adjustment_meta( $discount_id, '_edd_discount_uses', $use_count - 1 );
				} else {
					// EDD 2.x.
					update_post_meta( $discount_id, '_edd_discount_uses', $use_count - 1 );
				}
			}
		}

		// Ensure the discount is set back to active status.
		// It may have been set to inactive or archived after max_uses was reached.
		if ( function_exists( 'edd_update_discount_status' ) ) {
			edd_update_discount_status( $discount_id, 'active' );
		}
	}

	/**
	 * Deduct purchase points proportionally based on the refund amount for EDD.
	 *
	 * 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 int    $order_id       The original order/payment ID.
	 * @param int    $refund_id      The refund order ID.
	 * @param object $refund_order   The refund order object.
	 * @param object $original_order The original order object.
	 *
	 * @return void
	 */
	private function deduct_points_proportionally_edd( int $order_id, int $refund_id, $refund_order, $original_order ): void {
		// Get only purchase points (excludes first_order points).
		$purchase_points = $this->get_purchase_points_from_order( $order_id );

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

		// The refund total is negative in EDD 3.0+, so we use abs().
		$refund_amount = abs( (float) $refund_order->total );

		// Calculate the original order total (before any refunds).
		$original_total = $this->calculate_original_payment_total( $order_id, $original_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_id,
					$refund_id,
					$points_to_deduct
				);
			}
		}
	}

	/**
	 * Calculate the original payment total before any refunds for EDD.
	 *
	 * @since 1.1.0
	 *
	 * @param int    $order_id       The order/payment ID.
	 * @param object $original_order The original order object.
	 *
	 * @return float The original payment total.
	 */
	private function calculate_original_payment_total( int $order_id, $original_order ): float {
		// In EDD 3.0+, the order total already reflects refunds via edd_get_order_total().
		// We need to get the original total before refunds.

		// Get all refunds for this order.
		$refunds = function_exists( 'edd_get_order_refunds' ) ? edd_get_order_refunds( $order_id ) : [];

		// Start with the current total.
		$original_total = (float) $original_order->total;

		// Add back all refund amounts (they are stored as negative values).
		foreach ( $refunds as $refund ) {
			$original_total -= (float) $refund->total; // Subtracting a negative = adding.
		}

		return $original_total;
	}

	/**
	 * Link EDD guest purchases to a user account.
	 *
	 * Finds all guest purchases (where user_id = 0) with the given email
	 * and updates them to be associated with the new user ID.
	 *
	 * Supports both EDD 3.0+ (custom tables) and legacy EDD 2.x.
	 *
	 * @since 1.1.0
	 *
	 * @param string $email   The customer email to search for.
	 * @param int    $user_id The WordPress user ID to link purchases to.
	 *
	 * @return int Number of purchases linked.
	 */
	public function link_guest_orders( string $email, int $user_id ): int {
		// Check if EDD is active.
		if ( ! $this->is_active() ) {
			return 0;
		}

		// EDD 3.0+ uses edd_get_orders() with custom tables.
		if ( function_exists( 'edd_get_orders' ) && function_exists( 'edd_update_order' ) ) {
			return $this->link_guest_orders_edd3( $email, $user_id );
		}

		// Legacy EDD 2.x fallback.
		return $this->link_guest_orders_legacy( $email, $user_id );
	}

	/**
	 * Link guest orders for EDD 3.0+ using custom tables.
	 *
	 * @since 1.1.0
	 *
	 * @param string $email   The customer email to search for.
	 * @param int    $user_id The WordPress user ID to link purchases to.
	 *
	 * @return int Number of purchases linked.
	 */
	private function link_guest_orders_edd3( string $email, int $user_id ): int {
		// Find all guest orders with this email using EDD 3.0+ API.
		$guest_orders = edd_get_orders(
			[
				'email'   => $email,
				'user_id' => 0, // Guest orders only.
				'number'  => -1, // Get all orders to match count_guest_orders behavior.
			]
		);

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

		$linked_count = 0;

		foreach ( $guest_orders as $order ) {
			// Verify the email matches (extra safety).
			if ( strtolower( $order->email ) !== strtolower( $email ) ) {
				continue;
			}

			// Update the user ID using EDD 3.0+ API.
			$updated = edd_update_order(
				$order->id,
				[
					'user_id' => $user_id,
				]
			);

			if ( $updated ) {
				++$linked_count;
			}
		}

		return $linked_count;
	}

	/**
	 * Link guest orders for legacy EDD 2.x using post meta.
	 *
	 * @since 1.1.0
	 *
	 * @param string $email   The customer email to search for.
	 * @param int    $user_id The WordPress user ID to link purchases to.
	 *
	 * @return int Number of purchases linked.
	 */
	private function link_guest_orders_legacy( string $email, int $user_id ): int {
		if ( ! function_exists( 'edd_get_payments' ) ) {
			return 0;
		}

		// Find all guest purchases with this email.
		$guest_purchases = edd_get_payments(
			[
				'user'   => 0, // Guest purchases only.
				's'      => $email, // Search by email.
				'number' => -1, // Get all orders to match count_guest_orders behavior.
				'output' => 'payments', // Return payment objects.
			]
		);

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

		$linked_count = 0;

		foreach ( $guest_purchases as $payment ) {
			// Verify the email matches (extra safety).
			$payment_email = edd_get_payment_user_email( $payment->ID );
			if ( strtolower( $payment_email ) !== strtolower( $email ) ) {
				continue;
			}

			// Update the user ID using legacy post meta.
			update_post_meta( $payment->ID, '_edd_payment_user_id', $user_id );
			++$linked_count;
		}

		return $linked_count;
	}

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

		// EDD 3.0+ uses edd_get_orders() with custom tables.
		if ( function_exists( 'edd_get_orders' ) ) {
			$orders = edd_get_orders(
				[
					'email'   => $email,
					'user_id' => 0, // Only guest orders.
					'number'  => -1,
				]
			);

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

		// Legacy EDD 2.x fallback.
		if ( function_exists( 'edd_get_payments' ) ) {
			$purchases = edd_get_payments(
				[
					'user'   => 0, // Guest purchases only.
					's'      => $email, // Search by email.
					'number' => -1,
					'output' => 'payments',
				]
			);

			if ( ! is_array( $purchases ) ) {
				return 0;
			}

			// Filter to ensure email matches exactly.
			$count = 0;
			foreach ( $purchases as $payment ) {
				$payment_email = edd_get_payment_user_email( $payment->ID );
				if ( strtolower( $payment_email ) === strtolower( $email ) ) {
					++$count;
				}
			}

			return $count;
		}

		return 0;
	}

	/**
	 * Store a value in the EDD 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 ( ! EDD()->session ) {
			return false;
		}

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

		return true;
	}

	/**
	 * Retrieve a value from the EDD 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 ( ! EDD()->session ) {
			return null;
		}

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

	/**
	 * Clear a value from the EDD 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 ( ! EDD()->session ) {
			return false;
		}

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

		return true;
	}
}
