<?php
/**
 * PostGate Access Manager Class
 *
 * This file contains the access control logic that determines whether
 * users have access to protected content based on payments and subscriptions.
 *
 * Uses a structured access grant system where each purchase/subscription
 * is associated with specific content (posts and terms) based on the
 * Stripe price ID (from a product) that was purchased.
 *
 * @package PostGate
 * @subpackage AccessControl
 * @since 1.0.0
 * @author QodeSphere
 * @license GPL-2.0+
 */

defined( 'ABSPATH' ) || exit;

/**
 * PostGate Access Manager Class
 *
 * Manages user access to protected content using a product-based grant system.
 * Each purchase is tied to a specific Stripe price ID (from a product), and that
 * price determines what content the user can access (posts, terms, or global).
 *
 * Note: All price IDs are derived from Stripe Products. The system uses price IDs
 * as grant keys for backward compatibility with the access control structure.
 *
 * Access Structure:
 * _postgate_access_grants => [
 *     'one_time_purchases' => [
 *         'price_abc123' => [                    // Price ID from a Stripe Product
 *             'purchased_at' => '2025-01-15 10:30:00',
 *             'order_id' => 456,
 *             'product_id' => 'prod_xyz',         // Parent Stripe Product ID
 *             'grants' => ['posts' => [12, 34], 'terms' => [...]]
 *         ]
 *     ],
 *     'subscriptions' => [
 *         'price_sub789' => [                    // Price ID from a Stripe Product
 *             'activated_at' => '2025-01-15 10:30:00',
 *             'order_id' => 457,
 *             'product_id' => 'prod_abc',         // Parent Stripe Product ID
 *             'subscription_id' => 'sub_xyz',
 *             'status' => 'active',
 *             'grants' => ['posts' => [...], 'terms' => [...]]
 *         ]
 *     ]
 * ]
 *
 * @since 1.0.0
 */
class PostGate_Access_Manager {

	/**
	 * User meta key for storing access grants.
	 *
	 * @since 1.0.0
	 */
	private const META_KEY = '_postgate_access_grants';

	/**
	 * Request-level cache for access checks.
	 *
	 * Format: [ 'user_id:post_id' => bool ]
	 *
	 * @since 1.0.0
	 * @var array<string, bool>
	 */
	private static array $access_cache = [];

	/**
	 * Initialize the access manager.
	 *
	 * This is a utility class that doesn't require WordPress hooks.
	 * Provided for consistency with other plugin classes.
	 *
	 * @since 1.0.0
	 */
	public static function init() {
		// No hooks needed for this utility class
	}

	/**
	 * Get user's access grants structure.
	 *
	 * @since 1.0.0
	 * @param int $user_id The user ID.
	 * @return array Access grants structure.
	 */
	private static function get_access_grants( int $user_id ): array {
		$grants = get_user_meta( $user_id, self::META_KEY, true );

		if ( ! is_array( $grants ) ) {
			$grants = [
				'one_time_purchases' => [],
				'subscriptions' => [],
			];
		}

		return $grants;
	}

	/**
	 * Save user's access grants structure.
	 *
	 * @since 1.0.0
	 * @param int   $user_id The user ID.
	 * @param array $grants Access grants structure.
	 * @return void
	 */
	private static function save_access_grants( int $user_id, array $grants ): void {
		update_user_meta( $user_id, self::META_KEY, $grants );
	}

	/**
	 * Check if a user has access to a specific post.
	 *
	 * Checks all of the user's active grants (one-time purchases and subscriptions)
	 * to determine if they have access to the given post.
	 *
	 * Uses request-level caching to avoid redundant checks in archive/loop contexts.
	 *
	 * @since 1.0.0
	 * @param int $user_id The user ID to check access for.
	 * @param int $post_id The post ID to check access to.
	 * @return bool True if user has post access, false otherwise.
	 */
	public static function has_post_access( int $user_id, int $post_id ): bool {
		// Check cache first
		$cache_key = "{$user_id}:{$post_id}";
		if ( isset( self::$access_cache[ $cache_key ] ) ) {
			return self::$access_cache[ $cache_key ];
		}

		$grants = self::get_access_grants( $user_id );
		$has_access = false;

		// Check one-time purchases
		if ( ! empty( $grants['one_time_purchases'] ) ) {
			foreach ( $grants['one_time_purchases'] as $purchase_data ) {
				if ( ! empty( $purchase_data['status'] ) && $purchase_data['status'] === 'refunded' ) {
					continue;
				}
				if ( empty( $purchase_data['grants'] ) ) {
					continue;
				}
				if ( PostGate_Product_Content_Resolver::user_has_access_via_grants( $purchase_data['grants'], $post_id ) ) {
					$has_access = true;
					break;
				}
			}
		}

		// Check active subscriptions (only if not found in purchases)
		if ( ! $has_access && ! empty( $grants['subscriptions'] ) ) {
			foreach ( $grants['subscriptions'] as $subscription_data ) {
				if ( empty( $subscription_data['status'] ) || $subscription_data['status'] !== 'active' ) {
					continue;
				}
				if ( empty( $subscription_data['grants'] ) ) {
					continue;
				}
				if ( PostGate_Product_Content_Resolver::user_has_access_via_grants( $subscription_data['grants'], $post_id ) ) {
					$has_access = true;
					break;
				}
			}
		}

		// Store in cache and return
		self::$access_cache[ $cache_key ] = $has_access;
		return $has_access;
	}

	/**
	 * Check if a user has an active subscription.
	 *
	 * Determines if a user has any active subscription.
	 *
	 * @since 1.0.0
	 * @param int $user_id The user ID to check subscription status for.
	 * @return bool True if user has active subscription, false otherwise.
	 */
	public static function has_active_subscription( int $user_id ): bool {
		$grants = self::get_access_grants( $user_id );

		if ( empty( $grants['subscriptions'] ) ) {
			return false;
		}

		foreach ( $grants['subscriptions'] as $price_id => $subscription_data ) {
			if ( ! empty( $subscription_data['status'] ) && $subscription_data['status'] === 'active' ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Check if a user has access to content.
	 *
	 * Comprehensive access check that considers both individual post
	 * purchases and active subscription status.
	 *
	 * @since 1.0.0
	 * @param int $user_id The user ID to check access for.
	 * @param int $post_id The post ID to check access to.
	 * @return bool True if user has access, false otherwise.
	 */
	public static function user_has_access( int $user_id, int $post_id ): bool {
		return self::has_post_access( $user_id, $post_id );
	}

	/**
	 * Check if a user is currently on trial.
	 *
	 * Determines if a user has an active trial period that grants
	 * temporary access to content.
	 *
	 * @since 1.0.0
	 * @param int $user_id The user ID to check trial status for.
	 * @return bool True if user is on trial, false otherwise.
	 */
	public static function is_on_trial( int $user_id ): bool {
		$trial_until = get_user_meta( $user_id, '_postgate_trial_until', true );
		return $trial_until && strtotime( $trial_until ) >= time();
	}

	/**
	 * Check if an order has already granted access to a user.
	 *
	 * @since 1.0.0
	 * @param int $user_id The user ID.
	 * @param int $order_id The order ID.
	 * @return bool True if the order has already granted access, false otherwise.
	 */
	public static function order_has_granted_access( int $user_id, int $order_id ): bool {
		$grants = self::get_access_grants( $user_id );

		// Check one-time purchases
		if ( ! empty( $grants['one_time_purchases'] ) ) {
			foreach ( $grants['one_time_purchases'] as $purchase ) {
				if ( isset( $purchase['order_id'] ) && $purchase['order_id'] === $order_id ) {
					// Check if not refunded
					if ( empty( $purchase['status'] ) || $purchase['status'] !== 'refunded' ) {
						return true;
					}
				}
			}
		}

		// Check subscriptions
		if ( ! empty( $grants['subscriptions'] ) ) {
			foreach ( $grants['subscriptions'] as $subscription ) {
				if ( isset( $subscription['order_id'] ) && $subscription['order_id'] === $order_id ) {
					// Check if not canceled
					if ( empty( $subscription['status'] ) || $subscription['status'] !== 'canceled' ) {
						return true;
					}
				}
			}
		}

		return false;
	}

	/**
	 * Clear the request-level access cache.
	 *
	 * Use when granting/revoking access within the same request where access checks
	 * may be performed afterward (e.g., WP-CLI scripts, unit tests, batch processing).
	 *
	 * In normal web/webhook flows, this is not necessary as each request has its own
	 * isolated cache that is destroyed when the request ends.
	 *
	 * @since 1.0.0
	 * @param int|null $user_id Optional. Clear cache for specific user only.
	 * @return void
	 */
	public static function clear_access_cache( ?int $user_id = null ): void {
		if ( null === $user_id ) {
			self::$access_cache = [];
			return;
		}

		foreach ( array_keys( self::$access_cache ) as $key ) {
			if ( str_starts_with( (string) $key, "{$user_id}:" ) ) {
				unset( self::$access_cache[ $key ] );
			}
		}
	}

	/**
	 * Grant access to a user based on completed order.
	 *
	 * Called from webhooks when the order is paid.
	 * Creates a structured access grant based on the price purchased
	 * and the content it unlocks.
	 *
	 * @since 1.0.0
	 * @param array $order Order data array from database.
	 * @return void
	 */
	public static function grant_access_from_order( array $order ): void {
		$user_id = (int) $order['user_id'];
		$price_id = ! empty( $order['purchased_price_id'] ) ? (string) $order['purchased_price_id'] : '';
		$product_id = ! empty( $order['purchased_product_id'] ) ? (string) $order['purchased_product_id'] : '';
		$order_id = (int) $order['id'];
		$order_type = (string) $order['type'];

		if ( empty( $price_id ) ) {
			return;
		}

		// Parse unlocked content from order
		$unlocked_content = ! empty( $order['unlocked_content'] ) ? json_decode( $order['unlocked_content'], true ) : [];
		if ( ! is_array( $unlocked_content ) ) {
			$unlocked_content = [ 'posts' => [], 'terms' => [], 'scope' => 'none' ];
		}

		// Get current grants
		$grants = self::get_access_grants( $user_id );

		// Structure the grant data
		$grant_data = [
			'order_id' => $order_id,
			'grants' => [
				'posts' => ! empty( $unlocked_content['posts'] ) ? $unlocked_content['posts'] : [],
				'terms' => ! empty( $unlocked_content['terms'] ) ? $unlocked_content['terms'] : [],
				'scope' => ! empty( $unlocked_content['scope'] ) ? $unlocked_content['scope'] : 'none',
			],
		];

		// Add product_id (always present in product-based architecture)
		if ( '' !== $product_id ) {
			$grant_data['product_id'] = $product_id;
		}

		// Add type-specific data
		if ( $order_type === 'subscription' ) {
			$grant_data['activated_at'] = current_time( 'mysql' );
			$grant_data['subscription_id'] = ! empty( $order['stripe_subscription_id'] ) ? (string) $order['stripe_subscription_id'] : '';
			$grant_data['status'] = 'active';

			$grants['subscriptions'][ $price_id ] = $grant_data;
		} else {
			$grant_data['purchased_at'] = current_time( 'mysql' );

			$grants['one_time_purchases'][ $price_id ] = $grant_data;
		}

		// Save updated grants
		self::save_access_grants( $user_id, $grants );

		// Clear cache to prevent stale data in same-request scenarios
		self::clear_access_cache( $user_id );
	}

	/**
	 * Revoke access from a user based on refunded/canceled order.
	 *
	 * Called when refunded/canceled: revoke the access for a specific price.
	 *
	 * @since 1.0.0
	 * @param array $order Order data array from database.
	 * @return void
	 */
	public static function revoke_access_from_order( array $order ): void {
		$user_id = (int) $order['user_id'];
		$price_id = ! empty( $order['purchased_price_id'] ) ? (string) $order['purchased_price_id'] : '';
		$order_type = (string) $order['type'];

		if ( empty( $price_id ) ) {
			return;
		}

		$grants = self::get_access_grants( $user_id );

		// Remove the specific grant
		if ( $order_type === 'subscription' ) {
			if ( isset( $grants['subscriptions'][ $price_id ] ) ) {
				// Mark as canceled instead of removing completely (for audit trail)
				$grants['subscriptions'][ $price_id ]['status'] = 'canceled';
				$grants['subscriptions'][ $price_id ]['canceled_at'] = current_time( 'mysql' );
			}
		} else {
			// For one-time purchases, mark as refunded (for audit trail)
			if ( isset( $grants['one_time_purchases'][ $price_id ] ) ) {
				$grants['one_time_purchases'][ $price_id ]['status'] = 'refunded';
				$grants['one_time_purchases'][ $price_id ]['refunded_at'] = current_time( 'mysql' );
			}
		}

		self::save_access_grants( $user_id, $grants );

		// Clear cache to prevent stale data in same-request scenarios
		self::clear_access_cache( $user_id );
	}

	/**
	 * Update subscription status.
	 *
	 * Updates the status of a subscription grant (active, past_due, cancelled).
	 *
	 * @since 1.0.0
	 * @param int    $user_id The user ID.
	 * @param string $subscription_id The Stripe subscription ID.
	 * @param string $status The new status.
	 * @return void
	 */
	public static function update_subscription_status( int $user_id, string $subscription_id, string $status ): void {
		$grants = self::get_access_grants( $user_id );

		if ( empty( $grants['subscriptions'] ) ) {
			return;
		}

		// Find the subscription by ID
		foreach ( $grants['subscriptions'] as $price_id => $subscription_data ) {
			if ( ! empty( $subscription_data['subscription_id'] ) && $subscription_data['subscription_id'] === $subscription_id ) {
				$grants['subscriptions'][ $price_id ]['status'] = $status;
				$grants['subscriptions'][ $price_id ]['status_updated_at'] = current_time( 'mysql' );
				break;
			}
		}

		self::save_access_grants( $user_id, $grants );

		// Clear cache to prevent stale data in same-request scenarios
		self::clear_access_cache( $user_id );
	}

	/**
	 * Check block-level access: user must have purchased or subscribed to ANY of the provided price IDs.
	 * Falls back to post-level access already granted. Unlocking one price unlocks all blocks in same post sharing a price.
	 *
	 * Note: All price IDs come from Stripe Products configured in the plugin settings.
	 *
	 * @since 1.0.0
	 * @param int   $user_id  User ID.
	 * @param int   $post_id  Current post ID.
	 * @param array $price_ids Array of Stripe price IDs (from products) tied to this block.
	 * @return bool
	 */
	public static function has_block_access( int $user_id, int $post_id, array $price_ids ): bool {
		if ( $user_id <= 0 ) {
			return false;
		}
		// If post access already granted treat all nested blocks as unlocked.
		if ( self::has_post_access( $user_id, $post_id ) ) {
			return true;
		}
		$price_ids = array_values( array_filter( array_map( 'strval', $price_ids ) ) );
		if ( empty( $price_ids ) ) {
			return false; // will be handled with default price separately in render layer
		}
		$grants = self::get_access_grants( $user_id );
		// Consolidate active price IDs user owns (one-time not refunded + active subscriptions)
		$owned = [];
		foreach ( $grants['one_time_purchases'] as $pid => $data ) {
			if ( ! empty( $data['status'] ) && $data['status'] === 'refunded' ) {
				continue;
			}
			$owned[] = (string) $pid;
		}
		foreach ( $grants['subscriptions'] as $pid => $data ) {
			if ( empty( $data['status'] ) || $data['status'] !== 'active' ) {
				continue;
			}
			$owned[] = (string) $pid;
		}
		$owned = array_unique( $owned );
		return (bool) array_intersect( $owned, $price_ids );
	}
}
