<?php
/**
 * Abstract integration that supports coupon operations.
 *
 * @package RewardsWP
 */

namespace Awesomemotive\Rewardswp\Abstracts;

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

use Awesomemotive\Rewardswp\Factories\MailerFactory;
use Awesomemotive\Rewardswp\Factories\RewardTypesFactory;
use Awesomemotive\Rewardswp\Interfaces\IntegrationInterface;
use Awesomemotive\Rewardswp\Interfaces\IntegrationWithCouponInterface;
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\PointsService;
use Awesomemotive\Rewardswp\Services\RewardsService;
use Awesomemotive\Rewardswp\Services\TrackingService;
use Awesomemotive\Rewardswp\Settings\Options\Points\EarnActionsOption;
use Exception;
use function Awesomemotive\Rewardswp\Helpers\count_points_entries;
use function Awesomemotive\Rewardswp\Helpers\generate_custom_hash;
use function Awesomemotive\Rewardswp\Helpers\get_option_value;
use function Awesomemotive\Rewardswp\Helpers\get_points_entries;
use function Awesomemotive\Rewardswp\Helpers\get_default_points_base_amount;

/**
 * Base integration class for coupon-capable integrations.
 */
abstract class AbstractIntegrationWithCoupon implements IntegrationInterface, IntegrationWithCouponInterface {
	/**
	 * Error code for non-compatible coupon integrations.
	 *
	 * @since 1.0.0
	 *
	 * @var string
	 */
	const NON_COMPATIBLE_INTEGRATION = 'integration-not-compatible-with-coupons';

	/**
	 * Reward types factory instance.
	 *
	 * @since 1.0.0
	 *
	 * @var RewardTypesFactory
	 */
	protected RewardTypesFactory $reward_types_factory;

	/**
	 * Rewards service instance.
	 *
	 * @since 1.0.0
	 *
	 * @var RewardsService
	 */
	protected RewardsService $rewards_service;

	/**
	 * Tracking service instance.
	 *
	 * @since 1.0.0
	 *
	 * @var TrackingService
	 */
	protected TrackingService $tracking_service;

	/**
	 * Assets service instance.
	 *
	 * @since 1.0.0
	 *
	 * @var AssetsService
	 */
	protected AssetsService $assets_service;

	/**
	 * Mailer factory instance.
	 *
	 * @since 1.0.0
	 *
	 * @var MailerFactory
	 */
	protected MailerFactory $mailer_factory;

	/**
	 * Guest member service instance.
	 *
	 * @since 1.0.0
	 *
	 * @var GuestMemberService
	 */
	protected GuestMemberService $guest_member_service;

	/**
	 * Points service instance.
	 *
	 * @since 1.0.1
	 * @var PointsService
	 */
	protected PointsService $points_service;

	/**
	 * Constructor.
	 *
	 * @since 1.0.1
	 *
	 * @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.
	 */
	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
	) {
		$this->reward_types_factory = $reward_types_factory;
		$this->rewards_service      = $rewards_service;
		$this->tracking_service     = $tracking_service;
		$this->assets_service       = $assets_service;
		$this->mailer_factory       = $mailer_factory;
		$this->guest_member_service = $guest_member_service;
		$this->points_service       = $points_service;
	}

	/**
	 * Creates and registers a unique coupon for the customer.
	 *
	 * Generates a coupon code based on a unique identifier and an optional prefix.
	 *
	 * @since 1.0.0
	 *
	 * @param string $identifier     Unique identifier for the customer (e.g., email or user ID), used to increase the uniqueness of the coupon hash.
	 * @param string $coupon_prefix  Optional prefix for the coupon code.
	 * @param array  $coupon_options Optional configuration for the coupon (e.g., discount type, code).
	 *
	 * @return string|null           Returns the generated coupon code, or null if coupon creation fails.
	 *
	 * @throws Exception             Throws an exception if there is an error during coupon creation.
	 */
	public function generate_coupon( string $identifier, string $coupon_prefix = '', array $coupon_options = [] ): ?string {
		// Create a coupon using some parameters to make more unique.
		$coupon_code = generate_custom_hash(
			$identifier . microtime( true ),
			$coupon_prefix
		);

		$coupon = $this->create_coupon(
			$this->map_coupon_data(
				array_merge(
					$coupon_options,
					[
						'code' => $coupon_code,
					]
				)
			)
		);

		// For any error, except by non-compatible with coupon integration, throw an error and prevent to continue.
		if ( is_wp_error( $coupon ) && $coupon->get_error_code() !== self::NON_COMPATIBLE_INTEGRATION ) {
			throw new Exception(
				esc_html__( 'No integrations are currently configured on this site. Please contact the site administrator for further assistance.', 'rewardswp' )
			);
		}

		return $coupon_code;
	}

	/**
	 * Get the order total used for calculating purchase points.
	 *
	 * Each integration implements this to return the order subtotal excluding
	 * tax and shipping (the industry standard for loyalty programs).
	 *
	 * @since 1.4.2
	 *
	 * @param mixed $order The order object (WC_Order or EDD_Payment).
	 *
	 * @return float
	 */
	abstract protected function get_order_total_for_points( $order ): float;

	/**
	 * Calculate the order total for points, applying the filter hook.
	 *
	 * @since 1.4.2
	 *
	 * @param mixed $order The order object (WC_Order or EDD_Payment).
	 *
	 * @return float
	 */
	protected function calculate_order_total_for_points( $order ): float {
		$total = $this->get_order_total_for_points( $order );

		/**
		 * Filter the order total used for points calculation.
		 *
		 * By default, points are calculated from the order subtotal excluding tax and shipping.
		 * Use this filter to customize the calculation base.
		 *
		 * @since 1.4.2
		 *
		 * @param float $total The calculated order total.
		 * @param mixed $order The order object (WC_Order or EDD_Payment).
		 */
		$total = (float) apply_filters( 'rewardswp_order_total_for_points', $total, $order );

		return max( 0.0, $total );
	}

	/**
	 * Calculate the number of purchase points for a given order total.
	 *
	 * @since 1.4.2
	 *
	 * @param float $order_total The order total to calculate points from.
	 * @param array $earn_actions The earn actions settings.
	 *
	 * @return int
	 */
	protected function calculate_purchase_points( float $order_total, array $earn_actions ): int {
		$base_amount = $earn_actions['purchase']['base_amount'] ?? get_default_points_base_amount();

		return (int) floor( ( $order_total / $base_amount ) * $earn_actions['purchase']['points_per_dollar'] );
	}

	/**
	 * Award purchase points to a newly converted member if they haven't received them yet.
	 *
	 * This method is called after a customer is converted into a member to ensure
	 * they receive purchase points even if they weren't a member when the order was processed.
	 *
	 * @since 1.1.0
	 *
	 * @param Member $member   The newly converted member.
	 * @param int    $order_id The order/payment ID.
	 * @param float  $order_total The order total amount.
	 *
	 * @return void
	 */
	protected function maybe_award_purchase_points_after_conversion( Member $member, int $order_id, float $order_total ): void {
		$earn_actions = get_option_value( EarnActionsOption::class );

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

		// Check if points were already awarded for this order to avoid duplicates.
		$existing_points = count_points_entries(
			[
				'member_id'   => $member->id,
				'source_type' => 'purchase',
				'source_id'   => $order_id,
			]
		);

		if ( $existing_points > 0 ) {
			return; // Points already awarded for this order.
		}

		// Calculate points based on order total.
		$points = $this->calculate_purchase_points( $order_total, $earn_actions );

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

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

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

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

	/**
	 * Award first order bonus points to a newly converted member if they haven't received them yet.
	 *
	 * This method is called after a customer is converted into a member to ensure
	 * they receive first order bonus points even if they weren't a member when the order was processed.
	 *
	 * @since 1.1.0
	 *
	 * @param Member $member   The newly converted member.
	 * @param int    $order_id The order/payment ID.
	 *
	 * @return void
	 */
	protected function maybe_award_first_order_points_after_conversion( Member $member, int $order_id ): void {
		$earn_actions = get_option_value( EarnActionsOption::class );

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

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

		// Check if points were already awarded for this order to avoid duplicates.
		$existing_points = count_points_entries(
			[
				'member_id'   => $member->id,
				'source_type' => 'first_order',
				'source_id'   => $order_id,
			]
		);

		if ( $existing_points > 0 ) {
			return; // Points already awarded for this order.
		}

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

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

		// Create points entry.
		$points_entry = [
			'member_id'   => absint( $member->id ),
			'points'      => $points,
			'type'        => 'earned',
			'source_type' => 'first_order',
			'source_id'   => $order_id,
			'status'      => 'approved',
			'note_public' => sprintf(
				/* translators: %d: order number */
				esc_html__( 'Welcome bonus for your first order (#%d)', 'rewardswp' ),
				$order_id
			),
		];

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

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

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

	/**
	 * Get all points earned from a specific order.
	 *
	 * @since 1.1.0
	 *
	 * @param int $order_id The order/payment ID.
	 *
	 * @return Point[] Array of Point objects.
	 */
	protected function get_points_earned_from_order( int $order_id ): array {
		return get_points_entries(
			[
				'source_id' => $order_id,
				'type'      => 'earned',
				'status'    => 'approved',
			],
			9999,
			1
		);
	}

	/**
	 * Create a deduction point entry for refunded points.
	 *
	 * @since 1.1.0
	 *
	 * @param Point $original_point The original point entry to deduct.
	 * @param int   $order_id       The refunded order/payment ID.
	 *
	 * @return Point|null The created deduction point entry.
	 */
	protected function create_refund_deduction_entry( Point $original_point, int $order_id ): ?Point {
		// Verify if a deduction entry already exists for this specific point to prevent duplicates.
		$existing = get_points_entries(
			[
				'member_id'   => $original_point->member_id,
				'source_type' => 'refund',
				'source_id'   => $order_id,
				'type'        => 'spent',
				'status'      => 'approved',
				// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Necessary to prevent duplicate refund deductions. Query is limited to 1 result and only runs during refund processing.
				'meta_query'  => [
					[
						'key'   => 'refunded_point_id',
						'value' => $original_point->id,
					],
				],
			],
			1,
			1
		);

		if ( ! empty( $existing ) ) {
			return null; // Already processed for this specific point.
		}

		// Create deduction entry.
		$deduction_entry = [
			'member_id'   => $original_point->member_id,
			'points'      => absint( $original_point->points ), // Positive value for type='spent'.
			'type'        => 'spent',
			'source_type' => 'refund',
			'source_id'   => $order_id,
			'status'      => 'approved',
			'note_public' => sprintf(
				/* translators: %d: order ID */
				esc_html__( 'Points deducted due to refund of order #%d', 'rewardswp' ),
				$order_id
			),
		];

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

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

		return $point;
	}

	/**
	 * Get purchase points earned from a specific order.
	 *
	 * This method returns only points with source_type 'purchase',
	 * excluding 'first_order' and other types.
	 *
	 * @since 1.1.0
	 *
	 * @param int $order_id The order/payment ID.
	 *
	 * @return Point[] Array of Point objects.
	 */
	protected function get_purchase_points_from_order( int $order_id ): array {
		return get_points_entries(
			[
				'source_id'   => $order_id,
				'source_type' => 'purchase',
				'type'        => 'earned',
				'status'      => 'approved',
			],
			9999,
			1
		);
	}

	/**
	 * Create a partial refund deduction entry for proportionally deducted points.
	 *
	 * Unlike full refund deductions, partial refunds deduct a specific amount
	 * of points proportional to the refund value.
	 *
	 * @since 1.1.0
	 *
	 * @param Point $original_point   The original point entry to partially deduct from.
	 * @param int   $order_id         The order/payment ID.
	 * @param int   $refund_id        The refund ID (for tracking).
	 * @param int   $points_to_deduct The number of points to deduct.
	 *
	 * @return Point|null The created deduction point entry.
	 */
	protected function create_partial_refund_deduction_entry( Point $original_point, int $order_id, int $refund_id, int $points_to_deduct ): ?Point {
		// Verify if a deduction entry already exists for this specific refund to prevent duplicates.
		$existing = get_points_entries(
			[
				'member_id'   => $original_point->member_id,
				'source_type' => 'partial_refund',
				'source_id'   => $order_id,
				'type'        => 'spent',
				'status'      => 'approved',
				// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Necessary to prevent duplicate refund deductions. Query is limited to 1 result and only runs during refund processing.
				'meta_query'  => [
					[
						'key'   => 'refund_id',
						'value' => $refund_id,
					],
				],
			],
			1,
			1
		);

		if ( ! empty( $existing ) ) {
			return null; // Already processed for this specific refund.
		}

		// Ensure we don't deduct more than the original points.
		$points_to_deduct = min( $points_to_deduct, absint( $original_point->points ) );

		if ( $points_to_deduct <= 0 ) {
			return null;
		}

		// Create deduction entry.
		$deduction_entry = [
			'member_id'   => $original_point->member_id,
			'points'      => $points_to_deduct,
			'type'        => 'spent',
			'source_type' => 'partial_refund',
			'source_id'   => $order_id,
			'status'      => 'approved',
			'note_public' => sprintf(
				/* translators: %d: order ID */
				esc_html__( 'Points deducted due to partial refund of order #%d', 'rewardswp' ),
				$order_id
			),
		];

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

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

		return $point;
	}

	/**
	 * Reactivate a reward that was used in a refunded order.
	 *
	 * Note: This method only reverts the reward status. The coupon reset
	 * in the platform must be done by specific methods in each integration.
	 *
	 * @param Reward $reward The reward to reactivate.
	 *
	 * @return void
	 * @throws Exception
	 * @since 1.1.0
	 */
	protected function reactivate_reward( Reward $reward ): void {
		// Check if already reactivated.
		if ( 'redeemed' !== $reward->status ) {
			return;
		}

		// Revert status.
		$reward->status     = 'issued';
		$reward->applied_at = null;
		$reward->update();
	}
}
