<?php
declare(strict_types=1);

namespace Automattic\WooCommerce\Internal\Traits;

use Automattic\WooCommerce\Internal\Caches\VersionStringGenerator;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Proxies\LegacyProxy;
use Automattic\WooCommerce\Utilities\CallbackUtil;
use WP_REST_Request;
use WP_REST_Response;

/**
 * This trait provides caching capabilities for REST API endpoints using the WordPress cache.
 *
 * - The output of all the REST API endpoints whose callback declaration is wrapped
 *   in a call to 'with_cache' will be cached using wp_cache_* functions.
 * - Response headers are cached together with the response data, excluding certain fixed
 *   headers (like Set-Cookie) and optionally others specified via configuration
 *   (per-controller or per-endpoint).
 * - For the purposes of caching, a request is uniquely identified by its route,
 *   HTTP method, query string, and user ID.
 * - The VersionStringGenerator class is used to track versions of entities included
 *   in the responses (an "entity" is any object that is uniquely identified by type and id
 *   and contributes with information to be included in the response),
 *   so that when those entities change, the relevant cached responses become invalid.
 *   Modification of entity versions must be done externally by the code that modifies
 *   those entities (via calls to VersionStringGenerator::generate_version).
 * - Various parameters (cached outputs TTL, entity type for a given response, hooks that affect
 *   the response) can be configured globally for the controller (via overriding protected methods)
 *   or per-endpoint (via arguments passed to with_cache).
 * - Caching can be disabled for a given request by adding a '_skip_cache=true|1'
 *   to the query string.
 * - A X-WC-Cache HTTP header is added to responses to indicate cache status:
 *   HIT, MISS, or SKIP.
 *
 * Additionally to caching, this trait also handles the sending of appropriate
 * Cache-Control and ETag headers to instruct clients and proxies on how to cache responses.
 * The ETag is generated based on the cached response data and cache key, and a request
 * containing an If-None-Match header with a matching ETag will receive a 304 Not Modified response.
 *
 * Usage: Wrap endpoint callbacks with the `with_cache()` method when registering routes.
 *
 * Example:
 *
 * class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
 *     use RestApiCache;
 *
 *     public function __construct() {
 *         parent::__construct();
 *         $this->initialize_rest_api_cache();  // REQUIRED
 *     }
 *
 *     protected function get_default_response_entity_type(): ?string {
 *         return 'product';  // REQUIRED (or specify entity_type in each with_cache call)
 *     }
 *
 *     public function register_routes() {
 *         register_rest_route(
 *             $this->namespace,
 *             '/' . $this->rest_base . '/(?P<id>[\d]+)',
 *             array(
 *                 'methods'  => WP_REST_Server::READABLE,
 *                 'callback' => $this->with_cache(
 *                     array( $this, 'get_item' ),
 *                     array(
 *                         // String, optional if get_default_response_entity_type() is overridden.
 *                         'entity_type'    => 'product',
 *                         // Optional int, defaults to the controller's get_ttl_for_cached_response().
 *                         'cache_ttl'      => HOUR_IN_SECONDS,
 *                         // Optional array, defaults to the controller's get_hooks_relevant_to_caching().
 *                         'relevant_hooks'  => array( 'filter_name_1', 'filter_name_2' ),
 *                         // Optional array, defaults to the controller's get_files_relevant_to_response_caching().
 *                         // Paths can be absolute or relative to the first directory from
 *                         // get_allowed_directories_for_file_based_response_caching() (WC_ABSPATH by default).
 *                         'relevant_files'  => array( 'data/config.json', '/absolute/path/to/file.php' ),
 *                         // Optional array, defaults to the controller's get_version_strings_relevant_to_caching().
 *                         // Version string IDs to track; cache is invalidated when any version string changes.
 *                         'relevant_version_strings' => array( 'list_products' ),
 *                         // Optional bool, defaults to the controller's response_cache_vary_by_user().
 *                         'vary_by_user'    => true,
 *                         // Optional array, defaults to the controller's get_response_headers_to_include_in_caching().
 *                         'include_headers' => array( 'X-Custom-Header' ),
 *                         // Optional array, defaults to the controller's get_response_headers_to_exclude_from_caching().
 *                         'exclude_headers' => array( 'X-Private-Header' ),
 *                         // Optional, this will be passed to all the caching-related methods.
 *                         'endpoint_id'     => 'get_product'
 *                     )
 *                 ),
 *             )
 *         );
 *     }
 * }
 *
 * Override these methods in your controller as needed:
 * - get_default_response_entity_type(): Default entity type for endpoints without explicit config.
 * - response_cache_vary_by_user(): Whether cache should be user-specific.
 * - get_hooks_relevant_to_caching(): Hook names to track for cache invalidation.
 * - get_files_relevant_to_response_caching(): File paths to track for cache invalidation.
 * - get_version_strings_relevant_to_caching(): Version string IDs to track for cache invalidation.
 * - get_allowed_directories_for_file_based_response_caching(): Directories allowed for file tracking.
 * - get_file_check_interval_for_response_caching(): How long to cache file modification checks (default 10 minutes).
 * - get_ttl_for_cached_response(): TTL for cached outputs in seconds.
 * - get_response_headers_to_include_in_caching(): Headers to include in cache (false = use exclusion mode).
 * - get_response_headers_to_exclude_from_caching(): Headers to exclude from cache (when in exclusion mode).
 *
 * Cache invalidation happens when:
 * - Entity versions change (tracked via VersionStringGenerator).
 * - Hook callbacks change
 *   (if the `get_hooks_relevant_to_caching()` call result or the 'relevant_hooks' array isn't empty).
 * - Tracked files change or are deleted
 *   (if the `get_files_relevant_to_response_caching()` call result or the 'relevant_files' array isn't empty).
 * - Relevant version strings change or are deleted
 *   (if the `get_version_strings_relevant_to_caching()` call result or the 'relevant_version_strings' array isn't empty).
 * - Cached response TTL expires.
 *
 * NOTE: This caching mechanism uses the WordPress cache (wp_cache_* functions).
 * By default caching is only enabled when an external object cache is enabled
 * (checked via call to VersionStringGenerator::can_use()), so the cache is persistent
 * across requests and not just for the current request.
 *
 * @since 10.5.0
 */
trait RestApiCache {
	/**
	 * Cache group name for REST API responses.
	 *
	 * @var string
	 */
	private static string $cache_group = 'woocommerce_rest_api_cache';

	/**
	 * Response headers that are always excluded from caching.
	 *
	 * @var array
	 */
	private static array $always_excluded_headers = array(
		'X-WC-Cache',
		'Set-Cookie',
		'Date',
		'Expires',
		'Last-Modified',
		'Age',
		'ETag',
		'Cache-Control',
		'Pragma',
	);

	/**
	 * Cache group for warning suppression (separate from main cache to avoid interference).
	 *
	 * @var string
	 */
	private static string $warning_cache_group = 'woocommerce_rest_api_cache_warnings';

	/**
	 * TTL for suppressing duplicate file tracking warnings (1 hour).
	 *
	 * @var int
	 */
	private static int $file_warning_suppression_ttl = HOUR_IN_SECONDS;

	/**
	 * The instance of VersionStringGenerator to use, or null if caching is disabled.
	 *
	 * @var VersionStringGenerator|null
	 */
	private ?VersionStringGenerator $version_string_generator = null;

	/**
	 * Whether we are currently handling a cached endpoint.
	 *
	 * @var bool
	 */
	private $is_handling_cached_endpoint = false;

	/**
	 * Whether the REST API caching feature is enabled.
	 *
	 * @var bool
	 */
	private bool $rest_api_caching_feature_enabled = false;

	/**
	 * Initialize the trait.
	 * This MUST be called from the controller's constructor.
	 *
	 * @since 10.5.0
	 */
	protected function initialize_rest_api_cache(): void {
		// Guard against early instantiation before WooCommerce is fully initialized.
		// Some third-party plugins instantiate REST controllers during plugin loading,
		// before the WooCommerce container is available.
		if ( ! function_exists( 'wc_get_container' ) ) {
			return;
		}

		$features_controller = wc_get_container()->get( FeaturesController::class );

		$this->rest_api_caching_feature_enabled = $features_controller->feature_is_enabled( 'rest_api_caching' );
		if ( ! $this->rest_api_caching_feature_enabled ) {
			return;
		}

		$generator = wc_get_container()->get( VersionStringGenerator::class );

		$backend_caching_enabled        = 'yes' === get_option( 'woocommerce_rest_api_enable_backend_caching', 'no' );
		$this->version_string_generator = ( $backend_caching_enabled && $generator->can_use() ) ? $generator : null;

		add_filter( 'rest_send_nocache_headers', array( $this, 'handle_rest_send_nocache_headers' ), 10, 1 );
	}

	/**
	 * Wrap an endpoint callback declaration with caching logic.
	 * Usage: `'callback' => $this->with_cache( array( $this, 'endpoint_callback_method' ) )`
	 *        `'callback' => $this->with_cache( array( $this, 'endpoint_callback_method' ), [ 'entity_type' => 'product' ] )`
	 *
	 * @since 10.5.0
	 *
	 * @param callable $callback The original endpoint callback.
	 * @param array    $config   Caching configuration:
	 *                           - entity_type: string (falls back to get_default_response_entity_type()).
	 *                           - vary_by_user: bool (defaults to response_cache_vary_by_user()).
	 *                           - endpoint_id: string|null (optional friendly identifier for the endpoint).
	 *                           - cache_ttl: int (defaults to get_ttl_for_cached_response()).
	 *                           - relevant_hooks: array (defaults to get_hooks_relevant_to_caching()).
	 *                           - relevant_files: array (defaults to get_files_relevant_to_response_caching()).
	 *                           - relevant_version_strings: array (defaults to get_version_strings_relevant_to_caching()).
	 *                           - include_headers: array|false (defaults to get_response_headers_to_include_in_caching()).
	 *                           - exclude_headers: array (defaults to get_response_headers_to_exclude_from_caching()).
	 * @return callable Wrapped callback.
	 */
	protected function with_cache( callable $callback, array $config = array() ): callable {
		return $this->rest_api_caching_feature_enabled
			? fn( $request ) => $this->handle_cacheable_request( $request, $callback, $config )
			: fn( $request ) => call_user_func( $callback, $request );
	}

	/**
	 * Handle a request with caching logic.
	 *
	 * Strategy:
	 * - If backend caching is enabled: Try to use cached response if available, otherwise execute
	 *   the callback and cache the response.
	 * - If only cache headers are enabled: Execute the callback, generate ETag, and return 304
	 *   if the client's ETag matches.
	 *
	 * @param WP_REST_Request<array<string, mixed>> $request  The request object.
	 * @param callable                              $callback The original endpoint callback.
	 * @param array                                 $config   Caching configuration specified for the endpoint.
	 *
	 * @return WP_REST_Response|\WP_Error The response.
	 */
	private function handle_cacheable_request( WP_REST_Request $request, callable $callback, array $config ) { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
		$backend_caching_enabled = ! is_null( $this->version_string_generator );
		$cache_headers_enabled   = 'yes' === get_option( 'woocommerce_rest_api_enable_cache_headers', 'yes' );

		if ( ! $backend_caching_enabled && ! $cache_headers_enabled ) {
			return call_user_func( $callback, $request );
		}

		if ( ! $this->should_use_cache_for_request( $request ) ) {
			$response = call_user_func( $callback, $request );
			if ( ! is_wp_error( $response ) ) {
				$response = rest_ensure_response( $response );
				$response->header( 'X-WC-Cache', 'SKIP' );
			}
			return $response;
		}

		$cached_config = $this->build_cache_config( $request, $config );

		$this->is_handling_cached_endpoint = true;

		if ( $backend_caching_enabled ) {
			$cached_response = $this->get_cached_response( $request, $cached_config, $cache_headers_enabled );

			if ( $cached_response ) {
				$cached_response->header( 'X-WC-Cache', 'HIT' );
				return $cached_response;
			}
		}

		$authoritative_response = call_user_func( $callback, $request );

		return $backend_caching_enabled
			? $this->maybe_cache_response( $request, $authoritative_response, $cached_config, $cache_headers_enabled )
			: $this->maybe_add_cache_headers( $request, $authoritative_response, $cached_config );
	}

	/**
	 * Check if caching should be used for a particular incoming request.
	 *
	 * @param WP_REST_Request<array<string, mixed>> $request The request object.
	 *
	 * @return bool True if caching should be used, false otherwise.
	 */
	private function should_use_cache_for_request( WP_REST_Request $request ): bool { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
		$skip_cache   = $request->get_param( '_skip_cache' );
		$should_cache = ! ( 'true' === $skip_cache || '1' === $skip_cache );

		/**
		 * Filter whether to enable response caching for a given REST API controller.
		 *
		 * @since 10.5.0
		 *
		 * @param bool            $enable_caching Whether to enable response caching (result of !_skip_cache evaluation).
		 * @param object          $controller     The controller instance.
		 * @param WP_REST_Request<array<string, mixed>> $request        The request object.
		 * @return bool True to enable response caching, false to disable.
		 */
		return apply_filters(
			'woocommerce_rest_api_enable_response_caching',
			$should_cache,
			$this,
			$request
		);
	}

	/**
	 * Build the output cache entry configuration from the request and per-endpoint config.
	 *
	 * @param WP_REST_Request<array<string, mixed>> $request The request object.
	 * @param array                                 $config  Raw configuration array passed to with_cache.
	 *
	 * @return array Normalized cache config with keys: endpoint_id, entity_type, vary_by_user, cache_ttl, relevant_hooks, relevant_files, include_headers, exclude_headers, cache_key.
	 *
	 * @throws \InvalidArgumentException If entity_type is not provided and no default is available, or if include_headers is not false or an array.
	 */
	private function build_cache_config( WP_REST_Request $request, array $config ): array { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
		$endpoint_id  = $config['endpoint_id'] ?? null;
		$entity_type  = $config['entity_type'] ?? $this->get_default_response_entity_type();
		$vary_by_user = $config['vary_by_user'] ?? $this->response_cache_vary_by_user( $request, $endpoint_id );

		if ( ! $entity_type ) {
			throw new \InvalidArgumentException(
				'REST API cache: No entity type provided in with_cache() config and no default entity type available from get_default_response_entity_type(). ' .
				'Either pass "entity_type" in the config array or override get_default_response_entity_type() in your controller.'
			);
		}

		$include_headers = $config['include_headers'] ?? $this->get_response_headers_to_include_in_caching( $request, $endpoint_id );
		if ( false !== $include_headers && ! is_array( $include_headers ) ) {
			throw new \InvalidArgumentException(
				'include_headers must be either false or an array, ' . gettype( $include_headers ) . ' given.' // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
			);
		}

		return array(
			'endpoint_id'              => $endpoint_id,
			'entity_type'              => $entity_type,
			'vary_by_user'             => $vary_by_user,
			'cache_ttl'                => $config['cache_ttl'] ?? $this->get_ttl_for_cached_response( $request, $endpoint_id ),
			'relevant_hooks'           => $config['relevant_hooks'] ?? $this->get_hooks_relevant_to_caching( $request, $endpoint_id ),
			'relevant_files'           => $config['relevant_files'] ?? $this->get_files_relevant_to_response_caching( $request, $endpoint_id ),
			'relevant_version_strings' => $config['relevant_version_strings'] ?? $this->get_version_strings_relevant_to_caching( $request, $endpoint_id ),
			'include_headers'          => $include_headers,
			'exclude_headers'          => $config['exclude_headers'] ?? $this->get_response_headers_to_exclude_from_caching( $request, $endpoint_id ),
			'cache_key'                => $this->get_key_for_cached_response( $request, $entity_type, $vary_by_user, $endpoint_id ),
		);
	}

	/**
	 * Cache the response if it's successful and optionally add cache headers.
	 *
	 * Only caches responses with 2xx status codes. Always adds the X-WC-Cache header
	 * with value MISS if the response was cached, or SKIP if it was not cached.
	 *
	 * Supports both WP_REST_Response objects and raw data (which will be wrapped in a response object).
	 * Error objects are returned as-is without caching.
	 *
	 * @param WP_REST_Request<array<string, mixed>>   $request            The request object.
	 * @param WP_REST_Response|\WP_Error|array|object $response           The response to potentially cache.
	 * @param array                                   $cached_config      Caching configuration from build_cache_config().
	 * @param bool                                    $add_cache_headers  Whether to add cache control headers.
	 *
	 * @return WP_REST_Response|\WP_Error The response with appropriate cache headers.
	 */
	private function maybe_cache_response( WP_REST_Request $request, $response, array $cached_config, bool $add_cache_headers ) { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
		if ( is_wp_error( $response ) ) {
			return $response;
		}

		$response = rest_ensure_response( $response );

		$cached = false;

		$status = $response->get_status();
		if ( $status >= 200 && $status <= 299 ) {
			$data       = $response->get_data();
			$entity_ids = is_array( $data ) ? $this->extract_entity_ids_from_response( $data, $request, $cached_config['endpoint_id'] ) : array();

			$response_headers  = $response->get_headers();
			$cacheable_headers = $this->get_headers_to_cache(
				$response_headers,
				$cached_config['include_headers'],
				$cached_config['exclude_headers'],
				$request,
				$response,
				$cached_config['endpoint_id']
			);

			$etag_data = is_array( $data ) ? $this->get_data_for_etag( $data, $request, $cached_config['endpoint_id'] ) : $data;
			$etag      = '"' . md5( $cached_config['cache_key'] . wp_json_encode( $etag_data ) ) . '"';

			$this->store_cached_response(
				array_merge(
					$cached_config,
					array(
						'data'        => $data,
						'status_code' => $status,
						'entity_ids'  => $entity_ids,
						'headers'     => $cacheable_headers,
						'etag'        => $etag,
					)
				)
			);

			$cached = true;
		}

		$response->header( 'X-WC-Cache', $cached ? 'MISS' : 'SKIP' );

		return $add_cache_headers ?
			$this->maybe_add_cache_headers( $request, $response, $cached_config ) :
			$response;
	}

	/**
	 * Add cache control headers to a response.
	 *
	 * This method generates an ETag from the response data and returns a 304 Not Modified
	 * if the client's If-None-Match header matches. It can be used both with and without
	 * backend caching.
	 *
	 * @param WP_REST_Request<array<string, mixed>>   $request       The request object.
	 * @param WP_REST_Response|\WP_Error|array|object $response      The response to add headers to.
	 * @param array                                   $cached_config Caching configuration from build_cache_config().
	 *
	 * @return WP_REST_Response|\WP_Error The response with cache headers.
	 */
	private function maybe_add_cache_headers( WP_REST_Request $request, $response, array $cached_config ) { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
		if ( is_wp_error( $response ) ) {
			return $response;
		}

		$response = rest_ensure_response( $response );

		$status = $response->get_status();
		if ( $status < 200 || $status > 299 ) {
			return $response;
		}

		$response_data      = $response->get_data();
		$response_etag_data = is_array( $response_data ) ? $this->get_data_for_etag( $response_data, $request, $cached_config['endpoint_id'] ) : $response_data;
		$response_etag      = '"' . md5( $cached_config['cache_key'] . wp_json_encode( $response_etag_data ) ) . '"';

		$request_etag = $request->get_header( 'if-none-match' );

		$legacy_proxy        = wc_get_container()->get( LegacyProxy::class );
		$is_user_logged_in   = $legacy_proxy->call_function( 'is_user_logged_in' );
		$cache_visibility    = $cached_config['vary_by_user'] && $is_user_logged_in ? 'private' : 'public';
		$cache_control_value = $cache_visibility . ', must-revalidate, max-age=' . $cached_config['cache_ttl'];

		if ( $request_etag === $response_etag ) {
			$not_modified_response = $this->create_not_modified_response( $response_etag, $cache_control_value, $request, $cached_config['endpoint_id'] );
			if ( $not_modified_response ) {
				return $not_modified_response;
			}
		}

		$response->header( 'ETag', $response_etag );
		$response->header( 'Cache-Control', $cache_control_value );

		if ( ! array_key_exists( 'X-WC-Cache', $response->get_headers() ) ) {
			$response->header( 'X-WC-Cache', 'HEADERS' );
		}

		return $response;
	}

	/**
	 * Create a 304 Not Modified response if allowed by filters.
	 *
	 * @param string                                $etag                The ETag value.
	 * @param string                                $cache_control_value The Cache-Control header value.
	 * @param WP_REST_Request<array<string, mixed>> $request             The request object.
	 * @param string|null                           $endpoint_id         The endpoint identifier.
	 *
	 * @return WP_REST_Response|null 304 response if allowed, null otherwise.
	 */
	private function create_not_modified_response( string $etag, string $cache_control_value, WP_REST_Request $request, ?string $endpoint_id ): ?WP_REST_Response { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
		$response = new WP_REST_Response( null, 304 );
		$response->header( 'ETag', $etag );
		$response->header( 'Cache-Control', $cache_control_value );
		$response->header( 'X-WC-Cache', 'MATCH' );

		/**
		 * Filter the 304 Not Modified response before sending.
		 *
		 * @since 10.5.0
		 *
		 * @param WP_REST_Response|false $response    The 304 response object, or false to prevent sending it.
		 * @param WP_REST_Request        $request     The request object.
		 * @param string|null            $endpoint_id The endpoint identifier.
		 */
		$filtered_response = apply_filters( 'woocommerce_rest_api_not_modified_response', $response, $request, $endpoint_id );

		return false === $filtered_response ? null : rest_ensure_response( $filtered_response );
	}

	/**
	 * Get the default type for entities included in responses.
	 *
	 * This can be customized per-endpoint via the config array
	 * passed to with_cache() ('entity_type' key).
	 *
	 * @since 10.5.0
	 *
	 * @return string|null Entity type (e.g., 'product', 'order'), or null if no controller-wide default.
	 */
	protected function get_default_response_entity_type(): ?string {
		return null;
	}

	/**
	 * Get data for ETag generation.
	 *
	 * Override in classes to exclude fields that change on each request
	 * (e.g., random recommendations, timestamps).
	 *
	 * @since 10.5.0
	 *
	 * @param array                                 $data        Response data.
	 * @param WP_REST_Request<array<string, mixed>> $request     The request object.
	 * @param string|null                           $endpoint_id Optional friendly identifier for the endpoint.
	 *
	 * @return array Cleaned data for ETag generation.
	 */
	protected function get_data_for_etag( array $data, WP_REST_Request $request, ?string $endpoint_id = null ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
		return $data;
	}

	/**
	 * Whether the response cache should vary by user.
	 *
	 * When true, each user gets their own cached version of the response.
	 * When false, the same cached response is shared across all users.
	 *
	 * This can be customized per-endpoint via the config array
	 * passed to with_cache() ('vary_by_user' key).
	 *
	 * @since 10.5.0
	 *
	 * @param WP_REST_Request<array<string, mixed>> $request     The request object.
	 * @param string|null                           $endpoint_id Optional friendly identifier for the endpoint.
	 *
	 * @return bool True to make cache user-specific, false otherwise.
	 */
	protected function response_cache_vary_by_user( WP_REST_Request $request, ?string $endpoint_id = null ): bool { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
		return true;
	}

	/**
	 * Get the cache TTL (time to live) for cached responses.
	 *
	 * This can be customized per-endpoint via the config array
	 * passed to with_cache() ('cache_ttl' key).
	 *
	 * @since 10.5.0
	 *
	 * @param WP_REST_Request<array<string, mixed>> $request     The request object.
	 * @param string|null                           $endpoint_id Optional friendly identifier for the endpoint.
	 *
	 * @return int Cache TTL in seconds.
	 */
	protected function get_ttl_for_cached_response( WP_REST_Request $request, ?string $endpoint_id = null ): int { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
		return HOUR_IN_SECONDS;
	}

	/**
	 * Get the names of hooks (filters and actions) that can customize the response.
	 *
	 * All the existing instances of add_action/add_filter for these hooks
	 * will be included in the information that gets cached together with the response,
	 * and if any of these has changed when the cached response is retrieved,
	 * the cache entry will be invalidated.
	 *
	 * This can be customized per-endpoint via the config array
	 * passed to with_cache() ('relevant_hooks' key).
	 *
	 * @since 10.5.0
	 *
	 * @param WP_REST_Request<array<string, mixed>> $request     Request object.
	 * @param string|null                           $endpoint_id Optional friendly identifier for the endpoint.
	 *
	 * @return array Array of hook names to track.
	 */
	protected function get_hooks_relevant_to_caching( WP_REST_Request $request, ?string $endpoint_id = null ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
		return array();
	}

	/**
	 * Get the paths of files whose modification affects the response.
	 *
	 * All the returned files will be tracked for changes: whenever a response is cached,
	 * each file's modification time is recorded, and if any file has changed or disappeared
	 * when the cached response is retrieved, the cache entry will be invalidated.
	 *
	 * Paths can be absolute or relative. Relative paths are resolved relative to the first
	 * directory returned by get_allowed_directories_for_file_based_response_caching().
	 *
	 * This can be customized per-endpoint via the config array
	 * passed to with_cache() ('relevant_files' key).
	 *
	 * @since 10.6.0
	 *
	 * @param WP_REST_Request<array<string, mixed>> $request     Request object.
	 * @param string|null                           $endpoint_id Optional friendly identifier for the endpoint.
	 *
	 * @return array Array of file paths to track.
	 */
	protected function get_files_relevant_to_response_caching( WP_REST_Request $request, ?string $endpoint_id = null ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
		return array();
	}

	/**
	 * Get the identifiers of version strings that affect the response.
	 *
	 * All returned version strings will be tracked for changes: whenever a response is cached,
	 * each version string's current value is recorded, and if any has changed or disappeared
	 * when the cached response is retrieved, the cache entry will be invalidated.
	 *
	 * This is useful for collection endpoints where entities outside the current page
	 * could affect the response (e.g., a deleted entity shifts pagination).
	 *
	 * This can be customized per-endpoint via the config array
	 * passed to with_cache() ('relevant_version_strings' key).
	 *
	 * @since 10.6.0
	 *
	 * @param WP_REST_Request<array<string, mixed>> $request     Request object.
	 * @param string|null                           $endpoint_id Optional friendly identifier for the endpoint.
	 *
	 * @return array Array of version string identifiers to track.
	 */
	protected function get_version_strings_relevant_to_caching( WP_REST_Request $request, ?string $endpoint_id = null ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
		return array();
	}

	/**
	 * Get directories allowed for file-based response caching.
	 *
	 * Returns an array of directory paths that are allowed to contain files tracked
	 * for cache invalidation. The first directory in the array is also used as the
	 * base path for resolving relative file paths.
	 *
	 * @since 10.6.0
	 *
	 * @return array Array of absolute directory paths.
	 */
	protected function get_allowed_directories_for_file_based_response_caching(): array {
		return defined( 'WC_ABSPATH' ) ? array( WC_ABSPATH ) : array();
	}

	/**
	 * Get the interval for caching file modification checks.
	 *
	 * To avoid checking file modification times on every request, file checks are cached
	 * for this interval. During this period, files are assumed to be unchanged.
	 *
	 * Override this method to customize the interval. Return 0 to disable caching
	 * and check files on every request.
	 *
	 * @since 10.6.0
	 *
	 * @return int Interval in seconds. Default is 10 minutes (600 seconds).
	 */
	protected function get_file_check_interval_for_response_caching(): int {
		return 10 * MINUTE_IN_SECONDS;
	}

	/**
	 * Get the names of response headers to include in caching.
	 *
	 * When this returns an array, ONLY the headers whose names are returned
	 * will be included in the cache (subject to always-excluded headers).
	 * When this returns false, all headers will be included except those returned
	 * by get_response_headers_to_exclude_from_caching().
	 *
	 * This can be customized per-endpoint via the config array
	 * passed to with_cache() ('include_headers' key).
	 *
	 * @since 10.5.0
	 *
	 * @param WP_REST_Request<array<string, mixed>> $request     Request object.
	 * @param string|null                           $endpoint_id Optional friendly identifier for the endpoint.
	 *
	 * @return array|false Array of header names to include (case-insensitive), or false to use exclusion logic.
	 */
	protected function get_response_headers_to_include_in_caching( WP_REST_Request $request, ?string $endpoint_id = null ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
		return false;
	}

	/**
	 * Get the names of response headers to exclude from caching.
	 *
	 * These headers will not be stored in the cache, in addition to the
	 * always-excluded headers (X-WC-Cache, Set-Cookie, Date, Expires, Last-Modified,
	 * Age, ETag, Cache-Control, Pragma).
	 *
	 * This is only used when get_response_headers_to_include_in_caching() returns false.
	 *
	 * This can be customized per-endpoint via the config array
	 * passed to with_cache() ('exclude_headers' key).
	 *
	 * @since 10.5.0
	 *
	 * @param WP_REST_Request<array<string, mixed>> $request     Request object.
	 * @param string|null                           $endpoint_id Optional friendly identifier for the endpoint.
	 *
	 * @return array Array of header names to exclude (case-insensitive).
	 */
	protected function get_response_headers_to_exclude_from_caching( WP_REST_Request $request, ?string $endpoint_id = null ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
		return array();
	}

	/**
	 * Extract entity IDs from response data.
	 *
	 * This implementation assumes the response is either:
	 * - An array with an 'id' field (single item)
	 * - An array of arrays each having an 'id' field (collection)
	 *
	 * Controllers can override this method to customize entity ID extraction.
	 *
	 * @since 10.5.0
	 *
	 * @param array                                 $response_data Response data.
	 * @param WP_REST_Request<array<string, mixed>> $request       The request object.
	 * @param string|null                           $endpoint_id   Optional friendly identifier for the endpoint.
	 *
	 * @return array Array of entity IDs.
	 */
	protected function extract_entity_ids_from_response( array $response_data, WP_REST_Request $request, ?string $endpoint_id = null ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
		$ids = array();

		if ( isset( $response_data[0] ) && is_array( $response_data[0] ) ) {
			foreach ( $response_data as $item ) {
				if ( isset( $item['id'] ) ) {
					$ids[] = $item['id'];
				}
			}
		} elseif ( isset( $response_data['id'] ) ) {
			$ids[] = $response_data['id'];
		}

		// Filter out false values but keep 0 and empty strings as they could be valid IDs.
		// Note: null values can't exist here because isset() checks above exclude them.
		return array_unique(
			array_filter( $ids, fn ( $id ) => false !== $id )
		);
	}

	/**
	 * Filter response headers to get only those that should be cached.
	 *
	 * The filtering process follows these steps:
	 * 1. If $include_headers is an array, only those headers are included (case-insensitive).
	 *    If $include_headers is false, all headers are included except those in $exclude_headers.
	 * 2. Always-excluded headers (X-WC-Cache, Set-Cookie, Date, etc.) are removed.
	 * 3. The woocommerce_rest_api_cached_headers filter is applied, receiving both the candidate
	 *    headers list and all available headers. This allows filters to both add and remove
	 *    headers from the caching list.
	 * 4. Always-excluded headers are enforced again post-filter to prevent filters from
	 *    re-introducing dangerous headers like Set-Cookie.
	 * 5. Only headers from the response that are in the filtered list are returned.
	 *
	 * @param array                                 $nominal_headers Response headers.
	 * @param array|false                           $include_headers Header names to include (false to use exclusion logic).
	 * @param array                                 $exclude_headers Header names to exclude (case-insensitive).
	 * @param WP_REST_Request<array<string, mixed>> $request The request object.
	 * @param WP_REST_Response                      $response        The response object.
	 * @param string|null                           $endpoint_id     Optional friendly identifier for the endpoint.
	 *
	 * @return array Filtered headers array.
	 */
	private function get_headers_to_cache( array $nominal_headers, $include_headers, array $exclude_headers, WP_REST_Request $request, WP_REST_Response $response, ?string $endpoint_id ): array { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
		// Step 1: Determine which headers to consider based on include/exclude.
		if ( false !== $include_headers ) {
			$include_headers_lowercase = array_map( 'strtolower', $include_headers );
			$headers_to_cache          = array_filter(
				$nominal_headers,
				fn( $name ) => in_array( strtolower( $name ), $include_headers_lowercase, true ),
				ARRAY_FILTER_USE_KEY
			);
		} else {
			$exclude_headers_lowercase = array_map( 'strtolower', $exclude_headers );
			$headers_to_cache          = array_filter(
				$nominal_headers,
				fn( $name ) => ! in_array( strtolower( $name ), $exclude_headers_lowercase, true ),
				ARRAY_FILTER_USE_KEY
			);
		}

		// Step 2: Remove always-excluded headers.
		$always_exclude_lowercase = array_map( 'strtolower', self::$always_excluded_headers );
		$headers_to_cache         = array_filter(
			$headers_to_cache,
			fn( $name ) => ! in_array( strtolower( $name ), $always_exclude_lowercase, true ),
			ARRAY_FILTER_USE_KEY
		);

		// Step 3: Apply filter to header names.
		$cached_header_names = array_keys( $headers_to_cache );
		$all_header_names    = array_keys( $nominal_headers );

		/**
		 * Filter the list of response header names to cache.
		 *
		 * @since 10.5.0
		 *
		 * @param array            $cached_header_names Candidate list of header names to cache.
		 * @param array            $all_header_names    All header names available in the response.
		 * @param WP_REST_Request  $request             The request object.
		 * @param WP_REST_Response $response            The response object.
		 * @param string|null      $endpoint_id         Optional friendly identifier for the endpoint.
		 * @param object           $controller          The controller instance.
		 *
		 * @return array Filtered list of header names to cache.
		 */
		$filtered_header_names = apply_filters(
			'woocommerce_rest_api_cached_headers',
			$cached_header_names,
			$all_header_names,
			$request,
			$response,
			$endpoint_id,
			$this
		);

		// Step 4: Enforce always-excluded headers post-filter.
		$filtered_header_names_lowercase = array_map( 'strtolower', $filtered_header_names );
		$reintroduced_headers            = array_filter(
			$filtered_header_names,
			fn( $name ) => in_array( strtolower( $name ), $always_exclude_lowercase, true )
		);

		if ( ! empty( $reintroduced_headers ) ) {
			$legacy_proxy = wc_get_container()->get( LegacyProxy::class );
			$legacy_proxy->call_function(
				'wc_doing_it_wrong',
				__METHOD__,
				sprintf(
					/* translators: %s: comma-separated list of header names */
					'The woocommerce_rest_api_cached_headers filter attempted to cache always-excluded headers: %s. These headers have been removed for security reasons.',
					implode( ', ', $reintroduced_headers )
				),
				'10.5.0'
			);

			$filtered_header_names_lowercase = array_filter(
				$filtered_header_names_lowercase,
				fn( $name ) => ! in_array( $name, $always_exclude_lowercase, true )
			);
		}

		// Step 5: Return only the headers that are in the filtered list.
		return array_filter(
			$nominal_headers,
			fn( $name ) => in_array( strtolower( $name ), $filtered_header_names_lowercase, true ),
			ARRAY_FILTER_USE_KEY
		);
	}

	/**
	 * Get cache key information that uniquely identifies a request.
	 *
	 * @since 10.5.0
	 *
	 * @param WP_REST_Request<array<string, mixed>> $request      The request object.
	 * @param bool                                  $vary_by_user Whether to include user ID in cache key.
	 * @param string|null                           $endpoint_id  Optional friendly identifier for the endpoint.
	 *
	 * @return array Array of cache key information parts.
	 */
	protected function get_key_info_for_cached_response( WP_REST_Request $request, bool $vary_by_user = false, ?string $endpoint_id = null ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
		$request_query_params = $request->get_query_params();
		if ( is_array( $request_query_params ) ) {
			ksort( $request_query_params );
		}

		$cache_key_parts = array(
			$request->get_route(),
			$request->get_method(),
			wp_json_encode( $request_query_params ),
		);

		if ( $vary_by_user ) {
			$legacy_proxy = wc_get_container()->get( LegacyProxy::class );
			// @phpstan-ignore-next-line argument.type -- get_current_user_id returns int at runtime.
			$user_id           = intval( $legacy_proxy->call_function( 'get_current_user_id' ) );
			$cache_key_parts[] = "user_{$user_id}";
		}

		return $cache_key_parts;
	}

	/**
	 * Generate a cache key for a given request.
	 *
	 * @param WP_REST_Request<array<string, mixed>> $request      The request object.
	 * @param string                                $entity_type  The entity type.
	 * @param bool                                  $vary_by_user Whether to include user ID in cache key.
	 * @param string|null                           $endpoint_id  Optional friendly identifier for the endpoint.
	 *
	 * @return string Cache key.
	 */
	private function get_key_for_cached_response( WP_REST_Request $request, string $entity_type, bool $vary_by_user = false, ?string $endpoint_id = null ): string { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
		$cache_key_parts = $this->get_key_info_for_cached_response( $request, $vary_by_user, $endpoint_id );

		/**
		 * Filter the information used to generate the cache key for a REST API request.
		 *
		 * Allows customization of what uniquely identifies a request for caching purposes.
		 *
		 * @since 10.5.0
		 *
		 * @param array           $cache_key_parts Array of cache key information parts.
		 * @param WP_REST_Request<array<string, mixed>> $request         The request object.
		 * @param bool            $vary_by_user    Whether user ID is included in cache key.
		 * @param string|null     $endpoint_id     Optional friendly identifier for the endpoint (passed to with_cache).
		 * @param object          $controller      The controller instance.
		 *
		 * @return array Filtered cache key information parts.
		 */
		$cache_key_parts = apply_filters(
			'woocommerce_rest_api_cache_key_info',
			$cache_key_parts,
			$request,
			$vary_by_user,
			$endpoint_id,
			$this
		);

		$request_hash = md5( implode( '-', $cache_key_parts ) );
		return "wc_rest_api_cache_{$entity_type}-{$request_hash}";
	}

	/**
	 * Generate a hash based on the actual usages of the hooks that affect the response.
	 *
	 * @param array $hook_names Array of hook names to track.
	 *
	 * @return string Hooks hash.
	 */
	private function generate_hooks_hash( array $hook_names ): string {
		if ( empty( $hook_names ) ) {
			return '';
		}

		$cache_hash_data = array();

		foreach ( $hook_names as $hook_name ) {
			$signatures = CallbackUtil::get_hook_callback_signatures( $hook_name );
			if ( ! empty( $signatures ) ) {
				$cache_hash_data[ $hook_name ] = $signatures;
			}
		}

		/**
		 * Filter the data used to generate the hooks hash for REST API response caching.
		 *
		 * @since 10.5.0
		 *
		 * @param array  $cache_hash_data Hook callbacks data used for hash generation.
		 * @param array  $hook_names      Hook names being tracked.
		 * @param object $controller      Controller instance.
		 */
		$cache_hash_data = apply_filters(
			'woocommerce_rest_api_cache_hooks_hash_data',
			$cache_hash_data,
			$hook_names,
			$this
		);

		$json = wp_json_encode( $cache_hash_data );
		return md5( false === $json ? '' : $json );
	}

	/**
	 * Generate a hash based on the current values of the relevant version strings.
	 *
	 * @since 10.6.0
	 *
	 * @param array $version_string_ids Array of version string identifiers to track.
	 *
	 * @return string Version strings hash, or empty string if no version strings could be tracked.
	 */
	private function generate_version_strings_hash( array $version_string_ids ): string {
		if ( empty( $version_string_ids ) || is_null( $this->version_string_generator ) ) {
			return '';
		}

		$version_data = array();
		foreach ( $version_string_ids as $id ) {
			$version = $this->version_string_generator->get_version( $id );
			if ( $version ) {
				$version_data[ $id ] = $version;
			}
		}

		if ( empty( $version_data ) ) {
			return '';
		}

		/**
		 * Filter the version strings data used for REST API response cache invalidation.
		 *
		 * @since 10.6.0
		 *
		 * @param array  $version_data       Array mapping version string IDs to their current values.
		 * @param array  $version_string_ids Original version string identifiers passed to the method.
		 * @param object $controller         Controller instance.
		 */
		$version_data = apply_filters(
			'woocommerce_rest_api_cache_version_strings_hash_data',
			$version_data,
			$version_string_ids,
			$this
		);

		if ( empty( $version_data ) ) {
			return '';
		}

		ksort( $version_data );
		$json = wp_json_encode( $version_data );
		return md5( false === $json ? '' : $json );
	}

	/**
	 * Get the filtered list of allowed directories for file-based response caching.
	 *
	 * This method retrieves the allowed directories from the protected method
	 * and applies the woocommerce_rest_api_cache_allowed_file_directories filter.
	 *
	 * @since 10.6.0
	 *
	 * @return array Array of absolute directory paths.
	 */
	private function get_filtered_allowed_directories_for_response_caching(): array {
		$allowed_directories = $this->get_allowed_directories_for_file_based_response_caching();

		/**
		 * Filter the directories allowed for file-based REST API response caching.
		 *
		 * This filter allows extensions to add additional directories that can contain
		 * files tracked for cache invalidation. The first directory in the array is
		 * used as the base path for resolving relative file paths.
		 *
		 * @since 10.6.0
		 *
		 * @param array  $allowed_directories Array of absolute directory paths.
		 * @param object $controller          The controller instance.
		 *
		 * @return array Filtered array of directory paths.
		 */
		return apply_filters(
			'woocommerce_rest_api_cache_allowed_file_directories',
			$allowed_directories,
			$this
		);
	}

	/**
	 * Generate a hash for the given file paths based on their modification times.
	 *
	 * This method resolves relative paths (relative to the first allowed directory),
	 * gets file modification times, and generates a hash for cache invalidation.
	 * Files that cannot be accessed (permissions, non-existent) are logged as warnings
	 * and excluded from tracking.
	 *
	 * To avoid filesystem calls on every request, file check results are cached
	 * for the interval returned by get_file_check_interval_for_response_caching().
	 *
	 * @since 10.6.0
	 *
	 * @param array $file_paths Array of file paths (absolute or relative to the first allowed directory).
	 *
	 * @return string Hash string, or empty string if no files could be tracked.
	 */
	private function generate_files_hash( array $file_paths ): string {
		if ( empty( $file_paths ) ) {
			return '';
		}

		$allowed_directories = $this->get_filtered_allowed_directories_for_response_caching();
		if ( empty( $allowed_directories ) ) {
			$this->log_file_tracking_warning( '', 'No allowed directories configured for file tracking' );
			return '';
		}

		$files_data     = null;
		$check_interval = $this->get_file_check_interval_for_response_caching();

		// Try to get cached file check results to avoid filesystem calls on every request.
		if ( $check_interval > 0 ) {
			$file_check_cache_key = $this->get_file_check_cache_key( $file_paths, $allowed_directories );
			$files_data           = wp_cache_get( $file_check_cache_key, self::$cache_group );
			if ( false === $files_data ) {
				$files_data = null;
			}
		}

		// Cache miss or caching disabled - check all files.
		if ( is_null( $files_data ) ) {
			$files_data = $this->check_files( $file_paths, $allowed_directories );

			// Cache the results if caching is enabled.
			if ( $check_interval > 0 && ! empty( $files_data ) ) {
				wp_cache_set( $file_check_cache_key, $files_data, self::$cache_group, $check_interval );
			}
		}

		/**
		 * Filter the file data used for REST API response cache invalidation.
		 *
		 * This filter allows modification of the file tracking data before it is stored
		 * in the cache and used for invalidation checks.
		 *
		 * @since 10.6.0
		 *
		 * @param array  $files_data Array of file data, each with 'path' and 'time' keys.
		 * @param array  $file_paths Original file paths passed to the method.
		 * @param object $controller Controller instance.
		 */
		$files_data = apply_filters(
			'woocommerce_rest_api_cache_files_hash_data',
			$files_data,
			$file_paths,
			$this
		);

		if ( empty( $files_data ) ) {
			return '';
		}

		$json = wp_json_encode( $files_data );
		return md5( false === $json ? '' : $json );
	}

	/**
	 * Generate a cache key for file check results.
	 *
	 * @param array $file_paths          Array of file paths to track.
	 * @param array $allowed_directories Array of allowed directory paths.
	 *
	 * @return string Cache key.
	 */
	private function get_file_check_cache_key( array $file_paths, array $allowed_directories ): string {
		sort( $file_paths );
		sort( $allowed_directories );
		$key_data = array(
			'files' => $file_paths,
			'dirs'  => $allowed_directories,
		);
		$json     = wp_json_encode( $key_data );
		return 'wc_rest_file_check_' . md5( false === $json ? '' : $json );
	}

	/**
	 * Check files and return their tracking data.
	 *
	 * @param array $file_paths          Array of file paths to check.
	 * @param array $allowed_directories Array of allowed directory paths.
	 *
	 * @return array Array of file data, each with 'path' and 'time' keys.
	 */
	private function check_files( array $file_paths, array $allowed_directories ): array {
		$files_data = array();

		foreach ( $file_paths as $file_path ) {
			$resolved_path = $this->resolve_file_path( $file_path, $allowed_directories );

			if ( is_null( $resolved_path ) ) {
				$this->log_file_tracking_warning( $file_path, 'Path could not be resolved or is outside allowed directories' );
				continue;
			}

			$file_entry = $this->get_file_tracking_entry( $resolved_path );
			if ( is_null( $file_entry ) ) {
				$this->log_file_tracking_warning( $resolved_path, 'File does not exist or cannot be accessed' );
				continue;
			}

			$files_data[] = $file_entry;
		}

		return $files_data;
	}

	/**
	 * Resolve a file path to an absolute path.
	 *
	 * Relative paths are resolved relative to the first directory in the allowed directories list.
	 * All paths are converted to physical paths (symlinks resolved) for consistent comparison.
	 * Paths that resolve outside the allowed directories are rejected for security.
	 *
	 * @param string $file_path           The file path to resolve (absolute or relative).
	 * @param array  $allowed_directories Array of allowed directory paths.
	 *
	 * @return string|null The resolved absolute path, or null if the path is invalid or outside allowed directories.
	 */
	private function resolve_file_path( string $file_path, array $allowed_directories ): ?string {
		if ( empty( $allowed_directories ) ) {
			return null;
		}

		if ( ! path_is_absolute( $file_path ) ) {
			$base_path = trailingslashit( $allowed_directories[0] );
			$file_path = $base_path . ltrim( $file_path, '/' );
		}

		$legacy_proxy = wc_get_container()->get( LegacyProxy::class );

		$physical_path = $legacy_proxy->call_function( 'realpath', $file_path );
		if ( false === $physical_path ) {
			return null;
		}

		$normalized_path = wp_normalize_path( $physical_path );

		foreach ( $allowed_directories as $dir ) {
			$real_dir = $legacy_proxy->call_function( 'realpath', $dir );
			if ( false === $real_dir ) {
				continue;
			}

			$normalized_dir = trailingslashit( wp_normalize_path( $real_dir ) );
			if ( 0 === strpos( $normalized_path, $normalized_dir ) ) {
				return $normalized_path;
			}
		}

		return null;
	}

	/**
	 * Log a warning about a file that couldn't be tracked.
	 *
	 * Each unique file path + reason combination is logged only once per the
	 * suppression TTL period to avoid flooding the log with repeated warnings.
	 * With a persistent object cache (Redis, Memcached), this works across requests.
	 * Without one, it prevents duplicates within the same request.
	 *
	 * @since 10.6.0
	 *
	 * @param string $file_path The file path that couldn't be tracked.
	 * @param string $reason    The reason the file couldn't be tracked.
	 */
	private function log_file_tracking_warning( string $file_path, string $reason ): void {
		/**
		 * Filter the TTL for suppressing duplicate file tracking warnings.
		 *
		 * By default, each unique warning (file path + reason) is logged only once per hour
		 * to avoid flooding the log. Use this filter to customize the suppression period.
		 * Return 0 to disable suppression and log all warnings.
		 *
		 * @since 10.6.0
		 *
		 * @param int    $ttl       The suppression TTL in seconds. Default is HOUR_IN_SECONDS.
		 * @param string $file_path The file path that couldn't be tracked.
		 * @param string $reason    The reason the file couldn't be tracked.
		 */
		$suppression_ttl = apply_filters(
			'woocommerce_rest_api_cache_file_warning_suppression_ttl',
			self::$file_warning_suppression_ttl,
			$file_path,
			$reason
		);

		if ( $suppression_ttl > 0 ) {
			$warning_key = 'wc_rest_file_warning_' . md5( $file_path . '|' . $reason );

			if ( false !== wp_cache_get( $warning_key, self::$warning_cache_group ) ) {
				return;
			}

			wp_cache_set( $warning_key, true, self::$warning_cache_group, $suppression_ttl );
		}

		$logger = wc_get_container()->get( LegacyProxy::class )->call_function( 'wc_get_logger' );
		$logger->warning(
			sprintf(
				'REST API cache: Could not track file "%s" for cache invalidation. Reason: %s',
				$file_path,
				$reason
			),
			array( 'source' => 'rest-api-cache' )
		);
	}

	/**
	 * Get file tracking entry for a resolved path.
	 *
	 * @since 10.6.0
	 *
	 * @param string $resolved_path The resolved absolute file path.
	 *
	 * @return array{path: string, time: int}|null File entry with path and time, or null if file can't be accessed.
	 */
	private function get_file_tracking_entry( string $resolved_path ): ?array {
		$legacy_proxy = wc_get_container()->get( LegacyProxy::class );

		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- We handle the error gracefully.
		$mtime = @$legacy_proxy->call_function( 'filemtime', $resolved_path );
		if ( false === $mtime ) {
			return null;
		}

		return array(
			'path' => $resolved_path,
			'time' => $mtime,
		);
	}

	/**
	 * Get a cached response, but only if it's valid (otherwise the cached response will be invalidated).
	 *
	 * @param WP_REST_Request<array<string, mixed>> $request              The request object.
	 * @param array                                 $cached_config        Built caching configuration from build_cache_config().
	 * @param bool                                  $cache_headers_enabled Whether to add cache control headers.
	 *
	 * @return WP_REST_Response|null Cached response, or null if not available or has been invalidated.
	 */
	private function get_cached_response( WP_REST_Request $request, array $cached_config, bool $cache_headers_enabled ): ?WP_REST_Response { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
		$cache_key      = $cached_config['cache_key'];
		$entity_type    = $cached_config['entity_type'];
		$cache_ttl      = $cached_config['cache_ttl'];
		$relevant_hooks = $cached_config['relevant_hooks'];

		$cached = wp_cache_get( $cache_key, self::$cache_group );

		if ( ! is_array( $cached ) || ! array_key_exists( 'data', $cached ) || ! isset( $cached['entity_versions'], $cached['created_at'] ) ) {
			return null;
		}

		$legacy_proxy    = wc_get_container()->get( LegacyProxy::class );
		$current_time    = $legacy_proxy->call_function( 'time' );
		$expiration_time = $cached['created_at'] + $cache_ttl;
		if ( $current_time >= $expiration_time ) {
			wp_cache_delete( $cache_key, self::$cache_group );
			return null;
		}

		if ( ! empty( $relevant_hooks ) ) {
			$current_hooks_hash = $this->generate_hooks_hash( $relevant_hooks );
			$cached_hooks_hash  = $cached['hooks_hash'] ?? '';

			if ( $current_hooks_hash !== $cached_hooks_hash ) {
				wp_cache_delete( $cache_key, self::$cache_group );
				return null;
			}
		}

		// Validate files hash if files are being tracked.
		$relevant_files = $cached_config['relevant_files'];
		if ( ! empty( $relevant_files ) ) {
			$cached_files_hash  = $cached['files_hash'] ?? '';
			$current_files_hash = $this->generate_files_hash( $relevant_files );

			if ( $current_files_hash !== $cached_files_hash ) {
				wp_cache_delete( $cache_key, self::$cache_group );
				return null;
			}
		}

		// Validate version strings hash if version strings are being tracked.
		$relevant_version_strings = $cached_config['relevant_version_strings'];
		if ( ! empty( $relevant_version_strings ) ) {
			$cached_version_strings_hash  = $cached['version_strings_hash'] ?? '';
			$current_version_strings_hash = $this->generate_version_strings_hash( $relevant_version_strings );

			if ( $current_version_strings_hash !== $cached_version_strings_hash ) {
				wp_cache_delete( $cache_key, self::$cache_group );
				return null;
			}
		}

		if ( ! is_null( $this->version_string_generator ) ) {
			foreach ( $cached['entity_versions'] as $entity_id => $cached_version ) {
				$version_id      = "{$entity_type}_{$entity_id}";
				$current_version = $this->version_string_generator->get_version( $version_id );
				if ( $current_version !== $cached_version ) {
					wp_cache_delete( $cache_key, self::$cache_group );
					return null;
				}
			}
		}

		// At this point the cached response is valid.

		// Check if client sent an ETag and it matches - if so, return 304 Not Modified.
		$cached_etag  = $cached['etag'] ?? '';
		$request_etag = $request->get_header( 'if-none-match' );

		$response_headers = array();

		if ( $cache_headers_enabled ) {
			$legacy_proxy      = wc_get_container()->get( LegacyProxy::class );
			$is_user_logged_in = $legacy_proxy->call_function( 'is_user_logged_in' );
			$cache_visibility  = $cached_config['vary_by_user'] && $is_user_logged_in ? 'private' : 'public';

			if ( ! empty( $cached_etag ) ) {
				$response_headers['ETag'] = $cached_etag;
			}
			$response_headers['Cache-Control'] = $cache_visibility . ', must-revalidate, max-age=' . $cache_ttl;

			// If the server adds a 'Date' header by itself there will be two such headers in the response.
			// To help disambiguate them, we add also an 'X-WC-Date' header with the proper value.
			// @phpstan-ignore-next-line argument.type -- created_at is int, stored by store_cached_response.
			$created_at                    = gmdate( 'D, d M Y H:i:s', intval( $cached['created_at'] ) ) . ' GMT';
			$response_headers['Date']      = $created_at;
			$response_headers['X-WC-Date'] = $created_at;

			if ( ! empty( $cached_etag ) && $request_etag === $cached_etag ) {
				$cache_control         = $response_headers['Cache-Control'];
				$not_modified_response = $this->create_not_modified_response( $cached_etag, $cache_control, $request, $cached_config['endpoint_id'] );
				if ( $not_modified_response ) {
					$not_modified_response->header( 'Date', $response_headers['Date'] );
					$not_modified_response->header( 'X-WC-Date', $response_headers['X-WC-Date'] );
					return $not_modified_response;
				}
			}
		}

		$response = new WP_REST_Response( $cached['data'], $cached['status_code'] ?? 200 );

		foreach ( $response_headers as $name => $value ) {
			$response->header( $name, $value );
		}

		if ( ! empty( $cached['headers'] ) ) {
			foreach ( $cached['headers'] as $name => $value ) {
				$response->header( $name, $value );
			}
		}

		return $response;
	}

	/**
	 * Store a response in cache.
	 *
	 * @param array $args {
	 *     Arguments for storing the cached response.
	 *
	 *     @type string $cache_key                The cache key.
	 *     @type mixed  $data                     The response data to cache.
	 *     @type int    $status_code              The HTTP status code of the response.
	 *     @type string $entity_type              The entity type.
	 *     @type array  $entity_ids               Array of entity IDs in the response.
	 *     @type int    $cache_ttl                Cache TTL in seconds.
	 *     @type array  $relevant_hooks           Hook names to track for invalidation.
	 *     @type array  $relevant_files           File paths to track for invalidation.
	 *     @type array  $relevant_version_strings Version string IDs to track for invalidation.
	 *     @type array  $headers                  Response headers to cache.
	 *     @type string $etag                     ETag for the response.
	 * }
	 */
	private function store_cached_response( array $args ): void {
		$status_code              = $args['status_code'];
		$relevant_hooks           = $args['relevant_hooks'];
		$relevant_files           = $args['relevant_files'];
		$relevant_version_strings = $args['relevant_version_strings'];
		$headers                  = $args['headers'] ?? array();
		$etag                     = $args['etag'] ?? '';

		$entity_versions = array();
		if ( ! is_null( $this->version_string_generator ) ) {
			foreach ( $args['entity_ids'] as $entity_id ) {
				$version_id = "{$args['entity_type']}_{$entity_id}";
				$version    = $this->version_string_generator->get_version( $version_id );
				if ( $version ) {
					$entity_versions[ $entity_id ] = $version;
				}
			}
		}

		$legacy_proxy = wc_get_container()->get( LegacyProxy::class );
		$cache_data   = array(
			'data'            => $args['data'],
			'entity_versions' => $entity_versions,
			'created_at'      => $legacy_proxy->call_function( 'time' ),
		);

		if ( 200 !== $status_code ) {
			$cache_data['status_code'] = $status_code;
		}

		if ( ! empty( $relevant_hooks ) ) {
			$cache_data['hooks_hash'] = $this->generate_hooks_hash( $relevant_hooks );
		}

		if ( ! empty( $relevant_files ) ) {
			$files_hash = $this->generate_files_hash( $relevant_files );
			if ( ! empty( $files_hash ) ) {
				$cache_data['files_hash'] = $files_hash;
			}
		}

		if ( ! empty( $relevant_version_strings ) ) {
			$version_strings_hash = $this->generate_version_strings_hash( $relevant_version_strings );
			if ( ! empty( $version_strings_hash ) ) {
				$cache_data['version_strings_hash'] = $version_strings_hash;
			}
		}

		if ( ! empty( $headers ) ) {
			$cache_data['headers'] = $headers;
		}

		if ( ! empty( $etag ) ) {
			$cache_data['etag'] = $etag;
		}

		wp_cache_set( $args['cache_key'], $cache_data, self::$cache_group, $args['cache_ttl'] );
	}

	/**
	 * Handle rest_send_nocache_headers filter to prevent WordPress from overriding our cache headers.
	 *
	 * @internal
	 *
	 * @param bool $send_no_cache_headers Whether to send no-cache headers.
	 *
	 * @return bool False if we're handling caching for this request, original value otherwise.
	 */
	public function handle_rest_send_nocache_headers( bool $send_no_cache_headers ): bool {
		if ( ! $this->is_handling_cached_endpoint ) {
			return $send_no_cache_headers;
		}

		$this->is_handling_cached_endpoint = false;
		return false;
	}
}
