<?php
/**
 * Recommendation result cache.
 *
 * @package PRECO
 */

namespace PRECO\Includes\Frontend\Core;

defined( 'ABSPATH' ) || exit;

/**
 * Recommendations result cache.
 *
 * ============================================================================
 * CACHE ARCHITECTURE OVERVIEW
 * ============================================================================

 *
 * Results are cached at the recommendation-list level (one list = one set
 * of rules + sorting + context + limit). Each list is independently cached
 * and invalidated without affecting other lists on the same page.
 *
 * ── THREE CACHE TIERS ───────────────────────────────────────────────────────
 *
 * Tier 1 │ SHARED (stable)  │ TTL: CACHE_TTL_SHARED (default 15 min)        │
 *        │ Key: preco_s_{md5}                                                 │
 *        │ One entry shared by ALL visitors for the same page/product.        │
 *        │ Triggers: category, tag, brand, attribute, featured,               │
 *        │           created/newest_products, related, upsells,               │
 *        │           complementary, alternative, substitute,                  │
 *        │           bought_together,                                         │
 *        │           current_category (gate), current_tag (gate),             │
 *        │           current_attribute (gate), product_attributes,            │
 *        │           tag_similarity, category_similarity                      │
 * ────────────────────────────────────────────────────────────────────────────│
 * Tier 2 │ VOLATILE         │ TTL: CACHE_TTL_VOLATILE (default 5 min)        │
 *        │ Key: preco_s_{md5}  (same format, shorter TTL)                     │
 *        │ Shared, but expires quickly so price/stock changes surface fast.   │
 *        │ Triggers: on_sale, product_price, relative_price, stock_status,    │
 *        │           best_selling, high_revenue, low_selling,                 │
 *        │           high_conversion, high_click_through,                     │
 *        │           price_similarity                                         │
 * ────────────────────────────────────────────────────────────────────────────│
 * Tier 3 │ USER (per-visitor)│ TTL: CACHE_TTL_USER (default 2 min)           │
 *        │ Key: preco_u_{md5}_{cookie-salt-8}                                 │
 *        │ Each visitor's unique browse/cart/purchase history produces its    │
 *        │ own slot. The same visitor re-visiting within 2 min hits cache.    │
 *        │ Triggers: recently_viewed, products_viewed, frequently_viewed,     │
 *        │           added_to_cart, removed_from_cart, wishlist_items,        │
 *        │           previously_purchased, customer_interest_categories,      │
 *        │           customer_interest_tags, customer_interest_attributes     │
 * ────────────────────────────────────────────────────────────────────────────│
 * NO-CACHE│ (bypassed entirely – engine called on every request)              │
 *        │ Sorting: random      ("Randomized" UI option – non-deterministic)  │
 *        │ Rules:   cross_sells, added_to_cart, removed_from_cart             │
 *        │          (live cart data; the Tier 3 salt is derived from the      │
 *        │          recently-viewed cookie only, not cart state, so these     │
 *        │          cannot be safely cached at any tier)                      │
 *        │ @see NOCACHE_SORTING, NOCACHE_RULES                                │
 *
 * ── TTL CONFIGURATION ───────────────────────────────────────────────────────
 *
 * TTL defaults are defined as class constants (CACHE_TTL_SHARED, _VOLATILE,
 * _USER) so they live alongside the rest of the cache configuration in this
 * file and are easy to locate.
 *
 * To adjust TTLs at runtime without editing plugin files, use the filter:
 *
 *   add_filter( 'preco_cache_ttl_config', function( $ttl ) {
 *       $ttl['shared']   = 1800; // 30 min
 *       $ttl['volatile'] =  600; // 10 min
 *       $ttl['user']     =   60; //  1 min
 *       return $ttl;
 *   } );
 *
 * Values are exposed through the 'preco_cache_ttl_config' filter for
 * runtime adjustment from other plugins or mu-plugins (see get_ttl_config()).
 *
 * ── PERSISTENT OBJECT CACHE NOTE ────────────────────────────────────────────
 *
 * When Redis or Memcached is active, WordPress transients are stored in memory
 * rather than in wp_options. invalidate_all() still cleans the DB timeout
 * rows and additionally calls wp_cache_flush() as a best-effort memory purge.
 * This is an acceptable trade-off for a deliberate admin action.
 *
 * ── CACHE STAMPEDE / THUNDERING HERD ─────────────────────────────────────────
 *
 * A "cache stampede" occurs when many concurrent visitors hit the same page
 * at the exact moment its cache entry is cold (e.g. right after a manual flush)
 * and all simultaneously run the expensive engine.
 *
 * Mitigations already in place:
 *  - The generation counter (see GENERATION_OPTION) makes product-save
 *    invalidation O(1) and lazy, so the DB is never hit with a mass DELETE
 *    under traffic.
 *  - TTLs are intentionally staggered per list (different rule combinations
 *    produce different MD5 keys and were cached at different times).
 *  - Volatile tier TTL (5 min) means price-based results re-warm quickly.
 *
 * Remaining exposure: a deliberate admin "Flush Cache Now" on a very busy store
 * can cause a brief spike. The recommended mitigation for high-traffic stores is
 * installing a persistent object cache (Redis / Memcached) which handles
 * concurrent-write deduplication natively at the infrastructure level.
 */
class RecommendationsCache {

	// =========================================================================
	// TRANSIENT KEY PREFIXES
	// =========================================================================

	/**
	 * Prefix for Tier 1 and Tier 2 (shared) transient keys.
	 *
	 * All DB-stored transients inherit the WordPress '_transient_' prefix on
	 * top of this, so the actual option_name pattern is '_transient_preco_s_%'.
	 *
	 * @var string
	 */
	const PREFIX_SHARED = 'preco_s_';

	/**
	 * Prefix for Tier 3 (per-visitor) transient keys.
	 *
	 * The actual option_name pattern is '_transient_preco_u_%'.
	 *
	 * @var string
	 */
	const PREFIX_USER = 'preco_u_';

	/**
	 * Name of the wp_options entry that holds the current cache generation.
	 *
	 * The generation is a simple incrementing integer included in every cache
	 * key hash. Bumping it by 1 instantly makes ALL existing cache entries
	 * unreachable (they were hashed with the old generation) without requiring
	 * any DELETE query. Old transient rows then expire naturally via their TTL.
	 *
	 * This is used by lightweight invalidation events (e.g. a single product
	 * save) where a full DB DELETE would be too expensive under high traffic.
	 * Deliberate admin flushes still use invalidate_all() which both bumps the
	 * generation AND deletes stale rows for immediate storage reclamation.
	 *
	 * @var string
	 */
	const GENERATION_OPTION = 'preco_cache_generation';

	// =========================================================================
	// RULE / SORTING CLASSIFICATION
	// =========================================================================

	/**
	 * Sorting trigger types that make caching meaningless.
	 *
	 * When any active sorting rule uses one of these types the cache layer is
	 * bypassed entirely and the engine is called on every request.
	 *
	 * Current members:
	 *  - random : "Randomized" in the UI – must return a different order each time.
	 *
	 * Extend this list when new non-deterministic sorting types are introduced.
	 *
	 * @var string[]
	 */
	const NOCACHE_SORTING = array(
		'random',
	);

	/**
	 * Filter trigger types that depend on live session/cart data and must never
	 * be cached, because no stable per-visitor fingerprint is available: the
	 * Tier 3 salt (see make_key()) is derived only from the recently-viewed
	 * cookie, not from WC session/cart state.
	 *
	 * Results bypass the cache entirely (no read or write).
	 *
	 * IMPORTANT: this list is exclusively for the "no stable fingerprint"
	 * case above. Do NOT add trigger types here just because their data
	 * changes often or is per-visitor — those belong in VOLATILE_TRIGGERS
	 * (Tier 2, shared) or USER_DEPENDENT_TRIGGERS (Tier 3, cookie-salted)
	 * instead, both of which already cache such rules correctly. Adding a
	 * VOLATILE_TRIGGERS/USER_DEPENDENT_TRIGGERS member here silently makes
	 * it uncacheable, since rules_require_no_cache() is checked before
	 * resolve_ttl() at every call site.
	 *
	 * @var string[]
	 */
	const NOCACHE_RULES = array(
		'cross_sells', // Cart contents change live; WC session is not cookie-fingerprinted.
		'added_to_cart', // Cart contents change live; WC session is not cookie-fingerprinted.
		'removed_from_cart', // Cart contents change live; WC session is not cookie-fingerprinted.
	);

	/**
	 * Filter trigger types whose results depend on the current visitor's session.
	 *
	 * Results CANNOT be shared between visitors. Stored in per-visitor slots
	 * salted with a fingerprint of the visitor's cookie.
	 *
	 * Tier 3 – TTL: CACHE_TTL_USER (default 2 min).
	 *
	 * Filters in this group (from the UI):
	 *  - recently_viewed              "Recently Viewed"
	 *  - products_viewed              "Products Viewed" (by this visitor)
	 *  - frequently_viewed            "Frequently Viewed" (by this visitor)
	 *  - added_to_cart                "Added to Cart" (in current session cart)
	 *  - removed_from_cart            "Removed from Cart"
	 *  - wishlist_items               "Wishlist Items" (this visitor's wishlist)
	 *  - previously_purchased         "Previously Purchased" (this visitor)
	 *  - customer_interest_categories "Customer Interest Categories"
	 *  - customer_interest_tags       "Customer Interest Tags"
	 *  - customer_interest_attributes "Customer Interest Attributes"
	 *
	 * @var string[]
	 */
	const USER_DEPENDENT_TRIGGERS = array(
		'recently_viewed',
		'products_viewed',
		'frequently_viewed',
		'added_to_cart',
		'removed_from_cart',
		'wishlist_items',
		'previously_purchased',
		'customer_interest_categories',
		'customer_interest_tags',
		'customer_interest_attributes',
	);

	/**
	 * Filter trigger types whose data changes frequently but is shared by all visitors.
	 *
	 * Results CAN be shared between visitors, but expire quickly so that price
	 * changes, stock updates, and sale-start/end events surface within minutes.
	 *
	 * Tier 2 – TTL: CACHE_TTL_VOLATILE (default 5 min).
	 *
	 * Filters in this group (from the UI):
	 *  - on_sale            "On Sale Products"
	 *  - product_price      "Product Price" (absolute price filter)
	 *  - relative_price     "Relative Price" (relative to current product)
	 *  - stock_status       "Stock Status"
	 *  - best_selling       "Best-Selling Products"
	 *  - high_revenue       "High-Revenue Products"
	 *  - low_selling        "Low-Selling Products"
	 *  - high_conversion    "High Conversion Products"
	 *  - high_click_through "High Click-Through Products"
	 *  - price_similarity   Sorting: "Price Similarity" (price data can change)
	 *
	 * @var string[]
	 */
	const VOLATILE_TRIGGERS = array(
		'on_sale',
		'product_price',
		'relative_price',
		'stock_status',
		'best_selling',
		'high_revenue',
		'low_selling',
		'high_conversion',
		'high_click_through',
		'price_similarity',
	);

	/**
	 * Stable filter / sorting triggers (Tier 1 – all others).
	 *
	 * Anything not in USER_DEPENDENT_TRIGGERS or VOLATILE_TRIGGERS is
	 * considered stable and receives the longest TTL.
	 *
	 * Examples: category, tag, brand, attribute, product_attributes,
	 *           featured, created, newest_products, related, upsell,
	 *           complementary, alternative, substitute,
	 *           bought_together,
	 *           current_category / current_tag / current_attribute (gate),
	 *           rating, most_comments, freshness/newest (sorting),
	 *           tag_similarity, category_similarity (sorting).
	 *
	 * (No constant needed – this tier is the catch-all default.)
	 */

	// =========================================================================
	// STABLE CONTEXT KEYS
	// =========================================================================

	/**
	 * Keys from the request context that identify the page/product but carry
	 * NO visitor-specific information.
	 *
	 * Only these keys are included when building a SHARED cache key.
	 * Visitor-identity fields (cart contents, session data, user ID, etc.) must
	 * never be hashed into a shared key, or visitors could see each other's results.
	 *
	 * @var string[]
	 */
	const STABLE_CONTEXT_KEYS = array(
		'product_id',  // The product currently being viewed.
		'order_id',    // The order being viewed (thank-you page); stable, not visitor-identity.
		'match_mode',  // 'all' (AND) or 'any' (OR) rule relation.
		'page',        // Page type: product, cart, shop, checkout, order, etc.
	);

	// =========================================================================
	// DEFAULT TTL VALUES (seconds)
	// =========================================================================

	/**
	 * Default TTL for Tier 1 (stable rules: taxonomy, relationship, date, etc.).
	 * Override at runtime via the 'preco_cache_ttl_config' filter.
	 *
	 * @var int
	 */
	const CACHE_TTL_SHARED = 900;

	/**
	 * Default TTL for Tier 2 (volatile rules: price, stock, sales-rank, etc.).
	 * Override at runtime via the 'preco_cache_ttl_config' filter.
	 *
	 * @var int
	 */
	const CACHE_TTL_VOLATILE = 300;

	/**
	 * Default TTL for Tier 3 (per-visitor rules: recently-viewed, cart, etc.).
	 * Override at runtime via the 'preco_cache_ttl_config' filter.
	 *
	 * @var int
	 */
	const CACHE_TTL_USER = 120;

	// =========================================================================
	// TTL RESOLUTION
	// =========================================================================

	/**
	 * Return the active TTL configuration as an associative array.
	 *
	 * Value priority (highest first):
	 *  1. 'preco_cache_ttl_config' filter (runtime override by other code)
	 *  2. Class constants CACHE_TTL_SHARED / CACHE_TTL_VOLATILE / CACHE_TTL_USER
	 *
	 * Example – override via filter in a mu-plugin:
	 *   add_filter( 'preco_cache_ttl_config', function( $ttl ) {
	 *       $ttl['shared']   = 1800;
	 *       $ttl['volatile'] = 600;
	 *       $ttl['user']     = 60;
	 *       return $ttl;
	 *   } );
	 *
	 * @return array{ shared: int, volatile: int, user: int }
	 */
	public static function get_ttl_config(): array {
		$config = array(
			// Tier 1: stable taxonomy / relationship / date rules.
			'shared'   => self::CACHE_TTL_SHARED,
			// Tier 2: price / stock / sales-rank rules.
			'volatile' => self::CACHE_TTL_VOLATILE,
			// Tier 3: visitor browse/cart/purchase history rules.
			'user'     => self::CACHE_TTL_USER,
		);

		/**
		 * Filter: preco_cache_ttl_config
		 *
		 * Allows runtime override of recommendation cache TTL values (seconds).
		 *
		 * @param array{ shared: int, volatile: int, user: int } $config TTL map.
		 */
		return (array) apply_filters( 'preco_cache_ttl_config', $config );
	}

	// =========================================================================
	// PUBLIC API – GENERATION COUNTER
	// =========================================================================

	/**
	 * Return the current cache generation integer.
	 *
	 * Included in every cache key hash so that bumping the generation
	 * immediately orphans all existing entries without any DB DELETE.
	 *
	 * The option is auto-created on first read with a value of 1.
	 *
	 * @return int
	 */
	public static function get_generation(): int {
		$gen = (int) get_option( self::GENERATION_OPTION, 1 );
		return max( 1, $gen );
	}

	/**
	 * Increment the cache generation by 1.
	 *
	 * All existing cache keys encoded with the previous generation become
	 * unreachable and will expire through their normal TTL. No DB DELETE is
	 * performed, making this safe to call on high-frequency events such as
	 * individual product saves.
	 *
	 * @return int The new generation value.
	 */
	public static function bump_generation(): int {
		$current = self::get_generation();
		$next    = $current + 1;
		update_option( self::GENERATION_OPTION, $next, true ); // autoload=yes: cheap read on next request.
		return $next;
	}

	// =========================================================================
	// PUBLIC API – CACHE BYPASS CHECK
	// =========================================================================

	/**
	 * Determine whether caching must be skipped entirely for a list.
	 *
	 * Returns true when any active sorting rule uses a non-deterministic type
	 * (e.g. "Randomized"). When true, LocationRenderer will call the engine on
	 * every request and will not read from or write to the transient store.
	 *
	 * @param array $sorting Enabled sorting rule objects for this list.
	 * @return bool True if the result MUST NOT be cached.
	 */
	public function should_skip_cache( array $sorting ): bool {
		// Read-only gate on rendered content, not a state-changing action — no nonce needed.
		if ( isset( $_GET['preco_cache_test_mode'] ) && 'yes' === $_GET['preco_cache_test_mode'] ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			return true; // Force cache bypass for testing purposes.
		}
		foreach ( $sorting as $rule ) {
			if ( ! is_array( $rule ) ) {
				continue;
			}
			if ( isset( $rule['enabled'] ) && false === $rule['enabled'] ) {
				continue;
			}
			$trigger = isset( $rule['triggerType'] ) ? (string) $rule['triggerType'] : '';
			if ( in_array( $trigger, self::NOCACHE_SORTING, true ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Whether any enabled filter rule requires the cache to be bypassed entirely.
	 *
	 * Used for triggers backed by live session/cart data that cannot be
	 * fingerprinted (e.g. cross-sells).
	 *
	 * @param array $rules Enabled filter rule objects.
	 * @return bool True if the result MUST NOT be cached.
	 */
	public function rules_require_no_cache( array $rules ): bool {
		foreach ( $rules as $rule ) {
			if ( ! is_array( $rule ) ) {
				continue;
			}
			if ( isset( $rule['enabled'] ) && false === $rule['enabled'] ) {
				continue;
			}
			$trigger = isset( $rule['triggerType'] ) ? (string) $rule['triggerType'] : '';
			if ( in_array( $trigger, self::NOCACHE_RULES, true ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Whether any enabled filter rule in $rules depends on individual visitor data.
	 *
	 * Used internally to select the correct key prefix and TTL tier.
	 *
	 * @param array $rules Enabled filter rule objects.
	 * @return bool
	 */
	public function is_user_dependent( array $rules ): bool {
		foreach ( $rules as $rule ) {
			if ( ! is_array( $rule ) ) {
				continue;
			}
			if ( isset( $rule['enabled'] ) && false === $rule['enabled'] ) {
				continue;
			}
			$trigger = isset( $rule['triggerType'] ) ? (string) $rule['triggerType'] : '';
			if ( in_array( $trigger, self::USER_DEPENDENT_TRIGGERS, true ) ) {
				return true;
			}
		}
		return false;
	}

	// =========================================================================
	// PUBLIC API – TTL SELECTION
	// =========================================================================

	/**
	 * Resolve the TTL for a given set of enabled filter rules.
	 *
	 * Resolution order (highest priority first):
	 *  1. Any USER_DEPENDENT trigger present  → user TTL
	 *  2. Any VOLATILE trigger present        → volatile TTL
	 *  3. Otherwise                           → shared TTL
	 *
	 * @param array $rules Enabled filter rule objects.
	 * @return int TTL in seconds (always ≥ 1).
	 */
	public function resolve_ttl( array $rules ): int {
		$ttl          = self::get_ttl_config();
		$has_volatile = false;

		foreach ( $rules as $rule ) {
			if ( ! is_array( $rule ) ) {
				continue;
			}
			if ( isset( $rule['enabled'] ) && false === $rule['enabled'] ) {
				continue;
			}
			$trigger = isset( $rule['triggerType'] ) ? (string) $rule['triggerType'] : '';

			if ( in_array( $trigger, self::USER_DEPENDENT_TRIGGERS, true ) ) {
				return max( 1, (int) ( $ttl['user'] ?? self::CACHE_TTL_USER ) );
			}

			if ( in_array( $trigger, self::VOLATILE_TRIGGERS, true ) ) {
				$has_volatile = true;
			}
		}

		if ( $has_volatile ) {
			return max( 1, (int) ( $ttl['volatile'] ?? self::CACHE_TTL_VOLATILE ) );
		}

		return max( 1, (int) ( $ttl['shared'] ?? self::CACHE_TTL_SHARED ) );
	}

	// =========================================================================
	// PUBLIC API – KEY BUILDING
	// =========================================================================

	/**
	 * Build a transient key for a recommendation list.
	 *
	 * Key anatomy:
	 *  Shared (Tier 1/2): preco_s_{md5-32}              → 40 chars
	 *  User   (Tier 3) :  preco_u_{md5-32}_{salt-8}     → 49 chars
	 *
	 * Both formats are well within WordPress's 172-character transient key limit.
	 *
	 * The MD5 payload covers: page_key, location_key, list_index, serialized
	 * rules, serialized sorting, stable context fields, and requested limit.
	 *
	 * For user-dependent rule sets an 8-char salt derived from the visitor's
	 * recently-viewed cookie is appended, so each unique browse history maps to
	 * its own cache entry while remaining short-window cacheable.
	 *
	 * @param string $page_key     Page identifier   (e.g. 'product').
	 * @param string $location_key Hook location key (e.g. 'after_summary').
	 * @param int    $list_index   0-based index of the list within the location.
	 * @param array  $rules        Enabled filter rule objects.
	 * @param array  $sorting      Enabled sorting rule objects.
	 * @param array  $context      Full request context (visitor fields are stripped).
	 * @param int    $limit        Requested product count.
	 * @return string Transient key (never empty).
	 */
	public function make_key(
		string $page_key,
		string $location_key,
		int $list_index,
		array $rules,
		array $sorting,
		array $context,
		int $limit
	): string {
		$stable_ctx = $this->extract_stable_context( $context );

		$payload = implode(
			'|',
			array(
				$page_key,
				$location_key,
				$list_index,
				// Generation ties every key to the current invalidation epoch.
				// Bumping the generation makes existing keys unreachable instantly.
				self::get_generation(),
				// phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize
				serialize( $this->normalize_for_hash( $rules ) ),
				serialize( $this->normalize_for_hash( $sorting ) ),
				serialize( $this->normalize_for_hash( $stable_ctx ) ),
				// phpcs:enable
				$limit,
			)
		);

		$hash = md5( $payload );

		if ( $this->is_user_dependent( $rules ) ) {
			// Derive an 8-char visitor fingerprint from the recently-viewed cookie.
			// We only store the hash, never the raw cookie value.
			$cookie_raw  = isset( $_COOKIE[ RecentlyViewedTracker::COOKIE_NAME ] )
				? sanitize_text_field( wp_unslash( $_COOKIE[ RecentlyViewedTracker::COOKIE_NAME ] ) )
				: '';
			$cookie_salt = substr( md5( $cookie_raw ), 0, 8 );

			return self::PREFIX_USER . $hash . '_' . $cookie_salt;
		}

		return self::PREFIX_SHARED . $hash;
	}

	// =========================================================================
	// PUBLIC API – READ / WRITE
	// =========================================================================

	/**
	 * Retrieve cached product IDs for a key.
	 *
	 * Returns null on a cache miss so callers can reliably distinguish between
	 * "no products found (empty array, was cached)" and "not yet computed (null)".
	 *
	 * @param string $key Transient key from make_key().
	 * @return int[]|null Cached IDs, or null on cache miss.
	 */
	public function get( string $key ): ?array {
		$cached = get_transient( $key );
		return is_array( $cached ) ? $cached : null;
	}

	/**
	 * Store product IDs under a cache key.
	 *
	 * Empty arrays are intentionally stored: rules that produce no results
	 * (e.g. a gate rule blocking because the current product is not in the
	 * required category) should not re-run the expensive engine query on every
	 * page load during their TTL window.
	 *
	 * @param string $key Transient key from make_key().
	 * @param int[]  $ids Product IDs to cache (may be empty).
	 * @param int    $ttl Expiry in seconds. Use resolve_ttl() to obtain.
	 * @return void
	 */
	public function set( string $key, array $ids, int $ttl ): void {
		set_transient( $key, $ids, $ttl );
	}

	// =========================================================================
	// PUBLIC API – INVALIDATION
	// =========================================================================

	/**
	 * Flush ALL recommendation cache entries created by this plugin.
	 *
	 * Uses a direct $wpdb query so it works regardless of whether a persistent
	 * object cache (Redis / Memcached) is installed.
	 *
	 * When a persistent object cache IS active, wp_cache_flush() is called as
	 * a best-effort purge. This is intentional: it is a trade-off for an
	 * explicit admin action that is expected to clear stale data immediately.
	 *
	 * @see RecommendationsCacheAdmin::register_hooks() for the admin UI triggers.
	 *
	 * @global \wpdb $wpdb WordPress database abstraction object.
	 * @return int  Number of transient value rows deleted from wp_options.
	 *              (Timeout rows are also deleted but not counted separately.)
	 */
	public static function invalidate_all(): int {
		global $wpdb;

		// Bump the generation first so any new requests immediately start
		// building fresh keys while the DELETE runs in the background.
		self::bump_generation();

		$prefixes = array(
			self::PREFIX_SHARED,
			self::PREFIX_USER,
		);

		$deleted = 0;

		foreach ( $prefixes as $prefix ) {
			$like_value   = '_transient_' . $prefix . '%';
			$like_timeout = '_transient_timeout_' . $prefix . '%';

			// Delete the transient value rows and count them.
			$deleted += (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
				$wpdb->prepare(
					"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
					$like_value
				)
			);

			// Delete the corresponding timeout rows (no result count needed).
			$wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
				$wpdb->prepare(
					"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
					$like_timeout
				)
			);
		}

		// When a persistent object cache is active, try to clear memory too.
		if ( wp_using_ext_object_cache() ) {
			wp_cache_flush();
		}

		return $deleted;
	}

	// =========================================================================
	// INTERNAL HELPERS
	// =========================================================================

	/**
	 * Strip visitor-specific fields from context, keeping only keys listed in
	 * STABLE_CONTEXT_KEYS that safely identify the page without exposing any
	 * visitor identity.
	 *
	 * @param array $context Full request context.
	 * @return array Filtered context containing only stable keys.
	 */
	private function extract_stable_context( array $context ): array {
		$out = array();
		foreach ( self::STABLE_CONTEXT_KEYS as $k ) {
			if ( array_key_exists( $k, $context ) ) {
				$out[ $k ] = $context[ $k ];
			}
		}
		return $out;
	}

	/**
	 * Recursively sort array keys so that the same logical data structure
	 * always produces the same serialized string regardless of insertion order.
	 *
	 * PHP's serialize() is order-sensitive: two arrays with identical key/value
	 * pairs but different key insertion order produce different strings.
	 * Since the rules/sorting arrays originate from a stored WordPress option
	 * the order is usually stable, but this guard makes the key deterministic
	 * even if the caller builds the array differently across code paths.
	 *
	 * Scalar values and non-array nodes are returned unchanged.
	 *
	 * @param mixed $data Value to normalize.
	 * @return mixed
	 */
	private function normalize_for_hash( $data ) {
		if ( ! is_array( $data ) ) {
			return $data;
		}

		// Detect numeric-keyed (list) arrays – preserve their order since
		// position matters for rules (first rule = highest priority).
		$keys    = array_keys( $data );
		$is_list = $keys === array_keys( array_values( $data ) ); // Equivalent to array_is_list() (PHP 8.1+).

		$normalized = array();
		foreach ( $data as $k => $v ) {
			$normalized[ $k ] = $this->normalize_for_hash( $v );
		}

		if ( ! $is_list ) {
			// Associative map: sort by key so insertion order doesn't matter.
			ksort( $normalized );
		}

		return $normalized;
	}
}
