<?php
/**
 * Class WC_Payments_PM_Promotions_Service
 *
 * @package WooCommerce\Payments
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}

use WCPay\Constants\Track_Events;
use WCPay\Core\Server\Request;
use WCPay\Logger;

/**
 * Class handling WooPayments payment method promotions related business logic.
 */
class WC_Payments_PM_Promotions_Service {

	/**
	 * Transient key for caching promotions.
	 *
	 * @var string
	 */
	const PROMOTIONS_CACHE_KEY = 'wcpay_pm_promotions';

	/**
	 * Option key for promotion dismissals.
	 * Stores array of [id => timestamp].
	 *
	 * @var string
	 */
	const PROMOTION_DISMISSALS_OPTION = '_wcpay_pm_promotion_dismissals';

	/**
	 * The memoized raw promotions to avoid fetching multiple times during a request.
	 *
	 * @var array|null
	 */
	private $promotions_memo = null;

	/**
	 * The memoized visible promotions (filtered and normalized) for the current request.
	 * False means not yet computed, null means computed with no results, array means has results.
	 *
	 * @var array|null|false
	 */
	private $visible_promotions_memo = false;

	/**
	 * WC_Payment_Gateway_WCPay instance.
	 *
	 * @var WC_Payment_Gateway_WCPay|null
	 */
	private $gateway;

	/**
	 * WC_Payments_Account instance.
	 *
	 * @var WC_Payments_Account|null
	 */
	private $account;

	/**
	 * Class constructor.
	 *
	 * @param WC_Payment_Gateway_WCPay|null $gateway Optional gateway instance.
	 * @param WC_Payments_Account|null      $account Optional account instance.
	 */
	public function __construct( ?WC_Payment_Gateway_WCPay $gateway = null, ?WC_Payments_Account $account = null ) {
		$this->gateway = $gateway;
		$this->account = $account;
	}

	/**
	 * Initialise class hooks.
	 *
	 * @return void
	 */
	public function init_hooks() {
		// Hooks can be added here if needed in the future.
	}

	/**
	 * Clear the promotions cache.
	 *
	 * @return void
	 */
	public function clear_cache(): void {
		delete_transient( self::PROMOTIONS_CACHE_KEY );
		$this->reset_memo();
	}

	/**
	 * Reset the memoized promotions.
	 *
	 * This is useful for testing purposes.
	 *
	 * @return void
	 */
	public function reset_memo(): void {
		$this->promotions_memo         = null;
		$this->visible_promotions_memo = false;
	}

	/**
	 * Get promotions that should be visible to the user.
	 *
	 * @return array|null The promotions or null if there is no eligible promotion.
	 */
	public function get_visible_promotions(): ?array {
		// Promotions are only visible to users who can manage WooCommerce (aka act on the promotions).
		if ( ! current_user_can( 'manage_woocommerce' ) ) {
			return null;
		}

		// Return memoized result if available (false means not yet computed).
		if ( false !== $this->visible_promotions_memo ) {
			return $this->visible_promotions_memo;
		}

		$promotions = $this->get_promotions();

		// Validate each promotion's structure.
		$promotions = array_filter(
			$promotions,
			function ( $promotion ) {
				return $this->validate_promotion( $promotion );
			}
		);

		// Filter promotions by dismissal status, PM validity, enabled status, and promo_id uniqueness.
		$promotions = $this->filter_promotions( $promotions );

		// Normalize the promotions (apply fallbacks, derive fields).
		$promotions = $this->normalize_promotions( $promotions );

		// Return early if there are no promotions left.
		if ( empty( $promotions ) ) {
			$this->visible_promotions_memo = null;
			return null;
		}

		$this->visible_promotions_memo = array_values( $promotions );
		return $this->visible_promotions_memo;
	}

	/**
	 * Fetches and caches eligible promotions from the WooPayments API.
	 *
	 * @return array List of eligible promotions.
	 */
	private function get_promotions(): array {
		// Check memoized data first.
		if ( null !== $this->promotions_memo ) {
			return $this->promotions_memo;
		}

		// Try to use the cached data.
		$cache = get_transient( self::PROMOTIONS_CACHE_KEY );

		// If the cached data is not expired, and it's a WP_Error,
		// it means there was an API error previously, and we should not retry just yet.
		if ( is_wp_error( $cache ) ) {
			// Initialize the in-memory cache and return it.
			$this->promotions_memo = [];

			return $this->promotions_memo;
		}

		// Gather the store context data.
		$store_context = [
			// All the PM promotions dismissals.
			'dismissals' => $this->get_promotion_dismissals(),
			// Store locale, e.g. `en_US`.
			'locale'     => get_locale(),
		];

		// Fingerprint the store context through a hash of certain entries.
		$store_context_hash = $this->generate_context_hash( $store_context );

		// Use the transient cached data if it exists, it is not expired,
		// and the store context hasn't changed since we last requested from the WooPayments API (based on context hash).
		if ( false !== $cache
			&& ! empty( $cache['context_hash'] ) && is_string( $cache['context_hash'] )
			&& hash_equals( $store_context_hash, $cache['context_hash'] ) ) {

			// We have a store context hash, and it matches with the current context one.
			// We can use the cached data.
			$this->promotions_memo = $cache['promotions'] ?? [];

			return $this->promotions_memo;
		}

		// By this point, we have an expired transient or the store context has changed.
		// Query for promotions by calling the WooPayments API.
		$wcpay_request = Request\Get_PM_Promotions::create();
		$wcpay_request->set_store_context_params( $store_context );
		$response = $wcpay_request->handle_rest_request();

		// Return early if there is an error, waiting 6 hours before the next attempt.
		if ( is_wp_error( $response ) ) {
			// Store a trimmed down, lightweight error.
			/**
			 * Type hint for static analysis.
			 *
			 * @var WP_Error $response
			 */
			$error = new \WP_Error(
				$response->get_error_code(),
				$response->get_error_message(),
				wp_remote_retrieve_response_code( $response )
			);
			// Store the error in the transient so we know this is due to an API error.
			set_transient( self::PROMOTIONS_CACHE_KEY, $error, HOUR_IN_SECONDS * 6 );

			// Initialize the in-memory cache and return it.
			$this->promotions_memo = [];

			return $this->promotions_memo;
		}

		$cache_for = wp_remote_retrieve_header( $response, 'cache-for' );
		// Initialize the in-memory cache.
		$this->promotions_memo = [];

		if ( 200 === wp_remote_retrieve_response_code( $response ) ) {
			// Decode the results, falling back to an empty array.
			$results = json_decode( wp_remote_retrieve_body( $response ), true ) ?? [];

			$this->promotions_memo = $results;
		}

		// Skip transient cache if `cache-for` header equals zero.
		if ( '0' === $cache_for ) {
			// If we have a transient cache that is not expired, delete it so there are no leftovers.
			if ( false !== $cache ) {
				delete_transient( self::PROMOTIONS_CACHE_KEY );
			}

			return $this->promotions_memo;
		}

		// Store promotions in the transient cache (together with the context hash) for the given number of seconds
		// or 1 day in seconds. Also attach a timestamp to the transient data so we know when we last fetched.
		set_transient(
			self::PROMOTIONS_CACHE_KEY,
			[
				'promotions'   => $this->promotions_memo,
				'context_hash' => $store_context_hash,
				'timestamp'    => time(),
			],
			! empty( $cache_for ) ? (int) $cache_for : DAY_IN_SECONDS
		);

		return $this->promotions_memo;
	}

	/**
	 * Activate a promotion.
	 *
	 * This will:
	 * 1. Send a request to the server to apply the promotion discount.
	 * 2. Enable the payment method for checkout.
	 * Activating a promotion implies acceptance of terms and conditions for the promotion.
	 *
	 * @param string $id The promotion unique identifier (e.g., 'klarna-2026-promo__spotlight').
	 *
	 * @return bool True on success, false on failure.
	 */
	public function activate_promotion( string $id ): bool {
		// Find the promotion to get the payment method.
		$promotion = $this->find_promotion_by_id( $id );
		if ( null === $promotion ) {
			return false;
		}

		$payment_method_id = $promotion['payment_method'] ?? '';
		if ( empty( $payment_method_id ) ) {
			return false;
		}

		// Send request to server to apply the promotion discount.
		// The server should also handle capability requesting if it is not already requested.
		// This way we can keep things in sync and avoid applying discounts without having the capability requested.
		$wcpay_request = Request\Activate_PM_Promotion::create( $id );
		$wcpay_request->assign_hook( 'wcpay_activate_pm_promotion_request' );
		$response = $wcpay_request->handle_rest_request();
		if ( is_wp_error( $response ) ) {
			$error_message = sprintf(
				'Server activation request failed [%s]: %s',
				$response->get_error_code(),
				$response->get_error_message()
			);
			return $this->handle_promotion_activation_failure( $payment_method_id, $promotion, $error_message );
		}

		// Mark the promotion as dismissed so it won't be shown again.
		// Do it before the payment method gateway enabling in case that fails.
		$this->mark_promotion_dismissed( $id );

		// Enable the payment method for checkout.
		if ( ! $this->enable_payment_method_gateway( $payment_method_id, $promotion ) ) {
			return false;
		}

		// Clear the promotions cache to ensure fresh data on next fetch.
		$this->clear_cache();
		// Clear the account cache.
		if ( null !== $this->account ) {
			$this->account->clear_cache();
		}

		// Track successful activation.
		$this->tracks_event(
			Track_Events::PAYMENT_METHOD_PROMOTION_ACTIVATED,
			[
				'payment_method_id' => $payment_method_id,
				'promo_id'          => $promotion['promo_id'] ?? null,
				'promo_instance_id' => $id,
			]
		);

		return true;
	}

	/**
	 * Find a promotion by its ID.
	 *
	 * @param string $id The promotion ID (e.g., 'klarna-2026-promo__spotlight').
	 *
	 * @return array|null The promotion data or null if not found.
	 */
	private function find_promotion_by_id( string $id ): ?array {
		$promotions = $this->get_visible_promotions();

		if ( null === $promotions ) {
			return null;
		}

		foreach ( $promotions as $promotion ) {
			if ( isset( $promotion['id'] ) && $promotion['id'] === $id ) {
				return $promotion;
			}
		}

		return null;
	}

	/**
	 * Find a promotion for a payment method.
	 *
	 * We will return the first promotion found for the payment method.
	 *
	 * @param string $payment_method_id The payment method ID (e.g., 'klarna').
	 *
	 * @return array|null The promotion data or null if not found.
	 */
	private function find_promotion_by_payment_method( string $payment_method_id ): ?array {
		$promotions = $this->get_visible_promotions();

		if ( null === $promotions ) {
			return null;
		}

		foreach ( $promotions as $promotion ) {
			if ( isset( $promotion['payment_method'] ) && $promotion['payment_method'] === $payment_method_id ) {
				return $promotion;
			}
		}

		return null;
	}

	/**
	 * Enable a payment method gateway.
	 *
	 * @param string $payment_method_id The payment method ID (e.g., 'klarna').
	 * @param array  $promotion         The promotion data associated with the payment method.
	 *
	 * @return bool True on success, false on failure.
	 */
	private function enable_payment_method_gateway( string $payment_method_id, array $promotion ): bool {
		$gateway = WC_Payments::get_payment_gateway_by_id( $payment_method_id );
		if ( ! $gateway ) {
			$this->log_gateway_error( $payment_method_id, 'payment gateway instance not available' );
			return false;
		}

		// Attempt to enable the gateway with exception handling.
		try {
			$gateway->enable();
		} catch ( \Exception $e ) {
			$this->log_gateway_error( $payment_method_id, $e->getMessage() );
			return false;
		}

		// Verify the gateway was actually enabled.
		if ( 'yes' !== $gateway->get_option( 'enabled' ) ) {
			$this->log_gateway_error( $payment_method_id, 'gateway enable() did not persist enabled state' );
			return false;
		}

		$pm_to_capability_key_map = $gateway->get_payment_method_capability_key_map();
		$this->tracks_event(
			Track_Events::PAYMENT_METHOD_ENABLED,
			[
				'payment_method_id' => $payment_method_id,
				'capability_id'     => $pm_to_capability_key_map[ $payment_method_id ] ?? null,
				'promo_id'          => $promotion['promo_id'] ?? null,
			]
		);

		// Synchronize enabled payment method IDs across all gateways.
		$this->sync_enabled_payment_method_across_gateways( $payment_method_id );

		return true;
	}

	/**
	 * Log a gateway error.
	 *
	 * @param string $payment_method_id The payment method ID.
	 * @param string $error_message     The error message.
	 */
	private function log_gateway_error( string $payment_method_id, string $error_message ): void {
		/* translators: 1: Payment method ID, 2: Error message */
		WC_Payments_Utils::log_to_wc(
			sprintf( 'Failed to enable payment method %1$s: %2$s', $payment_method_id, $error_message ),
			'warning'
		);
	}

	/**
	 * Synchronize enabled payment method ID across all gateways.
	 *
	 * @param string $payment_method_id The payment method ID to sync.
	 */
	private function sync_enabled_payment_method_across_gateways( string $payment_method_id ): void {
		$gateway_map = WC_Payments::get_payment_gateway_map();
		if ( empty( $gateway_map ) ) {
			return;
		}

		foreach ( $gateway_map as $payment_gateway ) {
			$enabled_pm_ids = $payment_gateway->get_upe_enabled_payment_method_ids();

			// Skip if already present or not a valid array.
			if ( ! is_array( $enabled_pm_ids ) || in_array( $payment_method_id, $enabled_pm_ids, true ) ) {
				continue;
			}

			$enabled_pm_ids[] = $payment_method_id;
			$result           = $payment_gateway->update_option( 'upe_enabled_payment_method_ids', $enabled_pm_ids );

			if ( false === $result ) {
				WC_Payments_Utils::log_to_wc(
					sprintf( 'Failed to sync payment method %s to gateway %s', $payment_method_id, get_class( $payment_gateway ) ),
					'warning'
				);
			}
		}
	}

	/**
	 * Activate any visible promotions for a payment method being enabled via settings.
	 *
	 * This method should be called BEFORE the payment method is enabled for checkout,
	 * as visible promotions are filtered out for already-enabled payment methods.
	 *
	 * Handles its own exception catching, logging, and tracking internally.
	 *
	 * @param string $payment_method_id The payment method ID (e.g., 'klarna').
	 * @param bool   $should_enable     Whether to enable the payment method for checkout.
	 *
	 * @return bool True if a promotion was activated, false otherwise.
	 */
	public function maybe_activate_promotion_for_payment_method( string $payment_method_id, bool $should_enable = false ): bool {
		$promotion = $this->find_promotion_by_payment_method( $payment_method_id );
		if ( null === $promotion ) {
			return false;
		}

		// Send request to server to apply the promotion discount.
		// The server should also handle capability requesting if it is not already requested.
		$wcpay_request = Request\Activate_PM_Promotion::create( $promotion['id'] );
		$wcpay_request->assign_hook( 'wcpay_activate_pm_promotion_request' );
		$response = $wcpay_request->handle_rest_request();
		if ( is_wp_error( $response ) ) {
			$error_message = sprintf(
				'Server activation request failed [%s]: %s',
				$response->get_error_code(),
				$response->get_error_message()
			);
			return $this->handle_promotion_activation_failure( $payment_method_id, $promotion, $error_message );
		}

		// Enable the payment method for checkout if requested.
		if ( $should_enable && ! $this->enable_payment_method_gateway( $payment_method_id, $promotion ) ) {
			return $this->handle_promotion_activation_failure( $payment_method_id, $promotion, 'Failed to enable payment method gateway' );
		}

		// Clear the promotions cache to ensure fresh data on next fetch.
		$this->clear_cache();
		// Clear the account cache.
		if ( null !== $this->account ) {
			$this->account->clear_cache();
		}

		// Track successful activation.
		$this->tracks_event(
			Track_Events::PAYMENT_METHOD_PROMOTION_ACTIVATED,
			[
				'payment_method_id' => $payment_method_id,
				'promo_id'          => $promotion['promo_id'] ?? null,
				// The `unique_promo_id` is excluded intentionally as it's not a reliable without a specific promo type.
			]
		);

		return true;
	}

	/**
	 * Handle promotion activation failure by logging and tracking.
	 *
	 * @param string $payment_method_id The payment method ID.
	 * @param array  $promotion         The promotion data.
	 * @param string $error_message     The error message.
	 *
	 * @return bool Always returns false.
	 */
	private function handle_promotion_activation_failure( string $payment_method_id, array $promotion, string $error_message ): bool {
		/* translators: 1: Payment method ID, 2: Error message */
		WC_Payments_Utils::log_to_wc(
			sprintf( 'Failed to activate promotion for payment method %1$s: %2$s', $payment_method_id, $error_message )
		);

		// Track the failure.
		$this->tracks_event(
			Track_Events::PAYMENT_METHOD_PROMOTION_ACTIVATION_FAILED,
			[
				'payment_method_id' => $payment_method_id,
				'promo_id'          => $promotion['promo_id'] ?? null,
			]
		);

		return false;
	}

	/**
	 * Dismiss a promotion.
	 *
	 * @param string $id The promotion unique identifier (e.g., 'klarna-2026-promo__spotlight').
	 *
	 * @return bool True if dismissed, false if already dismissed or error.
	 */
	public function dismiss_promotion( string $id ): bool {
		// Cannot dismiss a non-existing promotion.
		$promotion = $this->find_promotion_by_id( $id );
		if ( null === $promotion ) {
			return false;
		}

		if ( ! $this->mark_promotion_dismissed( $id ) ) {
			return false;
		}

		// Track dismissal event.
		$this->tracks_event(
			Track_Events::PAYMENT_METHOD_PROMOTION_DISMISSED,
			[
				'payment_method_id' => $promotion['payment_method'] ?? null,
				'promo_id'          => $promotion['promo_id'] ?? null,
				'promo_instance_id' => $id,
			]
		);

		// Reset memo to ensure fresh data on next access.
		// The context hash change will also invalidate the transient cache.
		$this->reset_memo();

		return true;
	}

	/**
	 * Mark a promotion as dismissed in local state.
	 *
	 * @param string $id The promotion unique identifier (e.g., 'klarna-2026-promo__spotlight').
	 *
	 * @return bool True if dismissed, false if already dismissed.
	 */
	private function mark_promotion_dismissed( string $id ): bool {
		// Don't dismiss if already dismissed.
		if ( $this->is_promotion_dismissed( $id ) ) {
			return false;
		}

		$dismissals        = $this->get_promotion_dismissals();
		$dismissals[ $id ] = time();

		return update_option( self::PROMOTION_DISMISSALS_OPTION, $dismissals, false );
	}

	/**
	 * Get all promotion dismissals.
	 *
	 * @return array Associative array of [id => timestamp].
	 */
	public function get_promotion_dismissals(): array {
		return get_option( self::PROMOTION_DISMISSALS_OPTION, [] );
	}

	/**
	 * Check if a promotion has been dismissed.
	 *
	 * Being dismissed means having an entry in the dismissals option with a timestamp into the past.
	 *
	 * @param string $id The promotion unique identifier.
	 *
	 * @return bool True if dismissed, false otherwise.
	 */
	public function is_promotion_dismissed( string $id ): bool {
		$dismissals = $this->get_promotion_dismissals();

		return isset( $dismissals[ $id ] ) && is_int( $dismissals[ $id ] ) && $dismissals[ $id ] > 0 && $dismissals[ $id ] <= time();
	}

	/**
	 * Check whether the promotion data is valid.
	 * Validates required fields based on promotion type.
	 *
	 * @param mixed $promotion_data The promotion data.
	 *
	 * @return bool Whether the promotion data is valid.
	 */
	private function validate_promotion( $promotion_data ): bool {
		if ( ! is_array( $promotion_data ) || empty( $promotion_data ) ) {
			return false;
		}

		// Required fields for all promotions.
		$required_fields = [ 'id', 'promo_id', 'payment_method', 'type', 'title', 'description', 'tc_url' ];

		foreach ( $required_fields as $field ) {
			if ( ! isset( $promotion_data[ $field ] ) || ! is_string( $promotion_data[ $field ] ) ) {
				return false;
			}
		}

		// Validate type is supported.
		$valid_types = [ 'spotlight', 'badge' ];
		if ( ! in_array( $promotion_data['type'], $valid_types, true ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Generate a hash from the store context data.
	 *
	 * @param array $context The store context data.
	 *
	 * @return string The context hash.
	 */
	private function generate_context_hash( array $context ): string {
		// Include only certain entries in the context hash.
		// We need only discrete, user-interaction dependent data.
		// Do not include information that changes automatically (e.g., time since activation, etc.).
		return md5(
			wp_json_encode(
				[
					'dismissals' => $context['dismissals'] ?? [],
					'locale'     => $context['locale'] ?? '',
				]
			)
		);
	}

	/**
	 * Get list of valid payment method IDs from the gateway.
	 *
	 * @return array List of valid payment method IDs.
	 */
	private function get_valid_payment_method_ids(): array {
		if ( null === $this->gateway ) {
			$this->gateway = WC_Payments::get_gateway();
		}

		if ( null === $this->gateway || ! is_callable( [ $this->gateway, 'get_upe_available_payment_methods' ] ) ) {
			return [];
		}

		$result = $this->gateway->get_upe_available_payment_methods();

		return is_array( $result ) ? $result : [];
	}

	/**
	 * Get list of enabled payment method IDs.
	 *
	 * @return array List of enabled payment method IDs.
	 */
	private function get_enabled_payment_method_ids(): array {
		if ( null === $this->gateway ) {
			$this->gateway = WC_Payments::get_gateway();
		}

		if ( null === $this->gateway || ! is_callable( [ $this->gateway, 'get_upe_enabled_payment_method_ids' ] ) ) {
			return [];
		}

		$result = $this->gateway->get_upe_enabled_payment_method_ids();

		return is_array( $result ) ? $result : [];
	}

	/**
	 * Get the account fees.
	 *
	 * @return array Account fees indexed by payment method ID.
	 */
	private function get_account_fees(): array {
		if ( null === $this->account ) {
			$this->account = WC_Payments::get_account_service();
		}

		if ( null === $this->account || ! is_callable( [ $this->account, 'get_fees' ] ) ) {
			return [];
		}

		$result = $this->account->get_fees();

		return is_array( $result ) ? $result : [];
	}

	/**
	 * Check if a payment method has an active discount.
	 *
	 * @param string     $payment_method_id The payment method ID.
	 * @param array|null $account_fees      Optional. Pre-fetched account fees. If null, will be fetched.
	 *
	 * @return bool True if the payment method has an active discount.
	 */
	private function payment_method_has_active_discount( string $payment_method_id, ?array $account_fees = null ): bool {
		if ( null === $account_fees ) {
			$account_fees = $this->get_account_fees();
		}

		if ( empty( $account_fees[ $payment_method_id ] ) ) {
			return false;
		}

		$pm_fees = $account_fees[ $payment_method_id ];

		// Verify discount is a non-empty array.
		if ( ! isset( $pm_fees['discount'] ) || ! is_array( $pm_fees['discount'] ) || empty( $pm_fees['discount'] ) ) {
			return false;
		}

		// Get first discount entry regardless of array key structure.
		$first_discount = reset( $pm_fees['discount'] );
		if ( is_array( $first_discount ) && array_key_exists( 'discount', $first_discount ) && ! empty( $first_discount['discount'] ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Filter promotions by dismissal status, payment method validity, enabled status, discount status,
	 * and promo_id uniqueness per payment method.
	 *
	 * @param array $promotions Array of promotions.
	 *
	 * @return array Filtered promotions.
	 */
	private function filter_promotions( array $promotions ): array {
		// Pre-fetch all data needed for filtering to avoid N+1 queries.
		$enabled_pms    = $this->get_enabled_payment_method_ids();
		$valid_pms      = $this->get_valid_payment_method_ids();
		$account_fees   = $this->get_account_fees();
		$seen_promo_ids = []; // Track first promo_id per PM.
		$filtered       = [];

		foreach ( $promotions as $promotion ) {
			$id       = $promotion['id'] ?? '';
			$pm_id    = $promotion['payment_method'] ?? '';
			$promo_id = $promotion['promo_id'] ?? '';

			// Filters ordered by performance cost (cheapest first, all use pre-fetched data).

			// 1. Skip promotions for already enabled payment methods.
			if ( in_array( $pm_id, $enabled_pms, true ) ) {
				continue;
			}

			// 2. Skip invalid payment methods.
			if ( ! in_array( $pm_id, $valid_pms, true ) ) {
				continue;
			}

			// 3. Skip dismissed promotions (WP cached option).
			if ( $this->is_promotion_dismissed( $id ) ) {
				continue;
			}

			// 4. Skip promotions for payment methods that already have an active discount.
			if ( $this->payment_method_has_active_discount( $pm_id, $account_fees ) ) {
				continue;
			}

			// 5. Track first promo_id per PM - keep all surfaces for that promo_id.
			// Must be last as it has side effects (tracks seen promo_ids).
			if ( ! isset( $seen_promo_ids[ $pm_id ] ) ) {
				$seen_promo_ids[ $pm_id ] = $promo_id;
			}

			// Skip if this is a different promo_id for an already-seen PM.
			if ( $seen_promo_ids[ $pm_id ] !== $promo_id ) {
				continue;
			}

			$filtered[] = $promotion;
		}

		return $filtered;
	}

	/**
	 * Normalize promotions by applying fallbacks and deriving fields.
	 *
	 * @param array $promotions Array of promotions.
	 *
	 * @return array Normalized promotions.
	 */
	private function normalize_promotions( array $promotions ): array {
		$normalized = [];

		foreach ( $promotions as $promotion ) {
			// These fields are validated as required before normalization.
			$pm_id  = $promotion['payment_method'];
			$tc_url = $promotion['tc_url'];

			// Add derived payment_method_title if not provided.
			if ( empty( $promotion['payment_method_title'] ) ) {
				$promotion['payment_method_title'] = $this->get_payment_method_title( $pm_id );
			}

			// Apply fallback for cta_label using the final payment_method_title.
			if ( empty( $promotion['cta_label'] ) ) {
				/* translators: %s is the payment method title, e.g., "Klarna" */
				$promotion['cta_label'] = sprintf( __( 'Enable %s', 'woocommerce-payments' ), $promotion['payment_method_title'] );
			}

			// Apply type-specific sanitization BEFORE tc_label fallback.
			// This ensures we check against the sanitized description (which might lose the link).
			$promotion = $this->sanitize_promotion( $promotion );

			// Apply fallback for tc_label only if tc_url is not already in the sanitized description.
			// If tc_url is in the description, leaving tc_label empty signals frontend to not add a link.
			if ( empty( $promotion['tc_label'] ) ) {
				if ( strpos( $promotion['description'], $tc_url ) === false ) {
					$promotion['tc_label'] = __( 'See terms', 'woocommerce-payments' );
				} else {
					// Explicitly set to empty string when skipping fallback.
					$promotion['tc_label'] = '';
				}
			}

			$normalized[] = $promotion;
		}

		return $normalized;
	}

	/**
	 * Sanitize a promotion's fields based on its type.
	 *
	 * @param array $promotion The promotion data.
	 *
	 * @return array Sanitized promotion.
	 */
	private function sanitize_promotion( array $promotion ): array {
		$type = $promotion['type'] ?? '';

		// Sanitize identifier fields strictly with sanitize_key.
		$key_fields = [ 'id', 'promo_id', 'payment_method', 'type' ];
		foreach ( $key_fields as $field ) {
			if ( isset( $promotion[ $field ] ) ) {
				$promotion[ $field ] = sanitize_key( $promotion[ $field ] );
			}
		}

		// Sanitize text fields (no HTML allowed).
		$text_fields = [ 'payment_method_title', 'title', 'cta_label', 'tc_label', 'badge_text' ];
		foreach ( $text_fields as $field ) {
			if ( isset( $promotion[ $field ] ) ) {
				$promotion[ $field ] = sanitize_text_field( $promotion[ $field ] );
			}
		}

		// Normalize badge_type: ensure it's a valid type, defaulting to 'success'.
		$valid_badge_types       = [ 'primary', 'success', 'light', 'warning', 'alert' ];
		$promotion['badge_type'] = isset( $promotion['badge_type'] ) && in_array( $promotion['badge_type'], $valid_badge_types, true )
			? $promotion['badge_type']
			: 'success';

		// Sanitize URL fields.
		if ( isset( $promotion['tc_url'] ) ) {
			$promotion['tc_url'] = esc_url_raw( $promotion['tc_url'] );
		}
		if ( isset( $promotion['image'] ) ) {
			$promotion['image'] = esc_url_raw( $promotion['image'] );
		}

		// Sanitize description based on type.
		if ( isset( $promotion['description'] ) ) {
			$promotion['description'] = $this->sanitize_description( $promotion['description'], $type );
		}

		// Sanitize footnote (same as spotlight description - allows light HTML).
		if ( isset( $promotion['footnote'] ) ) {
			$promotion['footnote'] = $this->sanitize_description( $promotion['footnote'], 'spotlight' );
		}

		return $promotion;
	}

	/**
	 * Sanitize description field based on promotion type.
	 *
	 * Spotlight type allows light HTML: paragraphs, bold, italic, links, breaks.
	 * Badge type only allows links.
	 *
	 * @param string $description The description to sanitize.
	 * @param string $type        The promotion type.
	 *
	 * @return string Sanitized description.
	 */
	private function sanitize_description( string $description, string $type ): string {
		if ( 'spotlight' === $type ) {
			// Allow light HTML for spotlight: paragraphs, bold, italic, links, breaks.
			$allowed_html = [
				'p'      => [],
				'strong' => [],
				'b'      => [],
				'em'     => [],
				'i'      => [],
				'a'      => [
					'href'   => [],
					'target' => [],
					'rel'    => [],
				],
				'br'     => [],
			];
			return wp_kses( $description, $allowed_html );
		}

		if ( 'badge' === $type ) {
			// Badge type: only allow links.
			$allowed_html = [
				'a' => [
					'href'   => [],
					'target' => [],
					'rel'    => [],
				],
			];
			return wp_kses( $description, $allowed_html );
		}

		// Default: strip all HTML.
		return sanitize_text_field( $description );
	}

	/**
	 * Get the human-readable title for a payment method.
	 *
	 * @param string $payment_method_id The payment method ID.
	 *
	 * @return string The payment method title or a fallback.
	 */
	private function get_payment_method_title( string $payment_method_id ): string {
		$payment_method = WC_Payments::get_payment_method_by_id( $payment_method_id );

		if ( false !== $payment_method && method_exists( $payment_method, 'get_title' ) ) {
			return $payment_method->get_title();
		}

		// Fallback to formatted ID (e.g., 'klarna' -> 'Klarna').
		return ucfirst( str_replace( '_', ' ', $payment_method_id ) );
	}

	/**
	 * Send a Tracks event.
	 *
	 * By default Woo adds `url`, `blog_lang`, `blog_id`, `store_id`, `products_count`, and `wc_version`
	 * properties to every event.
	 *
	 * @todo This is a duplicate of the one in the WC_Payments_Account, WC_REST_Payments_Settings_Controller, and WC_Payments_Onboarding_Service classes.
	 *
	 * @param string $name       The event name.
	 * @param array  $properties Optional. The event custom properties.
	 *
	 * @return void
	 */
	private function tracks_event( string $name, array $properties = [] ) {
		if ( ! function_exists( 'wc_admin_record_tracks_event' ) ) {
			return;
		}

		// Add default properties to every event.
		$account_service = WC_Payments::get_account_service();
		$tracking_info   = $account_service ? $account_service->get_tracking_info() : [];

		$properties = array_merge(
			$properties,
			[
				'is_test_mode'      => WC_Payments::mode()->is_test(),
				'jetpack_connected' => true, // Any PM promotions require a Jetpack connection.
				'wcpay_version'     => WCPAY_VERSION_NUMBER,
				'woo_country_code'  => WC()->countries->get_base_country(),
			],
			$tracking_info ?? []
		);

		wc_admin_record_tracks_event( $name, $properties );

		Logger::info( 'Tracks event: ' . $name . ' with data: ' . wp_json_encode( WC_Payments_Utils::redact_array( $properties, [ 'woo_country_code' ] ) ) );
	}
}