<?php
/**
 * PostGate Content Lock Class
 *
 * Centralized content lock with inheritance:
 * Post (explicit) > Terms (most restrictive) > Global settings.
 *
 * Gates supported: paywall | login
 * All gates are template-based and theme-overridable.
 *
 * Templates receive *data only* (no business logic). The teaser/preview
 * is rendered outside the gate using effective preview settings.
 *
 * @package PostGate
 * @subpackage ContentFiltering
 * @since 1.0.0
 * @author QodeSphere
 * @license GPL-2.0+
 */

defined( 'ABSPATH' ) || exit;

/**
 * PostGate Content Lock Class
 *
 * Handles content filtering and access control based on user permissions,
 * payment status, and configuration settings. This class implements the core
 * content locking mechanism for PostGate with the following features:
 *
 * - Hierarchical inheritance system: Post (explicit) > Terms (most restrictive) > Global settings
 * - Multiple gate types: paywall, login-only
 * - Template-based rendering with theme override support
 * - Role-based bypass for administrators and custom roles
 * - Integration with PostGate_Access_Manager for purchase validation
 * - Preview/teaser rendering (paragraph or word-based)
 * - Block-level lock detection and coordination
 * - Site-wide lock mode support
 * - Default price resolution from Stripe price table
 * - Deterministic term ordering for consistent rule resolution
 *
 * The class uses a sophisticated rule resolution system:
 * 1. Post-level explicit overrides take priority
 * 2. Term-level settings aggregate with "first decisive wins" strategy
 * 3. Global settings provide fallback defaults
 *
 * Content is filtered at priority 11 (after do_blocks() at priority 10) to ensure
 * lock blocks are rendered before content lock evaluation.
 *
 * @since 1.0.0
 */
class PostGate_Content_Lock {

	/**
	 * Initialize the content lock functionality.
	 *
	 * @since 1.0.0
	 * @return void
	 */
	public static function init(): void {
		// Priority 11 to run AFTER do_blocks() at priority 10
		// This ensures lock blocks are rendered before we check for them
		add_filter( 'the_content', [ __CLASS__, 'maybe_lock_content' ], 11 );
	}

	/**
	 * Determine if content should be locked for the current user.
	 *
	 * This is a lightweight check used by the assets loader to determine
	 * whether to enqueue frontend paywall assets. It checks:
	 * - User bypass rules (admins, roles, access manager)
	 * - Lock resolution (post > term > global)
	 * - Presence of lock blocks for partial content locking
	 *
	 * Returns true if either the entire post is locked OR the post contains
	 * lock blocks (partial locking). This ensures assets are loaded for both
	 * post-level and block-level content protection scenarios.
	 *
	 * @since 1.0.0
	 *
	 * @param int $post_id The post ID to check.
	 *
	 * @return bool True if content should be locked, false otherwise.
	 */
	public static function should_lock_content( int $post_id ): bool {
		// Not singular or no post ID
		if ( ! $post_id ) {
			return false;
		}

		// User bypasses lock (admin, roles, has access)
		if ( self::user_bypasses_lock( $post_id ) ) {
			return false;
		}

		// Resolve lock rules
		$rules = self::resolve_effective_rules( $post_id );

		// Return true only if explicitly locked at the post/term/global level
		if ( $rules['lock'] === 'lock' ) {
			return true;
		}

		// Check for block-level locking: if post contains lock blocks
		if ( has_block( 'postgate/lock-block', $post_id ) ) {
			return true;
		}

		// No locking at any level
		return false;
	}

	/**
	 * Check if the current user can bypass content locks.
	 *
	 * Determines whether the current user should bypass all content locking mechanisms
	 * based on several criteria:
	 * - Admin context (always bypass)
	 * - Users with 'manage_options' capability (administrators)
	 * - Users with roles defined in 'postgate_bypass_roles' option
	 * - Users with active access via PostGate_Access_Manager (purchased, subscription, or trial)
	 *
	 * This check is performed early in the content filtering process (both singular and
	 * archive contexts) to avoid unnecessary rule resolution and gate rendering for
	 * privileged users or users who have purchased access.
	 *
	 * @since 1.0.0
	 *
	 * @param int $post_id The post ID being evaluated.
	 *
	 * @return bool True if user should bypass locks, false otherwise.
	 */
	private static function user_bypasses_lock( int $post_id ): bool {
		// Always bypass in admin
		if ( is_admin() ) {
			return true;
		}

		// Admins bypass
		if ( is_user_logged_in() && current_user_can( 'manage_options' ) ) {
			return true;
		}

		$bypass_roles = get_option( 'postgate_bypass_roles', [ 'administrator' ] );
		if ( is_user_logged_in() && is_array( $bypass_roles ) && ! empty( $bypass_roles ) ) {
			$current_user = wp_get_current_user();
			$user_roles = array_map( 'strval', (array) $current_user->roles );

			$wanted_roles = array_values(
				array_filter(
					array_map(
						static function ( $r ) {
							return sanitize_key( (string) $r );
						},
						$bypass_roles
					)
				)
			);

			if ( ! empty( $user_roles ) && ! empty( $wanted_roles ) && array_intersect( $user_roles, $wanted_roles ) ) {
				return true;
			}
		}

		// Access Manager: purchased, subscription, trial
		$user_id = get_current_user_id();
		if ( $user_id ) {
			if (
				PostGate_Access_Manager::has_post_access( $user_id, $post_id )
				|| PostGate_Access_Manager::is_on_trial( $user_id )
			) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Compute the effective lock rules for the current post using hierarchical inheritance.
	 *
	 * Implements a three-tier resolution strategy with cascading priority:
	 * 1. POST explicit override (if not "inherit")
	 * 2. TERMS: combine all attached terms with "first decisive wins" using stable sort
	 * 3. GLOBAL defaults from plugin options
	 *
	 * Resolution Logic:
	 * - Lock Status: Determined by lock_entire_site (master override) or enabled_post_types
	 * - Lock Mode: 'paywall' or 'login' with post > term > global priority
	 * - Price IDs: First non-empty from post > term > global, with default price fallback
	 * - Preview Settings: Post-level overrides global (terms ignored by design)
	 *
	 * Site-wide Lock Truth Table:
	 * - lock_entire_site=true  + ANY post_type            → lock (site-wide)
	 * - lock_entire_site=false + post_type NOT enabled   → unlock (not managed)
	 * - lock_entire_site=false + post_type IN enabled    → lock (selective)
	 *
	 * Term aggregation uses deterministic ordering (taxonomy:name) to ensure
	 * consistent results across multiple requests.
	 *
	 * @since 1.0.0
	 *
	 * @param int $post_id The post ID for which to resolve rules.
	 *
	 * @return array {
	 *     Effective lock rules for the post.
	 *
	 *     @type string   $lock          Lock status: 'lock' or 'unlock'.
	 *     @type string   $mode          Lock mode: 'paywall' or 'login'.
	 *     @type string[] $product_ids   Array of Stripe product IDs (may be empty).
	 *     @type string   $preview_mode  Preview mode: 'paragraph' or 'words'.
	 *     @type int      $preview_limit Number of paragraphs/words to show.
	 * }
	 */
	private static function resolve_effective_rules( int $post_id ): array {
		$post_type = get_post_type( $post_id );

		// Gather configuration from all three layers
		$global_config = self::get_global_configuration( $post_type );
		$post_config = self::get_post_configuration( $post_id );
		$term_config = self::get_term_configuration( $post_id, $post_type );

		// Merge with priority: POST > TERM > GLOBAL
		$effective_lock = self::resolve_lock_status( $post_config['lock'], $term_config['lock'], $global_config['lock'] );
		$effective_mode = self::resolve_lock_mode( $post_config['mode'], $term_config['mode'], $global_config['mode'] );
		$effective_product_ids = self::resolve_product_ids( $post_config['product_ids'], $term_config['product_ids'], $global_config['product_ids'] );
		$effective_preview = self::resolve_preview_settings( $post_config['preview'], $global_config['preview'] );

		return [
			'lock' => $effective_lock,
			'mode' => $effective_mode,
			'product_ids' => $effective_product_ids,
			'preview_mode' => $effective_preview['mode'],
			'preview_limit' => $effective_preview['limit'],
		];
	}

	/**
	 * Get global configuration settings.
	 *
	 * @since 1.0.0
	 * @param string $post_type The post type being evaluated.
	 * @return array {
	 *     Global configuration array.
	 *
	 *     @type string $lock      Lock status ('lock' or 'unlock')
	 *     @type string $mode      Lock mode ('paywall' or 'login')
	 *     @type array  $product_ids Array of Stripe product IDs (empty for global)
	 *     @type array  $preview   Preview settings array
	 * }
	 */
	private static function get_global_configuration( string $post_type ): array {
		$enabled_post_types = (array) get_option( 'postgate_enabled_post_types', [] );
		$lock_entire_site = (bool) get_option( 'postgate_lock_entire_site', false );

		// Determine global lock status:
		// - lock_entire_site=true → lock everything
		// - lock_entire_site=false + post_type not in enabled → unlock (not managed)
		// - lock_entire_site=false + post_type in enabled → lock (selective mode)
		if ( $lock_entire_site ) {
			$lock_status = 'lock';
		} elseif ( ! in_array( $post_type, $enabled_post_types, true ) ) {
			$lock_status = 'unlock';
		} else {
			$lock_status = 'lock';
		}

		$lock_mode = get_option( 'postgate_lock_mode', 'paywall' );
		$preview_mode = get_option( 'postgate_preview_mode', 'paragraph' );
		$preview_mode = in_array( $preview_mode, [ 'paragraph', 'words' ], true ) ? $preview_mode : 'paragraph';
		$preview_limit = (int) get_option( 'postgate_preview_limit', ( $preview_mode === 'paragraph' ? 2 : 55 ) );

		if ( $preview_limit <= 0 ) {
			$preview_limit = ( $preview_mode === 'paragraph' ) ? 2 : 55;
		}

		return [
			'lock' => $lock_status,
			'mode' => $lock_mode,
			'product_ids' => [], // Global products resolved via default product
			'preview' => [
				'mode' => $preview_mode,
				'limit' => $preview_limit,
			],
		];
	}

	/**
	 * Get post-level configuration from post meta.
	 *
	 * @since 1.0.0
	 * @param int $post_id The post ID.
	 * @return array {
	 *     Post configuration array.
	 *
	 *     @type string $lock      Lock override ('inherit', 'lock', or 'unlock')
	 *     @type string $mode      Lock mode ('inherit', 'paywall', or 'login')
	 *     @type array  $product_ids Array of Stripe product IDs
	 *     @type array  $preview   Preview settings array
	 * }
	 */
	private static function get_post_configuration( int $post_id ): array {
		$lock_override = get_post_meta( $post_id, '_postgate_lock_override', true ) ?: 'inherit';
		$lock_mode = get_post_meta( $post_id, '_postgate_lock_mode', true ) ?: 'inherit';
		$product_ids = get_post_meta( $post_id, '_postgate_product_ids', true );
		$product_ids = is_array( $product_ids ) ? array_values( array_filter( array_map( 'strval', $product_ids ) ) ) : [];

		$preview_mode = get_post_meta( $post_id, '_postgate_preview_mode', true ) ?: 'inherit';
		$preview_limit = get_post_meta( $post_id, '_postgate_preview_limit', true );
		$preview_limit = is_numeric( $preview_limit ) ? (int) $preview_limit : 0;

		return [
			'lock' => $lock_override,
			'mode' => $lock_mode,
			'product_ids' => $product_ids,
			'preview' => [
				'mode' => $preview_mode,
				'limit' => $preview_limit,
			],
		];
	}

	/**
	 * Get term-level configuration by aggregating all assigned terms.
	 *
	 * Uses deterministic alphabetical sorting to ensure consistent behavior
	 * when multiple terms define conflicting settings.
	 *
	 * @since 1.0.0
	 * @param int    $post_id   The post ID.
	 * @param string $post_type The post type.
	 * @return array {
	 *     Term configuration array (aggregated from all terms).
	 *
	 *     @type string $lock      First explicit lock value found ('inherit', 'lock', or 'unlock')
	 *     @type string $mode      First explicit mode found ('inherit', 'paywall', or 'login')
	 *     @type array  $product_ids First non-empty product IDs array found
	 * }
	 */
	private static function get_term_configuration( int $post_id, string $post_type ): array {
		$terms = wp_get_object_terms( $post_id, get_object_taxonomies( $post_type ) );

		$lock_result = 'inherit';
		$mode_result = 'inherit';
		$product_ids = [];

		if ( is_wp_error( $terms ) || empty( $terms ) ) {
			return [
				'lock' => $lock_result,
				'mode' => $mode_result,
				'product_ids' => $product_ids,
			];
		}

		// Sort terms deterministically by taxonomy + name
		usort(
			$terms,
			static function ( $a, $b ) {
				return strcmp( $a->taxonomy . ':' . $a->name, $b->taxonomy . ':' . $b->name );
			}
		);

		// Aggregate term meta - first explicit value wins
		foreach ( $terms as $term ) {
			if ( $lock_result === 'inherit' ) {
				$term_lock = get_term_meta( $term->term_id, '_postgate_lock_override', true ) ?: 'inherit';
				if ( in_array( $term_lock, [ 'lock', 'unlock' ], true ) ) {
					$lock_result = $term_lock;
				}
			}

			if ( $mode_result === 'inherit' ) {
				$term_mode = get_term_meta( $term->term_id, '_postgate_lock_mode', true ) ?: 'inherit';
				if ( in_array( $term_mode, [ 'paywall', 'login' ], true ) ) {
					$mode_result = $term_mode;
				}
			}

			if ( empty( $product_ids ) ) {
				$term_products = get_term_meta( $term->term_id, '_postgate_product_ids', true );
				if ( is_array( $term_products ) && ! empty( $term_products ) ) {
					$product_ids = array_values( array_filter( array_map( 'strval', $term_products ) ) );
				}
			}

			// Early exit if all values found
			if ( $lock_result !== 'inherit' && $mode_result !== 'inherit' && ! empty( $product_ids ) ) {
				break;
			}
		}

		return [
			'lock' => $lock_result,
			'mode' => $mode_result,
			'product_ids' => $product_ids,
		];
	}

	/**
	 * Resolve effective lock status with priority: POST > TERM > GLOBAL.
	 *
	 * @since 1.0.0
	 * @param string $post_lock  Post lock override ('inherit', 'lock', or 'unlock').
	 * @param string $term_lock  Term lock result ('inherit', 'lock', or 'unlock').
	 * @param string $global_lock Global lock status ('lock' or 'unlock').
	 * @return string Final lock status ('lock' or 'unlock').
	 */
	private static function resolve_lock_status( string $post_lock, string $term_lock, string $global_lock ): string {
		if ( in_array( $post_lock, [ 'lock', 'unlock' ], true ) ) {
			return $post_lock;
		}

		if ( in_array( $term_lock, [ 'lock', 'unlock' ], true ) ) {
			return $term_lock;
		}

		return $global_lock;
	}

	/**
	 * Resolve effective lock mode with priority: POST > TERM > GLOBAL.
	 *
	 * @since 1.0.0
	 * @param string $post_mode   Post lock mode ('inherit', 'paywall', or 'login').
	 * @param string $term_mode   Term lock mode ('inherit', 'paywall', or 'login').
	 * @param string $global_mode Global lock mode ('paywall' or 'login').
	 * @return string Final lock mode ('paywall' or 'login').
	 */
	private static function resolve_lock_mode( string $post_mode, string $term_mode, string $global_mode ): string {
		if ( in_array( $post_mode, [ 'paywall', 'login' ], true ) ) {
			return $post_mode;
		}

		if ( in_array( $term_mode, [ 'paywall', 'login' ], true ) ) {
			return $term_mode;
		}

		return $global_mode ?: 'paywall';
	}

	/**
	 * Resolve effective product IDs with priority: POST > TERM > DEFAULT.
	 *
	 * If all layers are empty, injects the default Stripe product.
	 *
	 * @since 1.0.0
	 * @param array $post_products   Post product IDs.
	 * @param array $term_products   Term product IDs.
	 * @param array $global_products Global product IDs (always empty, for signature consistency).
	 * @return array Final product IDs array.
	 */
	private static function resolve_product_ids( array $post_products, array $term_products, array $global_products ): array {
		if ( ! empty( $post_products ) ) {
			return $post_products;
		}

		if ( ! empty( $term_products ) ) {
			return $term_products;
		}

		if ( ! empty( $global_products ) ) {
			return $global_products;
		}

		// Inject default product as fallback
		$default_product = PostGate_Product_Utils::get_default_product_id();
		return $default_product !== '' ? [ $default_product ] : [];
	}

	/**
	 * Resolve effective preview settings.
	 *
	 * Note: Terms do not have preview settings, only POST > GLOBAL inheritance.
	 *
	 * @since 1.0.0
	 * @param array $post_preview   Post preview settings.
	 * @param array $global_preview Global preview settings.
	 * @return array {
	 *     Final preview settings.
	 *
	 *     @type string $mode  Preview mode ('paragraph' or 'words')
	 *     @type int    $limit Preview limit (paragraphs or words count)
	 * }
	 */
	private static function resolve_preview_settings( array $post_preview, array $global_preview ): array {
		$mode = in_array( $post_preview['mode'], [ 'paragraph', 'words' ], true )
			? $post_preview['mode']
			: $global_preview['mode'];

		$limit = $post_preview['limit'] > 0
			? $post_preview['limit']
			: $global_preview['limit'];

		// Final guardrail
		if ( $limit <= 0 ) {
			$limit = ( $mode === 'paragraph' ) ? 2 : 55;
		}

		return [
			'mode' => $mode,
			'limit' => $limit,
		];
	}

	/**
	 * Main content filter: applies access control and renders gates for locked content.
	 *
	 * This method handles content protection in two distinct contexts:
	 *
	 * === ARCHIVE PAGES (non-singular) ===
	 * - Checks user bypass rules (admins, roles, purchased access)
	 * - Checks if post is locked (via rules or lock blocks)
	 * - If locked, renders based on 'postgate_archive_display_mode' setting:
	 *   * 'archive_lock_indicator': Renders archive-lock.php template 
	 *   * 'excerpt' (default): Shows post excerpt + "Continue reading →" link
	 * - If not locked or user has access, returns full content
	 *
	 * === SINGULAR PAGES (single posts/pages) ===
	 * Applies hierarchical lock resolution:
	 * 1. Checks user bypass rules (admins, roles, access manager)
	 * 2. Handles lock blocks:
	 *    - If post has explicit 'lock' override, applies post-level locking
	 *    - If 'unlock' or 'inherit', lets lock blocks render themselves
	 * 3. Resolves effective rules (post > term > global)
	 * 4. If locked, renders: preview (teaser) + gate (paywall or login)
	 * 5. If not locked, returns full content
	 *
	 * Gate Rendering:
	 * - Preview: First N paragraphs or words (configurable)
	 * - Gate Type: 'paywall' (Stripe prices) or 'login' (authentication only)
	 * - Templates: Fully customizable via theme overrides
	 *
	 * Filter Priority: 11 (runs after do_blocks() at priority 10)
	 *
	 * @since 1.0.0
	 *
	 * @param string $content The original post content.
	 *
	 * @return string The filtered content with access control applied.
	 */
	public static function maybe_lock_content( string $content ): string {
		$post_id = get_the_ID();
		if ( ! $post_id ) {
			return $content;
		}

		// Route to appropriate handler based on context
		if ( ! is_singular() ) {
			return self::handle_archive_content( $post_id, $content );
		}

		return self::handle_singular_content( $post_id, $content );
	}

	/**
	 * Handle content locking for archive pages (non-singular contexts).
	 *
	 * Provides flexible display options for locked posts on archive pages:
	 * - Archive Lock Indicator: Custom template with lock icon and call-to-action
	 * - Excerpt Mode: Post excerpt with "Continue reading" link (default)
	 *
	 * Lock Detection:
	 * - Checks effective rules for explicit 'lock' status
	 * - Checks for presence of PostGate lock blocks
	 * - Does NOT perform user bypass checks (handled by user_bypasses_lock)
	 *
	 * Display Mode Logic:
	 * 1. If postgate_archive_display_mode = 'archive_lock_indicator':
	 *    - Renders archive-lock.php template (Solution 3)
	 *    - Shows lock icon + custom title + button
	 * 2. If postgate_archive_display_mode = 'excerpt' (default):
	 *    - Shows manual excerpt if set, otherwise generates from content
	 *    - Appends "Continue reading →" link to full post
	 *
	 * @since 1.0.0
	 *
	 * @param int    $post_id The post ID being processed.
	 * @param string $content The original post content.
	 *
	 * @return string Filtered content for archive display.
	 */
	private static function handle_archive_content( int $post_id, string $content ): string {
		// Early bypass checks (admins, roles, access manager)
		if ( self::user_bypasses_lock( $post_id ) ) {
			return $content;
		}

		// Check if post is locked
		if ( ! self::is_post_locked( $post_id ) ) {
			return $content;
		}

		// Get archive display mode setting
		$archive_display_mode = get_option( 'postgate_archive_display_mode', 'excerpt' );

		if ( $archive_display_mode === 'archive_lock_indicator' ) {
			// Render archive lock indicator template
			return PostGate_Template_Renderer::render_archive_lock( $post_id );
		}

		// Show excerpt with "Continue reading" link (default)
		return self::render_excerpt_with_link( $post_id );
	}

	/**
	 * Handle content locking for singular pages (single posts/pages).
	 *
	 * Applies comprehensive lock resolution with user bypass checks and gate rendering:
	 *
	 * Lock Resolution Flow:
	 * 1. Early bypass: Admins, privileged roles, users with access
	 * 2. Lock blocks coordination:
	 *    - If post explicitly locked → apply post-level locking
	 *    - If unlocked/inherit → let blocks handle their own locking
	 * 3. Hierarchical rules: Post > Term > Global
	 * 4. Gate rendering: Preview + Gate (paywall or login)
	 *
	 * Gate Types:
	 * - Paywall: Displays Stripe price options with purchase CTA
	 * - Login: Shows login form with registration link
	 *
	 * Preview/Teaser:
	 * - Configured via preview_mode (paragraph/words) and preview_limit
	 * - Rendered outside gate to entice conversion
	 *
	 * @since 1.0.0
	 *
	 * @param int    $post_id The post ID being processed.
	 * @param string $content The original post content.
	 *
	 * @return string Filtered content with gate applied if locked.
	 */
	private static function handle_singular_content( int $post_id, string $content ): string {
		// Early bypass checks (admins, roles, access manager)
		if ( self::user_bypasses_lock( $post_id ) ) {
			return $content;
		}

		// Handle lock blocks coordination
		if ( self::should_defer_to_lock_blocks( $post_id ) ) {
			return $content;
		}

		// Resolve effective rules for this post
		$rules = self::resolve_effective_rules( $post_id );

		// If not locked, return full content
		if ( $rules['lock'] !== 'lock' ) {
			return $content;
		}

		// Build and return locked content with gate
		return self::render_locked_content( $post_id, $content, $rules );
	}

	/**
	 * Check if a post is locked (for archive context).
	 *
	 * Determines lock status by checking:
	 * 1. Effective rules resolution (post > term > global)
	 * 2. Presence of PostGate lock blocks
	 *
	 * This is a lightweight check used in archive contexts. User bypass checks
	 * are performed separately in handle_archive_content() before calling this method.
	 *
	 * @since 1.0.0
	 *
	 * @param int $post_id The post ID to check.
	 *
	 * @return bool True if post is locked, false otherwise.
	 */
	private static function is_post_locked( int $post_id ): bool {
		$rules = self::resolve_effective_rules( $post_id );

		// Check if locked via rules OR has lock blocks
		return ( $rules['lock'] === 'lock' ) || has_block( 'postgate/lock-block', $post_id );
	}

	/**
	 * Determine if content locking should defer to lock blocks.
	 *
	 * Lock blocks provide granular, block-level content protection. This method
	 * coordinates between post-level locking and block-level locking:
	 *
	 * Coordination Logic:
	 * - If post has lock blocks AND post override != 'lock':
	 *   → Defer to blocks (let them render and handle their own locking)
	 * - If post has lock blocks AND post override == 'lock':
	 *   → Apply post-level locking (ignore blocks, lock entire post)
	 * - If no lock blocks:
	 *   → Continue with normal post-level evaluation
	 *
	 * This prevents conflicts between post-level gates and block-level gates.
	 *
	 * @since 1.0.0
	 *
	 * @param int $post_id The post ID to check.
	 *
	 * @return bool True if should defer to lock blocks, false otherwise.
	 */
	private static function should_defer_to_lock_blocks( int $post_id ): bool {
		if ( ! has_block( 'postgate/lock-block', $post_id ) ) {
			return false;
		}

		$post_lock_override = get_post_meta( $post_id, '_postgate_lock_override', true );

		// If explicitly locked at post level, don't defer to blocks
		if ( $post_lock_override === 'lock' ) {
			return false;
		}

		// Post is 'unlock' or 'inherit' - defer to blocks
		return true;
	}

	/**
	 * Render locked content with preview and appropriate gate.
	 *
	 * Assembles the final locked content output by combining:
	 * 1. Preview/teaser: First N paragraphs or words (configured)
	 * 2. Gate: Paywall or login gate based on lock mode
	 *
	 * Gate Selection:
	 * - 'paywall': Displays Stripe price table with purchase options
	 * - 'login': Displays login form with registration link
	 *
	 * Output is sanitized via wp_kses() with allowed gate HTML tags.
	 *
	 * @since 1.0.0
	 *
	 * @param int    $post_id The post ID being locked.
	 * @param string $content The original post content.
	 * @param array  $rules   Effective rules array from resolve_effective_rules().
	 *
	 * @return string HTML markup with preview + gate.
	 */
	private static function render_locked_content( int $post_id, string $content, array $rules ): string {
		// Build preview (outside the gate) with effective settings
		$preview_html = self::render_preview( $post_id, $content, $rules );

		// Determine which price IDs to use for paywall rendering from products
		$price_ids_for_gate = [];
		if ( ! empty( $rules['product_ids'] ) ) {
			// Get ALL price IDs from products (not just default) to show all options
			$price_ids_for_gate = PostGate_Product_Utils::get_all_price_ids_from_products( $rules['product_ids'] );
		}

		// Route to the appropriate gate based on mode
		$gate_html = ( $rules['mode'] === 'login' )
			? self::render_login_gate( $post_id )
			: self::render_paywall_gate( $post_id, $price_ids_for_gate );

		// Sanitize gate HTML
		$gate_html = wp_kses( $gate_html, PostGate_Template_Renderer::get_allowed_gate_html() );

		return $preview_html . $gate_html;
	}

	/**
	 * Render excerpt with "Continue reading" link for archive pages.
	 *
	 * Generates a user-friendly excerpt for locked posts on archive pages:
	 * 1. Uses manual excerpt if set by author
	 * 2. Auto-generates from content (55 words, stripped of shortcodes)
	 * 3. Appends "Continue reading →" link to full post
	 *
	 * Implementation avoids infinite loops by:
	 * - Using get_post() instead of get_the_excerpt()
	 * - Accessing raw post_content without filters
	 * - Using wp_trim_words() for safe excerpt generation
	 *
	 * Output is wrapped in wpautop() for proper paragraph formatting.
	 *
	 * @since 1.0.0
	 *
	 * @param int $post_id The post ID to generate excerpt for.
	 *
	 * @return string HTML markup with excerpt and continue reading link.
	 */
	private static function render_excerpt_with_link( int $post_id ): string {
		$post = get_post( $post_id );
		if ( ! $post ) {
			// Fallback: just show the continue reading link
			return '<p><a href="' . esc_url( get_permalink( $post_id ) ) . '">'
				. esc_html__( 'Continue reading →', 'postgate' )
				. '</a></p>';
		}

		// Use manual excerpt if set, otherwise generate from content
		if ( ! empty( $post->post_excerpt ) ) {
			$excerpt = $post->post_excerpt;
		} else {
			// Generate excerpt from raw content (no filters applied)
			$excerpt = wp_trim_words( strip_shortcodes( $post->post_content ), 55, '...' );
		}

		// Build excerpt with continue reading link
		if ( ! empty( $excerpt ) ) {
			return wpautop( $excerpt )
				. '<p><a href="' . esc_url( get_permalink( $post_id ) ) . '">'
				. esc_html__( 'Continue reading →', 'postgate' )
				. '</a></p>';
		}

		// Fallback if no excerpt is available
		return '<p><a href="' . esc_url( get_permalink( $post_id ) ) . '">'
			. esc_html__( 'Continue reading →', 'postgate' )
			. '</a></p>';
	}

	/**
	 * Generate and render the content preview (teaser) using effective settings.
	 *
	 * Renders a preview/teaser of the locked content that appears outside (before) the gate.
	 * The preview can be configured in two modes:
	 * - 'paragraph': Shows first N paragraphs (split on blank lines)
	 * - 'words': Shows first N words with ellipsis
	 *
	 * Implementation details:
	 * - Strips all HTML tags to get plain text
	 * - Paragraph mode uses regex to split on blank lines (\n\s*\n+)
	 * - Falls back to word mode if paragraph splitting yields no results
	 * - Final safety net: always shows at least 55 words if all else fails
	 * - Output is wrapped in wpautop() for proper paragraph formatting
	 *
	 * The teaser is rendered outside the gate to give users a preview of locked content
	 * before deciding to purchase or log in.
	 *
	 * @since 1.0.0
	 *
	 * @param int         $post_id The post ID for preview generation.
	 * @param string      $content The original post content (may contain HTML).
	 * @param array|null  $rules   Optional. Effective rules array from resolve_effective_rules().
	 *                             If null, will be resolved automatically.
	 *
	 * @return string HTML markup for the preview/teaser section.
	 */
	private static function render_preview( int $post_id, string $content, ?array $rules = null ): string {
		if ( ! is_array( $rules ) ) {
			$rules = self::resolve_effective_rules( $post_id );
		}

		$mode = $rules['preview_mode'] ?? 'paragraph';
		$limit = (int) ( $rules['preview_limit'] ?? ( $mode === 'paragraph' ? 2 : 55 ) );

		$text = trim( wp_strip_all_tags( (string) $content ) );

		if ( $mode === 'paragraph' ) {
			// Normalize line endings and split on blank lines as paragraph breaks.
			$normalized = preg_replace( "/\r\n|\r/", "\n", $text );
			$parts = preg_split( "/\n\s*\n+/", $normalized ) ?: [];
			$parts = array_values( array_filter( array_map( 'trim', $parts ) ) );
			$preview = implode( "\n\n", array_slice( $parts, 0, max( 1, $limit ) ) );

			// If paragraph split yields nothing (e.g., minified HTML), fall back to words.
			if ( $preview === '' ) {
				$preview = wp_trim_words( $text, 55, '…' );
			}
		} else {
			// Words mode
			$limit = max( 1, $limit );
			$preview = wp_trim_words( $text, $limit, '…' );
		}

		// Final safety net
		if ( trim( $preview ) === '' ) {
			$preview = wp_trim_words( $text, 55, '…' );
		}

		ob_start();
		?>
		<div class="postgate-teaser"><?php echo wp_kses_post( wpautop( $preview ) ); ?></div>
		<?php
		return ob_get_clean();
	}

	/**
	 * Render the login gate using template-based rendering.
	 *
	 * Delegates to PostGate_Template_Renderer for consistent rendering across the plugin.
	 * The login gate displays a message prompting users to log in to access locked content.
	 *
	 * Template Resolution:
	 * - Theme override: /postgate/login.php
	 * - Plugin fallback: POSTGATE_PATH . 'templates/login.php'
	 *
	 * Templates receive:
	 * - $post_id: The locked post ID
	 * - $login_url: WordPress login URL with redirect back to current post
	 * - $register_url: WordPress registration URL (if registration is enabled)
	 * - $post_title: Title of the locked post for context
	 *
	 * @since 1.0.0
	 *
	 * @param int $post_id The post ID being locked.
	 *
	 * @return string HTML markup for the login gate.
	 */
	private static function render_login_gate( int $post_id ): string {
		return PostGate_Template_Renderer::render_login_gate( $post_id );
	}

	/**
	 * Render the paywall gate using template-based rendering.
	 *
	 * Delegates to PostGate_Template_Renderer for consistent rendering across the plugin.
	 * The template layout is determined by the 'postgate_paywall_layout' option
	 * (default: 'classic', alternatives: 'compact').
	 *
	 * Template Resolution:
	 * - Theme override: /postgate/paywall-{layout}.php
	 * - Plugin fallback: POSTGATE_PATH . 'templates/paywall-{layout}.php'
	 *
	 * Templates receive:
	 * - $post_id: The locked post ID
	 * - $price_ids: Array of applicable Stripe price IDs
	 * - $price_options: Array of formatted price rows with name, amount, interval
	 * - $default_price_id: Pre-selected default price ID
	 * - $checkout_endpoint: REST endpoint for one-time purchase checkout
	 * - $subscribe_endpoint: REST endpoint for subscription checkout
	 *
	 * @since 1.0.0
	 *
	 * @param int      $post_id   The post ID being locked.
	 * @param string[] $price_ids Array of Stripe price IDs to offer.
	 *
	 * @return string HTML markup for the paywall gate.
	 */
	private static function render_paywall_gate( int $post_id, array $price_ids ): string {
		$layout = sanitize_key( get_option( 'postgate_paywall_layout', 'classic' ) ) ?: 'classic';
		return PostGate_Template_Renderer::render_paywall_gate( $post_id, $price_ids, $layout );
	}

	/**
	 * Check if a query contains any locked posts.
	 *
	 * Used by the assets loader to determine if frontend assets should be loaded
	 * on archive pages when archive_display_mode is set to 'archive_lock_indicator'.
	 *
	 * Performance Optimizations:
	 * - Batch loads post meta to avoid N+1 queries
	 * - Caches global settings (checked once, not per-post)
	 * - Early exits for site-wide locks
	 * - Batch loads term meta when needed
	 * - Defers expensive operations (has_block) until necessary
	 *
	 * Checks if any posts are locked by:
	 * 1. Explicit lock override set to 'lock'
	 * 2. Inheriting lock from term or global settings
	 * 3. Containing PostGate lock blocks (checked last as it's most expensive)
	 *
	 * @since 1.0.0
	 *
	 * @param WP_Query|null $query Optional. The query to check. If null, uses global $wp_query.
	 *
	 * @return bool True if at least one locked post is found, false otherwise.
	 */
	public static function has_locked_posts_in_query( ?WP_Query $query = null ): bool {
		if ( null === $query ) {
			global $wp_query;
			$query = $wp_query;
		}

		if ( ! isset( $query->posts ) || empty( $query->posts ) ) {
			return false;
		}

		// Early exit: if entire site is locked, at least one post will be locked
		$lock_entire_site = get_option( 'postgate_lock_entire_site', false );
		if ( $lock_entire_site ) {
			return true;
		}

		// Cache global settings (read once, not per-post)
		$enabled_post_types = (array) get_option( 'postgate_enabled_post_types', [] );

		// Batch load post meta to avoid N+1 queries
		$post_ids = wp_list_pluck( $query->posts, 'ID' );
		update_postmeta_cache( $post_ids );

		// First pass: check explicit locks and post type inheritance
		$posts_needing_term_check = [];
		$post_types_by_id = [];

		foreach ( $query->posts as $post ) {
			if ( ! isset( $post->ID ) ) {
				continue;
			}

			// Check explicit lock override (now cached)
			$lock_override = get_post_meta( $post->ID, '_postgate_lock_override', true );
			if ( 'lock' === $lock_override ) {
				return true; // Found locked post, early exit
			}

			// If explicitly unlocked, skip inheritance checks
			if ( 'unlock' === $lock_override ) {
				continue;
			}

			// Check if post type is in enabled post types (inheritance)
			$post_type = get_post_type( $post->ID );
			$post_types_by_id[ $post->ID ] = $post_type;

			if ( in_array( $post_type, $enabled_post_types, true ) ) {
				return true; // Found locked post via post type, early exit
			}

			// Need to check terms for this post
			$posts_needing_term_check[] = $post->ID;
		}

		// Second pass: check term-level locks (only for posts that need it)
		if ( ! empty( $posts_needing_term_check ) ) {
			// Collect all term IDs to batch load term meta
			$all_term_ids = [];

			foreach ( $posts_needing_term_check as $post_id ) {
				$post_type = $post_types_by_id[ $post_id ];
				$taxonomies = get_object_taxonomies( $post_type );

				foreach ( $taxonomies as $taxonomy ) {
					$terms = get_the_terms( $post_id, $taxonomy );
					if ( is_array( $terms ) && ! empty( $terms ) ) {
						$all_term_ids = array_merge( $all_term_ids, wp_list_pluck( $terms, 'term_id' ) );
					}
				}
			}

			// Batch load term meta if we have terms to check
			if ( ! empty( $all_term_ids ) ) {
				$all_term_ids = array_unique( $all_term_ids );
				update_termmeta_cache( $all_term_ids );

				// Now check term locks (meta is cached)
				foreach ( $posts_needing_term_check as $post_id ) {
					$post_type = $post_types_by_id[ $post_id ];
					$taxonomies = get_object_taxonomies( $post_type );

					foreach ( $taxonomies as $taxonomy ) {
						$terms = get_the_terms( $post_id, $taxonomy );
						if ( is_array( $terms ) && ! empty( $terms ) ) {
							foreach ( $terms as $term ) {
								$term_lock = get_term_meta( $term->term_id, '_postgate_lock_override', true );
								if ( 'lock' === $term_lock ) {
									return true; // Found locked post via term, early exit
								}
							}
						}
					}
				}
			}
		}

		// Third pass: check for lock blocks (most expensive, done last)
		// Only check if no other lock mechanism found
		foreach ( $query->posts as $post ) {
			if ( ! isset( $post->ID ) ) {
				continue;
			}

			// Skip if explicitly unlocked
			$lock_override = get_post_meta( $post->ID, '_postgate_lock_override', true );
			if ( 'unlock' === $lock_override ) {
				continue;
			}

			// Check for lock blocks (expensive operation)
			if ( has_block( 'postgate/lock-block', $post ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Check if a post inherits lock from term or global settings.
	 *
	 * Determines if a post is locked through inheritance from:
	 * 1. Global site-wide lock setting (postgate_lock_entire_site)
	 * 2. Post type enabled in settings (postgate_enabled_post_types)
	 * 3. Term-level lock settings (categories, tags, custom taxonomies)
	 *
	 * This method does NOT check the post's own lock override setting,
	 * as that should be checked separately by the caller.
	 *
	 * Performance Note:
	 * This method is optimized for single-post checks. For batch operations
	 * (checking multiple posts), use the optimized logic in has_locked_posts_in_query()
	 * which caches global settings and batch-loads meta data.
	 *
	 * @since 1.0.0
	 *
	 * @param int $post_id The post ID to check for inherited locks.
	 *
	 * @return bool True if post inherits a lock, false otherwise.
	 */
	public static function post_inherits_lock( int $post_id ): bool {
		// Check if entire site is locked
		$lock_entire_site = get_option( 'postgate_lock_entire_site', false );
		if ( $lock_entire_site ) {
			return true;
		}

		// Check if post type is in enabled post types
		$post_type = get_post_type( $post_id );
		$enabled_post_types = get_option( 'postgate_enabled_post_types', [] );
		if ( is_array( $enabled_post_types ) && in_array( $post_type, $enabled_post_types, true ) ) {
			return true;
		}

		// Check term-level locks (categories, tags, custom taxonomies)
		$taxonomies = get_object_taxonomies( $post_type );
		foreach ( $taxonomies as $taxonomy ) {
			$terms = get_the_terms( $post_id, $taxonomy );
			if ( is_array( $terms ) && ! empty( $terms ) ) {
				foreach ( $terms as $term ) {
					$term_lock = get_term_meta( $term->term_id, '_postgate_lock_override', true );
					if ( 'lock' === $term_lock ) {
						return true;
					}
				}
			}
		}

		return false;
	}
}