<?php
/**
 * CacheLoop – REST API Cache (Free)
 *
 * Caches GET responses for common WordPress REST API endpoints
 * to reduce database queries on high-traffic sites.
 *
 * @package CacheLoop
 */

// Security: Prevent direct file access.
defined( 'ABSPATH' ) || exit;

/**
 * Class CACHELOOP_REST_Cache
 *
 * Intercepts REST API requests and serves cached JSON responses
 * for eligible GET endpoints.
 */
class CACHELOOP_REST_Cache {

	/**
	 * @var array Settings array.
	 * @psalm-var array<string, mixed>
	 */
	private $settings;

	/**
	 * @var int Cache TTL in seconds.
	 * @psalm-var int
	 */
	private $rest_ttl;

	/**
	 * Constructor.
	 *
	 * @param array $settings Plugin settings.
	 */
	public function __construct( $settings = array() ) {
		$this->settings = $settings;
		$this->rest_ttl = isset( $settings['cache_ttl'] ) ? (int) $settings['cache_ttl'] : DAY_IN_SECONDS;

		// Intercept REST API requests.
		add_filter( 'rest_authentication_errors', array( $this, 'maybe_cache_rest_response' ), 99 );
	}

	/**
	 * Filters REST authentication errors to intercept GET requests.
	 *
	 * @param mixed $result Existing authentication result.
	 * @return mixed Modified result.
	 */
	public function maybe_cache_rest_response( $result ) {
		// Only proceed if auth is already fine (we don't block anything).
		if ( ! empty( $result ) ) {
			return $result;
		}

		// Never cache or serve cache for authenticated requests: a response generated
		// for a logged-in user (extra fields, elevated context, or an endpoint that
		// only authorized users can reach) must never be written to a cache file that
		// a later, unauthenticated request could be served without its own permission
		// check. Restricting this to anonymous traffic keeps every cached response
		// scoped to exactly what an unauthenticated visitor is already allowed to see.
		if ( is_user_logged_in() ) {
			return $result;
		}

		// Only cache GET requests.
		if ( isset( $_SERVER['REQUEST_METHOD'] ) && 'GET' !== strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) ) {
			return $result;
		}

		// Detect REST request by checking the rewrite rule or global.
		$is_rest = false;
		if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
			$is_rest = true;
			// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only route detection for cache-key generation, not a state-changing action.
		} elseif ( isset( $_GET['rest_route'] ) ) {
			$is_rest = true;
		} elseif ( isset( $_SERVER['REQUEST_URI'] ) && strpos( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ), '/wp-json/' ) !== false ) {
			$is_rest = true;
		}

		if ( ! $is_rest ) {
			return $result;
		}

		// Get the REST route.
		$rest_route = '';
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only route detection for cache-key generation, not a state-changing action.
		if ( isset( $_GET['rest_route'] ) ) {
			// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only route detection for cache-key generation, not a state-changing action.
			$rest_route = sanitize_text_field( wp_unslash( $_GET['rest_route'] ) );
		} elseif ( isset( $_SERVER['REQUEST_URI'] ) ) {
			$uri_path = wp_parse_url( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ), PHP_URL_PATH );
			if ( $uri_path ) {
				$rest_route = $uri_path;
			}
		}
		if ( empty( $rest_route ) ) {
			$rest_route = '/wp-json/';
		}

		// Check if this route should be cached.
		if ( ! $this->should_cache_rest_route( $rest_route ) ) {
			return $result;
		}

		// Build the cache key from the full request (path + query string), not just
		// the route path, so that different query variations of the same route
		// (?search=, ?per_page=, ?_fields=, ...) never share one cached file.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only cache-key generation, not a state-changing action.
		$query_args = wp_unslash( $_GET );
		unset( $query_args['rest_route'] );
		ksort( $query_args );
		$cache_key_source = $rest_route . ( $query_args ? '?' . http_build_query( $query_args ) : '' );

		// Try to serve cached response.
		$cached = $this->get_cached_rest_response( $cache_key_source );
		if ( $cached !== null ) {
			// Return cached response.
			header( 'Content-Type: application/json; charset=' . get_option( 'blog_charset' ) );
			header( 'X-CacheLoop-REST: HIT' );
			echo wp_json_encode( $cached );
			exit;
		}

		// Store the original response callback so we can cache it.
		$self = $this;

		add_filter(
			'rest_post_dispatch',
			function ( $response, $server, $request ) use ( $cache_key_source, $self ) {
				if ( ! is_wp_error( $response ) && ( $response instanceof WP_REST_Response ) && 200 === $response->get_status() ) {
					// This is a real response, cache it.
					$self->cache_rest_response( $cache_key_source, $response );
					header( 'X-CacheLoop-REST: MISS-CACHED' );
				}
				return $response;
			},
			10,
			3
		);

		return $result;
	}

	/**
	 * Checks if a REST route should be cached.
	 *
	 * @param string $route The REST route.
	 * @return bool True if cacheable.
	 */
	private function should_cache_rest_route( $route ) {
		// Must start with /wp-json/.
		if ( strpos( $route, '/wp-json/' ) !== 0 ) {
			return false;
		}

		// Whitelist of cacheable routes. Deliberately excludes any endpoint whose
		// permission_callback requires authentication or a capability check
		// (e.g. wp/v2/settings, wp/v2/users edit context) — this filter runs on
		// `rest_authentication_errors`, before the endpoint's own permission
		// callback, so only endpoints that are genuinely public for anonymous
		// requests may be cached/served this way.
		$cacheable_routes = array(
			'/wp-json/wp/v2/posts',
			'/wp-json/wp/v2/pages',
			'/wp-json/wp/v2/categories',
			'/wp-json/wp/v2/tags',
			'/wp-json/wp/v2/media',
			'/wp-json/wp/v2/types',
			'/wp-json/wp/v2/taxonomies',
		);

		// Exact match or prefix match.
		foreach ( $cacheable_routes as $cacheable ) {
			if ( $route === $cacheable || strpos( $route, $cacheable . '/' ) === 0 ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Gets a cached REST response.
	 *
	 * @param string $route The REST route + normalized query string (cache key source).
	 * @return mixed|null Cached response or null if not found/expired.
	 */
	private function get_cached_rest_response( $route ) {
		$version    = defined( 'CACHELOOP_VERSION' ) ? CACHELOOP_VERSION : '1.0.3';
		$hash       = md5( $route . $version );
		$sub_dir    = substr( $hash, 0, 2 );
		$cache_dir  = CACHELOOP_CACHE_DIR . 'rest/' . $sub_dir . '/';
		$cache_file = $cache_dir . $hash . '.json';

		if ( ! file_exists( $cache_file ) ) {
			return null;
		}

		// A concurrent flush_all() (e.g. a settings save on another request)
		// can remove this exact file between the file_exists() check above
		// and here — fail soft (cache miss) instead of a stat/read warning.
		$mtime = @filemtime( $cache_file );
		if ( false === $mtime || ( time() - $mtime ) >= $this->rest_ttl ) {
			return null;
		}

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		$data    = @file_get_contents( $cache_file );
		$decoded = $data ? json_decode( $data, true ) : null;

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

		return $decoded;
	}

	/**
	 * Caches a REST API response.
	 *
	 * @param string $route The REST route + normalized query string (cache key source).
	 * @param mixed  $response The response data.
	 * @return void
	 */
	private function cache_rest_response( $route, $response ) {
		if ( ! is_object( $response ) && ! is_array( $response ) ) {
			return;
		}

		$data = $response->get_data() ?: $response;

		$version   = defined( 'CACHELOOP_VERSION' ) ? CACHELOOP_VERSION : '1.0.3';
		$hash      = md5( $route . $version );
		$sub_dir   = substr( $hash, 0, 2 );
		$cache_dir = CACHELOOP_CACHE_DIR . 'rest/' . $sub_dir . '/';

		// Create directory.
		if ( ! is_dir( $cache_dir ) ) {
			wp_mkdir_p( $cache_dir );
		}

		$cache_file = $cache_dir . $hash . '.json';

		// A concurrent flush_all() can remove $cache_dir between the check
		// above and here — fail soft (skip caching this response) instead
		// of a write warning.
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_put_contents_file_put_contents
		@file_put_contents( $cache_file, wp_json_encode( $data ) );
	}

	/**
	 * Flushes REST API cache.
	 *
	 * @return void
	 */
	public static function flush_rest_cache() {
		$rest_cache_dir = CACHELOOP_CACHE_DIR . 'rest/';
		if ( is_dir( $rest_cache_dir ) ) {
			self::delete_dir_recursive( $rest_cache_dir );
		}
	}

	/**
	 * Recursively deletes a directory.
	 *
	 * @param string $dir Directory path.
	 * @return void
	 */
	private static function delete_dir_recursive( $dir ) {
		if ( ! is_dir( $dir ) ) {
			return;
		}

		$files = array_diff( scandir( $dir ), array( '.', '..' ) );
		foreach ( $files as $file ) {
			$path = $dir . $file;
			// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
			is_dir( $path ) ? self::delete_dir_recursive( $path ) : unlink( $path );
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
		rmdir( $dir );
	}
}
