<?php
/**
 * PostGate Product Content Resolver Class
 *
 * This file contains the product content resolution functionality that determines
 * which posts and terms a specific Stripe product grants access to. Implements
 * a priority-based resolution system with caching for optimal performance.
 *
 * @package PostGate
 * @subpackage Payments
 * @since 1.0.0
 * @author QodeSphere
 * @license GPL-2.0+
 */

defined( 'ABSPATH' ) || exit;

/**
 * PostGate Product Content Resolver Class
 *
 * Resolves content access permissions for Stripe products by determining which
 * posts and taxonomy terms a given product grants access to. Uses a three-tier
 * priority system to resolve content grants efficiently.
 *
 * Resolution Priority:
 * 1. Direct post assignment - Product assigned to a specific post in purchase context
 * 2. Global default product - Default product grants access to all enabled post types
 * 3. Aggregate assignments - All posts and terms with the product assigned site-wide
 *
 * This class provides crucial functionality for the paywall system, enabling
 * flexible content access control through Stripe product assignments. It supports
 * both individual post purchases and subscription-based access to categories,
 * tags, or custom taxonomy terms.
 *
 * Key Features:
 * - Three-tier priority resolution system
 * - Cached database queries for performance
 * - Support for post and term-based access grants
 * - Global default product handling
 * - Automatic cache invalidation on meta changes
 * - Publish status validation for posts
 * - Filtered cache TTL for customization
 *
 * @since 1.0.0
 */
class PostGate_Product_Content_Resolver {

	/**
	 * Cache group for wp_cache_*.
	 *
	 * @since 1.0.0
	 */
	private const CACHE_GROUP = 'postgate_product_resolver';

	/**
	 * Default TTL in seconds.
	 *
	 * @since 1.0.0
	 */
	private const CACHE_TTL = 900; // 15 minutes.

	/**
	 * Initialize the product content resolver.
	 *
	 * Registers hooks for cache invalidation when post or term meta is modified.
	 * Ensures cached product assignments remain synchronized with database changes.
	 *
	 * @since 1.0.0
	 * @return void
	 */
	public static function init(): void {
		add_action( 'added_post_meta', [ __CLASS__, 'invalidate_post_assignment_cache' ], 10, 4 );
		add_action( 'updated_post_meta', [ __CLASS__, 'invalidate_post_assignment_cache' ], 10, 4 );
		add_action( 'deleted_post_meta', [ __CLASS__, 'invalidate_post_assignment_cache' ], 10, 4 );

		add_action( 'added_term_meta', [ __CLASS__, 'invalidate_term_assignment_cache' ], 10, 4 );
		add_action( 'updated_term_meta', [ __CLASS__, 'invalidate_term_assignment_cache' ], 10, 4 );
		add_action( 'deleted_term_meta', [ __CLASS__, 'invalidate_term_assignment_cache' ], 10, 4 );
	}

	/**
	 * Resolve content access for a specific Stripe product.
	 *
	 * Determines which posts and terms a product grants access to, following this priority:
	 * 1. Global default product (grants access to all published posts regardless of type)
	 * 2. Aggregate post/term assignments site-wide (all posts and terms with this product)
	 *
	 * When a product is purchased, this method resolves ALL content that has that product assigned,
	 * ensuring the rule: "buy a product → unlock ALL content with that product" is respected.
	 *
	 * @since 1.0.0
	 * @param string $product_id      Stripe product ID
	 * @param int    $context_post_id Optional. Reserved for future use. Default 0.
	 * @return array {
	 *     Content access grants for the product.
	 *
	 *     @type int[]  $posts Post IDs granted access
	 *     @type array[] $terms Term grants with 'taxonomy' and 'term_id' keys
	 *     @type string $scope Access scope: 'post', 'term', 'global', or 'none'
	 * }
	 */
	public static function resolve_content_for_product( string $product_id, int $context_post_id = 0 ): array {
		$result = [
			'posts' => [],
			'terms' => [],
			'scope' => 'none',
		];

		$product_id = sanitize_text_field( $product_id );
		if ( '' === $product_id ) {
			return $result;
		}

		// 1) Global default product.
		if ( self::is_global_default_product( $product_id ) ) {
			return [
				'posts' => [],
				'terms' => [],
				'scope' => 'global',
			];
		}

		// 2) Aggregate assignments site-wide.
		$posts = self::find_all_post_assignments( $product_id );
		$terms = self::find_all_term_assignments( $product_id );

		$scope = 'none';
		if ( ! empty( $posts ) ) {
			$scope = 'post';
		} elseif ( ! empty( $terms ) ) {
			$scope = 'term';
		}

		return [
			'posts' => $posts,
			'terms' => $terms,
			'scope' => $scope,
		];
	}

	/**
	 * Generate cache key for post assignments.
	 *
	 * @since 1.0.0
	 * @param string $product_id Stripe product ID
	 * @return string Cache key
	 */
	private static function cache_key_posts( string $product_id ): string {
		return 'posts_' . md5( $product_id );
	}

	/**
	 * Generate cache key for term assignments.
	 *
	 * @since 1.0.0
	 * @param string $product_id Stripe product ID
	 * @return string Cache key
	 */
	private static function cache_key_terms( string $product_id ): string {
		return 'terms_' . md5( $product_id );
	}

	/**
	 * Find all posts that directly assign this product.
	 *
	 * Searches postmeta for posts with the specified product ID in their
	 * _postgate_product_ids meta. Only returns published posts. Results are cached.
	 *
	 * @since 1.0.0
	 * @param string $product_id Stripe product ID
	 * @return int[] Array of post IDs
	 */
	private static function find_all_post_assignments( string $product_id ): array {
		$cache_key = self::cache_key_posts( $product_id );

		$cached = wp_cache_get( $cache_key, self::CACHE_GROUP );
		if ( is_array( $cached ) ) {
			return $cached;
		}

		global $wpdb;

		$postmeta_table = $wpdb->postmeta;
		// Match PHP serialized format: s:19:"prod_ABC123"
		$like = '%:"' . $wpdb->esc_like( $product_id ) . '"%';

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
		$results = $wpdb->get_results(
			$wpdb->prepare(
				'SELECT post_id, meta_value
				 FROM %i
				 WHERE meta_key = %s
				   AND meta_value LIKE %s',
				$postmeta_table,
				'_postgate_product_ids',
				$like
			)
		);

		$post_ids = [];
		foreach ( (array) $results as $row ) {
			$product_ids = maybe_unserialize( $row->meta_value );
			if ( ! is_array( $product_ids ) || ! in_array( $product_id, $product_ids, true ) ) {
				continue;
			}

			$post = get_post( (int) $row->post_id );
			if ( $post && 'publish' === $post->post_status ) {
				$post_ids[] = (int) $post->ID;
			}
		}

		$ttl = (int) apply_filters( 'postgate_product_assignments_cache_ttl', self::CACHE_TTL, $product_id, 'posts' );
		wp_cache_set( $cache_key, $post_ids, self::CACHE_GROUP, max( 1, $ttl ) );

		return $post_ids;
	}

	/**
	 * Find all term grants for this product.
	 *
	 * Searches termmeta for terms with the specified product ID in their
	 * _postgate_product_ids meta. Results are cached.
	 *
	 * @since 1.0.0
	 * @param string $product_id Stripe product ID
	 * @return array[] Array of term grants, each with 'taxonomy' (string) and 'term_id' (int) keys
	 */
	private static function find_all_term_assignments( string $product_id ): array {
		$cache_key = self::cache_key_terms( $product_id );

		$cached = wp_cache_get( $cache_key, self::CACHE_GROUP );
		if ( is_array( $cached ) ) {
			return $cached;
		}

		global $wpdb;

		$termmeta_table = $wpdb->termmeta;
		// Match PHP serialized format: s:19:"prod_ABC123"
		$like = '%:"' . $wpdb->esc_like( $product_id ) . '"%';

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
		$results = $wpdb->get_results(
			$wpdb->prepare(
				'SELECT term_id, meta_value
				 FROM %i
				 WHERE meta_key = %s
				   AND meta_value LIKE %s',
				$termmeta_table,
				'_postgate_product_ids',
				$like
			)
		);

		$grants = [];
		foreach ( (array) $results as $row ) {
			$product_ids = maybe_unserialize( $row->meta_value );
			if ( ! is_array( $product_ids ) || ! in_array( $product_id, $product_ids, true ) ) {
				continue;
			}

			$term = get_term( (int) $row->term_id );
			if ( $term && ! is_wp_error( $term ) ) {
				$grants[] = [
					'taxonomy' => (string) $term->taxonomy,
					'term_id' => (int) $term->term_id,
				];
			}
		}

		$ttl = (int) apply_filters( 'postgate_product_assignments_cache_ttl', self::CACHE_TTL, $product_id, 'terms' );
		wp_cache_set( $cache_key, $grants, self::CACHE_GROUP, max( 1, $ttl ) );

		return $grants;
	}

	/**
	 * Check if a product is the global default product.
	 *
	 * Global default products grant access to all content of enabled post types
	 * without requiring specific post or term assignments.
	 *
	 * @since 1.0.0
	 * @param string $product_id Stripe product ID
	 * @return bool True if product is marked as default in current mode (live/sandbox)
	 */
	private static function is_global_default_product( string $product_id ): bool {
		return PostGate_Product_Utils::is_global_default_product( $product_id );
	}

	/**
	 * Check if grants provide access to a specific post.
	 *
	 * Evaluates content grants to determine if they provide access to the given post.
	 * Checks in order:
	 * 1. Direct post ID match
	 * 2. Term membership (if post has any of the granted terms)
	 * 3. Global scope (validates post exists and is published)
	 *
	 * For global scope grants (typically default product), access is granted to any
	 * published post regardless of post type. This ensures that purchasing a default
	 * product unlocks content site-wide without depending on enabled_post_types configuration.
	 *
	 * @since 1.0.0
	 * @param array $grants Content grants from resolve_content_for_product()
	 * @param int   $post_id Post ID to check access for
	 * @return bool True if grants provide access to the post
	 */
	public static function user_has_access_via_grants( array $grants, int $post_id ): bool {
		if ( ! empty( $grants['posts'] ) && in_array( $post_id, (array) $grants['posts'], true ) ) {
			return true;
		}

		if ( ! empty( $grants['terms'] ) && is_array( $grants['terms'] ) ) {
			foreach ( $grants['terms'] as $term_grant ) {
				$taxonomy = isset( $term_grant['taxonomy'] ) ? (string) $term_grant['taxonomy'] : '';
				$term_id = isset( $term_grant['term_id'] ) ? (int) $term_grant['term_id'] : 0;

				if ( '' === $taxonomy || $term_id <= 0 ) {
					continue;
				}

				if ( has_term( $term_id, $taxonomy, $post_id ) ) {
					return true;
				}
			}
		}

		if ( isset( $grants['scope'] ) && 'global' === $grants['scope'] ) {
			// Global scope = access to any published post, regardless of type
			// This ensures "buy product → unlock all content with that product" works correctly
			$post = get_post( $post_id );
			return ( $post && 'publish' === $post->post_status );
		}

		return false;
	}

	/**
	 * Invalidate post assignment cache when meta is modified.
	 *
	 * Hooked to added_post_meta, updated_post_meta, and deleted_post_meta.
	 * Clears cached post assignments for all affected product IDs.
	 *
	 * @since 1.0.0
	 * @param int    $meta_id    ID of the metadata entry
	 * @param int    $object_id  Object (post) ID
	 * @param string $meta_key   Meta key
	 * @param mixed  $meta_value Meta value
	 * @return void
	 */
	public static function invalidate_post_assignment_cache( $meta_id, $object_id, $meta_key, $meta_value ): void {
		if ( '_postgate_product_ids' !== $meta_key ) {
			return;
		}

		$product_ids = is_array( $meta_value ) ? $meta_value : maybe_unserialize( $meta_value );
		if ( ! is_array( $product_ids ) ) {
			return;
		}

		foreach ( $product_ids as $pid ) {
			$pid = sanitize_text_field( (string) $pid );
			if ( '' !== $pid ) {
				wp_cache_delete( self::cache_key_posts( $pid ), self::CACHE_GROUP );
			}
		}
	}

	/**
	 * Invalidate term assignment cache when meta is modified.
	 *
	 * Hooked to added_term_meta, updated_term_meta, and deleted_term_meta.
	 * Clears cached term assignments for all affected product IDs.
	 *
	 * @since 1.0.0
	 * @param int    $meta_id    ID of the metadata entry
	 * @param int    $object_id  Object (term) ID
	 * @param string $meta_key   Meta key
	 * @param mixed  $meta_value Meta value
	 * @return void
	 */
	public static function invalidate_term_assignment_cache( $meta_id, $object_id, $meta_key, $meta_value ): void {
		if ( '_postgate_product_ids' !== $meta_key ) {
			return;
		}

		$product_ids = is_array( $meta_value ) ? $meta_value : maybe_unserialize( $meta_value );
		if ( ! is_array( $product_ids ) ) {
			return;
		}

		foreach ( $product_ids as $pid ) {
			$pid = sanitize_text_field( (string) $pid );
			if ( '' !== $pid ) {
				wp_cache_delete( self::cache_key_terms( $pid ), self::CACHE_GROUP );
			}
		}
	}
}
