<?php

namespace Objectiv\Plugins\Checkout\Features;

use Exception;
use Objectiv\Plugins\Checkout\Factories\BumpFactory;
use Objectiv\Plugins\Checkout\Interfaces\SettingsGetterInterface;
use Objectiv\Plugins\Checkout\Managers\AssetManager;
use Objectiv\Plugins\Checkout\Managers\SettingsManager;
use Objectiv\Plugins\Checkout\Managers\StyleManager;
use WC_Frontend_Scripts;
use WC_Shipping_Free_Shipping;
use WC_Shipping_Zones;

class SideCart extends FeaturesAbstract {
	protected $item_just_added_to_cart = false;
	protected $order_bumps_controller  = false;

	public function __construct( bool $enabled, bool $available, string $required_plans_list, SettingsGetterInterface $settings_getter, OrderBumps $order_bumps_controller ) {
		$this->order_bumps_controller = $order_bumps_controller;

		parent::__construct( $enabled, $available, $required_plans_list, $settings_getter );
	}

	protected function run_if_cfw_is_enabled() {
		/**
		 * Disable side cart if filter is set
		 *
		 * @param bool $disable_side_cart Whether to disable the side cart
		 *
		 * @since 7.2.0
		 */
		if ( apply_filters( 'cfw_disable_side_cart', false ) ) {
			return;
		}

		// Prevent redirecting after add to cart when side cart is on
		add_filter(
			'pre_option_woocommerce_cart_redirect_after_add',
			function () {
				return 'no';
			}
		);

		add_filter( 'woocommerce_add_to_cart_redirect', [ $this, 'maybe_prevent_add_to_cart_redirect' ], 1000 );
		add_action( 'woocommerce_add_to_cart', [ $this, 'detect_item_just_added_to_cart' ] );
		add_action( 'wp', [ $this, 'run_sidecart' ] );
		add_shortcode( 'checkoutwc_cart', [ $this, 'render_shortcode' ] );
		add_filter( 'cfw_custom_css_properties', [ $this, 'add_custom_css_property' ] );
		add_filter( 'cfw_get_cart_static_actions_data', [ $this, 'add_side_cart_actions_data' ] );
		add_filter( 'cfw_checkout_data', [ $this, 'add_side_cart_data' ] );
		add_filter( 'woocommerce_add_to_cart_fragments', [ $this, 'add_data_to_add_to_cart_fragments' ] );
		add_filter( 'wc_add_to_cart_message_html', '__return_empty_string' ); // prevent add to cart notices when side cart is in use
		add_filter( 'cfw_disable_cart_editing', [ $this, 'maybe_disable_side_cart_editing' ], 10, 3 );
		add_filter( 'cfw_disable_cart_variation_editing', [ $this, 'maybe_disable_side_cart_variation_editing' ], 10, 2 );
		add_action( 'woocommerce_set_cart_cookies', [ $this, 'detect_cart_changes' ], 10 );
		add_filter( 'cfw_compatibility_all_products_for_subscriptions_run_on_side_cart', '__return_true' );
		add_filter( 'cfw_compatibility_free_gifts_for_woocommerce_prevent_redirect', '__return_true' );
		add_filter( 'cfw_compatibility_nexcessmu_prevent_disable_fragments', '__return_true' );

		// Surface the gift-tier validation notice while the feature is active. The provisioning
		// listener itself is registered in register_gift_reconciliation(), independent of this
		// feature gate, so toggling the side cart back on can re-provision bumps that were swept
		// while it was off.
		add_action( 'admin_notices', [ $this, 'maybe_show_gift_validation_notice' ] );

		// Gate the managed gift's auto-add on the one shared qualifying-subtotal routine (and the
		// feature toggle) instead of the order-bump engine's own separate subtotal. This is what
		// keeps "bar unlocked", "gift added" and "gift removed" from ever disagreeing.
		add_filter( 'cfw_is_cart_bump_valid', [ $this, 'gate_managed_gift_bump_validity' ], 10, 2 );
		add_filter( 'cfw_order_bumps_allow_auto_add_after_removal', [ $this, 'allow_gift_readd_after_removal' ], 10, 2 );

		// The managed gift bump is provisioned automatically, so let it publish even when the
		// merchant is already at their plan's manual-bump limit (it's excluded from that count).
		add_filter( 'cfw_restricted_post_types_publish_override', [ $this, 'allow_managed_gift_publish' ], 10, 2 );

		// Remove the free gift when the cart drops below its tier (or the feature is off). Kept off
		// woocommerce_before_calculate_totals: remove_cart_item() recalculates totals internally, so
		// removing during the price pass would re-enter this method. Two triggers give full coverage
		// with valid (post-calculation) totals:
		// - cfw_cart_updated: immediate response to side-cart AJAX add/qty/remove.
		// - woocommerce_check_cart_items: cart + checkout load, so a below-threshold or
		// feature-disabled gift can't survive to purchase even if it lingered on other pages.
		// (woocommerce_cart_loaded_from_session is deliberately NOT used — it fires before totals are
		// calculated, so get_displayed_subtotal() reads 0 there and would strip qualified gifts.)
		add_action( 'cfw_cart_updated', [ $this, 'maybe_remove_unqualified_gifts' ], 10 );
		add_action( 'woocommerce_check_cart_items', [ $this, 'maybe_remove_unqualified_gifts' ], 20 );

		// Pull a managed gift whose product went unavailable after it was auto-added (out of stock
		// with backorders off, unpublished, no longer purchasable). WC's own stock check runs at
		// priority 1 on woocommerce_check_cart_items and would add a blocking "not in stock" error
		// the shopper can't clear — the gift's remove link is hidden — so this runs at priority 0,
		// ahead of it. Also on cfw_cart_updated so the gift leaves the side cart on AJAX re-render.
		add_action( 'cfw_cart_updated', [ $this, 'maybe_remove_unavailable_gifts' ], 10 );
		add_action( 'woocommerce_check_cart_items', [ $this, 'maybe_remove_unavailable_gifts' ], 0 );

		// Add the gift on the same render trigger as the removal. The order-bump engine's own auto-add
		// (OrderBumps::maybe_auto_add_bumps) only runs on woocommerce_add_to_cart and cfw_cart_updated,
		// so a cart that crosses the threshold via a WooCommerce cart-page quantity change, or at
		// checkout, never triggers it — the bar unlocks with no gift. Runs before the removal above so
		// a freshly-qualified gift is added and then kept.
		add_action( 'woocommerce_check_cart_items', [ $this, 'maybe_add_qualified_gifts' ], 15 );

		// Present the auto-added free gift as a locked, badged "Free" line item.
		add_filter( 'cfw_cart_item_row_class', [ $this, 'mark_free_gift_cart_item' ], 20, 2 );
		add_filter( 'cfw_disable_side_cart_item_quantity_control', [ $this, 'lock_free_gift_quantity' ], 20, 3 );
		add_filter( 'woocommerce_cart_item_remove_link', [ $this, 'maybe_hide_free_gift_remove_link' ], 20, 2 );
		add_filter( 'woocommerce_cart_item_subtotal', [ $this, 'maybe_free_gift_subtotal' ], 20, 3 );

		// Lock the gift quantity in the Cart/Checkout blocks. The side cart and classic cart lock it
		// via CheckoutWC/WooCommerce cart filters the blocks ignore; the blocks read editability and
		// limits from the Store API, so without this a shopper could raise the quantity and collect
		// multiple free products.
		add_filter( 'woocommerce_store_api_product_quantity_editable', [ $this, 'lock_free_gift_store_api_quantity' ], 20, 3 );
		add_filter( 'woocommerce_store_api_product_quantity_maximum', [ $this, 'lock_free_gift_store_api_quantity' ], 20, 3 );

		// WooCommerce Setting Page Augmentation
		add_action( 'woocommerce_sections_products', [ $this, 'output_woocommerce_product_settings_notice' ] );
		add_filter( 'woocommerce_get_settings_products', [ $this, 'mark_possibly_overridden_product_settings' ], 10, 1 );
	}

	public function run_sidecart() {
		/**
		 * Filter to disable side cart on specific pages/contexts
		 *
		 * @since 11.0.1
		 * @param bool $disable Whether to disable side cart. Default false.
		 */
		if ( apply_filters( 'cfw_disable_side_cart', false ) ) {
			return;
		}

		add_filter( 'cfw_breadcrumbs', [ $this, 'remove_cart_breadcrumb' ] );
		add_filter( 'cfw_show_return_to_cart_link', '__return_false' );

		if ( SettingsManager::instance()->get_setting( 'enable_order_bumps' ) === 'yes' && SettingsManager::instance()->get_setting( 'enable_order_bumps_on_side_cart' ) === 'yes' ) {
			add_action(
				'cfw_after_side_cart_items_table',
				[
					$this->order_bumps_controller,
					'output_cart_summary_bumps',
				]
			);
			add_action(
				'cfw_after_side_cart_items_table',
				/**
				 * Output side cart bumps
				 */
				function () {
					echo '<div id="cfw_bumps_below_side_cart_items"></div>';
				}
			);

			// Track bumps on checkout for conversion rate purposes
			add_action(
				'cfw_checkout_payment_method_tab',
				function () {
					$bumps     = BumpFactory::get_all( 'publish' );
					$count     = 0;
					$max_bumps = (int) SettingsManager::instance()->get_setting( 'max_bumps' );

					if ( $max_bumps < 0 ) {
						$max_bumps = 999;
					}

					ob_start();
					foreach ( $bumps as $bump ) {
						if ( $count >= $max_bumps ) {
							break;
						}

						if ( $bump->is_displayable() && $bump->is_published() && $bump->get_display_location() === 'below_side_cart_items' ) {
							?>
							<input type="hidden" name="cfw_displayed_order_bump[]" value="<?php echo esc_attr( $bump->get_id() ); ?>"/>
							<?php
							$count++;
						}
					}
				},
				38
			);
		}

		add_filter( 'cfw_side_cart_event_object', [ $this, 'add_localized_settings' ] );

		// Turn off empty cart notice
		remove_action( 'woocommerce_cart_is_empty', 'wc_empty_cart_message', 10 );
		add_action( 'woocommerce_cart_is_empty', 'cfw_output_empty_cart_message', 1 );

		// Move notices output
		remove_action( 'woocommerce_cart_is_empty', 'woocommerce_output_all_notices', 5 );

		add_filter(
			'pre_option_woocommerce_enable_ajax_add_to_cart',
			function ( $result ) {
				if ( SettingsManager::instance()->get_setting( 'enable_side_cart' ) === 'yes' && SettingsManager::instance()->get_setting( 'enable_ajax_add_to_cart' ) === 'yes' ) {
					$result = 'yes';
				}

				return $result;
			},
			10,
			1
		);

		add_filter(
			'woocommerce_cart_redirect_after_error',
			function ( $url ) {
				if ( SettingsManager::instance()->get_setting( 'enable_side_cart' ) !== 'yes' ) {
					return $url;
				}

				// Add cache busting parameter to url
				return add_query_arg( 'nocache', time(), $url );
			}
		);

		// Output custom styles
		add_action(
			'wp_head',
			function () {
				StyleManager::add_styles( 'cfw-side-cart-styles' );

				if ( ! is_checkout() ) {
					StyleManager::queue_custom_font_includes();
				}
			},
			5
		);

		if ( ! is_cfw_page() ) {
			/**
			 * Compatibility Nightmare Avoidance
			 */
			add_action(
				'wp_enqueue_scripts',
				[
					$this,
					'make_sure_cart_fragments_script_is_enqueued',
				],
				100 * 1000
			);
			add_action( 'wp_print_scripts', [ WC_Frontend_Scripts::class, 'localize_printed_scripts' ], 5 );
			add_action( 'wp_print_footer_scripts', [ WC_Frontend_Scripts::class, 'localize_printed_scripts' ], 5 );
			add_action( 'wp_footer', [ $this, 'output_side_cart_and_overlay_markup' ], 1 );
		}
	}

	public function unhook_default_mini_cart_buttons() {
		// Remove default cart widget buttons
		remove_action( 'woocommerce_widget_shopping_cart_buttons', 'woocommerce_widget_shopping_cart_button_view_cart', 10 );
		remove_action( 'woocommerce_widget_shopping_cart_buttons', 'woocommerce_widget_shopping_cart_proceed_to_checkout', 20 );
	}

	public function rehook_default_mini_cart_buttons() {
		// Re-add default cart widget buttons
		add_action( 'woocommerce_widget_shopping_cart_buttons', 'woocommerce_widget_shopping_cart_button_view_cart', 10 );
		add_action( 'woocommerce_widget_shopping_cart_buttons', 'woocommerce_widget_shopping_cart_proceed_to_checkout', 20 );
	}

	public function detect_item_just_added_to_cart() {
		$this->item_just_added_to_cart = true;
	}

	public function make_sure_cart_fragments_script_is_enqueued() {
		WC_Frontend_Scripts::load_scripts();
		wp_enqueue_script( 'wc-cart-fragments' );
	}

	public function output_side_cart_and_overlay_markup() {
		if ( SettingsManager::instance()->get_setting( 'enable_side_cart_payment_buttons' ) === 'yes' ) {
			cfw_do_action( 'woocommerce_before_mini_cart' );
		}
		?>
		<div id="cfw-side-cart-overlay"></div>
		<div class="checkoutwc cfw-grid" id="cfw-side-cart" role="dialog" aria-modal="true" aria-label="<?php _e( 'Cart', 'woocommerce' ); ?>">
			<?php echo '<div id="cfw-side-cart-container"></div>'; ?>
		</div>

		<?php if ( SettingsManager::instance()->get_setting( 'enable_floating_cart_button' ) === 'yes' ) : ?>
			<div id="cfw-side-cart-floating-button"></div>
		<?php endif; ?>
		<?php
	}

	/**
	 * @param array|null $data The data to get the qualification for free shipping status.
	 *
	 * @return bool
	 */
	public function does_cart_qualifies_for_free_shipping( ?array $data = null ): bool {
		$data = $data ?? $this->get_free_shipping_data();

		if ( empty( $data ) ) {
			return false;
		}

		return (bool) $data['has_free_shipping'];
	}

	/**
	 * @param array|null $data The data to use to get the amount remaining.
	 *
	 * @return float|null
	 */
	public function get_remaining_amount_to_qualify_for_free_shipping( ?array $data = null ): ?float {
		$data = $data ?? $this->get_free_shipping_data();

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

		return floatval( $data['amount_remaining'] );
	}

	/**
	 * @param array|null $data The data to use to get the fill percentage.
	 *
	 * @return int
	 */
	public function get_fill_percentage( ?array $data = null ): int {
		$data = $data ?? $this->get_free_shipping_data();

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

		return intval( $data['fill_percentage'] );
	}

	/**
	 * @return array
	 */
	public function get_free_shipping_data(): array {
		// Don't do calculations if the feature is disabled
		if ( SettingsManager::instance()->get_setting( 'enable_free_shipping_progress_bar' ) !== 'yes' ) {
			return [];
		}

		$data = [];

		$raw_threshold = SettingsManager::instance()->get_setting( 'side_cart_free_shipping_threshold' );
		$threshold     = (float) $raw_threshold;

		// Set up a dummy product for the filter
		$dummy_product = new \WC_Product();
		$dummy_product->set_price( $threshold );
		$dummy_product->set_regular_price( $threshold );

		/**
		 * Filters the free shipping threshold amount
		 *
		 * @param float $threshold The free shipping threshold amount
		 *
		 * @since 8.1.12
		 */
		$threshold                = apply_filters( 'cfw_side_cart_free_shipping_threshold', cfw_apply_filters( 'woocommerce_product_get_price', $threshold, $dummy_product ) );
		$has_free_shipping        = false;
		$amount_remaining         = null;
		$fill_percentage          = null;
		$subtotal                 = $this->get_qualifying_subtotal();
		$has_free_shipping_coupon = false;

		foreach ( WC()->cart->get_applied_coupons() as $coupon_code ) {
			$coupon = new \WC_Coupon( $coupon_code );

			if ( $coupon->get_free_shipping() ) {
				$has_free_shipping_coupon = true;
				break;
			}
		}

		// Multi-tier "spend-to-unlock" rail. When tiers are configured they take over the
		// progress bar entirely; the single-threshold / shipping-detection logic below is the
		// fallback for stores that have not configured tiers.
		$tiers = SettingsManager::instance()->get_setting( 'side_cart_free_shipping_tiers' );

		if ( ! empty( $tiers ) && is_array( $tiers ) ) {
			$tiered_data = $this->get_tiered_free_shipping_data( $tiers, $subtotal, $has_free_shipping_coupon );

			// Only take over the bar when at least one tier has a usable threshold. If every
			// tier is blank (e.g. the admin seeded an empty tier but never set an amount), fall
			// through to the single-threshold / auto-detection logic below so behavior is unchanged.
			if ( ! empty( $tiered_data ) ) {
				/**
				 * Filters the free shipping data when the multi-tier rail is configured.
				 *
				 * @param array $tiered_data
				 *
				 * @since 7.0.5
				 */
				return apply_filters( 'cfw_shipping_bar_data', $tiered_data );
			}
		}

		if ( $has_free_shipping_coupon ) {
			$data = [
				'has_free_shipping' => true,
				'amount_remaining'  => 0,
				'fill_percentage'   => 100,
			];

			/**
			 * Filters the free shipping data when a free shipping coupon is applied
			 *
			 * @param array $data
			 *
			 * @since 7.0.5
			 */
			return apply_filters( 'cfw_shipping_bar_data', $data );
		}

		// Check the original, raw threshold value because it will be 0.00 if not set
		if ( ! empty( $raw_threshold ) && is_numeric( $raw_threshold ) ) {
			$data = [
				'has_free_shipping' => ( $subtotal >= $threshold ),
				'amount_remaining'  => $subtotal >= $threshold ? 0 : $threshold - $subtotal,
				'fill_percentage'   => $threshold > 0 ? min( ceil( ( $subtotal / $threshold ) * 100 ), 100 ) : 100,
			];

			/**
			 * Filters the free shipping data when a threshold is set
			 *
			 * @param array $data
			 *
			 * @since 7.0.5
			 */
			return apply_filters( 'cfw_shipping_bar_data', $data );
		}

		WC()->cart->calculate_shipping();

		$packages = WC()->shipping()->get_packages();

		if ( empty( $packages ) ) {
			/**
			 * Filters the free shipping data when no packages are available
			 *
			 * @param array $data
			 *
			 * @since 7.0.5
			 */
			return apply_filters( 'cfw_shipping_bar_data', $data );
		}

		// Only look at first package for this feature
		$available_methods = $packages[0]['rates'] ?? [];

		// Guard against invalid argument for foreach
		if ( ! is_array( $available_methods ) ) {
			$available_methods = [];
		}

		foreach ( $available_methods as $available_method ) {
			if ( $available_method instanceof WC_Shipping_Free_Shipping ) {
				$has_free_shipping = true;
				break;
			}

			if ( 'cfw_local_pickup' !== $available_method->id && 'local_pickup' !== $available_method->method_id && (float) $available_method->cost <= 0 ) {
				$has_free_shipping = true;

				break;
			}
		}

		if ( ! $has_free_shipping ) {
			$shipping_zone    = WC_Shipping_Zones::get_zone_matching_package( $packages[0] );
			$shipping_methods = $shipping_zone->get_shipping_methods( true );

			foreach ( $shipping_methods as $shipping_method ) {
				if ( $shipping_method instanceof WC_Shipping_Free_Shipping && ( 'min_amount' === $shipping_method->requires || 'either' === $shipping_method->requires ) ) {

					if ( 'no' !== $shipping_method->ignore_discounts && $exclude_discounts && ! empty( WC()->cart->get_coupon_discount_totals() ) ) {
						// Maybe add back the discounts if a coupon code rule is overriding
						foreach ( WC()->cart->get_coupon_discount_totals() as $coupon_value ) {
							$subtotal += $coupon_value;
						}
					}

					$min_amount = $shipping_method->min_amount;

					// Set up a dummy product for the filter
					$dummy_product = new \WC_Product();
					$dummy_product->set_price( $min_amount );
					$dummy_product->set_regular_price( $min_amount );

					if ( $subtotal >= cfw_apply_filters( 'cfw_side_cart_free_shipping_threshold', cfw_apply_filters( 'woocommerce_product_get_price', $min_amount, $dummy_product ) ) ) {
						$has_free_shipping = true;
					} else {
						$amount_remaining = $shipping_method->min_amount - $subtotal;
						$fill_percentage  = ceil( ( $subtotal / $shipping_method->min_amount ) * 100 );
					}
					break;
				}
			}
		}

		if ( ! $has_free_shipping && is_null( $amount_remaining ) ) {
			/**
			 * Filters the free shipping data when no free shipping methods are available
			 *
			 * @param array $data
			 *
			 * @since 7.0.5
			 */
			return apply_filters( 'cfw_shipping_bar_data', $data );
		}

		$data = [
			'has_free_shipping' => $has_free_shipping,
			'amount_remaining'  => $amount_remaining,
			'fill_percentage'   => $has_free_shipping ? 100 : $fill_percentage,
		];

		/**
		 * Filters the free shipping data
		 *
		 * @param array $data
		 *
		 * @since 7.0.5
		 */
		return apply_filters( 'cfw_shipping_bar_data', $data );
	}

	/**
	 * Build progress bar data for the multi-tier "spend-to-unlock" rail.
	 *
	 * Returns the legacy top-level keys (has_free_shipping, amount_remaining, fill_percentage)
	 * for back-compatible consumers, plus a `tiers` array describing each configured tier.
	 *
	 * @param array $tiers                    Configured tiers (raw setting value).
	 * @param float $subtotal                 Qualifying cart subtotal.
	 * @param bool  $has_free_shipping_coupon Whether a free shipping coupon is applied.
	 *
	 * @return array
	 */
	protected function get_tiered_free_shipping_data( array $tiers, float $subtotal, bool $has_free_shipping_coupon ): array {
		$normalized = [];
		$gift_bumps = null;

		foreach ( $tiers as $index => $tier ) {
			if ( ! is_array( $tier ) ) {
				continue;
			}

			$threshold = isset( $tier['threshold'] ) ? (float) $tier['threshold'] : 0.0;

			// Skip tiers without a usable threshold.
			if ( $threshold <= 0 ) {
				continue;
			}

			$reward_type = isset( $tier['reward_type'] ) ? (string) $tier['reward_type'] : 'free_shipping';

			// Hide a free-gift tier whose gift can't actually be added — the bar must not promise a
			// gift the cart refuses to deliver. Reuse the exact gate the auto-add uses
			// (maybe_add_qualified_gifts) so display and fulfillment never drift: out-of-stock gifts
			// with backorders off are dropped, backordered gifts pass the gate and still show.
			if ( 'free_gift' === $reward_type ) {
				if ( null === $gift_bumps ) {
					$gift_bumps = $this->get_managed_gift_bumps();
				}

				$tier_id   = isset( $tier['id'] ) ? (string) $tier['id'] : 'tier-' . $index;
				$gift_bump = isset( $gift_bumps[ $tier_id ] ) ? BumpFactory::get( (int) $gift_bumps[ $tier_id ] ) : null;

				if ( $gift_bump && ! $gift_bump->can_offer_product_be_added_to_the_cart() ) {
					continue;
				}
			}

			// A free shipping coupon satisfies free-shipping reward tiers regardless of spend.
			$met = $subtotal >= $threshold || ( 'free_shipping' === $reward_type && $has_free_shipping_coupon );

			$normalized[] = [
				'id'                => isset( $tier['id'] ) ? (string) $tier['id'] : 'tier-' . $index,
				'threshold'         => $threshold,
				'reward_type'       => $reward_type,
				'label'             => isset( $tier['label'] ) ? (string) $tier['label'] : '',
				'met'               => $met,
				'amount_remaining'  => $met ? 0.0 : $threshold - $subtotal,
				'unlocked_message'  => isset( $tier['unlocked_message'] ) ? (string) $tier['unlocked_message'] : '',
				'remaining_message' => isset( $tier['remaining_message'] ) ? (string) $tier['remaining_message'] : '',
				'color'             => isset( $tier['color'] ) ? (string) $tier['color'] : '',
			];
		}

		// No usable tiers — return empty so the caller's fallback logic can run.
		if ( empty( $normalized ) ) {
			return [];
		}

		// Sort by threshold ascending so markers render left-to-right.
		usort(
			$normalized,
			function ( $a, $b ) {
				return $a['threshold'] <=> $b['threshold'];
			}
		);

		$max_threshold = (float) end( $normalized )['threshold'];
		reset( $normalized );

		// Overall fill is relative to the highest tier.
		$overall_fill = $max_threshold > 0 ? (int) min( ceil( ( $subtotal / $max_threshold ) * 100 ), 100 ) : 100;

		// Position each marker along the bar and flag the first unmet tier as "next".
		$next_unmet_index = null;

		foreach ( $normalized as $i => $tier ) {
			$normalized[ $i ]['position_percentage'] = $max_threshold > 0 ? (int) min( round( ( $tier['threshold'] / $max_threshold ) * 100 ), 100 ) : 100;
			$normalized[ $i ]['is_next']             = false;

			if ( null === $next_unmet_index && ! $tier['met'] ) {
				$next_unmet_index = $i;
			}
		}

		if ( null !== $next_unmet_index ) {
			$normalized[ $next_unmet_index ]['is_next'] = true;
		}

		// Derive legacy top-level keys: free shipping is unlocked once any free-shipping tier is met.
		$has_free_shipping = false;

		foreach ( $normalized as $tier ) {
			if ( 'free_shipping' === $tier['reward_type'] && $tier['met'] ) {
				$has_free_shipping = true;
				break;
			}
		}

		$legacy_amount_remaining = null !== $next_unmet_index ? $normalized[ $next_unmet_index ]['amount_remaining'] : 0.0;

		return [
			'has_free_shipping' => $has_free_shipping,
			'amount_remaining'  => $legacy_amount_remaining,
			'fill_percentage'   => $overall_fill,
			'tiers'             => $normalized,
		];
	}

	/**
	 * Register the managed-gift reconciliation listener independently of the side-cart toggle.
	 *
	 * The gift lifecycle hooks live in run_if_cfw_is_enabled(), which only runs while the side cart
	 * is enabled — so the request that flips the toggle back on would not re-provision the bumps
	 * that were swept while it was off (the bar would then promise a gift it can't deliver). This
	 * listener is registered on plan availability alone, so enabling or disabling the side cart or
	 * the rewards bar always reconciles managed bumps to the current state. sync_gift_bumps() is
	 * self-gating via gift_tiers_active(): it sweeps when the feature is off and provisions when on.
	 */
	public function register_gift_reconciliation() {
		add_action( 'cfw_updated_setting', [ $this, 'maybe_sync_gift_bumps' ], 10, 3 );
	}

	/**
	 * React to the side cart tiers setting being saved and reconcile gift bumps.
	 *
	 * @param string $setting   The (prefixed) setting key that changed.
	 * @param mixed  $value     The new value.
	 * @param mixed  $old_value The previous value.
	 */
	public function maybe_sync_gift_bumps( $setting, $value, $old_value ) {
		$setting = (string) $setting;

		$is_tiers = false !== strpos( $setting, 'side_cart_free_shipping_tiers' );

		// Re-sync when the rewards bar or the whole side cart is toggled too, so disabling the
		// feature sweeps the managed gift bumps (they must not survive as live order bumps the
		// engine could still auto-add). The `$` anchor keeps this off unrelated side-cart keys
		// like enable_side_cart_payment_buttons.
		$is_feature = (bool) preg_match( '/(enable_side_cart|enable_free_shipping_progress_bar)$/', $setting );

		if ( ! $is_tiers && ! $is_feature ) {
			return;
		}

		// On a tiers save the new tiers arrive in $value; on a feature toggle re-read the stored tiers.
		$tiers = ( $is_tiers && is_array( $value ) )
			? $value
			: SettingsManager::instance()->get_setting( 'side_cart_free_shipping_tiers' );

		$this->sync_gift_bumps( is_array( $tiers ) ? $tiers : [] );
	}

	/**
	 * Reconcile managed gift order bumps against the configured tiers.
	 *
	 * Each free_gift tier with a chosen product owns exactly one auto-add, 100%-off
	 * order bump gated to its threshold. Tiers that are no longer gifts (removed,
	 * reward type changed, or product cleared) have their managed bump deleted. The
	 * link lives on the bump (meta), so nothing is written back to the tiers setting.
	 *
	 * @param array $tiers The configured tiers.
	 */
	public function sync_gift_bumps( array $tiers ) {
		// If the rewards bar (or the whole side cart) is switched off, no tier can fulfill a gift.
		// Treat every managed bump as stale so the deletion sweep below removes it.
		if ( ! $this->gift_tiers_active() ) {
			$tiers = [];
		}

		$existing      = $this->get_managed_gift_bumps(); // [ tier_id => bump_post_id ]
		$invalid_gifts = [];

		foreach ( $tiers as $index => $tier ) {
			if ( ! is_array( $tier ) ) {
				continue;
			}

			$is_gift   = isset( $tier['reward_type'] ) && 'free_gift' === $tier['reward_type'];
			$product   = ( isset( $tier['gift_product'] ) && is_array( $tier['gift_product'] ) ) ? array_values( $tier['gift_product'] ) : [];
			$threshold = isset( $tier['threshold'] ) ? (float) $tier['threshold'] : 0.0;

			// A gift tier is only fulfillable with a gift product AND a positive spend threshold. A
			// blank/zero threshold is dropped from the rewards bar (get_tiered_free_shipping_data), so
			// it must not provision a managed bump either: the bump's cartTotalValue >= 0 rule would
			// match every cart and auto-add the gift at all times while its tier shows nowhere. Any
			// existing bump for such a tier stays in $existing and is removed by the sweep below.
			if ( ! $is_gift || $threshold <= 0 || empty( $product ) || ! isset( $product[0]['key'] ) || ! is_numeric( $product[0]['key'] ) ) {
				continue;
			}

			$tier_id    = isset( $tier['id'] ) ? (string) $tier['id'] : 'tier-' . $index;
			$product_id = (int) $product[0]['key'];
			$label      = isset( $product[0]['label'] ) ? (string) $product[0]['label'] : '';
			$tier_label = isset( $tier['label'] ) ? (string) $tier['label'] : '';

			// Only simple products can be auto-added as a gift — a variable product would require
			// the shopper to pick a variation. Reject anything else so we never provision a bump
			// that can't fulfill, and surface an error to the merchant.
			$wc_product = function_exists( 'wc_get_product' ) ? wc_get_product( $product_id ) : null;

			if ( ! $wc_product || ! $wc_product->is_type( 'simple' ) ) {
				$invalid_gifts[] = '' !== $label ? $label : ( $wc_product ? $wc_product->get_name() : '#' . $product_id );

				continue; // Leave any existing bump for this tier in $existing so it gets deleted below.
			}

			$bump_id = isset( $existing[ $tier_id ] ) ? (int) $existing[ $tier_id ] : 0;

			$this->upsert_gift_bump( $tier_id, $bump_id, $product_id, $label, $threshold, $tier_label );

			unset( $existing[ $tier_id ] );
		}

		// Any managed bump still here belongs to a tier that is gone, no longer a gift, or invalid.
		foreach ( $existing as $stale_bump_id ) {
			wp_delete_post( (int) $stale_bump_id, true );
		}

		// Surface (or clear) the variable-product validation error for the admin.
		if ( ! empty( $invalid_gifts ) ) {
			set_transient( 'cfw_side_cart_invalid_gift_products', $invalid_gifts, HOUR_IN_SECONDS );
		} else {
			delete_transient( 'cfw_side_cart_invalid_gift_products' );
		}
	}

	/**
	 * Show an admin error when a gift tier points at a non-simple (e.g. variable) product.
	 */
	public function maybe_show_gift_validation_notice() {
		$invalid = get_transient( 'cfw_side_cart_invalid_gift_products' );

		if ( empty( $invalid ) || ! is_array( $invalid ) ) {
			return;
		}

		$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;

		if ( ! $screen || false === strpos( (string) $screen->id, 'cfw-settings' ) ) {
			return;
		}

		printf(
			'<div class="notice notice-error"><p>%s</p></div>',
			esc_html(
				sprintf(
					/* translators: %s is a comma-separated list of product names */
					__( 'These rewards-bar gift products can\'t be auto-added because they are not simple products — please choose a simple product instead: %s', 'checkout-wc' ),
					implode( ', ', array_map( 'sanitize_text_field', $invalid ) )
				)
			)
		);
	}

	/**
	 * The single source of truth for "how much of the cart counts toward a tier."
	 *
	 * The bar, the gift auto-add gate, and the gift removal all call this so they can never
	 * disagree — a disagreement between three separate subtotal calculations is what caused the
	 * free-gift add/remove churn on tax-inclusive stores. The basis is the store's displayed
	 * subtotal (the same number the shopper sees on the bar), minus any excluded shipping
	 * classes, through the integration filter.
	 *
	 * @return float
	 */
	public function get_qualifying_subtotal(): float {
		if ( ! WC()->cart ) {
			return 0.0;
		}

		$subtotal = (float) WC()->cart->get_displayed_subtotal();

		/**
		 * Filters whether to exclude discounts from the subtotal when calculating the free shipping bar
		 *
		 * @param bool $exclude_discounts Whether to exclude discounts from the subtotal.
		 *
		 * @since 7.10.2
		 */
		$exclude_discounts = apply_filters( 'cfw_side_cart_shipping_bar_data_exclude_discounts', false );

		if ( $exclude_discounts ) {
			if ( WC()->cart->display_prices_including_tax() ) {
				$discount = WC()->cart->get_cart_discount_total() + WC()->cart->get_cart_discount_tax_total();
			} else {
				$discount = WC()->cart->get_cart_discount_total();
			}

			$subtotal -= $discount;
		}

		// Exclude items in selected shipping classes from the qualifying amount.
		$excluded_shipping_classes = SettingsManager::instance()->get_setting( 'side_cart_free_shipping_excluded_shipping_classes' );

		if ( ! empty( $excluded_shipping_classes ) && is_array( $excluded_shipping_classes ) ) {
			$excluded_shipping_classes = array_map( 'strval', $excluded_shipping_classes );
			$including_tax             = WC()->cart->display_prices_including_tax();

			foreach ( WC()->cart->get_cart() as $cart_item ) {
				$product = $cart_item['data'] ?? null;

				if ( ! $product instanceof \WC_Product ) {
					continue;
				}

				if ( ! in_array( (string) $product->get_shipping_class_id(), $excluded_shipping_classes, true ) ) {
					continue;
				}

				$line_total = (float) ( $cart_item['line_subtotal'] ?? 0 );

				if ( $including_tax ) {
					$line_total += (float) ( $cart_item['line_subtotal_tax'] ?? 0 );
				}

				$subtotal -= $line_total;
			}
		}

		// Exclude the managed free gift's own line. Its value must not count toward its own (or a
		// higher tier's) threshold — otherwise the gift self-qualifies and won't drop back out when
		// the shopper's real spend falls below the threshold.
		foreach ( WC()->cart->get_cart() as $cart_item ) {
			if ( ! $this->is_free_gift_cart_item( $cart_item ) ) {
				continue;
			}

			$line_total = (float) ( $cart_item['line_subtotal'] ?? 0 );

			if ( WC()->cart->display_prices_including_tax() ) {
				$line_total += (float) ( $cart_item['line_subtotal_tax'] ?? 0 );
			}

			$subtotal -= $line_total;
		}

		$subtotal = max( 0, $subtotal );

		/**
		 * Filters the cart amount that counts toward the free shipping / rewards bar.
		 *
		 * Integration seam for table-rate and other custom shipping setups that determine
		 * the qualifying amount differently than the displayed subtotal.
		 *
		 * @param float $subtotal The qualifying subtotal.
		 *
		 * @since 11.3.0
		 */
		return (float) apply_filters( 'cfw_side_cart_qualifying_subtotal', $subtotal );
	}

	/**
	 * Is the rewards-bar gift lifecycle allowed to run at all?
	 *
	 * Gates auto-add and removal on the feature actually being on, so a merchant who turns off
	 * the bar (or the whole side cart) never has free products silently dropped into carts.
	 *
	 * @return bool
	 */
	protected function gift_tiers_active(): bool {
		return SettingsManager::instance()->get_setting( 'enable_side_cart' ) === 'yes'
			&& SettingsManager::instance()->get_setting( 'enable_free_shipping_progress_bar' ) === 'yes';
	}

	/**
	 * Enforce the tier's spend threshold on the managed gift bump's cart-validity, using the shared
	 * qualifying subtotal + feature gate rather than the bump's own rule.
	 *
	 * The bump's rule (cartTotalValue, see upsert_gift_bump) already gates display and auto-add via
	 * is_displayable(); this filter covers is_cart_bump_valid() so removal and pricing agree with the
	 * same threshold — but only while the gift is not yet in the cart.
	 *
	 * Once the gift is in the cart, its presence is owned by maybe_remove_unqualified_gifts, which
	 * runs on cart update against a settled subtotal. We must not re-gate here, because
	 * is_cart_bump_valid() also runs during woocommerce_before_calculate_totals (bump price sync),
	 * when WC has just reset the cart subtotal to 0 — re-checking the threshold then reads 0, wrongly
	 * invalidates the gift, and skips its 100% discount, charging the shopper for a "free" gift.
	 *
	 * @param bool $valid Whether the engine considers the bump valid.
	 * @param mixed $bump  The bump being evaluated (BumpAbstract).
	 * @return bool
	 */
	public function gate_managed_gift_bump_validity( $valid, $bump ) {
		$bump_id = (int) $bump->get_id();

		if ( 'side_cart_tier' !== get_post_meta( $bump_id, '_cfw_managed_by', true ) ) {
			return $valid;
		}

		if ( $bump->bump_is_in_cart() ) {
			return $valid;
		}

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

		$threshold = (float) get_post_meta( $bump_id, '_cfw_side_cart_tier_threshold', true );

		return $threshold > 0 && $this->get_qualifying_subtotal() >= $threshold;
	}

	/**
	 * Remove any managed free gift once the cart drops below its tier threshold, or the feature
	 * is switched off.
	 *
	 * Uses the same get_qualifying_subtotal() and feature gate as the auto-add, so the add and
	 * remove conditions are exact inverses — no add/remove churn. Runs on cfw_cart_updated, not
	 * woocommerce_before_calculate_totals: remove_cart_item() recalculates totals internally, so
	 * removing during the price pass would re-enter this method. Removal is idempotent.
	 */
	public function maybe_remove_unqualified_gifts() {
		if ( is_admin() && ! wp_doing_ajax() ) {
			return;
		}

		$cart = WC()->cart;

		if ( ! $cart || $cart->is_empty() ) {
			return;
		}

		$feature_on = $this->gift_tiers_active();
		$qualifying = $this->get_qualifying_subtotal();

		foreach ( $cart->get_cart() as $key => $item ) {
			if ( ! $this->is_free_gift_cart_item( $item ) ) {
				continue;
			}

			$threshold = (float) get_post_meta( (int) $item['_cfw_order_bump_id'], '_cfw_side_cart_tier_threshold', true );

			if ( ! $feature_on || $threshold <= 0 || $qualifying < $threshold ) {
				$cart->remove_cart_item( $key );
			}
		}
	}

	/**
	 * Remove a managed free gift whose product can no longer be added to the cart — it went out of
	 * stock (with backorders off), was unpublished, or otherwise became unpurchasable after being
	 * auto-added.
	 *
	 * Reuses the exact gate the auto-add uses (can_offer_product_be_added_to_the_cart), so a
	 * backordered gift — which stays purchasable — is kept, and display, add and remove never
	 * disagree. Runs at priority 0 on woocommerce_check_cart_items (before WC's own stock check)
	 * so the shopper never sees the un-clearable "not in stock" error for a gift they didn't choose.
	 */
	public function maybe_remove_unavailable_gifts() {
		if ( is_admin() && ! wp_doing_ajax() ) {
			return;
		}

		$cart = WC()->cart;

		if ( ! $cart || $cart->is_empty() ) {
			return;
		}

		foreach ( $cart->get_cart() as $key => $item ) {
			if ( ! $this->is_free_gift_cart_item( $item ) ) {
				continue;
			}

			$bump = BumpFactory::get( (int) $item['_cfw_order_bump_id'] );

			if ( ! $bump || ! $bump->can_offer_product_be_added_to_the_cart() ) {
				$cart->remove_cart_item( $key );
			}
		}
	}

	/**
	 * Add any qualified managed free gift that isn't in the cart yet.
	 *
	 * The order-bump engine's own auto-add (OrderBumps::maybe_auto_add_bumps) only fires on
	 * woocommerce_add_to_cart and cfw_cart_updated, so crossing a tier threshold via a WooCommerce
	 * cart-page quantity change, or arriving at checkout already qualified, never adds the gift. This
	 * mirrors maybe_remove_unqualified_gifts on the same render trigger and shares its qualifying
	 * subtotal, so add and remove stay exact inverses. Idempotent via bump_is_in_cart().
	 */
	public function maybe_add_qualified_gifts() {
		if ( is_admin() && ! wp_doing_ajax() ) {
			return;
		}

		$cart = WC()->cart;

		if ( ! $cart || $cart->is_empty() || ! $this->gift_tiers_active() ) {
			return;
		}

		$qualifying = $this->get_qualifying_subtotal();

		foreach ( $this->get_managed_gift_bumps() as $bump_id ) {
			$bump_id   = (int) $bump_id;
			$threshold = (float) get_post_meta( $bump_id, '_cfw_side_cart_tier_threshold', true );

			if ( $threshold <= 0 || $qualifying < $threshold ) {
				continue;
			}

			$bump = BumpFactory::get( $bump_id );

			if ( ! $bump || $bump->bump_is_in_cart() || ! $bump->can_offer_product_be_added_to_the_cart() ) {
				continue;
			}

			$bump->add_to_cart( $cart );
		}
	}

	/**
	 * Let a managed gift re-add itself after removal once the cart re-qualifies.
	 *
	 * Order bumps normally stay removed once a shopper (or our threshold logic) removes them;
	 * a spend-gated gift should come back when the cart clears the threshold again.
	 *
	 * @param bool $allow   Whether to allow auto-add after removal.
	 * @param int  $bump_id The bump id.
	 * @return bool
	 */
	public function allow_gift_readd_after_removal( $allow, $bump_id ) {
		return 'side_cart_tier' === get_post_meta( (int) $bump_id, '_cfw_managed_by', true ) ? true : $allow;
	}

	/**
	 * Exempt the managed gift bump from the plan's manual-bump publish limit.
	 *
	 * The publish guard reverts a new bump to non-publish once the merchant is at their allowance;
	 * the managed gift must publish regardless (it doesn't count toward that allowance). On first
	 * insert the meta row doesn't exist yet, so the marker is read from the postarr's meta_input
	 * (set in upsert_gift_bump); on later saves it's read from post meta.
	 *
	 * @param bool  $override Whether to allow publishing past the limit.
	 * @param mixed $post     The post being saved (postarr array during insert, or WP_Post).
	 * @return bool
	 */
	public function allow_managed_gift_publish( $override, $post ) {
		if ( is_array( $post ) && ! empty( $post['meta_input']['_cfw_managed_by'] ) ) {
			return true;
		}

		$post_id = is_object( $post ) ? (int) ( $post->ID ?? 0 ) : ( is_array( $post ) ? (int) ( $post['ID'] ?? 0 ) : 0 );

		if ( $post_id && '' !== (string) get_post_meta( $post_id, '_cfw_managed_by', true ) ) {
			return true;
		}

		return $override;
	}

	/**
	 * Is this cart item the auto-added free gift from a rewards-bar tier?
	 *
	 * @param array $cart_item The cart item.
	 * @return bool
	 */
	protected function is_free_gift_cart_item( $cart_item ): bool {
		if ( ! is_array( $cart_item ) || empty( $cart_item['_cfw_order_bump_id'] ) ) {
			return false;
		}

		return 'side_cart_tier' === get_post_meta( (int) $cart_item['_cfw_order_bump_id'], '_cfw_managed_by', true );
	}

	/**
	 * Tag the free gift's cart row so the side cart can badge and animate it.
	 *
	 * @param string $row_class The existing row class.
	 * @param array  $cart_item The cart item.
	 * @return string
	 */
	public function mark_free_gift_cart_item( $row_class, $cart_item ) {
		if ( $this->is_free_gift_cart_item( $cart_item ) ) {
			$row_class = trim( $row_class . ' cfw-free-gift-item' );
		}

		return $row_class;
	}

	/**
	 * Lock the free gift's quantity — it's spend-driven, not shopper-editable.
	 *
	 * @param bool   $disable   Whether the quantity control is disabled.
	 * @param array  $cart_item The cart item.
	 * @param string $key       The cart item key.
	 * @return bool
	 */
	public function lock_free_gift_quantity( $disable, $cart_item, $key ) {
		return $this->is_free_gift_cart_item( $cart_item ) ? true : $disable;
	}

	/**
	 * Lock the free gift's quantity in the Cart/Checkout blocks (Store API).
	 *
	 * One callback for both the boolean "editable" filter and the numeric min/max filters: pin the
	 * gift to a fixed quantity of one so it can't be edited up into multiple free products. The
	 * classic and side carts lock it through their own filters; the blocks only honour these.
	 *
	 * @param mixed      $value     The value being filtered (bool for "editable", int for min/max).
	 * @param mixed      $product   The product (unused).
	 * @param array|null $cart_item The cart item, or null when the product is not in the cart.
	 * @return mixed
	 */
	public function lock_free_gift_store_api_quantity( $value, $product, $cart_item ) {
		if ( is_array( $cart_item ) && $this->is_free_gift_cart_item( $cart_item ) ) {
			return is_bool( $value ) ? false : 1;
		}

		return $value;
	}

	/**
	 * Hide the remove link on the free gift — removing it would just re-add on the next update.
	 *
	 * @param string $link          The remove link markup.
	 * @param string $cart_item_key The cart item key.
	 * @return string
	 */
	public function maybe_hide_free_gift_remove_link( $link, $cart_item_key ) {
		$cart = WC()->cart ? WC()->cart->get_cart() : [];

		if ( isset( $cart[ $cart_item_key ] ) && $this->is_free_gift_cart_item( $cart[ $cart_item_key ] ) ) {
			return '';
		}

		return $link;
	}

	/**
	 * Render the free gift's price as the original struck through plus the word "Free".
	 *
	 * @param string $subtotal  The subtotal markup.
	 * @param array  $cart_item The cart item.
	 * @param string $key       The cart item key.
	 * @return string
	 */
	public function maybe_free_gift_subtotal( $subtotal, $cart_item, $key ) {
		if ( ! $this->is_free_gift_cart_item( $cart_item ) ) {
			return $subtotal;
		}

		$product = $cart_item['data'] ?? null;
		$free    = '<span class="cfw-free-gift-price">' . esc_html__( 'Free', 'checkout-wc' ) . '</span>';

		if ( $product instanceof \WC_Product ) {
			$regular = (float) $product->get_regular_price();

			if ( $regular > 0 ) {
				$qty       = isset( $cart_item['quantity'] ) ? (int) $cart_item['quantity'] : 1;
				$was_price = wc_price( $regular * max( 1, $qty ) );

				return '<del aria-hidden="true">' . $was_price . '</del> ' . $free;
			}
		}

		return $free;
	}

	/**
	 * Map currently-managed gift bumps by their owning tier id.
	 *
	 * @return array [ tier_id => bump_post_id ]
	 */
	protected function get_managed_gift_bumps(): array {
		$post_ids = get_posts(
			[
				'post_type'   => 'cfw_order_bumps',
				'post_status' => 'any',
				'numberposts' => -1, // Bounded: at most one per tier (max 3 tiers).
				'fields'      => 'ids',
				'meta_key'    => '_cfw_managed_by', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
				'meta_value'  => 'side_cart_tier', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
			]
		);

		$map = [];

		foreach ( $post_ids as $post_id ) {
			$tier_id = get_post_meta( $post_id, '_cfw_side_cart_tier_id', true );

			if ( $tier_id ) {
				$map[ (string) $tier_id ] = (int) $post_id;
			} else {
				// Managed bump with no tier link — orphan; remove it.
				wp_delete_post( (int) $post_id, true );
			}
		}

		return $map;
	}

	/**
	 * Create or update the managed order bump that fulfills a gift tier.
	 *
	 * @param string $tier_id    The owning tier id.
	 * @param int    $bump_id    Existing managed bump id, or 0 to create.
	 * @param int    $product_id The gift product id.
	 * @param string $label      The gift product label.
	 * @param float  $threshold  The spend threshold that unlocks the gift.
	 */
	protected function upsert_gift_bump( string $tier_id, int $bump_id, int $product_id, string $label, float $threshold, string $tier_label = '' ) {
		if ( empty( $bump_id ) || ! get_post( $bump_id ) ) {
			$bump_id = wp_insert_post(
				[
					'post_type'   => 'cfw_order_bumps',
					'post_status' => 'publish',
					'post_title'  => sprintf(
						/* translators: %s is the gift product name */
						__( 'Auto-managed free gift: %s — created by the Side Cart rewards bar, do not edit', 'checkout-wc' ),
						'' !== $label ? $label : (string) $product_id
					),
					// Set the managed marker via meta_input so it rides along in the postarr at
					// wp_insert_post_data time — that's what allow_managed_gift_publish() checks to
					// exempt the gift from the plan's manual-bump publish limit on first insert,
					// before any meta row exists.
					'meta_input'  => [
						'_cfw_managed_by'        => 'side_cart_tier',
						'_cfw_side_cart_tier_id' => $tier_id,
					],
				]
			);

			if ( is_wp_error( $bump_id ) || ! $bump_id ) {
				return;
			}
		}

		// The tier's short label drives the cart badge (falls back to "Free gift" when blank).
		update_post_meta( $bump_id, '_cfw_side_cart_tier_label', $tier_label );

		// Removal (maybe_remove_unqualified_gifts) and pricing (gate_managed_gift_bump_validity)
		// read the threshold here so they share one number with the bar.
		update_post_meta( $bump_id, '_cfw_side_cart_tier_threshold', $threshold );

		$meta = [
			'cfw_ob_offer_product_v9'             => [ [ 'key' => $product_id, 'label' => $label ] ],
			'cfw_ob_auto_add'                     => 'yes',
			'cfw_ob_discount_type'                => 'percent',
			'cfw_ob_offer_discount'               => '100',
			'cfw_ob_display_location'             => 'below_side_cart_items',
			'cfw_ob_offer_quantity'               => '1',
			'cfw_ob_upsell'                       => 'no',
			'cfw_ob_enable_auto_match'            => 'no',
			'cfw_ob_match_offer_product_quantity' => 'no',
			// Gate display and auto-add on the threshold via the bump's own rule: is_displayable()
			// (which drives the offer card and maybe_auto_add_bumps) evaluates these rules, so the
			// gift is only offered/auto-added once the cart qualifies — without it the card would
			// show, and the gift churn-add, below the threshold. RulesProcessor's cartTotalValue
			// excludes the bump's own line and uses the same tax basis as the qualifying subtotal,
			// so it agrees with the removal/pricing gate at the boundary.
			'cfw_ob_rules'                        => [
				[
					'fieldKey'  => 'cartTotalValue',
					'subFields' => [
						'operator' => 'greaterThanEqual',
						'value'    => $threshold,
					],
				],
			],
		];

		foreach ( $meta as $key => $val ) {
			update_post_meta( $bump_id, $key, $val );
		}

		// Retire the old threshold-rule meta on bumps provisioned by earlier dev builds.
		delete_post_meta( $bump_id, 'cfw_ob_minimum_subtotal' );
	}

	public function add_custom_css_property( $properties ) {
		$properties['--cfw-side-cart-free-shipping-progress-indicator']  = SettingsManager::instance()->get_setting( 'side_cart_free_shipping_progress_indicator_color' );
		$properties['--cfw-side-cart-free-shipping-progress-background'] = SettingsManager::instance()->get_setting( 'side_cart_free_shipping_progress_bg_color' );
		$properties['--cfw-side-cart-button-bottom-position']            = SettingsManager::instance()->get_setting( 'floating_cart_button_bottom_position' ) . 'px';
		$properties['--cfw-side-cart-button-right-position']             = SettingsManager::instance()->get_setting( 'floating_cart_button_right_position' ) . 'px';
		$properties['--cfw-side-cart-icon-color'] = SettingsManager::instance()->get_setting( 'side_cart_icon_color' );
		$properties['--cfw-side-cart-icon-width'] = SettingsManager::instance()->get_setting( 'side_cart_icon_width' ) . 'px';

		return $properties;
	}

	public function maybe_prevent_add_to_cart_redirect( $redirect_url ) {
		// Special handling for this aptly named plugin: Direct checkout, Add to cart redirect, Quick purchase button, Buy now button, Quick View button for WooCommerce
		if ( isset( $_POST['pi_quick_checkout'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
			return $redirect_url;
		}

		// Otherwise, stop it
		return false;
	}

	public function add_localized_settings( array $event_data ): array {
		$event_data['settings']['enable_ajax_add_to_cart'] = SettingsManager::instance()->get_setting( 'enable_ajax_add_to_cart' ) === 'yes';
		$event_data['runtime_params']['openCart']          = $this->item_just_added_to_cart;

		return $event_data;
	}

	public function remove_cart_breadcrumb( $breadcrumbs ) {
		unset( $breadcrumbs['cart'] );

		return $breadcrumbs;
	}

	public function render_shortcode( $attributes ): string {
		$attributes = shortcode_atts(
			[
				'color'      => SettingsManager::instance()->get_setting( 'side_cart_icon_color' ),
				'width'      => SettingsManager::instance()->get_setting( 'side_cart_icon_width' ) . 'px',
				'text_color' => '#222',
			],
			$attributes,
			'checkoutwc_cart'
		);

		/**
		 * Filters additional classes for the cart icon shortcode
		 *
		 * @param array $additional_classes Additional classes for the cart icon shortcode
		 *
		 * @since 8.2.18
		 */
		$additional_classes = apply_filters( 'checkoutwc_cart_shortcode_additional_classes', [] );

		$additional_classes[] = 'cfw_cart_icon_shortcode';
		$additional_classes[] = 'cfw-side-cart-open-trigger';

		$output  = "<style>.cfw_cart_icon_shortcode { --cfw-side-cart-icon-color: {$attributes['color']}; --cfw-side-cart-icon-width: {$attributes['width']}; --cfw-side-cart-icon-text-color: {$attributes['text_color']}; }</style>";
		$output .= '<div class="cfw-checkoutwc_cart-shortcode" data-additional-classes="' . join( ' ', $additional_classes ) . '"></div>';

		return $output;
	}

	public static function get_cart_icon_file_contents(): string {
		$custom_attachment_id = SettingsManager::instance()->get_setting( 'side_cart_custom_icon_attachment_id' );

		// Get local file path to attachment
		if ( ! empty( $custom_attachment_id ) ) {
			$path = get_attached_file( $custom_attachment_id );
		} else {
			$filename = SettingsManager::instance()->get_setting( 'side_cart_icon' );
			$path     = CFW_PATH . '/assets/images/cart-icons/' . $filename;
		}

		if ( ! file_exists( $path ) ) {
			return '';
		}

		/**
		 * The path to the side cart icon file
		 *
		 * @since 8.2.7
		 * @param string $path The path to the side cart icon file
		 */
		$path = apply_filters( 'cfw_side_cart_icon_file_path', $path );

		$contents = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents

		/**
		 * If the custom SVG does not have any meaningful strokes, assume it's solid and apply our special class
		 */
		if ( ! empty( $custom_attachment_id ) && ! self::has_meaningful_stroke( $contents ) ) {
			$contents = str_replace( '<svg ', '<svg class="cfw-side-cart-icon-solid" ', $contents );
		}

		/**
		 * The contents of the side cart icon file
		 *
		 * @since 8.2.7
		 * @param string $path The contents of the side cart icon file
		 */
		return apply_filters( 'cfw_side_cart_icon', $contents ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
	}

	public static function has_meaningful_stroke( $svg_contents ): bool {
		// Remove stroke-width:0 and stroke:none cases (these are solid icons)
		$normalized = preg_replace( '/stroke-width\s*:\s*0(px|%|em|rem)?/i', '', $svg_contents );
		$normalized = preg_replace( '/stroke\s*:\s*none/i', '', $normalized );

		// Now check if any stroke-related properties remain
		return stripos( $normalized, 'stroke' ) !== false;
	}

	public function add_side_cart_actions_data( $actions ) {
		/**
		 * Fires after side cart items table
		 *
		 * @since 7.0.0
		 */
		$actions['cfw_after_side_cart_items_table'] = cfw_get_action_output( 'cfw_after_side_cart_items_table' );

		/**
		 * Fires after side cart totals table
		 *
		 * @since 7.0.0
		 */
		$actions['cfw_before_side_cart_totals'] = cfw_get_action_output( 'cfw_before_side_cart_totals' );

		/**
		 * Fires after side cart totals table
		 *
		 * @since 7.0.0
		 */
		$actions['cfw_after_side_cart_totals'] = cfw_get_action_output( 'cfw_after_side_cart_totals' );

		$actions['cfw_side_cart_footer_start'] = cfw_get_action_output( 'cfw_side_cart_footer_start' );

		/**
		 * Filter to enable or disable the WooCommerce cart actions
		 *
		 * @since 8.0.0
		 * @param bool $cfw_run_woocommerce_cart_actions
		 */
		$cfw_run_woocommerce_cart_actions = apply_filters( 'cfw_run_woocommerce_cart_actions', false );

		$actions['woocommerce_cart_actions'] = $cfw_run_woocommerce_cart_actions ? cfw_get_action_output( 'woocommerce_cart_actions' ) : '';

		/**
		 * Fires after checkout proceed to cart (and maybe continue shopping) buttons
		 *
		 * @since 7.0.6
		 */
		$actions['cfw_after_side_cart_proceed_to_checkout_button'] = cfw_get_action_output( 'cfw_after_side_cart_proceed_to_checkout_button' );

		$this->unhook_default_mini_cart_buttons();
		$actions['woocommerce_widget_shopping_cart_buttons'] = cfw_get_action_output( 'woocommerce_widget_shopping_cart_buttons' );
		$this->rehook_default_mini_cart_buttons();

		$actions['cfw_after_side_cart_header'] = cfw_get_action_output( 'cfw_after_side_cart_header' );

		return $actions;
	}

	public function add_side_cart_data( $data ): array {
		$free_shipping_data       = $this->get_free_shipping_data();
		$fill_percent             = $this->get_fill_percentage( $free_shipping_data );
		$has_free_shipping        = $this->does_cart_qualifies_for_free_shipping( $free_shipping_data );
		$amount_remaining         = $this->get_remaining_amount_to_qualify_for_free_shipping( $free_shipping_data );
		$random_fallback          = SettingsManager::instance()->get_setting( 'enable_side_cart_suggested_products_random_fallback' ) === 'yes';
		$products                 = SettingsManager::instance()->get_setting( 'enable_side_cart_suggested_products' ) === 'yes' ? cfw_get_suggested_products( 3, $random_fallback ) : [];
		$free_shipping_message    = SettingsManager::instance()->get_setting( 'side_cart_free_shipping_message' );
		$amount_remaining_message = SettingsManager::instance()->get_setting( 'side_cart_amount_remaining_message' );

		if ( empty( $free_shipping_message ) ) {
			$free_shipping_message = __( 'Congrats! You get free standard shipping.', 'checkout-wc' );
		}

		/**
		 * Filter the message displayed when the cart qualifies for free shipping.
		 *
		 * @param string $free_shipping_message
		 *
		 * @since 7.3.0
		 */
		$free_shipping_message = apply_filters( 'cfw_side_cart_free_shipping_progress_bar_free_shipping_message', $free_shipping_message );

		if ( empty( $amount_remaining_message ) ) {
			// translators: %s is the amount remaining for free shipping
			$amount_remaining_message = __( 'You\'re %s away from free shipping!', 'checkout-wc' );
		}

		/**
		 * Filter the message format for the amount remaining for free shipping
		 *
		 * @param string $amount_remaining_message
		 *
		 * @since 7.3.0
		 */
		$amount_remaining_message = apply_filters( 'cfw_side_cart_free_shipping_progress_bar_amount_remaining_message_format', $amount_remaining_message );
		$amount_remaining         = null !== $amount_remaining ? '<strong>' . wc_price( $amount_remaining, [ 'decimals' => 0.0 === fmod( $amount_remaining ?? 0, 1 ) ? 0 : 2 ] ) . '</strong>' : null;
		$amount_remaining_message = sprintf( $amount_remaining_message, $amount_remaining );

		$suggested_products = [];

		foreach ( $products as $product ) {
			$suggested_products[] = [
				'productId'     => $product->get_id(),
				'title'         => $product->get_title(),
				/**
				 * Fires after the suggested product title
				 *
				 * @since 9.0.33
				 * @param int $product_id The ID of the suggested product
				 */
				'afterTitle'    => cfw_get_action_output( 'cfw_after_suggested_product_title', $product->get_id() ),
				'priceHtml'     => $product->get_price_html(),
				'imageTag'      => $product->get_image( 'cfw_cart_thumb' ),
				'isVariable'    => $product->is_type( 'variable' ) && 0 === $product->get_parent_id(),
				'addToCartText' => $product->single_add_to_cart_text(),
				'permalink'     => get_permalink( $product->get_id() ),
			];
		}

		$tiers_data = [];

		if ( ! empty( $free_shipping_data['tiers'] ) && is_array( $free_shipping_data['tiers'] ) ) {
			foreach ( $free_shipping_data['tiers'] as $tier ) {
				$unlocked = $tier['unlocked_message'];

				if ( '' === $unlocked ) {
					$unlocked = 'free_shipping' === $tier['reward_type']
						? __( 'Congrats! You get free standard shipping.', 'checkout-wc' )
						: __( 'Reward unlocked!', 'checkout-wc' );
				}

				$remaining_format = $tier['remaining_message'];

				if ( '' === $remaining_format ) {
					$remaining_format = 'free_shipping' === $tier['reward_type']
						// translators: %s is the amount remaining for free shipping
						? __( "You're %s away from free shipping!", 'checkout-wc' )
						// translators: %s is the amount remaining for the next reward
						: __( "You're %s away from your next reward!", 'checkout-wc' );
				}

				$tier_amount_remaining      = (float) $tier['amount_remaining'];
				$tier_amount_remaining_html = '<strong>' . wc_price( $tier_amount_remaining, [ 'decimals' => 0.0 === fmod( $tier_amount_remaining, 1 ) ? 0 : 2 ] ) . '</strong>';

				$reward_labels = [
					'free_shipping' => __( 'Free shipping', 'checkout-wc' ),
					'free_gift'     => __( 'Free gift', 'checkout-wc' ),
					'message'       => __( 'Reward', 'checkout-wc' ),
				];

				$tier_threshold = (float) $tier['threshold'];

				// Merchant-set short label wins; otherwise fall back to the reward-type default.
				$tier_label = ! empty( $tier['label'] ) ? (string) $tier['label'] : ( $reward_labels[ $tier['reward_type'] ] ?? $reward_labels['message'] );

				/**
				 * Filters the short label shown beneath a rewards rail tier node.
				 *
				 * @param string $tier_label The resolved label.
				 * @param array  $tier       The tier data.
				 *
				 * @since 11.3.0
				 */
				$tier_label = apply_filters( 'cfw_side_cart_tier_label', $tier_label, $tier );

				$tier_data = [
					'id'                  => $tier['id'],
					'reward_type'         => $tier['reward_type'],
					'met'                 => $tier['met'],
					'is_next'             => $tier['is_next'] ?? false,
					'position_percentage' => $tier['position_percentage'] ?? 100,
					'color'               => $tier['color'],
					'label'               => $tier_label,
					'threshold_html'      => wc_price( $tier_threshold, [ 'decimals' => 0.0 === fmod( $tier_threshold, 1 ) ? 0 : 2 ] ),
					'unlocked_message'    => $unlocked,
					'remaining_message'   => sprintf( $remaining_format, $tier_amount_remaining_html ),
				];

				/**
				 * Filters the full data for a single rewards rail tier before it is sent to the side cart.
				 *
				 * @param array $tier_data The display data (label, threshold, messages, color, state).
				 * @param array $tier      The underlying tier data.
				 *
				 * @since 11.3.0
				 */
				$tiers_data[] = apply_filters( 'cfw_side_cart_tier_data', $tier_data, $tier );
			}
		}

		$data['side_cart'] = [
			'free_shipping_progress_bar' => [
				'has_free_shipping'        => $has_free_shipping,
				'amount_remaining'         => $amount_remaining,
				'fill_percentage'          => $fill_percent,
				'free_shipping_message'    => $free_shipping_message,
				'amount_remaining_message' => $amount_remaining_message,
				'tiers'                    => $tiers_data,
			],
			'suggested_products'         => $suggested_products,
		];

		return $data;
	}

	/**
	 * Add data to add to cart fragment
	 *
	 * @param array $fragments The add to cart fragments.
	 * @throws Exception The exception.
	 */
	public function add_data_to_add_to_cart_fragments( $fragments ) {
		if ( SettingsManager::instance()->get_setting( 'enable_side_cart_totals' ) === 'yes' ) {
			WC()->cart->calculate_shipping();
		}

		$fragments['cfw_data'] = AssetManager::get_data();

		return $fragments;
	}

	public function maybe_disable_side_cart_editing( $disable, $cart_item, $cart_item_key ) {
		if ( is_checkout() ) {
			return $disable;
		}

		/**
		 * Filters whether to disable cart item quantity control on the Side Cart
		 *
		 * @param bool $disable Whether to disable cart item quantity control on the Side Cart
		 * @param array $cart_item The cart item
		 * @param string $cart_item_key The cart item key
		 *
		 * @since 9.0.0
		 */
		return apply_filters( 'cfw_disable_side_cart_item_quantity_control', $disable, $cart_item, $cart_item_key );
	}

	public function maybe_disable_side_cart_variation_editing( $disable, $item ): bool {
		if ( is_checkout() ) {
			return (bool) $disable;
		}

		return SettingsManager::instance()->get_setting( 'allow_side_cart_item_variation_changes' ) !== 'yes' || empty( $item['variation_id'] );
	}

	public function detect_cart_changes( $set ) {
		if ( true !== $set ) {
			return; // cookies aren't being set by Woo so bail
		}

		if ( ! WC()->cart ) {
			return;
		}

		if ( headers_sent() ) {
			return;
		}

		if ( empty( $_COOKIE['cfw_cart_hash'] ) || WC()->cart->get_cart_hash() !== $_COOKIE['cfw_cart_hash'] ) {
			setcookie( 'cfw_cart_hash', WC()->cart->get_cart_hash(), time() + ( DAY_IN_SECONDS * 30 ), '/', COOKIE_DOMAIN, is_ssl() );
		}
	}

	public function output_woocommerce_product_settings_notice() {
		if ( SettingsManager::instance()->get_setting( 'enable_side_cart' ) !== 'yes' ) {
			return;
		}
		?>
		<div id="message" class="updated woocommerce-message inline">
			<p>
				<strong><?php _e( 'CheckoutWC:' ); ?></strong>
				<?php _e( 'Settings marked with asterisks (**) may be overridden based on your Side Cart settings. (CheckoutWC > Side Cart)' ); ?>
			</p>
		</div>
		<?php
	}

	public function mark_possibly_overridden_product_settings( array $settings ): array {
		foreach ( $settings as $key => $setting ) {
			if ( 'woocommerce_enable_ajax_add_to_cart' === $setting['id'] || 'woocommerce_cart_redirect_after_add' === $setting['id'] ) {
				$settings[ $key ]['desc'] = "{$setting['desc']} **";
			}
		}

		return $settings;
	}
}
