<?php
/**
 * Points service for managing points accrual, expiration and redemption.
 *
 * @package RewardsWP
 */

/* phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- We have control over our query engine */

namespace Awesomemotive\Rewardswp\Services;

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

use Awesomemotive\Rewardswp\Abstracts\AbstractModelService;
use Awesomemotive\Rewardswp\Database\DatabaseHandler;
use Awesomemotive\Rewardswp\GlobalConfig;
use Awesomemotive\Rewardswp\Database\Tables\PointsTable;
use Awesomemotive\Rewardswp\Database\Tables\RewardsTable;
use Awesomemotive\Rewardswp\Models\Point;
use Awesomemotive\Rewardswp\Models\Member;
use Awesomemotive\Rewardswp\Factories\MailerFactory;
use Awesomemotive\Rewardswp\Factories\RewardTypesFactory;
use Awesomemotive\Rewardswp\Notifications\Emails\RewardRedemptionEmail;
use Awesomemotive\Rewardswp\Settings\Options\Points\EarnActionsOption;
use Awesomemotive\Rewardswp\Settings\Options\Points\SpendActionsOption;
use DI\Container;
use Exception;
use WP_Error;
use function Awesomemotive\Rewardswp\Helpers\get_member;
use function Awesomemotive\Rewardswp\Helpers\get_option_value;
use function Awesomemotive\Rewardswp\Helpers\generate_custom_hash;
use function Awesomemotive\Rewardswp\Helpers\create_reward;
use function Awesomemotive\Rewardswp\Helpers\add_time_to_date;
use function Awesomemotive\Rewardswp\Helpers\get_current_time;
use function Awesomemotive\Rewardswp\Helpers\format_points_with_label;
use function Awesomemotive\Rewardswp\Helpers\get_option_object;
use function Awesomemotive\Rewardswp\Helpers\get_spend_action_display_title;
use function Awesomemotive\Rewardswp\Helpers\get_reward_display_title_from_values;
use function Awesomemotive\Rewardswp\Helpers\rewardswp_log_error;


/**
 * Points Service.
 *
 * @since 1.0.0
 */
class PointsService extends AbstractModelService {
	/**
	 * Global configuration service.
	 *
	 * @since 1.0.1
	 *
	 * @var GlobalConfig
	 */
	private GlobalConfig $global_config;

	/**
	 * Mailer factory instance.
	 *
	 * @since 1.1.0
	 *
	 * @var MailerFactory
	 */
	private MailerFactory $mailer_factory;

	/**
	 * Reward types factory instance.
	 *
	 * @since 1.1.0
	 *
	 * @var RewardTypesFactory
	 */
	private RewardTypesFactory $reward_types_factory;

	/**
	 * Rewards table instance.
	 *
	 * @since 1.2.0
	 *
	 * @var RewardsTable
	 */
	private RewardsTable $rewards_table;

	/**
	 * Default coupon duration for points redemption (Lite).
	 *
	 * @since 1.1.0
	 */
	public const DEFAULT_COUPON_DURATION = 'never';

	/**
	 * Constructor.
	 *
	 * @since 1.0.0
	 *
	 * @param DatabaseHandler    $db_handler           The database handler.
	 * @param PointsTable        $db_table             The points table.
	 * @param Container          $container            The dependency injection container.
	 * @param GlobalConfig       $global_config        The global configuration.
	 * @param MailerFactory      $mailer_factory       The mailer factory.
	 * @param RewardTypesFactory $reward_types_factory The reward types factory.
	 * @param RewardsTable       $rewards_table        The rewards table.
	 */
	public function __construct(
		DatabaseHandler $db_handler,
		PointsTable $db_table,
		Container $container,
		GlobalConfig $global_config,
		MailerFactory $mailer_factory,
		RewardTypesFactory $reward_types_factory,
		RewardsTable $rewards_table
	) {
		parent::__construct( $db_handler, $db_table, $container );

		$this->global_config        = $global_config;
		$this->mailer_factory       = $mailer_factory;
		$this->reward_types_factory = $reward_types_factory;
		$this->rewards_table        = $rewards_table;
	}

	/**
	 * Get points entries based on the given parameters and pagination options.
	 *
	 * @since 1.0.0
	 *
	 * @param array $where    An associative array of conditions to filter points.
	 *                        See {@see PointsTable::get_columns()} for available parameters.
	 * @param int   $per_page Optional. The number of items per page for pagination. Defaults to 20.
	 * @param int   $page     Optional. The page number for pagination. Defaults to 1.
	 *
	 * @return Point[] Array of Points objects.
	 */
	public function get_points_entries( array $where = [], int $per_page = 20, int $page = 1 ): array {
		return $this->get_objects( Point::class, $where, $per_page, $page );
	}

	/**
	 * Get the number of points entries based on the given parameters.
	 *
	 * @since 1.0.0
	 *
	 * @param array $where An associative array of conditions to filter points.
	 *                     See {@see PointsTable::get_columns()} for available parameters.
	 *
	 * @return int The number of entries found.
	 */
	public function count_points_entries( array $where = [] ): int {
		return $this->count_objects( $where );
	}

	/**
	 * Get total points earned by a member.
	 *
	 * @since 1.0.0
	 *
	 * @param int $member_id The member ID.
	 *
	 * @return int
	 */
	public function get_member_points_earned( int $member_id ): int {
		$sql = "SELECT SUM(points) FROM {$this->db_table->get_prefixed_table_name()}
				WHERE member_id = %d AND type = 'earned' AND status = 'approved'";

		$result = $this->db_handler->get_var( $sql, [ $member_id ] );

		return (int) $result;
	}

	/**
	 * Get total points spent by a member.
	 *
	 * @since 1.0.0
	 *
	 * @param int $member_id The member ID.
	 *
	 * @return int
	 */
	public function get_member_points_spent( int $member_id ): int {
		$sql = "SELECT ABS(SUM(points)) FROM {$this->db_table->get_prefixed_table_name()}
				WHERE member_id = %d AND type = 'spent' AND status = 'approved'";

		$result = $this->db_handler->get_var( $sql, [ $member_id ] );

		return (int) $result;
	}

	/**
	 * Get total points redeemed by a member (excluding manual adjustments).
	 *
	 * @since 1.0.0
	 *
	 * @param int $member_id The member ID.
	 *
	 * @return int
	 */
	public function get_member_points_redeemed( int $member_id ): int {
		$sql = "SELECT ABS(SUM(points)) FROM {$this->db_table->get_prefixed_table_name()}
				WHERE member_id = %d AND type = 'spent' AND status = 'approved' AND source_type != 'manual_adjustment'";

		$result = $this->db_handler->get_var( $sql, [ $member_id ] );

		return (int) $result;
	}

	/**
	 * Get net points earned for a specific order.
	 *
	 * Calculates earned points minus any refund deductions for the order.
	 * This is useful for displaying the correct points earned on order
	 * confirmation/details pages, accounting for partial or full refunds.
	 *
	 * @since 1.1.0
	 *
	 * @param int $order_id  The order/payment ID.
	 * @param int $member_id The member ID.
	 *
	 * @return int Net points earned (can be 0 if fully refunded).
	 */
	public function get_net_points_earned_for_order( int $order_id, int $member_id ): int {
		// Get total earned points for this order.
		$earned_sql = "SELECT COALESCE(SUM(points), 0) FROM {$this->db_table->get_prefixed_table_name()}
				WHERE member_id = %d AND source_id = %d AND type = 'earned' AND status = 'approved'";

		$points_earned = (int) $this->db_handler->get_var( $earned_sql, [ $member_id, $order_id ] );

		// Get total refund deductions for this order.
		$refund_sql = "SELECT COALESCE(SUM(points), 0) FROM {$this->db_table->get_prefixed_table_name()}
				WHERE member_id = %d AND source_id = %d AND type = 'spent' AND status = 'approved'
				AND source_type IN ('refund', 'partial_refund')";

		$points_deducted = (int) $this->db_handler->get_var( $refund_sql, [ $member_id, $order_id ] );

		return max( 0, $points_earned - $points_deducted );
	}

	/**
	 * Get the sum of pending points for a member.
	 *
	 * @since 1.2.0
	 *
	 * @param int $member_id The member ID.
	 *
	 * @return int Sum of pending points (can be negative if there are pending deductions).
	 */
	public function get_pending_points_sum( int $member_id ): int {
		$sql = "SELECT COALESCE(SUM(points), 0) FROM {$this->db_table->get_prefixed_table_name()}
				WHERE member_id = %d AND status = 'pending'";

		return (int) $this->db_handler->get_var( $sql, [ $member_id ] );
	}

	/**
	 * Create a new points entry.
	 *
	 * @since 1.0.1
	 *
	 * @param array $args {
	 *     Array of arguments for creating a points entry.
	 *
	 *     @type int    $member_id     Required. The member ID who will receive the points.
	 *     @type int    $points        Required. The number of points.
	 *     @type string $type          Required. The type of points (earned, redeemed, etc).
	 *     @type string $source_type   Required. The source of the points (signup, purchase, etc).
	 *     @type int    $source_id     Optional. Related source ID (user_id, order_id, etc).
	 *     @type string $status        Optional. Points status. Default 'approved'.
	 *     @type string $note_internal Optional. Internal notes added by admins.
	 *     @type string $note_public   Optional. Public visible notes for users.
	 * }
	 * @param bool  $notify         Optional. Whether to send notification email. Default true.
	 * @param bool  $update_balance Optional. Whether to update member balance automatically. Default true.
	 *
	 * @return Point|WP_Error Point object if successful, WP_Error object on failure.
	 */
	public function create_points_entry( array $args, bool $notify = true, bool $update_balance = true ) {
		try {
			/**
			 * Point model instance.
			 *
			 * @var Point $point
			 */
			$point = $this->container->make( Point::class );

			$point->member_id     = $args['member_id'];
			$point->points        = absint( $args['points'] );
			$point->type          = $args['type'];
			$point->source_type   = $args['source_type'];
			$point->source_id     = $args['source_id'] ?? null;
			$point->status        = $args['status'] ?? 'approved';
			$point->note_internal = $args['note_internal'] ?? null;
			$point->note_public   = $args['note_public'] ?? null;

			// Allow custom created_at date (useful for testing/imports).
			if ( isset( $args['created_at'] ) ) {
				$point->created_at = $args['created_at'];
			}

			// Set expiration date for earned points only.
			if ( 'earned' === $args['type'] ) {
				$point->expires_at = $this->calculate_points_expiration_date();
			}

			$point->create();

			// Automatically update member balance if approved and update_balance is true.
			$member = null;
			if ( $update_balance && 'approved' === $point->status ) {
				$member = get_member( $args['member_id'] );
				if ( $member ) {
					if ( 'earned' === $point->type ) {
						$member->increase_points_balance( absint( $point->points ) );
					} elseif ( 'spent' === $point->type ) {
						$member->deduct_points_balance( absint( $point->points ), true, false );
					}
				}
			}

			// Send notification email if enabled and points are earned.
			// Note: The member balance is now updated, so the email will show the correct balance.
			if ( $notify && 'earned' === $args['type'] && 'approved' === $point->status ) {
				if ( ! $member ) {
					$member = get_member( $args['member_id'] );
				}
				if ( $member ) {
					$this->send_points_notification_email( $point, $member );
				}
			}

		if ( ! $member ) {
			$member = get_member( $args['member_id'] );
		}

		/**
		 * Fires after a points entry is created, balance is updated, and notification is sent.
		 *
		 * @since 1.3.0
		 *
		 * @param Point       $point  The created points entry.
		 * @param Member|null $member The member who received the points.
		 */
		do_action( 'rewardswp_points_entry_created', $point, $member );

		return $point;

	} catch ( Exception $e ) {
		return new WP_Error(
			'points_creation_failed',
			$e->getMessage()
		);
	}
}

/**
 * Update an existing points entry.
 *
 * This method is used to update a point entry from 'pending' to 'approved' status,
 * recalculating points if necessary. It handles balance updates only when status
 * changes to 'approved'.
 *
 * @since 1.2.0
 *
 * @param array $args {
 *     Array of arguments for updating a points entry.
 *
 *     @type int    $member_id     Required. The member ID who owns the points.
 *     @type string $source_type   Required. The source type of the points (purchase, etc).
 *     @type int    $source_id     Required. The source ID (order_id, payment_id, etc).
 *     @type int    $points        Optional. The new number of points. If not provided, will be recalculated.
 *     @type string $status        Optional. The new status. Default 'approved'.
 *     @type string $note_public   Optional. Public visible notes for users.
 * }
 * @param bool  $notify         Optional. Whether to send notification email. Default true.
 * @param bool  $update_balance Optional. Whether to update member balance automatically. Default true.
 *
 * @return Point|WP_Error Point object if successful, WP_Error object on failure.
 */
public function update_points_entry( array $args, bool $notify = true, bool $update_balance = true ) {
	try {
		// Find existing point entry.
		$existing_points = $this->get_points_entries(
			[
				'member_id'   => $args['member_id'],
				'source_type' => $args['source_type'],
				'source_id'   => $args['source_id'],
				'status'      => 'pending',
			],
			1,
			1
		);

		if ( empty( $existing_points ) ) {
			return new WP_Error(
				'points_entry_not_found',
				esc_html__( 'No pending points entry found to update.', 'rewardswp' )
			);
		}

		/**
		 * Point model instance.
		 *
		 * @var Point $point
		 */
		$point = $existing_points[0];
		$old_status = $point->status;
		$old_points = $point->points;

		// Update points if provided, otherwise keep existing.
		if ( isset( $args['points'] ) ) {
			$point->points = absint( $args['points'] );
		}

		// Update status if provided.
		if ( isset( $args['status'] ) ) {
			$point->status = $args['status'];
		}

		// Update note if provided.
		if ( isset( $args['note_public'] ) ) {
			$point->note_public = $args['note_public'];
		}

		// Update expiration date for earned points if status is changing to approved.
		if ( 'earned' === $point->type && 'approved' === $point->status && 'pending' === $old_status ) {
			$point->expires_at = $this->calculate_points_expiration_date();
		}

		$point->update();

		// Handle balance update only when status changes from 'pending' to 'approved'.
		$member = null;
		if ( $update_balance && 'pending' === $old_status && 'approved' === $point->status ) {
			$member = get_member( $args['member_id'] );
			if ( $member ) {
				if ( 'earned' === $point->type ) {
					$member->increase_points_balance( absint( $point->points ) );
				} elseif ( 'spent' === $point->type ) {
					$member->deduct_points_balance( absint( $point->points ), true, false );
				}
			}
		}

		// Send notification email if enabled and points are earned and approved.
		if ( $notify && 'earned' === $point->type && 'approved' === $point->status && 'pending' === $old_status ) {
			if ( ! $member ) {
				$member = get_member( $args['member_id'] );
			}
			if ( $member ) {
				$this->send_points_notification_email( $point, $member );
			}
		}

		return $point;

	} catch ( Exception $e ) {
		return new WP_Error(
			'points_update_failed',
			$e->getMessage()
		);
	}
}

/**
 * Delete all points and their associated metadata for a given member ID.
	 *
	 * @since 1.1.0
	 *
	 * @param int $member_id The ID of the member.
	 * @return bool True if all deletions were successful or if no points/metadata existed, false on any error.
	 */
	public function delete_by_member_id( int $member_id ): bool {
		if ( $member_id <= 0 ) {
			return false;
		}

		// Get all point IDs associated with this member_id.
		$point_ids = $this->db_handler->get_results(
			$this->db_table->get_prefixed_table_name(),
			[ 'member_id' => $member_id ],
			'id',
			apply_filters( 'am_rewardswp_unlimited', 99999 )
		);

		$all_successful = true;

		if ( ! empty( $point_ids ) ) {
			$meta_table_name         = $this->db_table->get_prefixed_meta_table_name();
			$meta_relationship_field = $this->db_table->get_meta_table_relationship_field_name(); // Should be 'points_id'.

			foreach ( $point_ids as $point_row ) {
				$point_id = $point_row['id'] ?? null;
				if ( $point_id && $meta_table_name && $meta_relationship_field ) {
					$meta_deleted = $this->db_handler->delete(
						$meta_table_name,
						[ $meta_relationship_field => $point_id ]
					);
					if ( false === $meta_deleted ) {
						$all_successful = false;
						// Optionally log error for this specific point_id's metadata deletion.
					}
				}
			}
		}

		// Now delete the main point entries.
		$points_deleted = $this->db_handler->delete(
			$this->db_table->get_prefixed_table_name(),
			[ 'member_id' => $member_id ]
		);

		if ( false === $points_deleted ) {
			$all_successful = false;
		}

		return $all_successful;
	}

	/**
	 * Send appropriate email notification based on the points source type.
	 *
	 * @since 1.1.0
	 *
	 * @param Point  $point   The created point entry.
	 * @param Member $member  The member who received the points.
	 *
	 * @return void
	 */
	private function send_points_notification_email( Point $point, Member $member ): void {
		try {
			// Get member's email address.
			$member_email = $member->email;
			if ( empty( $member_email ) ) {
				return; // No email to send to.
			}

			// Get current balance and points earned.
			// Note: The member's points_balance has already been updated by create_points_entry().
			$current_balance = $member->points_balance ?? 0;
			$points_earned   = absint( $point->points );

			// Common email data.
			$email_data = [
				'points_earned'             => $points_earned,
				'points_earned_formatted'   => format_points_with_label( $points_earned ),
				'current_balance'           => $current_balance,
				'current_balance_formatted' => format_points_with_label( $current_balance ),
				'member_name'               => $member->get_first_name() ?: $member->email,
				'member_full_name'          => $member->get_full_name() ?: $member->email,
				'site_url'                  => home_url(),
				'currency_singular'         => $this->get_currency_singular(),
				'currency_plural'           => $this->get_currency_plural(),
			];

			$email_notification = null;

			// Determine which email template to use based on source_type.
			switch ( $point->source_type ) {
				// case 'signup': // Temporarily disabled - uses generic points_earned email instead
				// $email_notification = $this->mailer_factory->get_mailer( 'welcome_member_points' );
				// break;

				case 'birthday':
					$email_notification = $this->mailer_factory->get_mailer( 'birthday_reward' );
					$email_data['action_description'] = $this->get_action_description( $point->source_type );
					break;

				default:
					// Generic points earned email for all other source types.
					$email_notification = $this->mailer_factory->get_mailer( 'points_earned' );

					// Get action description for generic email.
					$email_data['action_description'] = $this->get_action_description( $point->source_type );
					break;
			}

			if ( $email_notification ) {
				$email_notification
					->set_recipient( $member_email )
					->set_data( $email_data )
					->send_async();
			}
		} catch ( Exception $e ) {
			// Log error but don't break the points creation process.
			rewardswp_log_error(
				'Failed to send points notification email: {message}',
				[
					'message'  => $e->getMessage(),
					'point_id' => $point->id ?? 0,
				]
			);
		}
	}

	/**
	 * Format the reward amount for display in email.
	 *
	 * @since 1.1.0
	 *
	 * @param string $reward_type   The type of reward (fixed_discount, percentage_discount, etc).
	 * @param float  $reward_amount The reward amount.
	 *
	 * @return string Formatted reward amount display.
	 */
	private function format_reward_amount_display( string $reward_type, float $reward_amount ): string {
		// Get the reward type instance from factory.
		$reward_type_instance = $this->reward_types_factory->get_reward_type( $reward_type );

		if ( $reward_type_instance && method_exists( $reward_type_instance, 'get_reward_formatted_amount' ) ) {
			return $reward_type_instance->get_reward_formatted_amount( $reward_amount );
		}

		// Fallback if reward type not found.
		return sprintf(
			/* translators: %s: reward type */
			esc_html__( '%s Reward', 'rewardswp' ),
			ucwords( str_replace( '_', ' ', $reward_type ) )
		);
	}

	/**
	 * Get human-readable description for action source types.
	 *
	 * @since 1.1.0
	 *
	 * @param string $source_type The points source type.
	 *
	 * @return string Action description.
	 */
	private function get_action_description( string $source_type ): string {
		// Get configured earn actions to get custom descriptions.
		$earn_actions = get_option_value( EarnActionsOption::class );

		// If we have a configured description for this action, use it.
		if ( ! empty( $earn_actions[ $source_type ]['description'] ) ) {
			return $earn_actions[ $source_type ]['description'];
		}

		// Fallback to default descriptions.
		$default_descriptions = [
			'purchase'          => __( 'Making a purchase', 'rewardswp' ),
			'referral'          => __( 'Referring a friend', 'rewardswp' ),
			'review'            => __( 'Writing a product review', 'rewardswp' ),
			'social_follow'     => __( 'Following on social media', 'rewardswp' ),
			'social_share'      => __( 'Sharing on social media', 'rewardswp' ),
			'newsletter_signup' => __( 'Signing up for newsletter', 'rewardswp' ),
			'manual_adjustment' => __( 'Manual points adjustment', 'rewardswp' ),
			'birthday'          => __( 'Birthday bonus', 'rewardswp' ),
		];

		return $default_descriptions[ $source_type ] ?? ucwords( str_replace( '_', ' ', $source_type ) );
	}

	/**
	 * Calculate expiration date for points based on setting.
	 *
	 * @since 1.1.0
	 *
	 * @return string|null The expiration date in 'Y-m-d H:i:s' format or null if points never expire.
	 */
	protected function calculate_points_expiration_date(): ?string {
		// Lite: Points never expire.
		return null;
	}

	/**
	 * Redeem member points for a coupon.
	 *
	 * @since 1.1.0
	 *
	 * @param array $args {
	 *     Array of arguments for redeeming points.
	 *
	 *     @type int    $member_id          Required. The member ID who is redeeming points.
	 *     @type string $spend_action_type  Required. The type of spend action.
	 *     @type string $spend_action_id    Required. The spend action ID.
	 *     @type int    $points_to_deduct   Required. The number of points to deduct.
	 *     @type array  $coupon_data        Required. Coupon creation data.
	 *     @type object $integration        Required. Integration instance for coupon creation.
	 * }
	 *
	 * @return array|WP_Error Array with coupon_code and new_balance on success, WP_Error on failure.
	 */
	public function redeem_points_for_coupon( array $args ) {
		// Validate required arguments.
		$required_args = [ 'member_id', 'spend_action_type', 'spend_action_id', 'points_to_deduct', 'coupon_data', 'integration' ];
		foreach ( $required_args as $required_arg ) {
			if ( empty( $args[ $required_arg ] ) ) {
				return new WP_Error(
					'missing_argument',
					sprintf(
						/* translators: %s: argument name */
						esc_html__( 'Required argument missing: %s', 'rewardswp' ),
						$required_arg
					)
				);
			}
		}

		// Check if member exists and is active.
		$member = get_member( $args['member_id'] );
		if ( ! $member || 'active' !== $member->status ) {
			return new WP_Error(
				'invalid_member',
				esc_html__( 'Member not found or not active.', 'rewardswp' )
			);
		}

		// Validate member has sufficient points.
		if ( $member->points_balance < $args['points_to_deduct'] ) {
			return new WP_Error(
				'insufficient_points',
				esc_html__( 'Member does not have enough points for this redemption.', 'rewardswp' )
			);
		}

		// Check redemption limit for free_product rewards.
		if ( 'free_product' === $args['spend_action_type'] && ! empty( $args['spend_action_id'] ) ) {
			$redemption_limit = $args['coupon_data']['redemption_limit'] ?? '1';

			if ( 'unlimited' !== $redemption_limit ) {
				$current_redemptions = $this->count_member_redemptions_for_action(
					$args['member_id'],
					$args['spend_action_id']
				);

				if ( $current_redemptions >= (int) $redemption_limit ) {
					return new WP_Error(
						'redemption_limit_reached',
						esc_html__( 'You have reached the maximum number of redemptions for this reward.', 'rewardswp' )
					);
				}
			}
		}

		try {
			// Create negative points entry for spending.
			$points_entry = [
				'member_id'     => $args['member_id'],
				'points'        => -abs( $args['points_to_deduct'] ), // Negative for spending.
				'type'          => 'spent',
				'source_type'   => 'reward_redemption',
				'source_id'     => null, // Will be set to reward ID after creation.
				'status'        => 'approved',
				'note_internal' => sprintf(
					/* translators: %s: spend action type */
					esc_html__( 'Points redeemed for %s', 'rewardswp' ),
					ucwords( str_replace( '_', ' ', $args['spend_action_type'] ) )
				),
				'note_public'   => sprintf(
					/* translators: %s: spend action type */
					esc_html__( 'Points redeemed for %s', 'rewardswp' ),
					ucwords( str_replace( '_', ' ', $args['spend_action_type'] ) )
				),
			];

			// Create the points entry.
			$point = $this->create_points_entry( $points_entry, false ); // Don't send notification for spending.
			if ( is_wp_error( $point ) ) {
				return $point;
			}

			// Deduct points from member balance.
			$member->deduct_points_balance( $args['points_to_deduct'], true, false );

			// Create coupon using the integration.
			$integration   = $args['integration'];
			$coupon_data   = $integration->map_coupon_data( $args['coupon_data'] );
			$coupon_result = $integration->create_coupon( $coupon_data );

			if ( is_wp_error( $coupon_result ) ) {
				// Rollback: restore points if coupon creation fails.
				$member->increase_points_balance( $args['points_to_deduct'], true );

				// Delete the points entry.
				if ( $point && isset( $point->id ) ) {
					$this->db_handler->delete(
						$this->db_table->get_prefixed_table_name(),
						[ 'id' => $point->id ]
					);
				}

				return new WP_Error(
					'coupon_creation_failed',
					sprintf(
						/* translators: %s: error message */
						esc_html__( 'Failed to create coupon: %s', 'rewardswp' ),
						$coupon_result->get_error_message()
					)
				);
			}

			// The points entry source_id will be updated with reward_id after reward creation.

			// Use the full expiry timestamp (not just date with end-of-day).
			// This ensures the expiry time matches the creation time exactly.
			$reward_expires_at = $args['coupon_data']['expiry_timestamp'] ?? null;

			$reward = create_reward(
				[
					'referral_id'    => 0, // No referral for points redemption.
					'advocate_id'    => $args['member_id'],
					'reward_type'    => $args['spend_action_type'], // Use spend action type as reward type.
					'recipient_type' => 'advocate',
					'status'         => 'issued',
					'amount'         => $args['coupon_data']['reward_amount'] ?? 0, // Use coupon value as reward amount.
					'expires_at'     => $reward_expires_at, // Use exact expiry timestamp.
				]
			);

			if ( is_wp_error( $reward ) ) {
				// Log the error but don't fail the redemption since coupon was already created.
				rewardswp_log_error(
					'Failed to create reward for points redemption: {message}',
					[
						'message'   => $reward->get_error_message(),
						'member_id' => $member->id ?? 0,
					]
				);
			} else {
				// Update points entry with reward ID as source_id.
				$point->source_id = $reward->id;
				$point->update();

				// Store coupon code in reward meta.
				$reward->update_meta( 'coupon_code', $args['coupon_data']['code'] );

				// Store spend_action_id for tracking redemption limits.
				if ( ! empty( $args['spend_action_id'] ) ) {
					$reward->update_meta( 'spend_action_id', sanitize_text_field( $args['spend_action_id'] ) );
				}

				// Persist free product details (ID and name) when applicable.
				if ( 'free_product' === $args['spend_action_type'] ) {
					$product_ids = $args['coupon_data']['products_included'] ?? [];
					if ( is_array( $product_ids ) && count( $product_ids ) === 1 ) {
						$product_id = $product_ids[0];
						// Support both int IDs (WooCommerce) and string IDs (EDD "download_id:price_id").
						if ( is_numeric( $product_id ) ) {
							$product_id = absint( $product_id );
						} else {
							$product_id = sanitize_text_field( $product_id );
						}
						if ( ! empty( $product_id ) ) {
							$reward->update_meta( 'free_product_id', $product_id );
							$product_name = '';
							if ( method_exists( $integration, 'get_product' ) ) {
								$product_data = $integration->get_product( $product_id );
								if ( is_array( $product_data ) && ! empty( $product_data['name'] ) ) {
									$product_name = (string) $product_data['name'];
								}
							}
							if ( '' !== $product_name ) {
								$reward->update_meta( 'free_product_name', $product_name );
							}
						}
					}

					// Store product_value for display purposes (shows "$X off" instead of "Free" in the widget).
					$product_value = $args['coupon_data']['product_value'] ?? '';
					if ( '' !== $product_value && floatval( $product_value ) > 0 ) {
						$reward->update_meta( 'product_value', floatval( $product_value ) );
					}

					// Store minimum_order_amount for display in coupon_terms.
					$minimum_order_amount = $args['coupon_data']['minimum_order_amount'] ?? 0;
					if ( floatval( $minimum_order_amount ) > 0 ) {
						$reward->update_meta( 'minimum_order_amount', floatval( $minimum_order_amount ) );
					}

					// Store reward_description (custom name) for display in the widget.
					$reward_description = $args['action_config']['reward_description'] ?? '';
					if ( ! empty( $reward_description ) ) {
						$reward->update_meta( 'reward_description', sanitize_text_field( $reward_description ) );
					}
				}

				// Send redemption notification email.
				$mailer = $this->mailer_factory->get_mailer( RewardRedemptionEmail::class );

				if ( $mailer ) {
					// Compute unified display title: custom title > specific cases (free product name) > formatted amount.
					$custom_title           = $args['coupon_data']['description'] ?? '';
					$reward_amount_for_type = $args['coupon_data']['reward_amount'] ?? 0;

					$display_title = '';
					if ( ! empty( trim( (string) $custom_title ) ) ) {
						$display_title = (string) $custom_title;
					} elseif (
					'free_product' === $args['spend_action_type'] &&
					! empty( $args['coupon_data']['products_included'] ) &&
					1 === count( (array) $args['coupon_data']['products_included'] )
					) {
						$product_id = (int) $args['coupon_data']['products_included'][0];
						if ( $product_id > 0 && method_exists( $integration, 'get_product' ) ) {
							$product      = $integration->get_product( $product_id );
							$product_name = is_array( $product ) ? ( $product['name'] ?? '' ) : '';
							if ( ! empty( $product_name ) ) {
								/* translators: %s is the product name */
								$display_title = sprintf( __( 'Free %s', 'rewardswp' ), $product_name );
							}
						}
					}
					if ( '' === $display_title ) {
						$display_title = get_reward_display_title_from_values(
							'',
							$args['spend_action_type'],
							(float) $reward_amount_for_type
						);
					}

					// We now rely solely on a single display title for emails.
					// Get shop URL from integration for the email button.
					$integration = $this->container->get( \Awesomemotive\Rewardswp\Interfaces\IntegrationInterface::class );
					$shop_url    = method_exists( $integration, 'get_store_url' ) ? $integration->get_store_url() : home_url();

					$mailer
					->set_recipient( $member->email )
					->set_data(
						[
							'reward_title'              => $display_title,
							'coupon_code'               => $args['coupon_data']['code'],
							'points_redeemed_formatted' => format_points_with_label( $args['points_to_deduct'] ),
							'current_balance_formatted' => format_points_with_label( $member->points_balance ),
							'site_url'                  => home_url(),
							'shop_url'                  => $shop_url,
						]
					)
					->send();
				}
			}

			// Return coupon details for success screen (no auto-apply).
			$coupon_code = $args['coupon_data']['code'];

			// Check if limit is now reached after this redemption (for UI update).
			$limit_reached = false;
			if ( 'free_product' === $args['spend_action_type'] && ! empty( $args['spend_action_id'] ) ) {
				$redemption_limit = $args['coupon_data']['redemption_limit'] ?? '1';

				if ( 'unlimited' !== $redemption_limit ) {
					$current_redemptions = $this->count_member_redemptions_for_action(
						$args['member_id'],
						$args['spend_action_id']
					);

					$limit_reached = $current_redemptions >= (int) $redemption_limit;
				}
			}

			if ( ! is_wp_error( $reward ) ) {
				/**
				 * Fires after points are redeemed for a coupon.
				 *
				 * @since 1.3.0
				 *
				 * @param Point  $point  The spending points entry.
				 * @param Reward $reward The reward created from redemption.
				 * @param Member $member The member who redeemed.
				 */
				do_action( 'rewardswp_points_redeemed', $point, $reward, $member );
			}

			return [
				'coupon_code'   => $coupon_code,
				'new_balance'   => $member->points_balance,
				'coupon_id'     => $coupon_result,
				'points_spent'  => $args['points_to_deduct'],
				'reward_id'     => is_wp_error( $reward ) ? null : $reward->id,
				'expiry_date'   => $args['coupon_data']['expiry_date'] ?? null,
				'limit_reached' => $limit_reached,
				'action_id'     => $args['spend_action_id'] ?? '',
			];

		} catch ( Exception $e ) {
			return new WP_Error(
				'redemption_failed',
				sprintf(
					/* translators: %s: error message */
					esc_html__( 'Redemption failed: %s', 'rewardswp' ),
					$e->getMessage()
				)
			);
		}
	}

	/**
	 * Build coupon data array from action configuration.
	 *
	 * @since 1.1.0
	 *
	 * @param array  $action_config The spend action configuration.
	 * @param string $spend_action_type The type of spend action.
	 * @param float  $reward_amount The calculated reward amount.
	 * @param object $member The member object.
	 *
	 * @return array The coupon data array.
	 */
	public function build_coupon_data( array $action_config, string $spend_action_type, float $reward_amount, object $member ): array {
		// Determine coupon prefix.
		if ( empty( $action_config['coupon_prefix'] ) ) {
			// Get currency plural for default prefix only when needed.
			$currency_plural = $this->get_currency_plural();
			$coupon_prefix   = sanitize_title( $currency_plural ) . '-';
		} else {
			$coupon_prefix = $action_config['coupon_prefix'];
		}

		// Get coupon duration from service (handles Lite/Pro logic).
		$coupon_duration  = $this->get_coupon_duration( $action_config );
		$expiry_timestamp = null;

		// Handle duration formats.
		if ( 'never' === $coupon_duration ) {
			// Never expires (null).
			$expiry_timestamp = null;
		} elseif ( is_string( $coupon_duration ) && str_starts_with( $coupon_duration, '+' ) ) {
			// String format: '+30 days', '+3 months', '+1 year', etc.
			$current_time     = get_current_time();
			$expiry_timestamp = add_time_to_date( $coupon_duration, $current_time );
		}
		// If invalid format or empty, defaults to null (never expires).

		// Handle free product special case.
		if ( 'free_product' === $spend_action_type ) {
			// Use product_value as fixed amount if configured (protects against price increases).
			// If not configured, fall back to 100% discount.
			$product_value = isset( $action_config['product_value'] ) && '' !== $action_config['product_value']
				? floatval( $action_config['product_value'] )
				: 0;

			if ( $product_value > 0 ) {
				// Fixed amount coupon - limits discount to the configured value.
				$coupon_type   = 'flat';
				$coupon_amount = $product_value;
			} else {
				// Fallback: 100% discount (no protection).
			$coupon_type   = 'percent';
				$coupon_amount = 100;
			}
		} else {
			$coupon_type   = 'percentage_discount' === $spend_action_type ? 'percent' : 'flat';
			$coupon_amount = $reward_amount;
		}

		// Base coupon data.
		$coupon_data = [
			'name'                 => sprintf(
				/* translators: %s: spend action type */
				__( 'Points Redemption - %s', 'rewardswp' ),
				ucwords( str_replace( '_', ' ', $spend_action_type ) )
			),
			'code'                 => generate_custom_hash( $member->email . microtime( true ), $coupon_prefix ),
			'type'                 => $coupon_type,
			'reward_amount'        => $coupon_amount,
			'description'          => isset( $action_config['custom_title'] ) && ! empty( trim( $action_config['custom_title'] ) )
				? $action_config['custom_title']
				: '', // Custom title from spend action config.
			'expiry_date'          => $expiry_timestamp ? substr( $expiry_timestamp, 0, 10 ) : null, // Extract date part (Y-m-d) for WooCommerce coupon, null = never expires.
			'expiry_timestamp'     => $expiry_timestamp, // Store full timestamp for reward expiration, null = never expires.
			// Free product uses 'minimum_order_value', other types use 'minimum_spent'.
			'minimum_order_amount' => 'free_product' === $spend_action_type
				? ( isset( $action_config['minimum_order_value'] ) ? floatval( $action_config['minimum_order_value'] ) : 0 )
				: ( isset( $action_config['minimum_spent'] ) ? floatval( $action_config['minimum_spent'] ) : 0 ),
			'products_included'    => isset( $action_config['products_included'] )
				? array_column( (array) $action_config['products_included'], 'id' )
				: [],
		];

		// Add free shipping flag for free_shipping type.
		if ( 'free_shipping' === $spend_action_type ) {
			$coupon_data['free_shipping'] = true;
		}

		// Add redemption limit and product_value for free_product type.
		if ( 'free_product' === $spend_action_type ) {
			if ( isset( $action_config['redemption_limit'] ) ) {
				$coupon_data['redemption_limit'] = $action_config['redemption_limit'];
			}
			// Store product_value for display purposes in the widget.
			if ( isset( $action_config['product_value'] ) && '' !== $action_config['product_value'] ) {
				$coupon_data['product_value'] = floatval( $action_config['product_value'] );
			}
		}

		return $coupon_data;
	}

	/**
	 * Get points currency singular label.
	 *
	 * Lite stub: Always returns 'Point'.
	 * Pro override: Returns customized value from settings.
	 *
	 * @since 1.1.0
	 *
	 * @return string The singular currency label.
	 */
	public function get_currency_singular(): string {
		return __( 'Point', 'rewardswp' );
	}

	/**
	 * Get points currency plural label.
	 *
	 * Lite stub: Always returns 'Points'.
	 * Pro override: Returns customized value from settings.
	 *
	 * @since 1.1.0
	 *
	 * @return string The plural currency label.
	 */
	public function get_currency_plural(): string {
		return __( 'Points', 'rewardswp' );
	}

	/**
	 * Get coupon duration for a spend action.
	 *
	 * Lite stub: Returns default duration ('never') for Lite users.
	 * Pro override: Returns customized value from settings if user has access.
	 *
	 * @since 1.1.0
	 *
	 * @param array $action_config The spend action configuration.
	 *
	 * @return string The coupon duration (e.g., 'never', '+30 days', '+3 months').
	 */
	public function get_coupon_duration( array $action_config ): string {
		return self::DEFAULT_COUPON_DURATION;
	}

	/**
	 * Get member points expiring balance.
	 *
	 * Lite stub: Points expiration is a Pro feature.
	 * Returns 0 for Lite users.
	 *
	 * @since 1.0.6
	 *
	 * @param int $member_id   The member ID.
	 * @param int $days_before Number of days before expiration to look for.
	 *
	 * @return int Always returns 0 in Lite version.
	 */
	public function get_member_points_expiring_balance( int $member_id, int $days_before ): int {
		// Lite: Points expiration not available.
		return 0;
	}

	/**
	 * Count how many times a member has redeemed a specific spend action.
	 *
	 * Uses the rewards table with meta_query to count redemptions.
	 *
	 * @since 1.2.0
	 *
	 * @param int    $member_id       The member ID.
	 * @param string $spend_action_id The unique spend action ID.
	 *
	 * @return int Number of times the member has redeemed this action.
	 */
	public function count_member_redemptions_for_action( int $member_id, string $spend_action_id ): int {
		return $this->db_handler->count(
			$this->rewards_table->get_prefixed_table_name(),
			[
				'advocate_id' => $member_id,
				'reward_type' => 'free_product',
				'status'      => [ 'issued', 'applied' ],
				'meta_query'  => [
					'metatable'           => $this->rewards_table->get_prefixed_meta_table_name(),
					'relationship_column' => $this->rewards_table->get_meta_table_relationship_field_name(),
					[
						'key'   => 'spend_action_id',
						'value' => $spend_action_id,
					],
				],
			]
		);
	}
}
