<?php
namespace QuantumCache\Cache;
\defined( 'ABSPATH' ) || exit;
use QuantumCache\Support\StoreFactory;

class QueryCache {

	private static array $hit_keys = array();
		/** Aggregate of tags seen/stored this request */
	private static array $request_tags = array();


	public static function register(): void {
		add_filter( 'posts_pre_query', array( __CLASS__, 'on_posts_pre_query' ), 10, 2 );
		add_filter( 'the_posts', array( __CLASS__, 'on_the_posts' ), 10, 2 );

		// Invalidation hooks.
		add_action( 'clean_post_cache', array( __CLASS__, 'purge_post' ), 10, 2 );
		add_action( 'save_post', array( __CLASS__, 'purge_post' ), 10, 2 );
		add_action( 'deleted_post', array( __CLASS__, 'purge_post' ), 10, 1 );
		add_action( 'created_term', array( __CLASS__, 'purge_tax' ), 10, 3 );
		add_action( 'edited_term', array( __CLASS__, 'purge_tax' ), 10, 3 );
		add_action( 'delete_term', array( __CLASS__, 'purge_tax' ), 10, 4 );
		add_action( 'updated_option', array( __CLASS__, 'maybe_purge_option' ), 10, 3 );

				// Expose current-request tags once on front-end requests.
		add_action(
			'template_redirect',
			array( __CLASS__, 'expose_current_request_tags' ),
			1
		);
	}

	/**
	 * Emit a header (append) — unguarded.
	 */
	private static function h( string $k, string $v ): void {
		if ( ! headers_sent() ) {
			// false = append.
			header( $k . ': ' . $v, false );
		}
	}

	/**
	 * Emit a header only when debug headers are enabled.
	 */
	private static function dh( string $k, string $v ): void {
		if (
			function_exists( 'QuantumCache\\quantumcache_should_debug_headers' )
			&& \QuantumCache\quantumcache_should_debug_headers()
			&& ! headers_sent()
		) {
			header( $k . ': ' . $v, false );
		}
	}

	/** Merge request-level tag accumulator (deduped). */
	private static function add_request_tags( array $tags ): void {
		if ( empty( $tags ) ) {
			return;
		}

		self::$request_tags = array_values(
			array_unique(
				array_merge(
					self::$request_tags,
					array_values( array_filter( $tags, 'is_string' ) )
				)
			)
		);

		// keep a copy globally in case other components want to read it directly
		$GLOBALS['quantumcache_request_tags'] = self::$request_tags;
	}

	/** Expose current-request tags once early in the render life-cycle. */
	public static function expose_current_request_tags(): void {
		$current                              = (array) ( $GLOBALS['quantumcache_request_tags'] ?? array() );
		$GLOBALS['quantumcache_request_tags'] = (array) apply_filters(
			'quantumcache_current_request_tags',
			$current
		);
	}


	private static function site_prefix(): string {
		return StoreFactory::sitePrefix();
	}

	private static function key( string $type, array $ctx ): string {
		ksort( $ctx );
		$fp = substr( sha1( json_encode( $ctx ) ), 0, 16 );
		return self::site_prefix() . $type . ':' . $fp;
	}

	private static function normalize( $v ) {
		if ( is_array( $v ) ) {
			$o = array();
			foreach ( $v as $k => $val ) {
				$o[ (string) $k ] = self::normalize( $val );
			}
			ksort( $o );
			return $o;
		}
		if ( is_object( $v ) ) {
			return self::normalize( (array) $v );
		}
		if ( is_bool( $v ) ) {
			return $v ? 1 : 0;
		}
		return $v;
	}

	private static function current_role(): string {
		if ( is_user_logged_in() && \quantumcache_allow_logged_in_cache() ) {
			$user  = wp_get_current_user();
			$roles = is_object( $user ) ? (array) $user->roles : array();
			sort( $roles );
			return $roles ? implode( ',', $roles ) : 'anon';
		}
		return 'anon';
	}

	private static function fingerprint_query( \WP_Query $q ): array {
		$args = $q->query_vars;

		foreach (
			array(
				'no_found_rows',
				'cache_results',
				'update_post_meta_cache',
				'update_post_term_cache',
				'ignore_sticky_posts',
				'suppress_filters',
				'lazy_load_term_meta',
				'fields',
				'caller_get_posts',
				'comments_per_page',
				'cache_pagination_links',
				'comment_status',
				'perm',
				'trackbacks',
				'search_orderby_title',
			) as $k
		) {
			unset( $args[ $k ] );
		}

		$args['lang'] = function_exists( 'get_locale' ) ? get_locale() : 'en_US';

		// Role bucket: either 'anon' or comma list of roles when allowed.
		$args['role'] = self::current_role();

		if (
		function_exists( 'QuantumCache\\quantumcache_should_debug_headers' )
		&& \QuantumCache\quantumcache_should_debug_headers()
		&& ! headers_sent()
		) {
			header( 'X-QC-Role: ' . (string) $args['role'], false );
		}

				$args = apply_filters( 'quantumcache_query_key_context', $args, $q );

		return self::normalize( $args );
	}

	private static function tags_for_posts( array $ids, \WP_Query $q ): array {
		$tags      = array();
		$post_type = (string) $q->get( 'post_type' );
		if ( '' === $post_type ) {
				$post_type = 'post';
		}

		$tags[] = 'post_type:' . $post_type;

		foreach ( $ids as $id ) {
			$id     = (int) $id;
			$tags[] = 'post:' . $id;

			$_qc_pt_for_terms = get_post_type( $id );
			if ( false === $_qc_pt_for_terms || '' === $_qc_pt_for_terms ) {
					$_qc_pt_for_terms = $post_type;
			}
			$taxes = get_object_taxonomies( $_qc_pt_for_terms, 'names' );

			foreach ( $taxes as $tax ) {
				$terms = wp_get_object_terms( $id, $tax, array( 'fields' => 'ids' ) );
				if ( ! is_wp_error( $terms ) ) {
					foreach ( $terms as $tid ) {
						$tags[] = 'term:' . (int) $tid;
					}
				}
			}
		}
		return array_values( array_unique( $tags ) );
	}


	private static function cacheable( \WP_Query $q ): bool {
		// Admin is never cacheable.
		if ( is_admin() ) {
			return false;
		}

		// Only touch the main query.
		if ( ! $q->is_main_query() ) {
			return false;
		}

		// Don't cache search or feeds.
		if ( $q->is_search() || $q->is_feed() ) {
			return false;
		}

		// Logged-in: only cache if explicitly allowed.
		if ( is_user_logged_in() && ! \quantumcache_allow_logged_in_cache() ) {
			return false;
		}

		// Read-only query-string bypass.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only toggle to bypass cache.
		$no_cache = (bool) filter_input( INPUT_GET, 'qc_nocache', FILTER_VALIDATE_BOOLEAN );
		if ( true === $no_cache ) {
			return false;
		}

		// Debug marker that main query is cacheable.
		if ( \QuantumCache\quantumcache_should_debug_headers() && ! headers_sent() ) {
			self::dh( 'X-QC-Main', 'ok' );
			self::dh( 'X-QC-OC', wp_using_ext_object_cache() ? 'on' : 'off' );
		}

                // After method + logged-in checks, before returning true:
                $bypass = (bool) apply_filters( 'quantumcache_should_bypass_cache', false );
                if ( $bypass ) {
                  return false;
                }

                // Also respect the generic "is_cacheable_request" policy gate Free already defines:
                $ok = (bool) apply_filters( 'quantumcache_is_cacheable_request', true );
                if ( ! $ok ) {
                   return false;
                }


		return true;
	}


	/**
 * Return a display-safe request path with an indicator if a query string exists.
 * Example: "/category/widgets?…" or "/about"
 */
	private static function safe_req_path(): string {
		// REQUEST_URI is path+query (no scheme/host). Keep it raw, then normalize.
		$req_uri = isset( $_SERVER['REQUEST_URI'] )
						? esc_url_raw( wp_unslash( (string) $_SERVER['REQUEST_URI'] ) )
						: '';

		// Strip control chars (defensive) but keep legal URL punctuation.
		$req_uri = preg_replace( '/[\x00-\x1F\x7F]+/u', '', $req_uri );

		// Ensure it starts with a slash to avoid oddities when joining with home_url().
		if ( '' === $req_uri || '/' !== $req_uri[0] ) {
			$req_uri = '/' . ltrim( $req_uri, '/' );
		}

		// Build absolute URL so esc_url_raw can validate; then parse back to components.
		$abs   = esc_url_raw( home_url( $req_uri ) );
		$parts = wp_parse_url( $abs );
		if ( isset( $parts['path'] ) && '' !== $parts['path'] ) {
					$path = $parts['path'];
		} else {
			$path = '/';
		}

		$has_qs = ! empty( $parts['query'] );

		// Deliberately do NOT include actual query in this display-safe string.
		return $has_qs ? $path . '?…' : $path;
	}



	/**
	 * READ PATH
	 */
	public static function on_posts_pre_query( $posts, \WP_Query $q ) {
		if ( ! $q->is_main_query() ) {
			return $posts;
		}
		if ( \QuantumCache\quantumcache_should_debug_headers() && ! headers_sent() ) {
			self::dh( 'X-QC-Main-Stage', 'pre' );
			if ( function_exists( 'get_queried_object_id' ) ) {
				self::dh( 'X-QC-QID', (string) get_queried_object_id() );
			}
			self::dh( 'X-QC-Req', self::safe_req_path() );
		}
		if ( true !== self::cacheable( $q ) ) {
			return $posts;
		}

		$fp    = self::fingerprint_query( $q );
		$k     = self::key( 'query', $fp );
		$store = StoreFactory::get();

		if ( \QuantumCache\quantumcache_should_debug_headers() && ! headers_sent() ) {
			self::dh( 'X-QC-Key', substr( $k, -12 ) );
			self::dh( 'X-QC-Store', \QuantumCache\Support\StoreFactory::storeName() );
		}
		// 1) Try PRIMARY key.
		$raw = $store->get( $k );
		if ( \QuantumCache\quantumcache_should_debug_headers() && ! headers_sent() ) {
			$val = ( false === $raw ) ? 'MISS' : ( 'bytes=' . strlen( (string) $raw ) );
						header( 'X-QC-Store-Get: ' . $val, false );
		}
		if ( is_string( $raw ) && '' !== $raw ) {
			$payload = json_decode( $raw, true );
			if ( ! is_array( $payload ) ) {
				if ( ! headers_sent() ) {
					header( 'X-QC-Reason: bad-json', false );
				}
			}
			// DEBUG: show payload type and ids length we parsed
			if ( \QuantumCache\quantumcache_should_debug_headers() && ! headers_sent() ) {
				header( 'X-QC-Payload-Type: ' . ( is_array( $payload ) ? 'array' : gettype( $payload ) ), false );
				if ( is_array( $payload ) ) {
					$ids_dbg = isset( $payload['ids'] ) && is_array( $payload['ids'] ) ? count( $payload['ids'] ) : -1;
					header( 'X-QC-Ids-Len: ' . $ids_dbg, false );
				}
			}
			if ( is_array( $payload ) && ! empty( $payload['ids'] ) && is_array( $payload['ids'] ) ) {
				$ids      = array_map( 'intval', $payload['ids'] );
				$hydrated = array();

				if ( \QuantumCache\quantumcache_should_debug_headers() && ! headers_sent() ) {
					header( 'X-QC-Ids-Plan: ' . count( $ids ), false );
					if ( count( $ids ) ) {
						header( 'X-QC-Ids-Head: ' . $ids[0], false );
					}
				}
				if ( function_exists( '_prime_post_caches' ) && $ids ) {
					_prime_post_caches( $ids, /* update_meta_cache */ false, /* update_term_cache */ false );
				}
				// Hydrate from cache (get_post() resolves to cache; no DB after prime)
				$hydrated = array();
				foreach ( $ids as $id ) {
					$p = get_post( $id );
					if ( $p ) { // don't insist on \WP_Post here; get_post() returns falsey if missing
						$hydrated[] = $p;
					}
				}

				// DEBUG: show how many WP_Posts we actually produced
				if ( \QuantumCache\quantumcache_should_debug_headers() && ! headers_sent() ) {
					header( 'X-QC-Hydrate-Len: ' . count( $hydrated ), false );
				}

				if ( ! empty( $hydrated ) ) {
					// Prime aux caches so templates don't trigger DB later.
					$post_type = (string) $q->get( 'post_type' );
					if ( '' === $post_type ) {
						$post_type = 'post';
					}
					if ( function_exists( 'update_post_caches' ) && $q->is_singular ) {
						update_post_caches( $hydrated, $post_type, false, false );
					}
					if ( $q->is_singular() ) {
						foreach ( $hydrated as $p ) {
							wp_cache_get( (int) $p->ID, 'comment_count' );
						}
					}

					if ( \QuantumCache\quantumcache_should_debug_headers() && ! headers_sent() ) {
						self::dh( 'X-QC-Main', 'HIT' );
						self::dh( 'X-QC-Hydrated', (string) count( $hydrated ) );
					}
					self::$hit_keys[ $k ] = true;

					// Also mark the primary key explicitly to suppress re-store later.
					$primary_key                    = self::key( 'query', self::fingerprint_query( $q ) );
					self::$hit_keys[ $primary_key ] = true;

					do_action( 'quantumcache_cache_hit', 'query' );
					return $hydrated; // short-circuit WP_Query.
				}
			}
			// malformed/empty payload → fall through to alias.
		}

		// 2) Try ID-alias for singular queries (handles slug/permalink drift).
		$try_id = 0;
		if ( $q->is_singular() ) {
			// Prefer resolved vars first.
			$p_val       = (int) $q->get( 'p' );
			$page_id_val = (int) $q->get( 'page_id' );
			if ( 0 !== $p_val ) {
				$try_id = $p_val;
			} elseif ( 0 !== $page_id_val ) {
				$try_id = $page_id_val;
			} else {
				// Fallback: derive from slug once (OC cushions).
				$slug = (string) $q->get( 'name', '' );
				if ( '' !== $slug ) { // Yoda.
					$post = get_page_by_path( $slug, OBJECT, array( 'post', 'page' ) );
					if ( $post ) {
						$try_id = (int) $post->ID;
					}
				}
			}
		}

		if ( 0 < $try_id ) {
			$id_key = self::key( 'query', array( 'p' => $try_id, 'role' => self::current_role() ) );
			$raw2   = $store->get( $id_key );
			if ( is_string( $raw2 ) && '' !== $raw2 ) {
				$payload = json_decode( $raw2, true );
				if ( is_array( $payload ) && ! empty( $payload['ids'] ) && is_array( $payload['ids'] ) ) {
					$ids      = array_map( 'intval', $payload['ids'] );
					$hydrated = array();

					if ( function_exists( '_prime_post_caches' ) && $ids ) {
						_prime_post_caches( $ids, false, false );
					}

					// Hydrate from cache (get_post() resolves to cache; no DB after prime)
					$hydrated = array();
					foreach ( $ids as $id ) {
						$p = get_post( $id );
						if ( $p ) { // don't insist on \WP_Post here; get_post() returns falsey if missing
							$hydrated[] = $p;
						}
					}
					if ( ! empty( $hydrated ) ) {
						if ( \QuantumCache\quantumcache_should_debug_headers() && ! headers_sent() ) {
							self::dh( 'X-QC-Main', 'HIT' );
							self::dh( 'X-QC-Hydrated', (string) count( $hydrated ) );
							self::dh( 'X-QC-Key', substr( $id_key, -12 ) );
						}
						self::$hit_keys[ $id_key ] = true;

						// Also mark primary key to avoid same-request STORE.
						$primary_key                    = self::key( 'query', self::fingerprint_query( $q ) );
						self::$hit_keys[ $primary_key ] = true;

						do_action( 'quantumcache_cache_hit', 'query' );
						return $hydrated;
					}
				}
			}
		}

		// 3) Nothing hit → MISS, let WP run normally.
		if ( \QuantumCache\quantumcache_should_debug_headers() && ! headers_sent() ) {
					self::dh( 'X-QC-Main', 'MISS' );
		}
		do_action( 'quantumcache_cache_miss', 'query' );
		return $posts;
	}

	/**
	 * STORE PATH
	 */
	public static function on_the_posts( array $posts, \WP_Query $q ): array {

		if ( ! $q->is_main_query() ) {
			return $posts;
		}
		if ( \QuantumCache\quantumcache_should_debug_headers() && ! headers_sent() ) {
			self::dh( 'X-QC-Main-Stage', 'post' );
		}
		if ( true !== self::cacheable( $q ) ) {
			return $posts;
		}
		if ( empty( $posts ) ) {
			return $posts;
		}

		$ids   = array_map( static fn( $p ) => (int) $p->ID, $posts );
		$fp    = self::fingerprint_query( $q );
		$k     = self::key( 'query', $fp );
		$store = StoreFactory::get();

		if ( \QuantumCache\quantumcache_should_debug_headers() && ! headers_sent() ) {
			self::dh( 'X-QC-StoreKey', substr( $k, -12 ) );
			self::dh( 'X-QC-StoreIDs', (string) count( $ids ) );
		}
		// Avoid re-store if we already HIT this key (or alias) in this request.
		if ( ! empty( self::$hit_keys[ $k ] ) ) {
			return $posts;
		}

		$payload = json_encode( array( 'ids' => $ids ), JSON_UNESCAPED_SLASHES );

		$min_ids = (int) apply_filters( 'quantumcache_min_archive_ids', 12, $q );

		// Only gate *non-singular* archives. Singular (product pages, posts, pages) should always be cached.
		if (
		! $q->is_singular()
		&& count( $ids ) < $min_ids
		&& ! $q->is_category()
		&& ! $q->is_tag()
		&& ! $q->is_tax()
		) {
			return $posts; // too cheap to cache for archives only
		}

		// Primary store.
		$ttl       = (int) apply_filters( 'quantumcache_query_ttl', 24 * 3600, $q );
		$stale_ttl = (int) apply_filters( 'quantumcache_query_stale_ttl', 7 * 24 * 3600, $q );
		$store->set( $k, $payload, $ttl, $stale_ttl );
				$post_type = $q->get( 'post_type' );
				$post_type = ( $post_type ) ? (string) $post_type : 'post';

		$_base_tags = self::tags_for_posts( $ids, $q );
		$_ctx       = array(
			'surface'   => 'query',
			'post_type' => $post_type,
		);
		$_final     = (array) apply_filters( 'quantumcache_store_additional_tags', $_base_tags, $_ctx );

		$store->addTags( $k, $_final );
		self::add_request_tags( $_final );

		$store->addTags( $k, self::tags_for_posts( $ids, $q ) );
		if ( \QuantumCache\quantumcache_should_debug_headers() && ! headers_sent() ) {
			self::dh( 'X-QC-Main', 'STORE' );
		}
		do_action( 'quantumcache_cache_store', 'query' );

		// ID-alias store (robust against slug/query-var drift).
		if ( $q->is_singular() && 1 === count( $ids ) ) { // Yoda.
			$id_key = self::key( 'query', array( 'p' => (int) $ids[0], 'role' => self::current_role() ) );
			if ( $id_key !== $k ) { // Yoda-ready comparison (string).
				$store->set( $id_key, $payload, 24 * 3600, 7 * 24 * 3600 );

								$post_type = $q->get( 'post_type' );
								$post_type = ( $post_type ) ? (string) $post_type : 'post';
				$_base_tags2               = self::tags_for_posts( $ids, $q );
				$_ctx2                     = array(
					'surface'   => 'query',
					'alias'     => true,
					'post_type' => $post_type,
				);
				$_final2                   = (array) apply_filters( 'quantumcache_store_additional_tags', $_base_tags2, $_ctx2 );

				$store->addTags( $id_key, $_final2 );
				self::add_request_tags( $_final2 );

				if ( \QuantumCache\quantumcache_should_debug_headers() && ! headers_sent() ) {
					self::dh( 'X-QC-StoreKeyID', substr( $id_key, -12 ) );
				}
			}
		}

		return $posts;
	}

	public static function purge_post( $post_id, $post = null ): void {
		$post_id = (int) $post_id;
		$type    = $post ? get_post_type( $post ) : get_post_type( $post_id );
		$tags    = array( 'post:' . $post_id );
		if ( $type ) {
			$tags[] = 'post_type:' . $type;
		}
		if ( \QuantumCache\quantumcache_should_debug_headers() && function_exists( 'headers_sent' ) && ! headers_sent() ) {
			header( 'X-QC-Purge: ' . implode( ',', array_slice( $tags, 0, 4 ) ), false );
		}

		$tax_type = $type ? $type : 'post';
		$taxes    = get_object_taxonomies( $tax_type );
		foreach ( $taxes as $tax ) {
			$tags[] = 'taxonomy:' . $tax;
		}
		StoreFactory::get()->purgeTags( array_values( array_unique( $tags ) ) );
	}

	public static function purge_tax( $term_id, $tt_id, $taxonomy, $deleted_term = null ): void {
		StoreFactory::get()->purgeTags( array( 'taxonomy:' . $taxonomy, 'term:' . (int) $term_id ) );
	}

	public static function maybe_purge_option( $option, $old, $new_value ): void {
		if ( in_array( $option, array( 'blogname', 'blogdescription', 'page_on_front', 'show_on_front' ), true ) ) {
			StoreFactory::get()->purgeTags( array( 'option:' . $option ) );
		}
	}
}
