<?php

/**
 * Ecommerce Restriction Service
 *
 * Enforces members-only ecommerce rules on the frontend.
 * Handles product visibility, purchase blocking, cart validation,
 * sitemap filtering, and WooCommerce Store API protection.
 *
 * @package ArraySubs\Features\MembersAccess\Services
 * @since 1.10.0
 */

namespace ArraySubs\Features\MembersAccess\Services;

defined('ABSPATH') || exit;

use ArraySubs\Supports\Config;
use Automattic\WooCommerce\StoreApi\Schemas\V1\ProductSchema;

/**
 * EcommerceRestriction class
 */
class EcommerceRestriction
{

	/**
	 * Enable auto-loading
	 *
	 * @var bool
	 */
	public static $loadable = true;

	/**
	 * Condition evaluator instance
	 *
	 * @var ConditionEvaluator
	 */
	private ConditionEvaluator $evaluator;

	/**
	 * Cached rules for current request
	 *
	 * @var array|null
	 */
	private ?array $rules_cache = null;

	/**
	 * Cached evaluation results per product per user
	 *
	 * @var array
	 */
	private array $access_cache = [];

	/**
	 * Internal flag to avoid recursive query filtering while collecting product IDs
	 *
	 * @var bool
	 */
	private bool $collecting_product_ids = false;

	/**
	 * Constructor
	 */
	public function __construct()
	{
		if (! $this->isEnabled()) {
			return;
		}

		$this->evaluator = new ConditionEvaluator();

		// Product visibility: hide products with 404 action from queries.
		add_action('woocommerce_product_query', array($this, 'filterProductQuery'), 20);
		add_action('pre_get_posts', array($this, 'filterFrontendProductQueries'), 20);
		add_filter('woocommerce_shortcode_products_query', array($this, 'filterShortcodeQuery'), 20);

		// Single product page: redirect or 404.
		add_action('template_redirect', array($this, 'handleSingleProductAccess'));

		// Purchase blocking: disable add-to-cart button.
		add_filter('woocommerce_is_purchasable', array($this, 'filterIsPurchasable'), 90, 2);
		add_filter('woocommerce_variation_is_purchasable', array($this, 'filterVariationIsPurchasable'), 90, 2);

		// Replace add-to-cart template for block_purchase action.
		add_action('woocommerce_single_product_summary', array($this, 'maybeShowBlockedMessage'), 29);
		add_filter('woocommerce_loop_add_to_cart_link', array($this, 'filterLoopAddToCartLink'), 90, 2);

		// Add-to-cart validation — prevent adding restricted products.
		add_filter('woocommerce_add_to_cart_validation', array($this, 'validateAddToCart'), 10, 4);

		// Cart & checkout validation — catch items added before restriction applied.
		add_action('woocommerce_check_cart_items', array($this, 'validateCartItems'));

		// Related products, upsells, cross-sells filtering.
		add_filter('woocommerce_related_products', array($this, 'filterRelatedProducts'), 20);
		add_filter('woocommerce_product_upsell_ids', array($this, 'filterUpsellIds'), 20, 2);
		add_filter('woocommerce_product_crosssell_ids', array($this, 'filterCrossSellIds'), 20, 2);

		// Widget & block product queries.
		add_filter('woocommerce_products_widget_query_args', array($this, 'filterWidgetQueryArgs'), 20);

		// XML Sitemap filtering.
		add_filter('wp_sitemaps_posts_query_args', array($this, 'filterSitemapQueryArgs'), 10, 2);
		// Yoast SEO sitemap compatibility.
		add_filter('wpseo_exclude_from_sitemap_by_post_ids', array($this, 'filterYoastSitemapExclusions'));
		// Rank Math sitemap compatibility.
		add_filter('rank_math/sitemap/entry', array($this, 'filterRankMathSitemapEntry'), 10, 3);

		// WooCommerce Store API protection.
		add_filter('woocommerce_store_api_product_quantity_maximum', array($this, 'filterStoreApiQuantityMaximum'), 10, 3);
		add_filter('woocommerce_store_api_product_quantity_editable', array($this, 'filterStoreApiQuantityEditable'), 10, 3);
		add_action('woocommerce_store_api_validate_add_to_cart', array($this, 'validateStoreApiAddToCart'), 10, 2);
		add_action('woocommerce_store_api_validate_cart_item', array($this, 'validateStoreApiCartItem'), 10, 2);

		// WooCommerce REST API protection (standard API).
		add_filter('woocommerce_rest_prepare_product_object', array($this, 'filterRestProductResponse'), 20, 3);
		add_filter('rest_request_before_callbacks', array($this, 'maybeRestrictStoreApiProductRequest'), 10, 3);

		// Customer portal plan switching — hide restricted products.
		add_filter('arraysubs_plan_switch_available_products', array($this, 'filterPlanSwitchProducts'), 20);

		// Frontend styles for restriction notices and badges.
		add_action('wp_enqueue_scripts', array($this, 'enqueueFrontendStyles'));
		add_action('rest_api_init', array($this, 'registerStoreApiProductData'));

		// Allow third-party plugins to extend or override.
		add_filter('arraysubs_ecommerce_user_has_access', array($this, 'applyExternalAccessFilter'), 10, 3);
	}

	/**
	 * Check if ecommerce restrictions are enabled
	 *
	 * @return bool
	 */
	private function isEnabled(): bool
	{
		$members_access_enabled = (bool) arraysubs_get_setting('members_access.enabled', true);
		$rules                  = $this->getRules();
		return $members_access_enabled && ! empty($rules);
	}

	// ─────────────────────────────────────────────────────────
	// Rule Loading & Matching
	// ─────────────────────────────────────────────────────────

	/**
	 * Get ecommerce rules from settings (cached per request)
	 *
	 * @return array
	 */
	private function getRules(): array
	{
		if (null === $this->rules_cache) {
			$this->rules_cache = arraysubs_get_setting('members_access.ecommerce_rules', array());
		}
		return $this->rules_cache;
	}

	/**
	 * Get enabled rules only (public, filterable)
	 *
	 * @return array
	 */
	public function getEnabledRules(): array
	{
		$enabled_rules = array_values(
			array_filter(
				$this->getRules(),
				function ($rule) {
					return ! empty($rule['enabled']);
				}
			)
		);

		/**
		 * Filter enabled ecommerce restriction rules.
		 *
		 * @param array                $enabled_rules Enabled rules only.
		 * @param array                $all_rules     All configured ecommerce rules.
		 * @param EcommerceRestriction $service       Current restriction service instance.
		 */
		return apply_filters('arraysubs_ecommerce_restriction_rules', $enabled_rules, $this->getRules(), $this);
	}

	/**
	 * Find the first matching rule for a product
	 *
	 * Rules are processed in order. First match wins.
	 *
	 * @param int $product_id Product ID.
	 * @return array|null Matching rule or null.
	 */
	private function getMatchingRule(int $product_id): ?array
	{
		$rules = $this->getEnabledRules();

		foreach ($rules as $rule) {
			if ($this->productMatchesRule($product_id, $rule)) {
				return $rule;
			}
		}

		return null;
	}

	/**
	 * Check if a product matches a rule's target scope
	 *
	 * @param int   $product_id Product ID.
	 * @param array $rule       Rule configuration.
	 * @return bool
	 */
	private function productMatchesRule(int $product_id, array $rule): bool
	{
		// Check exclusions first.
		if ($this->isProductExcluded($product_id, $rule)) {
			return false;
		}

		$scope = $rule['scope'] ?? 'all';

		switch ($scope) {
			case 'all':
				return true;

			case 'specific_products':
				$target_ids = array_map('absint', $rule['target_product_ids'] ?? array());
				if (empty($target_ids)) {
					return false;
				}
				// Match parent product or variation's parent.
				$parent_id = wp_get_post_parent_id($product_id);
				return in_array($product_id, $target_ids, true)
					|| ($parent_id && in_array($parent_id, $target_ids, true));

			case 'specific_categories':
				$target_cat_ids = array_map('absint', $rule['target_category_ids'] ?? array());
				if (empty($target_cat_ids)) {
					return false;
				}
				// Check parent product ID for variations.
				$check_id    = wp_get_post_parent_id($product_id) ?: $product_id;
				$product_cats = wp_get_post_terms($check_id, 'product_cat', array('fields' => 'ids'));
				if (is_wp_error($product_cats)) {
					return false;
				}
				return ! empty(array_intersect($product_cats, $target_cat_ids));

			default:
				return false;
		}
	}

	/**
	 * Check if a product is excluded from a rule
	 *
	 * @param int   $product_id Product ID.
	 * @param array $rule       Rule configuration.
	 * @return bool True if product is excluded.
	 */
	private function isProductExcluded(int $product_id, array $rule): bool
	{
		// Check product exclusions.
		$exclusion_product_ids = array_map('absint', $rule['exclusion_product_ids'] ?? array());
		if (! empty($exclusion_product_ids)) {
			$parent_id = wp_get_post_parent_id($product_id);
			if (
				in_array($product_id, $exclusion_product_ids, true)
				|| ($parent_id && in_array($parent_id, $exclusion_product_ids, true))
			) {
				return true;
			}
		}

		// Check category exclusions.
		$exclusion_category_ids = array_map('absint', $rule['exclusion_category_ids'] ?? array());
		if (! empty($exclusion_category_ids)) {
			$check_id    = wp_get_post_parent_id($product_id) ?: $product_id;
			$product_cats = wp_get_post_terms($check_id, 'product_cat', array('fields' => 'ids'));
			if (! is_wp_error($product_cats) && ! empty(array_intersect($product_cats, $exclusion_category_ids))) {
				return true;
			}
		}

		return false;
	}

	// ─────────────────────────────────────────────────────────
	// Access Evaluation
	// ─────────────────────────────────────────────────────────

	/**
	 * Check if the current user can access a product
	 *
	 * @param int        $product_id Product ID.
	 * @param array|null $rule       Optional pre-matched rule.
	 * @return bool True if user has access.
	 */
	public function userCanAccessProduct(int $product_id, ?array $rule = null): bool
	{
		// Admins and shop managers always bypass.
		if ($this->isPrivilegedUser()) {
			return true;
		}

		$user_id = get_current_user_id();

		// Check cache.
		$cache_key = $product_id . '_' . $user_id;
		if (isset($this->access_cache[$cache_key])) {
			return $this->access_cache[$cache_key];
		}

		// Find matching rule if not provided.
		if (null === $rule) {
			$rule = $this->getMatchingRule($product_id);
		}

		// No matching rule = access granted.
		if (null === $rule) {
			$this->access_cache[$cache_key] = true;
			return true;
		}

		// Evaluate conditions — if user meets conditions, they have access.
		$conditions = $rule['conditions'] ?? array();
		$has_access = $this->evaluator->evaluate($user_id, $conditions);

		// Check dripping schedule — conditions may pass but access is delayed.
		if ($has_access) {
			$schedule_evaluator = new ScheduleEvaluator();
			if (!$schedule_evaluator->isScheduleSatisfied($user_id, $rule)) {
				$has_access = false;
			}
		}

		/**
		 * Filter whether a user has access to a restricted product
		 *
		 * @param bool  $has_access  Whether the user has access.
		 * @param int   $product_id  Product ID.
		 * @param array $rule        The matching restriction rule.
		 */
		$has_access = (bool) apply_filters('arraysubs_ecommerce_user_has_access', $has_access, $product_id, $rule);

		$this->access_cache[$cache_key] = $has_access;
		return $has_access;
	}

	/**
	 * Get the restriction details for a product (rule + access status)
	 *
	 * @param int $product_id Product ID.
	 * @return array|null Array with 'rule' and 'has_access' keys, or null if no rule matches.
	 */
	public function getProductRestriction(int $product_id): ?array
	{
		$rule = $this->getMatchingRule($product_id);
		if (null === $rule) {
			return null;
		}

		return array(
			'rule'       => $rule,
			'has_access' => $this->userCanAccessProduct($product_id, $rule),
		);
	}

	/**
	 * Check if the current user is an admin or shop manager
	 *
	 * @return bool
	 */
	private function isPrivilegedUser(): bool
	{
		if (! is_user_logged_in()) {
			return false;
		}
		return current_user_can('manage_options') || current_user_can('manage_woocommerce');
	}

	/**
	 * Get all product IDs that should be hidden (404 action) from the current user
	 *
	 * @return int[]
	 */
	private function getHiddenProductIds(): array
	{
		static $hidden_ids = null;

		if (null !== $hidden_ids) {
			return $hidden_ids;
		}

		$hidden_ids = array();

		if ($this->isPrivilegedUser()) {
			return $hidden_ids;
		}

		$rules   = $this->getEnabledRules();
		$user_id = get_current_user_id();

		foreach ($rules as $rule) {
			if (($rule['action'] ?? '') !== '404') {
				continue;
			}

			// Check if user meets conditions.
			$conditions = $rule['conditions'] ?? array();
			if ($this->evaluator->evaluate($user_id, $conditions)) {
				// Conditions pass — check dripping schedule.
				$schedule_evaluator = new ScheduleEvaluator();
				if ($schedule_evaluator->isScheduleSatisfied($user_id, $rule)) {
					continue; // User has access and schedule is met, skip.
				}
			}

			// User doesn't have access — collect hidden product IDs.
			$scope = $rule['scope'] ?? 'all';

			switch ($scope) {
				case 'all':
					$product_ids = $this->getAllPublishedProductIds();
					$hidden_ids  = array_merge($hidden_ids, $product_ids);
					break;

				case 'specific_products':
					$target_ids = array_map('absint', $rule['target_product_ids'] ?? array());
					$hidden_ids = array_merge($hidden_ids, $target_ids);
					break;

				case 'specific_categories':
					$target_cat_ids = array_map('absint', $rule['target_category_ids'] ?? array());
					if (! empty($target_cat_ids)) {
						$cat_product_ids = $this->getProductIdsByCategories($target_cat_ids);
						$hidden_ids      = array_merge($hidden_ids, $cat_product_ids);
					}
					break;
			}

			// Remove exclusions.
			$exclusion_product_ids  = array_map('absint', $rule['exclusion_product_ids'] ?? array());
			$exclusion_category_ids = array_map('absint', $rule['exclusion_category_ids'] ?? array());

			if (! empty($exclusion_product_ids)) {
				$hidden_ids = array_diff($hidden_ids, $exclusion_product_ids);
			}

			if (! empty($exclusion_category_ids)) {
				$excluded_cat_products = $this->getProductIdsByCategories($exclusion_category_ids);
				$hidden_ids            = array_diff($hidden_ids, $excluded_cat_products);
			}
		}

		$hidden_ids = array_values(array_unique(array_filter(array_map('absint', $hidden_ids))));

		/**
		 * Filter product IDs hidden from the current user by ecommerce restrictions.
		 *
		 * @param int[]                $hidden_ids Hidden product IDs.
		 * @param int                  $user_id    Current user ID.
		 * @param array                $rules      Enabled ecommerce rules.
		 * @param EcommerceRestriction $service    Current restriction service instance.
		 */
		$hidden_ids = apply_filters('arraysubs_ecommerce_hidden_product_ids', $hidden_ids, $user_id, $rules, $this);

		return $hidden_ids;
	}

	/**
	 * Get all published product IDs
	 *
	 * @return int[]
	 */
	private function getAllPublishedProductIds(): array
	{
		$this->collecting_product_ids = true;

		$product_ids = get_posts(
			array(
				'post_type'              => 'product',
				'post_status'            => 'publish',
				'fields'                 => 'ids',
				'posts_per_page'         => -1,
				'no_found_rows'          => true,
				'orderby'                => 'ID',
				'order'                  => 'ASC',
				'update_post_meta_cache' => false,
				'update_post_term_cache' => false,
			)
		);

		$this->collecting_product_ids = false;

		return array_map('absint', $product_ids);
	}

	/**
	 * Get product IDs by category IDs
	 *
	 * @param int[] $category_ids Category IDs.
	 * @return int[]
	 */
	private function getProductIdsByCategories(array $category_ids): array
	{
		$category_ids = array_values(array_filter(array_map('absint', $category_ids)));

		if (empty($category_ids)) {
			return array();
		}

		$this->collecting_product_ids = true;

		$product_ids = get_posts(
			array(
				'post_type'              => 'product',
				'post_status'            => 'publish',
				'fields'                 => 'ids',
				'posts_per_page'         => -1,
				'no_found_rows'          => true,
				'orderby'                => 'ID',
				'order'                  => 'ASC',
				'update_post_meta_cache' => false,
				'update_post_term_cache' => false,
				'tax_query'              => array(
					array(
						'taxonomy' => 'product_cat',
						'field'    => 'term_id',
						'terms'    => $category_ids,
						'operator' => 'IN',
					),
				),
			)
		);

		$this->collecting_product_ids = false;

		return array_map('absint', $product_ids);
	}

	/**
	 * Filter frontend and REST product queries, including Store API and blocks.
	 *
	 * @param \WP_Query $query Query instance.
	 * @return void
	 */
	public function filterFrontendProductQueries(\WP_Query $query): void
	{
		if ($this->collecting_product_ids || $query->get('arraysubs_ecommerce_filtered')) {
			return;
		}

		if (is_admin() && ! wp_is_serving_rest_request()) {
			return;
		}

		if (! $this->shouldFilterProductQuery($query)) {
			return;
		}

		$hidden_ids = $this->getHiddenProductIds();
		if (empty($hidden_ids)) {
			return;
		}

		$existing_excludes = $query->get('post__not_in');
		$existing_excludes = is_array($existing_excludes) ? array_map('absint', $existing_excludes) : array();

		$query->set('post__not_in', array_values(array_unique(array_merge($existing_excludes, $hidden_ids))));
		$query->set('arraysubs_ecommerce_filtered', true);
	}

	/**
	 * Determine whether a query should be filtered for hidden ecommerce products.
	 *
	 * @param \WP_Query $query Query instance.
	 * @return bool
	 */
	private function shouldFilterProductQuery(\WP_Query $query): bool
	{
		$post_type = $query->get('post_type');

		if (is_string($post_type) && in_array($post_type, array('product', 'product_variation'), true)) {
			return true;
		}

		if (is_array($post_type) && ! empty(array_intersect($post_type, array('product', 'product_variation')))) {
			return true;
		}

		if ('product_query' === $query->get('wc_query')) {
			return true;
		}

		if ($query->is_post_type_archive('product') || $query->is_tax(array('product_cat', 'product_tag'))) {
			return true;
		}

		return false;
	}

	// ─────────────────────────────────────────────────────────
	// Product Query Filtering (404 action — hide from shop)
	// ─────────────────────────────────────────────────────────

	/**
	 * Filter main product queries to exclude hidden products
	 *
	 * @param \WP_Query $query Product query.
	 * @return void
	 */
	public function filterProductQuery($query): void
	{
		if (is_admin()) {
			return;
		}

		$hidden_ids = $this->getHiddenProductIds();
		if (empty($hidden_ids)) {
			return;
		}

		$existing_excludes = $query->get('post__not_in');
		$existing_excludes = is_array($existing_excludes) ? array_map('absint', $existing_excludes) : array();
		$query->set('post__not_in', array_values(array_unique(array_merge($existing_excludes, $hidden_ids))));
	}

	/**
	 * Filter shortcode product queries
	 *
	 * @param array $query_args Query arguments.
	 * @return array
	 */
	public function filterShortcodeQuery(array $query_args): array
	{
		$hidden_ids = $this->getHiddenProductIds();
		if (empty($hidden_ids)) {
			return $query_args;
		}

		$existing_excludes         = isset($query_args['post__not_in']) && is_array($query_args['post__not_in'])
			? array_map('absint', $query_args['post__not_in'])
			: array();
		$query_args['post__not_in'] = array_values(array_unique(array_merge($existing_excludes, $hidden_ids)));
		return $query_args;
	}

	/**
	 * Filter widget product query args
	 *
	 * @param array $query_args Widget query args.
	 * @return array
	 */
	public function filterWidgetQueryArgs(array $query_args): array
	{
		$hidden_ids = $this->getHiddenProductIds();
		if (empty($hidden_ids)) {
			return $query_args;
		}

		$existing_excludes         = isset($query_args['post__not_in']) && is_array($query_args['post__not_in'])
			? array_map('absint', $query_args['post__not_in'])
			: array();
		$query_args['post__not_in'] = array_values(array_unique(array_merge($existing_excludes, $hidden_ids)));
		return $query_args;
	}

	// ─────────────────────────────────────────────────────────
	// Single Product Page Access
	// ─────────────────────────────────────────────────────────

	/**
	 * Handle access control on single product pages
	 *
	 * @return void
	 */
	public function handleSingleProductAccess(): void
	{
		if (! is_product() || is_admin()) {
			return;
		}

		global $post;
		if (! $post) {
			return;
		}

		$product_id  = $post->ID;
		$restriction = $this->getProductRestriction($product_id);

		if (null === $restriction || $restriction['has_access']) {
			return;
		}

		$action = $restriction['rule']['action'] ?? 'block_purchase';

		switch ($action) {
			case '404':
				global $wp_query;
				$wp_query->set_404();
				status_header(404);
				nocache_headers();
				include get_404_template();
				exit;

			case 'redirect_login':
				$redirect_to = get_permalink($product_id);
				wp_safe_redirect(wp_login_url($redirect_to));
				exit;

			case 'redirect_page':
				$redirect_url = $restriction['rule']['redirect_url'] ?? '';

				if (empty($redirect_url)) {
					$redirect_url = arraysubs_get_setting('members_access.default_redirect_url', '');
				}

				if (! empty($redirect_url)) {
					wp_safe_redirect(esc_url_raw($redirect_url));
					exit;
				}
				break;

			case 'block_purchase':
				// Handled via woocommerce_is_purchasable and message hooks.
				break;
		}
	}

	// ─────────────────────────────────────────────────────────
	// Frontend Styles
	// ─────────────────────────────────────────────────────────

	/**
	 * Enqueue frontend styles for restriction notices and badges
	 *
	 * @return void
	 */
	public function enqueueFrontendStyles(): void
	{
		if (is_admin()) {
			return;
		}

		$asset_file = Config::get('plugin.public_path') . 'build/ecommerce-restriction.asset.php';
		$asset      = file_exists($asset_file)
			? require $asset_file
			: array(
				'dependencies' => array(),
				'version'      => Config::get('plugin.version'),
			);

		wp_enqueue_style(
			'arraysubs-ecommerce-restriction',
			Config::get('plugin.public_url') . 'build/ecommerce-restriction.css',
			array(),
			$asset['version']
		);
	}

	// ─────────────────────────────────────────────────────────
	// Purchase Blocking
	// ─────────────────────────────────────────────────────────

	/**
	 * Filter whether a product is purchasable
	 *
	 * @param bool        $purchasable Whether the product is purchasable.
	 * @param \WC_Product $product     Product object.
	 * @return bool
	 */
	public function filterIsPurchasable(bool $purchasable, $product): bool
	{
		if (! $purchasable) {
			return false;
		}

		$product_id = $product->get_id();
		return $this->userCanAccessProduct($product_id);
	}

	/**
	 * Filter whether a variation is purchasable
	 *
	 * @param bool        $purchasable Whether the variation is purchasable.
	 * @param \WC_Product $variation   Variation object.
	 * @return bool
	 */
	public function filterVariationIsPurchasable(bool $purchasable, $variation): bool
	{
		if (! $purchasable) {
			return false;
		}

		$product_id = $variation->get_id();
		return $this->userCanAccessProduct($product_id);
	}

	/**
	 * Show blocked purchase message on single product pages
	 *
	 * Hooks at priority 29 so it appears just before the default add-to-cart
	 * template at priority 30.
	 *
	 * @return void
	 */
	public function maybeShowBlockedMessage(): void
	{
		global $product;
		if (! $product instanceof \WC_Product) {
			return;
		}

		$restriction = $this->getProductRestriction($product->get_id());

		if (null === $restriction || $restriction['has_access']) {
			return;
		}

		$action = $restriction['rule']['action'] ?? '';

		if ('block_purchase' !== $action) {
			return;
		}

		$message = $restriction['rule']['message'] ?? '';
		if (empty($message)) {
			$message = __('This product is available to members only.', 'arraysubs');
		}

		// Remove the default add-to-cart templates so our message replaces them.
		remove_action('woocommerce_single_product_summary', 'woocommerce_template_single_add_to_cart', 30);

		printf(
			'<div class="arraysubs-ecommerce-restricted-notice"><p>%s</p></div>',
			wp_kses_post($message)
		);
	}

	/**
	 * Filter the loop add-to-cart link for restricted products
	 *
	 * @param string      $link    Add to cart link HTML.
	 * @param \WC_Product $product Product object.
	 * @return string
	 */
	public function filterLoopAddToCartLink(string $link, $product): string
	{
		if (! $product instanceof \WC_Product) {
			return $link;
		}

		$restriction = $this->getProductRestriction($product->get_id());

		if (null === $restriction || $restriction['has_access']) {
			return $link;
		}

		$action = $restriction['rule']['action'] ?? '';

		// Replace loop purchase actions for any restricted product to avoid misleading CTAs.
		if (in_array($action, array('404', 'block_purchase', 'redirect_login', 'redirect_page'), true)) {
			return sprintf(
				'<span class="arraysubs-ecommerce-restricted-badge">%s</span>',
				esc_html__('Members Only', 'arraysubs')
			);
		}

		return $link;
	}

	// ─────────────────────────────────────────────────────────
	// Add-to-Cart & Cart Validation
	// ─────────────────────────────────────────────────────────

	/**
	 * Validate add-to-cart requests
	 *
	 * @param bool $passed       Whether validation passed.
	 * @param int  $product_id   Product ID being added.
	 * @param int  $quantity     Requested quantity.
	 * @param int  $variation_id Variation ID being added.
	 * @return bool
	 */
	public function validateAddToCart(bool $passed, int $product_id, int $quantity = 1, int $variation_id = 0): bool
	{
		if (! $passed) {
			return false;
		}

		$product_id = $variation_id > 0 ? $variation_id : $product_id;

		if ($this->userCanAccessProduct($product_id)) {
			return true;
		}

		wc_add_notice(
			__('This product is restricted to members only and cannot be added to your cart.', 'arraysubs'),
			'error'
		);

		return false;
	}

	/**
	 * Validate cart items during checkout
	 *
	 * Catches products that were added before a restriction was applied
	 * or before a membership expired.
	 *
	 * @return void
	 */
	public function validateCartItems(): void
	{
		$cart = WC()->cart;
		if (! $cart) {
			return;
		}

		foreach ($cart->get_cart() as $cart_item_key => $cart_item) {
			$product_id = ! empty($cart_item['variation_id']) ? (int) $cart_item['variation_id'] : (int) $cart_item['product_id'];

			if (! $this->userCanAccessProduct($product_id)) {
				$product = $cart_item['data'] ?? null;
				$name    = $product instanceof \WC_Product ? $product->get_name() : __('a product', 'arraysubs');

				wc_add_notice(
					sprintf(
						/* translators: %s: product name */
						__('"%s" is restricted to members only and has been removed from your cart.', 'arraysubs'),
						esc_html($name)
					),
					'error'
				);

				$cart->remove_cart_item($cart_item_key);
			}
		}
	}

	// ─────────────────────────────────────────────────────────
	// Related Products, Upsells, Cross-sells
	// ─────────────────────────────────────────────────────────

	/**
	 * Filter related product IDs
	 *
	 * @param int[] $related_ids Related product IDs.
	 * @return int[]
	 */
	public function filterRelatedProducts(array $related_ids): array
	{
		$hidden_ids = $this->getHiddenProductIds();
		if (empty($hidden_ids)) {
			return $related_ids;
		}

		return array_values(array_diff($related_ids, $hidden_ids));
	}

	/**
	 * Filter upsell product IDs
	 *
	 * @param int[]       $upsell_ids Upsell product IDs.
	 * @param \WC_Product $product    Product object.
	 * @return int[]
	 */
	public function filterUpsellIds(array $upsell_ids, $product): array
	{
		$hidden_ids = $this->getHiddenProductIds();
		if (empty($hidden_ids)) {
			return $upsell_ids;
		}

		return array_values(array_diff($upsell_ids, $hidden_ids));
	}

	/**
	 * Filter cross-sell product IDs
	 *
	 * @param int[]       $crosssell_ids Cross-sell product IDs.
	 * @param \WC_Product $product       Product object.
	 * @return int[]
	 */
	public function filterCrossSellIds(array $crosssell_ids, $product): array
	{
		$hidden_ids = $this->getHiddenProductIds();
		if (empty($hidden_ids)) {
			return $crosssell_ids;
		}

		return array_values(array_diff($crosssell_ids, $hidden_ids));
	}

	// ─────────────────────────────────────────────────────────
	// SEO / Sitemap Filtering
	// ─────────────────────────────────────────────────────────

	/**
	 * Filter WordPress core sitemap query args
	 *
	 * Excludes 404-actioned products from XML sitemaps.
	 *
	 * @param array  $args      Query arguments.
	 * @param string $post_type Post type being queried.
	 * @return array
	 */
	public function filterSitemapQueryArgs(array $args, string $post_type): array
	{
		if ('product' !== $post_type) {
			return $args;
		}

		$hidden_ids = $this->getGlobalHiddenProductIds();
		if (empty($hidden_ids)) {
			return $args;
		}

		$existing_excludes    = isset($args['post__not_in']) && is_array($args['post__not_in'])
			? array_map('absint', $args['post__not_in'])
			: array();
		$args['post__not_in'] = array_values(array_unique(array_merge($existing_excludes, $hidden_ids)));
		return $args;
	}

	/**
	 * Filter Yoast SEO sitemap post exclusions
	 *
	 * @param int[] $excluded_ids Post IDs to exclude.
	 * @return int[]
	 */
	public function filterYoastSitemapExclusions(array $excluded_ids): array
	{
		$hidden_ids = $this->getGlobalHiddenProductIds();
		return array_unique(array_merge($excluded_ids, $hidden_ids));
	}

	/**
	 * Filter Rank Math sitemap entry
	 *
	 * @param array  $url       Sitemap URL entry.
	 * @param string $type      Sitemap type.
	 * @param object $sitemap   Sitemap object.
	 * @return array|false
	 */
	public function filterRankMathSitemapEntry($url, $type, $sitemap)
	{
		if (! is_array($url) || empty($url['loc'])) {
			return $url;
		}

		$post_id = url_to_postid($url['loc']);
		if (! $post_id || 'product' !== get_post_type($post_id)) {
			return $url;
		}

		$hidden_ids = $this->getGlobalHiddenProductIds();
		if (in_array($post_id, $hidden_ids, true)) {
			return false;
		}

		return $url;
	}

	/**
	 * Get product IDs hidden globally (for sitemaps — not user-specific)
	 *
	 * Evaluates rules for a guest user (user_id = 0) to determine
	 * which products should be excluded from sitemaps.
	 *
	 * @return int[]
	 */
	private function getGlobalHiddenProductIds(): array
	{
		static $global_hidden = null;

		if (null !== $global_hidden) {
			return $global_hidden;
		}

		$global_hidden = array();
		$rules         = $this->getEnabledRules();

		foreach ($rules as $rule) {
			if (($rule['action'] ?? '') !== '404') {
				continue;
			}

			$conditions = $rule['conditions'] ?? array();
			if ($this->evaluator->evaluate(0, $conditions)) {
				continue;
			}

			// For sitemaps, evaluate for a guest (user_id=0) — worst case scenario.
			$scope = $rule['scope'] ?? 'all';

			switch ($scope) {
				case 'all':
					$product_ids   = $this->getAllPublishedProductIds();
					$global_hidden = array_merge($global_hidden, $product_ids);
					break;

				case 'specific_products':
					$target_ids    = array_map('absint', $rule['target_product_ids'] ?? array());
					$global_hidden = array_merge($global_hidden, $target_ids);
					break;

				case 'specific_categories':
					$target_cat_ids = array_map('absint', $rule['target_category_ids'] ?? array());
					if (! empty($target_cat_ids)) {
						$cat_product_ids = $this->getProductIdsByCategories($target_cat_ids);
						$global_hidden   = array_merge($global_hidden, $cat_product_ids);
					}
					break;
			}

			// Remove exclusions.
			$exclusion_product_ids  = array_map('absint', $rule['exclusion_product_ids'] ?? array());
			$exclusion_category_ids = array_map('absint', $rule['exclusion_category_ids'] ?? array());

			if (! empty($exclusion_product_ids)) {
				$global_hidden = array_diff($global_hidden, $exclusion_product_ids);
			}

			if (! empty($exclusion_category_ids)) {
				$excluded_cat_products = $this->getProductIdsByCategories($exclusion_category_ids);
				$global_hidden         = array_diff($global_hidden, $excluded_cat_products);
			}
		}

		$global_hidden = array_values(array_unique(array_filter(array_map('absint', $global_hidden))));
		return $global_hidden;
	}

	// ─────────────────────────────────────────────────────────
	// WooCommerce Store API Protection (Block-based checkout)
	// ─────────────────────────────────────────────────────────

	/**
	 * Filter Store API maximum quantity to 0 for restricted products.
	 *
	 * @param int         $maximum  Maximum quantity.
	 * @param \WC_Product $product  Product object.
	 * @param array|null  $cart_item Optional cart item.
	 * @return int
	 */
	public function filterStoreApiQuantityMaximum(int $maximum, $product, $cart_item = null): int
	{
		if (! $product instanceof \WC_Product) {
			return $maximum;
		}

		if (! $this->userCanAccessProduct($product->get_id())) {
			return 0;
		}

		return $maximum;
	}

	/**
	 * Make Store API quantity controls non-editable for restricted products.
	 *
	 * @param bool        $editable Whether quantity is editable.
	 * @param \WC_Product $product  Product object.
	 * @param array|null  $cart_item Optional cart item.
	 * @return bool
	 */
	public function filterStoreApiQuantityEditable(bool $editable, $product, $cart_item = null): bool
	{
		if (! $product instanceof \WC_Product) {
			return $editable;
		}

		if (! $this->userCanAccessProduct($product->get_id())) {
			return false;
		}

		return $editable;
	}

	/**
	 * Validate Store API add-to-cart requests
	 *
	 * @param \WC_Product $product Product being added.
	 * @param array       $request Add to cart request payload.
	 * @return void
	 * @throws \Automattic\WooCommerce\StoreApi\Exceptions\RouteException When product is restricted.
	 */
	public function validateStoreApiAddToCart($product, array $request = array()): void
	{
		if (! $product instanceof \WC_Product) {
			return;
		}

		if (! $this->userCanAccessProduct($product->get_id())) {
			throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException(
				'arraysubs_ecommerce_restricted',
				esc_html__('This product is restricted to members only.', 'arraysubs'),
				403
			);
		}
	}

	/**
	 * Validate Store API cart items
	 *
	 * @param \WC_Product $product   Product in cart.
	 * @param array       $cart_item Cart item data.
	 * @return void
	 * @throws \Automattic\WooCommerce\StoreApi\Exceptions\RouteException When product is restricted.
	 */
	public function validateStoreApiCartItem($product, array $cart_item = array()): void
	{
		if (! $product instanceof \WC_Product) {
			return;
		}

		if (! $this->userCanAccessProduct($product->get_id())) {
			throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException(
				'arraysubs_ecommerce_restricted',
				sprintf(
					/* translators: %s: product name */
					esc_html__('"%s" is restricted to members only and cannot be purchased.', 'arraysubs'),
					esc_html($product->get_name())
				),
				403
			);
		}
	}

	/**
	 * Restrict direct Store API product reads for denied actions.
	 *
	 * Collection routes remain filterable via product queries, but direct
	 * product-by-id and product-by-slug endpoints need an explicit guard.
	 *
	 * @param mixed            $response Current REST short-circuit response.
	 * @param array            $handler  Matched REST handler data.
	 * @param \WP_REST_Request $request REST request instance.
	 * @return mixed
	 */
	public function maybeRestrictStoreApiProductRequest($response, array $handler, \WP_REST_Request $request)
	{
		if (! $this->isStoreApiSingleProductRequest($request)) {
			return $response;
		}

		$product = $this->getStoreApiRequestedProduct($request);
		if (! $product instanceof \WC_Product) {
			return $response;
		}

		$restriction = $this->getProductRestriction($product->get_id());
		if (null === $restriction || $restriction['has_access']) {
			return $response;
		}

		$action = $restriction['rule']['action'] ?? 'block_purchase';
		if ('block_purchase' === $action) {
			return $response;
		}

		return $this->getStoreApiRestrictionError($product, $restriction['rule']);
	}

	/**
	 * Check whether the REST request targets a single WooCommerce Store API product.
	 *
	 * @param \WP_REST_Request $request REST request instance.
	 * @return bool
	 */
	private function isStoreApiSingleProductRequest(\WP_REST_Request $request): bool
	{
		if ('GET' !== $request->get_method()) {
			return false;
		}

		$route = $request->get_route();
		if (! is_string($route) || ! str_starts_with($route, '/wc/store/')) {
			return false;
		}

		return 1 === preg_match('#^/wc/store/v[0-9]+/products/[^/]+$#', $route);
	}

	/**
	 * Resolve the product requested from a Store API single-product route.
	 *
	 * @param \WP_REST_Request $request REST request instance.
	 * @return \WC_Product|null
	 */
	private function getStoreApiRequestedProduct(\WP_REST_Request $request): ?\WC_Product
	{
		$product_id = absint($request->get_param('id'));
		if ($product_id > 0) {
			$product = wc_get_product($product_id);
			return $product instanceof \WC_Product ? $product : null;
		}

		$slug = sanitize_title((string) $request->get_param('slug'));
		if (empty($slug)) {
			return null;
		}

		$post = get_page_by_path($slug, OBJECT, array('product', 'product_variation'));
		if (! $post instanceof \WP_Post) {
			return null;
		}

		$product = wc_get_product($post->ID);

		return $product instanceof \WC_Product ? $product : null;
	}

	/**
	 * Build the REST error returned when Store API access is denied.
	 *
	 * @param \WC_Product $product Product object.
	 * @param array       $rule    Matching restriction rule.
	 * @return \WP_Error
	 */
	private function getStoreApiRestrictionError(\WC_Product $product, array $rule): \WP_Error
	{
		$action       = $rule['action'] ?? 'block_purchase';
		$redirect_url = $this->getRestrictionRedirectUrl($rule);
		$status       = '404' === $action ? 404 : 403;
		$code         = '404' === $action ? 'arraysubs_ecommerce_not_found' : 'arraysubs_ecommerce_restricted';

		$message = __('This product is restricted to members only.', 'arraysubs');
		if ('404' === $action) {
			$message = __('Product not found.', 'arraysubs');
		} elseif ('redirect_login' === $action) {
			$message = __('This product is available to members only. Please log in to continue.', 'arraysubs');
		} elseif ('redirect_page' === $action && ! empty($redirect_url)) {
			$message = __('This product is available to members only. Please follow the configured access page.', 'arraysubs');
		}

		return new \WP_Error(
			$code,
			$message,
			array(
				'status'                          => $status,
				'arraysubs_ecommerce_restricted'  => true,
				'arraysubs_ecommerce_action'      => $action,
				'arraysubs_ecommerce_redirect_url' => $redirect_url,
				'arraysubs_product_id'            => $product->get_id(),
			)
		);
	}

	// ─────────────────────────────────────────────────────────
	// WooCommerce REST API Protection
	// ─────────────────────────────────────────────────────────

	/**
	 * Filter REST API product responses to mark restricted products
	 *
	 * @param \WP_REST_Response $response REST response.
	 * @param \WC_Product       $product  Product object.
	 * @param \WP_REST_Request  $request  REST request.
	 * @return \WP_REST_Response
	 */
	public function filterRestProductResponse($response, $product, $request): \WP_REST_Response
	{
		if (! $product instanceof \WC_Product) {
			return $response;
		}

		$restriction = $this->getProductRestriction($product->get_id());

		if (null === $restriction) {
			return $response;
		}

		$data = $response->get_data();

		$data['arraysubs_ecommerce_restricted'] = ! $restriction['has_access'];
		$data['arraysubs_ecommerce_action']     = $restriction['rule']['action'] ?? 'block_purchase';

		if (! $restriction['has_access'] && ($restriction['rule']['action'] ?? '') === 'block_purchase') {
			$data['purchasable'] = false;
		}

		$response->set_data($data);
		return $response;
	}

	/**
	 * Register Store API product extension data for blocks and headless clients.
	 *
	 * @return void
	 */
	public function registerStoreApiProductData(): void
	{
		if (! function_exists('woocommerce_store_api_register_endpoint_data')) {
			return;
		}

		woocommerce_store_api_register_endpoint_data(
			array(
				'endpoint'        => ProductSchema::IDENTIFIER,
				'namespace'       => 'arraysubs',
				'schema_callback' => array($this, 'getStoreApiProductSchema'),
				'data_callback'   => array($this, 'getStoreApiProductData'),
				'schema_type'     => ARRAY_A,
			)
		);
	}

	/**
	 * Get Store API schema additions for product restriction data.
	 *
	 * @return array
	 */
	public function getStoreApiProductSchema(): array
	{
		return array(
			'ecommerce_restricted' => array(
				'description' => __('Whether the product is restricted by ecommerce access rules.', 'arraysubs'),
				'type'        => 'boolean',
				'readonly'    => true,
				'context'     => array('view'),
			),
			'ecommerce_action'     => array(
				'description' => __('The ecommerce restriction action that applies to the product.', 'arraysubs'),
				'type'        => 'string',
				'readonly'    => true,
				'context'     => array('view'),
			),
			'ecommerce_message'    => array(
				'description' => __('The restriction message that should be shown for the current product.', 'arraysubs'),
				'type'        => 'string',
				'readonly'    => true,
				'context'     => array('view'),
			),
			'ecommerce_redirect_url' => array(
				'description' => __('The redirect URL that applies when the ecommerce restriction action redirects users.', 'arraysubs'),
				'type'        => 'string',
				'format'      => 'uri',
				'readonly'    => true,
				'context'     => array('view'),
			),
		);
	}

	/**
	 * Get Store API extension data for a product.
	 *
	 * @param \WC_Product $product Product object.
	 * @return array
	 */
	public function getStoreApiProductData($product): array
	{
		if (! $product instanceof \WC_Product) {
			return array();
		}

		$restriction = $this->getProductRestriction($product->get_id());
		if (null === $restriction) {
			return array(
				'ecommerce_restricted' => false,
				'ecommerce_action'     => '',
				'ecommerce_message'    => '',
				'ecommerce_redirect_url' => '',
			);
		}

		$rule = $restriction['rule'];

		return array(
			'ecommerce_restricted' => ! $restriction['has_access'],
			'ecommerce_action'     => $rule['action'] ?? 'block_purchase',
			'ecommerce_message'    => $this->getRestrictionMessage($rule),
			'ecommerce_redirect_url' => $this->getRestrictionRedirectUrl($rule),
		);
	}

	/**
	 * Get the frontend restriction message for a rule.
	 *
	 * @param array $rule Matching rule.
	 * @return string
	 */
	private function getRestrictionMessage(array $rule): string
	{
		$message = isset($rule['message']) ? wp_kses_post($rule['message']) : '';
		if (! empty($message)) {
			return $message;
		}

		$action = $rule['action'] ?? 'block_purchase';

		if ('redirect_login' === $action) {
			return __('This product is available to members only. Please log in to continue.', 'arraysubs');
		}

		return __('This product is available to members only.', 'arraysubs');
	}

	/**
	 * Get the redirect URL configured for a restriction rule.
	 *
	 * @param array $rule Matching rule.
	 * @return string
	 */
	private function getRestrictionRedirectUrl(array $rule): string
	{
		$redirect_url = isset($rule['redirect_url']) ? esc_url_raw((string) $rule['redirect_url']) : '';

		if (empty($redirect_url) && 'redirect_page' === ($rule['action'] ?? '')) {
			$redirect_url = esc_url_raw((string) arraysubs_get_setting('members_access.default_redirect_url', ''));
		}

		return $redirect_url;
	}

	// ─────────────────────────────────────────────────────────
	// Plan Switching Integration
	// ─────────────────────────────────────────────────────────

	/**
	 * Filter plan switch products to hide restricted ones
	 *
	 * @param int[] $product_ids Available product IDs.
	 * @return int[]
	 */
	public function filterPlanSwitchProducts(array $product_ids): array
	{
		return array_values(
			array_filter(
				$product_ids,
				function ($id) {
					return $this->userCanAccessProduct(absint($id));
				}
			)
		);
	}

	// ─────────────────────────────────────────────────────────
	// Third-Party Integration Hooks
	// ─────────────────────────────────────────────────────────

	/**
	 * Pass-through for external access filter
	 *
	 * Allows third-party plugins to override access decisions.
	 *
	 * @param bool  $has_access Whether user has access.
	 * @param int   $product_id Product ID.
	 * @param array $rule       Matching rule.
	 * @return bool
	 */
	public function applyExternalAccessFilter(bool $has_access, int $product_id, array $rule): bool
	{
		return $has_access;
	}
}
