<?php
/**
 * Code Snippet Module
 *
 * Manages custom code snippets (HTML, CSS, JavaScript, PHP) with auto-insert
 * in header, body, or footer, or shortcode placement.
 *
 * Hooks (extend from themes/plugins):
 * - `wpps_code_snippet_the_content_priority` (int) — priority for content location injection (default 12, after block rendering).
 * - `wpps_code_snippet_output` (string $code, string $type, \WP_Post $post) — filter stored code before execute/output.
 * - `wpps_code_snippet_php_before_eval` (string $code, ?\WP_Post $post, string $context) — $context: merged_run_everywhere|merged_frontend_only|single (merged strings call this once per snippet before concat).
 * - `wpps_code_snippet_merged_php_code` (string $merged, string $context, array $line_map) — $context matches merge batch; $line_map is snippet_id => [ start, end ] line numbers in merged string.
 * - `wpps_code_snippet_merge_frontend_only_php` (bool) — after the Settings value; filter to force merged (true) or separate (false) Frontend Only PHP.
 * - `wpps_code_snippet_after_php_eval` (string $output, \WP_Post $post, string $context) — after PHP capture; $context: single|php_file_include.
 * - `wpps_code_snippet_merged_php_output` (string $buffer, \WP_Post[] $posts, string $context) — after merged Frontend Only eval output; $context is `merged_frontend_only`.
 * - `wpps_code_snippet_error_log_max_entries` (int $max) — cap stored log rows (default 50, clamped 1–500).
 * - `wpps_code_snippet_shutdown_fatal_types` (int[] $e_* constants) — which error types trigger shutdown auto-deactivate (default includes E_USER_ERROR).
 * - `wpps_code_snippet_auto_deactivate_admin_notice` (bool, \WP_Post $post, string $message) — show dashboard notice when a snippet is drafted due to PHP error.
 * - `wpps_snippet_loaded` ( \WP_Post $post, string $location ) — after snippet runs.
 * - `wpps_code_snippet_disallowed_php` (string[]) — extra regex fragments to block in PHP.
 * - `wpps_code_snippet_php_auto_deactivate_locations` (string[]) — locations where PHP errors draft the snippet (default includes frontend_conditional_logic).
 * - `wpps_code_snippet_force_runtime_test_with_hooks`, `wpps_code_snippet_runtime_test_fire_admin_notices` — save-time test toggles.
 * - `wpps_code_snippet_php_binary` (string|null $path) — return absolute path to PHP CLI for `php -l` save-time syntax check (Plesk/FPM often has no `php` in web PATH).
 * - `wpps_code_snippet_skip_php_syntax_check` (bool) — if true, skip CLI syntax check on save (hosts with exec disabled or no CLI).
 *
 * @package CodeSnippet
 * @since 1.0.0
 */

namespace CodeSnippet;

use WP_PowerSuite\Core\Contracts\DataLocationsInterface;
use WP_PowerSuite\Core\AbstractModule;
require_once __DIR__ . '/snippet-php-eval.php';

/**
 * Plugin brand name for snippet markers (respects white label).
 */
function wpps_cs_brand_name(): string {
	return function_exists( 'wp_powersuite_get_plugin_name' ) ? wp_powersuite_get_plugin_name() : 'WP PowerSuite';
}

class Module extends AbstractModule implements DataLocationsInterface {

	private const MODULE_ID = 'code-snippet';

	/**
	 * Flat location labels for list display (admin table).
	 *
	 * @return array<string, string>
	 */
	public static function getLocations(): array {
		$flat = [];
		foreach ( self::getLocationsForUI() as $cat ) {
			foreach ( $cat['sections'] as $section ) {
				foreach ( $section['locations'] as $loc ) {
					$flat[ $loc['value'] ] = $loc['label'];
				}
			}
		}
		$flat['shortcode'] = __( 'Shortcode only', 'wp-powersuite' );
		return $flat;
	}

	/**
	 * Locations that require PHP code type only.
	 *
	 * @return string[]
	 */
	public static function getPhpOnlyLocations(): array {
		return [ 'admin_only', 'run_everywhere', 'frontend_only', 'frontend_conditional_logic' ];
	}

	/**
	 * Locations that require WooCommerce.
	 *
	 * @return string[]
	 */
	public static function getWooLocations(): array {
		return [
			'woo_before_shop_loop',
			'woo_after_shop_loop',
			'woo_before_single_product',
			'woo_after_single_product',
			'woo_before_add_to_cart',
			'woo_after_add_to_cart',
			'woo_before_checkout',
			'woo_after_checkout',
			'woo_before_cart',
			'woo_after_cart',
		];
	}

	/**
	 * Flat list of all location slugs for validation.
	 *
	 * @return string[]
	 */
	public static function getAllLocationSlugs(): array {
		$slugs = [ 'shortcode' ];
		foreach ( self::getLocationsForUI() as $cat ) {
			foreach ( $cat['sections'] as $section ) {
				foreach ( $section['locations'] as $loc ) {
					$slugs[] = $loc['value'];
				}
			}
		}
		return $slugs;
	}

	/**
	 * Get the shortcode string for a snippet.
	 *
	 * @param int    $snippet_id      Snippet post ID.
	 * @param string $custom_shortcode Optional custom shortcode slug.
	 * @return string Shortcode string e.g. [wpps_code id="123"] or [my_snippet].
	 */
	public static function getShortcodeForSnippet( int $snippet_id, string $custom_shortcode = '' ): string {
		$custom = preg_replace( '/[^a-z0-9_]/', '', strtolower( $custom_shortcode ) );
		if ( $custom !== '' ) {
			return '[' . $custom . ']';
		}
		return '[wpps_code id="' . $snippet_id . '"]';
	}

	/**
	 * Locations for UI panel: categories, labels, descriptions.
	 * Structure: [ 'global' => [ 'sections' => [ [ 'heading' => '...', 'locations' => [ [ 'value' => '...', 'label' => '...', 'desc' => '...' ] ] ] ] ], ... ]
	 *
	 * @return array<string, array{sections: array}>
	 */
	public static function getLocationsForUI(): array {
		return [
			'global' => [
				'label'   => __( 'Global', 'wp-powersuite' ),
				'sections' => [
					[
						'heading'  => __( 'Site Wide (Frontend)', 'wp-powersuite' ),
						'locations' => [
							[
								'value' => 'header',
								'label' => __( 'Site Wide Header', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet between the head tags of your website on all pages.', 'wp-powersuite' ),
							],
							[
								'value' => 'body',
								'label' => __( 'Site Wide Body', 'wp-powersuite' ),
								'desc'  => __( 'Insert the snippet just after the opening body tag.', 'wp-powersuite' ),
							],
							[
								'value' => 'footer',
								'label' => __( 'Site Wide Footer', 'wp-powersuite' ),
								'desc'  => __( 'Insert the snippet in the footer just before the closing body tag.', 'wp-powersuite' ),
							],
						],
					],
					[
						'heading'  => __( 'Admin Area', 'wp-powersuite' ),
						'locations' => [
							[
								'value' => 'admin_header',
								'label' => __( 'Admin header', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet in the wp-admin header area.', 'wp-powersuite' ),
							],
							[
								'value' => 'admin_footer',
								'label' => __( 'Admin footer', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet in the wp-admin footer.', 'wp-powersuite' ),
							],
						],
					],
					[
						'heading'  => __( 'CSS Selector', 'wp-powersuite' ),
						'css_selector' => true,
						'locations' => [
							[
								'value' => 'before_element',
								'label' => __( 'Before HTML Element', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet before a specific HTML element using CSS selector.', 'wp-powersuite' ),
							],
							[
								'value' => 'after_element',
								'label' => __( 'After HTML Element', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet after a specific HTML element using CSS selector.', 'wp-powersuite' ),
							],
							[
								'value' => 'start_element',
								'label' => __( 'At Start of HTML Element', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet at the start (prepend) inside the targeted element.', 'wp-powersuite' ),
							],
							[
								'value' => 'end_element',
								'label' => __( 'At End of HTML Element', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet at the end (append) inside the targeted element.', 'wp-powersuite' ),
							],
						],
					],
					[
						'heading'  => __( 'PHP Snippets Only', 'wp-powersuite' ),
						'php_only' => true,
						'locations' => [
							[
								'value' => 'admin_only',
								'label' => __( 'Admin Only (PHP)', 'wp-powersuite' ),
								'desc'  => __( 'Execute PHP snippet on admin_init. Use for admin-only hooks.', 'wp-powersuite' ),
							],
							[
								'value' => 'run_everywhere',
								'label' => __( 'Run Everywhere', 'wp-powersuite' ),
								'desc'  => __( 'Execute PHP snippet on plugins_loaded. Use for init hooks, register post types, etc.', 'wp-powersuite' ),
							],
							[
								'value' => 'frontend_only',
								'label' => __( 'Frontend Only', 'wp-powersuite' ),
								'desc'  => __( 'Execute PHP snippet on plugins_loaded, but only on the frontend.', 'wp-powersuite' ),
							],
							[
								'value' => 'frontend_conditional_logic',
								'label' => __( 'Frontend Conditional Logic', 'wp-powersuite' ),
								'desc'  => __( 'Execute PHP snippet on wp hook (after page data loads). Use with conditional logic rules.', 'wp-powersuite' ),
							],
						],
					],
				],
			],
			'page_specific' => [
				'label'   => __( 'Page-Specific', 'wp-powersuite' ),
				'sections' => [
					[
						'heading'  => __( 'Single Post / Page Content', 'wp-powersuite' ),
						'locations' => [
							[
								'value' => 'before_content',
								'label' => __( 'Insert Before Content', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet before the main content of posts and pages.', 'wp-powersuite' ),
							],
							[
								'value' => 'after_content',
								'label' => __( 'Insert After Content', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet after the main content of posts and pages.', 'wp-powersuite' ),
							],
							[
								'value' => 'content_25',
								'label' => __( '25% through content', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet at 25% through the post content.', 'wp-powersuite' ),
							],
							[
								'value' => 'content_33',
								'label' => __( '33% through content', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet at 33% through the post content.', 'wp-powersuite' ),
							],
							[
								'value' => 'content_50',
								'label' => __( '50% through content', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet at 50% through the post content.', 'wp-powersuite' ),
							],
							[
								'value' => 'content_66',
								'label' => __( '66% through content', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet at 66% through the post content.', 'wp-powersuite' ),
							],
							[
								'value' => 'content_75',
								'label' => __( '75% through content', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet at 75% through the post content.', 'wp-powersuite' ),
							],
							[
								'value' => 'content_80',
								'label' => __( '80% through content', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet at 80% through the post content.', 'wp-powersuite' ),
							],
						],
					],
					[
						'heading'  => __( 'Paragraph', 'wp-powersuite' ),
						'locations' => [
							[
								'value' => 'before_paragraph',
								'label' => __( 'Insert Before Paragraph', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet before a specific paragraph number.', 'wp-powersuite' ),
							],
							[
								'value' => 'after_paragraph',
								'label' => __( 'Insert After Paragraph', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet after a specific paragraph number.', 'wp-powersuite' ),
							],
						],
					],
					[
						'heading'  => __( 'Archives / Categories / Tags', 'wp-powersuite' ),
						'locations' => [
							[
								'value' => 'before_excerpt',
								'label' => __( 'Insert Before Excerpt', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet before the excerpt on archive pages.', 'wp-powersuite' ),
							],
							[
								'value' => 'after_excerpt',
								'label' => __( 'Insert After Excerpt', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet after the excerpt on archive pages.', 'wp-powersuite' ),
							],
							[
								'value' => 'between_posts',
								'label' => __( 'Between Posts', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet between each post in archive loops.', 'wp-powersuite' ),
							],
							[
								'value' => 'before_post',
								'label' => __( 'Before Post', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet before a specific post number in archive loops.', 'wp-powersuite' ),
							],
							[
								'value' => 'after_post',
								'label' => __( 'After Post', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet after a specific post number in archive loops.', 'wp-powersuite' ),
							],
						],
					],
				],
			],
			'ecommerce' => [
				'label'   => __( 'eCommerce', 'wp-powersuite' ),
				'sections' => [
					[
						'heading'  => __( 'WooCommerce', 'wp-powersuite' ),
						'locations' => [
							[
								'value' => 'woo_before_shop_loop',
								'label' => __( 'Before Shop Loop', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet before the product loop on shop/archive pages.', 'wp-powersuite' ),
							],
							[
								'value' => 'woo_after_shop_loop',
								'label' => __( 'After Shop Loop', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet after the product loop on shop/archive pages.', 'wp-powersuite' ),
							],
							[
								'value' => 'woo_before_single_product',
								'label' => __( 'Before Single Product', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet before the single product content.', 'wp-powersuite' ),
							],
							[
								'value' => 'woo_after_single_product',
								'label' => __( 'After Single Product', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet after the single product content.', 'wp-powersuite' ),
							],
							[
								'value' => 'woo_before_add_to_cart',
								'label' => __( 'Before Add to Cart', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet before the add to cart button.', 'wp-powersuite' ),
							],
							[
								'value' => 'woo_after_add_to_cart',
								'label' => __( 'After Add to Cart', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet after the add to cart button.', 'wp-powersuite' ),
							],
							[
								'value' => 'woo_before_checkout',
								'label' => __( 'Before Checkout', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet before the checkout form.', 'wp-powersuite' ),
							],
							[
								'value' => 'woo_after_checkout',
								'label' => __( 'After Checkout', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet after the checkout form.', 'wp-powersuite' ),
							],
							[
								'value' => 'woo_before_cart',
								'label' => __( 'Before Cart', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet before the cart contents.', 'wp-powersuite' ),
							],
							[
								'value' => 'woo_after_cart',
								'label' => __( 'After Cart', 'wp-powersuite' ),
								'desc'  => __( 'Insert snippet after the cart contents.', 'wp-powersuite' ),
							],
						],
					],
				],
			],
		];
	}
	private const MODULE_VERSION = '1.0.0';
	private const POST_TYPE = 'wpps_code_snippet';
	private const CACHE_OPTION = 'wpps_code_snippet_cache';
	private const SHORTCODE_CACHE_OPTION = 'wpps_code_snippet_shortcode_map';
	private const CACHE_GROUP = 'wpps_snippets';
	private const SAFE_MODE_PARAM = 'wpps-cs-safe-mode';
	private const ERROR_LOG_OPTION = 'wpps_code_snippet_errors';
	private const SETTINGS_OPTION = 'wpps_code_snippet_settings';
	private const LAST_PHP_ERROR_META = '_wpps_last_php_error';
	private const AUTO_DEACTIVATE_NOTICE_OPTION = 'wpps_cs_auto_deactivate_notice';
	/** Run after do_blocks/wpautop so percentage/paragraph insertion uses rendered HTML. */
	private const CONTENT_FILTER_PRIORITY = 12;

/** @var \WP_Post|null Last PHP snippet executed (for fatal error handling). */
	private ?\WP_Post $last_executed_php_snippet = null;

	/** @var array<int, true> Post IDs that already had the_content locations injected this request. */
	private array $content_locations_injected = [];

	/** @var array<int, true> Post IDs that already had excerpt/archive snippet locations injected. */
	private array $excerpt_locations_injected = [];

	/** @var array<int, true> Post IDs that already received after_post output (avoids excerpt + content double). */
	private array $after_post_injected = [];

	/**
	 * Map snippet post ID => [ 'start' => 1-based line in merged eval, 'end' => line ] (inclusive).
	 * Set only during merged PHP eval; used for fatals and accurate deactivation.
	 *
	 * @var array<int, array{start:int,end:int}>
	 */
	private array $merged_php_line_map = [];

	/** @var string[] Stack of output locations (for admin bar tracking). */
	private array $output_location_stack = [];

	/** @var bool True if body snippets were output via wp_body_open (skip buffer injection). */
	private bool $body_snippets_output_via_hook = false;

	/** @var array<int, bool> Snippet IDs whose post meta has already been primed this request. */
	private array $meta_primed = [];

	public function getId(): string {
		return self::MODULE_ID;
	}

	public function getName(): string {
		return __( 'Code Snippet', 'wp-powersuite' );
	}

	public function getVersion(): string {
		return self::MODULE_VERSION;
	}

	public function getDescription(): string {
		return __( 'Managed snippets for head, body, footer, or shortcodes—HTML, CSS, JS, and PHP.', 'wp-powersuite' );
	}

	public function getContext(): string|array {
		return [ 'admin', 'frontend', 'ajax' ];
	}

	public function getDependencies(): array {
		return [];
	}


	protected function registerHooks(): void {
		require_once __DIR__ . '/safe-mode.php';
		require_once __DIR__ . '/class-wpps-revisions.php';
		require_once __DIR__ . '/class-wpps-revisions-display.php';
		require_once __DIR__ . '/class-snippet-threat-scanner.php';



		// Register snippet post type while this module is enabled.
		add_action( 'init', [ $this, 'registerPostType' ], 5 );

		// Fatal error handling: catch errors that bypass try/catch.
		register_shutdown_function( [ $this, 'maybeDisableSnippetFromShutdown' ] );

		// Snippet list cache: clear on save or any _wpps_* meta change (even if module disabled).
		add_action( 'save_post_' . self::POST_TYPE, [ $this, 'clearCache' ], 10, 3 );
		add_action( 'updated_post_meta', [ $this, 'maybeClearSnippetCacheFromMeta' ], 10, 4 );
		add_action( 'added_post_meta', [ $this, 'maybeClearSnippetCacheFromMeta' ], 10, 4 );
		add_action( 'deleted_post_meta', [ $this, 'maybeClearSnippetCacheFromDeletedMeta' ], 10, 4 );
		add_action( 'wp_trash_post', [ $this, 'clearCacheIfSnippetPost' ], 10, 1 );
		add_action( 'untrash_post', [ $this, 'clearCacheIfSnippetPost' ], 10, 1 );
		add_action( 'before_delete_post', [ $this, 'clearCacheIfSnippetPost' ], 10, 1 );

		if ( is_admin() ) {
		add_action( 'admin_init', [ $this, 'maybeDismissAutoDeactivateNotice' ], 1 );
		add_action( 'admin_notices', [ $this, 'renderAutoDeactivateAdminNotice' ], 5 );
		}
		// Run Everywhere / Admin Only / Frontend Only (plugins_loaded — PHP-only locations).
		// When boot tier defers init past plugins_loaded (e.g. admin_menu), run immediately.
		if ( did_action( 'plugins_loaded' ) ) {
			$this->runPhpEverywhereSnippets();
		} else {
			add_action( 'plugins_loaded', [ $this, 'runPhpEverywhereSnippets' ], 10 );
		}

		// Frontend Conditional Logic (wp hook - after page data, for conditional logic)
		add_action( 'wp', [ $this, 'outputFrontendConditionalLogicSnippets' ], 1 );

		// Admin header/footer: one callback per hook at priority 9.
		add_action( 'admin_head', [ $this, 'outputAdminHeaderSnippets' ], 9 );
		add_action( 'admin_footer', [ $this, 'outputAdminFooterSnippets' ], 9 );

		// WooCommerce (when active)
		if ( class_exists( 'WooCommerce' ) ) {
		add_action( 'woocommerce_before_shop_loop', [ $this, 'outputWooBeforeShopLoopSnippets' ], 10 );
		add_action( 'woocommerce_after_shop_loop', [ $this, 'outputWooAfterShopLoopSnippets' ], 10 );
		add_action( 'woocommerce_before_single_product', [ $this, 'outputWooBeforeSingleProductSnippets' ], 10 );
		add_action( 'woocommerce_after_single_product', [ $this, 'outputWooAfterSingleProductSnippets' ], 10 );
		add_action( 'woocommerce_before_add_to_cart_button', [ $this, 'outputWooBeforeAddToCartSnippets' ], 10 );
		add_action( 'woocommerce_after_add_to_cart_button', [ $this, 'outputWooAfterAddToCartSnippets' ], 10 );
		add_action( 'woocommerce_before_checkout_form', [ $this, 'outputWooBeforeCheckoutSnippets' ], 10 );
		add_action( 'woocommerce_after_checkout_form', [ $this, 'outputWooAfterCheckoutSnippets' ], 10 );
		add_action( 'woocommerce_before_cart', [ $this, 'outputWooBeforeCartSnippets' ], 10 );
		add_action( 'woocommerce_after_cart', [ $this, 'outputWooAfterCartSnippets' ], 10 );
		}

		// Frontend: site-wide, content, archive, shortcode
		if ( ! is_admin() && ! ( defined( 'REST_REQUEST' ) && REST_REQUEST ) && ! ( defined( 'DOING_AJAX' ) && DOING_AJAX ) && ! ( defined( 'DOING_CRON' ) && DOING_CRON ) ) {
		add_action( 'wp_enqueue_scripts', [ $this, 'enqueueFileBasedSnippets' ], 5 );
		// Site-wide header/body/footer: one callback per hook at priority 10. All snippets for that
		// location run inside it in _wpps_priority order so PHP snippets can register theme hooks predictably.
		add_action( 'wp_head', [ $this, 'outputHeaderSnippets' ], 10 );
		add_action( 'wp_body_open', [ $this, 'outputBodySnippetsViaHook' ], 10 );
		add_action( 'wp_footer', [ $this, 'outputFooterSnippets' ], 10 );
		add_action( 'wp_footer', [ $this, 'outputElementSelectorSnippets' ], 99998 );
		add_filter(
			'the_content',
			[ $this, 'filterTheContent' ],
			(int) apply_filters( 'wpps_code_snippet_the_content_priority', self::CONTENT_FILTER_PRIORITY )
		);
		add_filter( 'get_the_excerpt', [ $this, 'filterTheExcerpt' ], 10, 2 );
		add_action( 'loop_start', [ $this, 'onLoopStart' ], 10, 1 );
		add_action( 'the_post', [ $this, 'onThePost' ], 10, 2 );
		add_shortcode( 'wpps_code', [ $this, 'shortcodeHandler' ] );
		add_action( 'init', [ $this, 'registerCustomShortcodes' ], 20 );
		add_action( 'template_redirect', [ $this, 'maybeStartElementBuffer' ], 1 );
		}

		// Admin: menu and AJAX
		if ( is_admin() ) {
		new Wpps_Revisions();
		add_action( 'admin_menu', [ $this, 'addAdminMenu' ], 20 );
		add_action( 'admin_enqueue_scripts', [ $this, 'enqueueCodeSnippetAssets' ] );
		add_action( 'wp_ajax_wpps_cs_save_snippet', [ $this, 'ajaxSaveSnippet' ] );
		add_action( 'wp_ajax_wpps_cs_delete_snippet', [ $this, 'ajaxDeleteSnippet' ] );
		add_action( 'wp_ajax_wpps_cs_toggle_snippet', [ $this, 'ajaxToggleSnippet' ] );
		add_action( 'wp_ajax_wpps_cs_get_snippet', [ $this, 'ajaxGetSnippet' ] );
		add_action( 'wp_ajax_wpps_cs_load_revisions_compare', [ $this, 'ajaxLoadRevisionsCompare' ] );
		add_action( 'wp_ajax_wpps_cs_get_revisions_list', [ $this, 'ajaxGetRevisionsList' ] );
		add_action( 'wp_ajax_wpps_cs_restore_revision', [ $this, 'ajaxRestoreRevision' ] );
		}

		// Admin bar info (when enabled) - wrapped to avoid module auto-disable on admin bar errors
		try {
		require_once __DIR__ . '/admin-bar.php';
		new \Wpps_Cs_Admin_Bar( $this );
		} catch ( \Throwable $e ) {
		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
		error_log(
		sprintf(
		wpps_cs_brand_name() . ' Code Snippet: Admin bar init failed: %s in %s:%d',
		$e->getMessage(),
		$e->getFile(),
		$e->getLine()
		)
		);
		}
		if ( is_admin() && current_user_can( 'manage_options' ) ) {
		add_action( 'admin_notices', function () use ( $e ) {
		echo '<div class="notice notice-warning"><p>';
		printf(
		/* translators: %s: error message */
		esc_html__( 'Code Snippet admin bar could not load: %s', 'wp-powersuite' ),
		esc_html( $e->getMessage() )
		);
		echo '</p></div>';
		} );
		}
		}
	}


	/**
	 * Get current output location (for admin bar tracking).
	 *
	 * @return string
	 */
	public function getCurrentOutputLocation(): string {
		if ( empty( $this->output_location_stack ) ) {
			return '';
		}
		return (string) end( $this->output_location_stack );
	}

	public function registerPostType(): void {
		register_post_type(
			self::POST_TYPE,
			[
				'labels'       => [
					'name'          => __( 'Code Snippets', 'wp-powersuite' ),
					'singular_name' => __( 'Code Snippet', 'wp-powersuite' ),
				],
				'public'       => false,
				'show_ui'      => false,
				'can_export'   => true,
				'supports'     => [ 'title', 'editor' ],
				'rewrite'      => false,
				'capability_type' => 'post',
			]
		);
	}

	/**
	 * Check if Safe Mode is active (disables all snippets).
	 *
	 * @return bool
	 */
	public static function isSafeMode(): bool {
		if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) {
			return false;
		}
		return isset( $_GET[ self::SAFE_MODE_PARAM ] ) && sanitize_key( wp_unslash( $_GET[ self::SAFE_MODE_PARAM ] ) ) === '1';
	}

	/**
	 * Whether a valid PowerSuite PRO license is active.
	 */
	public static function isProLicensed(): bool {
		return function_exists( 'wp_powersuite_is_license_valid' ) && wp_powersuite_is_license_valid();
	}

	/**
	 * Code types that require PRO.
	 *
	 * @param string $type html|css|js|php
	 */
	public static function isProCodeType( string $type ): bool {
		return in_array( $type, [ 'js', 'php' ], true );
	}

	/**
	 * Whether a snippet uses PRO-only settings (device targeting or conditional logic).
	 */
	public function snippetUsesProFeatures( \WP_Post $post ): bool {
		if ( self::isProLicensed() ) {
			return false;
		}
		$device = get_post_meta( $post->ID, '_wpps_device_type', true ) ?: 'all';
		if ( $device !== 'all' ) {
			return true;
		}
		return get_post_meta( $post->ID, '_wpps_conditional_enabled', true ) === '1';
	}

	/**
	 * Block activation/execution when PRO is required but missing.
	 */
	public function getProLicenseBlockReason( \WP_Post $post ): ?string {
		if ( self::isProLicensed() ) {
			return null;
		}
		$type = get_post_meta( $post->ID, '_wpps_code_type', true ) ?: 'html';
		if ( self::isProCodeType( $type ) ) {
			return __( 'JavaScript and PHP code types require a PRO license. Please upgrade to use this feature.', 'wp-powersuite' );
		}
		if ( $this->snippetUsesProFeatures( $post ) ) {
			return __( 'Device targeting and conditional logic require a PRO license. Please upgrade to use this feature.', 'wp-powersuite' );
		}
		return null;
	}

	/**
	 * @return bool True when the snippet may run on the current request.
	 */
	private function passesProLicense( \WP_Post $post ): bool {
		return $this->getProLicenseBlockReason( $post ) === null;
	}

	/**
	 * Get active snippets for a location, ordered by priority.
	 *
	 * @param string $location One of: header, body, footer.
	 * @return array Array of WP_Post objects.
	 */
	private function getSnippetsForLocation( string $location ): array {
		if ( ! apply_filters( 'wpps_do_auto_insert', true ) ) {
			return [];
		}
		$cache = get_transient( self::CACHE_OPTION );
		if ( $cache !== false && isset( $cache[ $location ] ) ) {
			$this->primeSnippetMeta( $cache[ $location ] );
			return $cache[ $location ];
		}

		$query = new \WP_Query(
			[
				'post_type'      => self::POST_TYPE,
				'post_status'    => 'publish',
				'posts_per_page' => -1,
				'meta_query'     => [
					'relation' => 'AND',
					[
						'key'   => '_wpps_auto_insert',
						'value' => '1',
					],
					[
						'key'   => '_wpps_location',
						'value' => $location,
					],
				],
				'meta_key'       => '_wpps_priority',
				'orderby'        => [
					'meta_value_num' => 'ASC',
					'ID'             => 'ASC',
				],
			]
		);

		$snippets = $query->posts;
		// WP_Query already primed meta for these; record so we don't re-prime on later cache hits.
		foreach ( $snippets as $p ) {
			if ( $p instanceof \WP_Post ) {
				$this->meta_primed[ $p->ID ] = true;
			}
		}
		$this->updateCache( $location, $snippets );
		return $snippets;
	}

	/**
	 * Prime the post-meta cache for snippet objects pulled from the transient.
	 *
	 * WP_Post objects restored from a transient do not carry a warm meta cache, so the
	 * first get_post_meta() on each would hit the DB. Priming once per request collapses
	 * those N lookups into a single query.
	 *
	 * @param array $snippets Array of WP_Post objects.
	 */
	private function primeSnippetMeta( array $snippets ): void {
		$ids = [];
		foreach ( $snippets as $p ) {
			if ( $p instanceof \WP_Post && empty( $this->meta_primed[ $p->ID ] ) ) {
				$ids[]                       = $p->ID;
				$this->meta_primed[ $p->ID ] = true;
			}
		}
		if ( $ids !== [] ) {
			update_meta_cache( 'post', $ids );
		}
	}

	private function updateCache( string $location, array $snippets ): void {
		$cache = get_transient( self::CACHE_OPTION );
		if ( ! is_array( $cache ) ) {
			$cache = [];
		}
		$cache[ $location ] = $snippets;
		set_transient( self::CACHE_OPTION, $cache, HOUR_IN_SECONDS );
	}

	public function clearCache( int $post_id, \WP_Post $post, bool $update ): void {
		if ( $post->post_type !== self::POST_TYPE ) {
			return;
		}
		$this->bustSnippetCaches();
	}

	/**
	 * Clear location and shortcode lookup transients.
	 */
	private function bustSnippetCaches(): void {
		delete_transient( self::CACHE_OPTION );
		delete_transient( self::SHORTCODE_CACHE_OPTION );
	}

	/**
	 * Invalidate location cache when snippet meta changes (e.g. quick edit, imports, REST).
	 *
	 * @param int    $meta_id    Meta row ID.
	 * @param int    $object_id  Post ID.
	 * @param string $meta_key   Meta key.
	 * @param mixed  $_meta_value Stored value (unused).
	 */
	public function maybeClearSnippetCacheFromMeta( int $meta_id, int $object_id, string $meta_key, $_meta_value ): void {
		if ( $meta_key === '' ) {
			return;
		}
		if ( strpos( $meta_key, '_wpps_' ) !== 0 ) {
			return;
		}
		if ( get_post_type( $object_id ) !== self::POST_TYPE ) {
			return;
		}
		$this->bustSnippetCaches();
	}

	/**
	 * Clear location cache when a snippet post is trashed, restored, or deleted.
	 *
	 * @param int $post_id Post ID.
	 */
	public function clearCacheIfSnippetPost( int $post_id ): void {
		if ( $post_id <= 0 ) {
			return;
		}
		if ( get_post_type( $post_id ) !== self::POST_TYPE ) {
			return;
		}
		$this->bustSnippetCaches();
	}

	/**
	 * Same as maybeClearSnippetCacheFromMeta for deleted meta rows.
	 *
	 * @param int[]|mixed $meta_ids Deleted meta row IDs (unused).
	 * @param int         $object_id Post ID.
	 * @param string      $meta_key  Meta key.
	 * @param mixed       $_meta_value Former value (unused).
	 */
	public function maybeClearSnippetCacheFromDeletedMeta( $meta_ids, int $object_id, string $meta_key, $_meta_value ): void {
		if ( $meta_key === '' || strpos( $meta_key, '_wpps_' ) !== 0 ) {
			return;
		}
		if ( get_post_type( $object_id ) !== self::POST_TYPE ) {
			return;
		}
		$this->bustSnippetCaches();
	}

	/**
	 * Max error log rows to keep (newest wins).
	 */
	private function getErrorLogMaxEntries(): int {
		$max = (int) apply_filters( 'wpps_code_snippet_error_log_max_entries', 50 );
		if ( $max < 1 ) {
			$max = 1;
		}
		if ( $max > 500 ) {
			$max = 500;
		}
		return $max;
	}

	/**
	 * Queue a dismissible admin notice after auto-deactivate (shutdown or caught Throwable).
	 */
	private function notifyAutoDeactivateInAdmin( \WP_Post $post, string $message ): void {
		if ( ! apply_filters( 'wpps_code_snippet_auto_deactivate_admin_notice', true, $post, $message ) ) {
			return;
		}
		update_option(
			self::AUTO_DEACTIVATE_NOTICE_OPTION,
			[
				'snippet_id' => $post->ID,
				'title'      => $post->post_title,
				'message'    => $message,
				'time'       => time(),
			],
			false
		);
	}

	/**
	 * Dismiss auto-deactivate dashboard notice.
	 */
	public function maybeDismissAutoDeactivateNotice(): void {
		if ( ! isset( $_GET['wpps_cs_dismiss_ad'], $_GET['_wpnonce'] ) ) {
			return;
		}
		if ( sanitize_key( wp_unslash( $_GET['wpps_cs_dismiss_ad'] ) ) !== '1' ) {
			return;
		}
		if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'wpps_cs_dismiss_ad' ) ) {
			return;
		}
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}
		delete_option( self::AUTO_DEACTIVATE_NOTICE_OPTION );
		$target = wp_get_referer() ?: admin_url( 'index.php' );
		wp_safe_redirect( remove_query_arg( [ 'wpps_cs_dismiss_ad', '_wpnonce' ], $target ) );
		exit;
	}

	/**
	 * Show notice when a snippet was moved to draft due to a PHP error.
	 */
	public function renderAutoDeactivateAdminNotice(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}
		$data = get_option( self::AUTO_DEACTIVATE_NOTICE_OPTION );
		if ( ! is_array( $data ) || empty( $data['snippet_id'] ) ) {
			return;
		}
		$id    = (int) $data['snippet_id'];
		$title = isset( $data['title'] ) ? (string) $data['title'] : '';
		$msg   = isset( $data['message'] ) ? (string) $data['message'] : '';
		$edit  = admin_url( 'admin.php?page=powersuite-code-snippet&action=edit&id=' . $id );
		$dismiss_url = wp_nonce_url( admin_url( 'admin.php?wpps_cs_dismiss_ad=1' ), 'wpps_cs_dismiss_ad' );
		echo '<div class="notice notice-error is-dismissible"><p>';
		echo '<strong>' . esc_html__( 'Code Snippet: a snippet was auto-deactivated after a PHP error.', 'wp-powersuite' ) . '</strong> ';
		if ( $title !== '' ) {
			echo esc_html( $title );
			echo ' — ';
		}
		if ( $msg !== '' ) {
			echo esc_html( $msg );
		}
		echo ' ';
		printf(
			'<a href="%1$s">%2$s</a> · <a href="%3$s">%4$s</a>',
			esc_url( $edit ),
			esc_html__( 'Edit snippet', 'wp-powersuite' ),
			esc_url( $dismiss_url ),
			esc_html__( 'Dismiss', 'wp-powersuite' )
		);
		echo '</p></div>';
	}

	/**
	 * Error types that trigger shutdown-based snippet deactivation.
	 *
	 * @return int[]
	 */
	private function shutdownFatalErrorTypes(): array {
		$default = [ E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR ];
		$types   = apply_filters( 'wpps_code_snippet_shutdown_fatal_types', $default );
		if ( ! is_array( $types ) ) {
			$types = $default;
		}
		$out = [];
		foreach ( $types as $t ) {
			if ( is_int( $t ) ) {
				$out[] = $t;
			}
		}
		return $out !== [] ? array_values( array_unique( $out ) ) : $default;
	}

	/**
	 * Run PHP snippets for Run Everywhere + Admin Only (merged eval).
	 * Frontend Only: merged inline PHP by default (filterable); file-based snippets still run per file.
	 */
	public function runPhpEverywhereSnippets(): void {
		$snippets = $this->getSnippetsForLocation( 'run_everywhere' );
		if ( is_admin() ) {
			$snippets = array_merge( $snippets, $this->getSnippetsForLocation( 'admin_only' ) );
		}
		$merge_items = [];
		$loc         = is_admin() ? 'admin_only' : 'run_everywhere';
		foreach ( $snippets as $post ) {
			$type = get_post_meta( $post->ID, '_wpps_code_type', true ) ?: 'html';
			if ( $type !== 'php' ) {
				continue;
			}
			if ( ! $this->passesProLicense( $post ) || ! $this->passesConditionalLogic( $post ) || ! $this->passesDeviceCheck( $post ) || ! $this->isSnippetScheduled( $post ) ) {
				continue;
			}
			$php_file = $this->getPhpSnippetFilePath( $post );
			if ( $this->shouldLoadPhpFromFile( $post ) ) {
				$this->ensurePhpSnippetFileIfNeeded( $post );
				if ( file_exists( $php_file ) ) {
					$this->loadPhpFromFile( $php_file, $post );
					do_action( 'wpps_snippet_loaded', $post, get_post_meta( $post->ID, '_wpps_location', true ) ?: $loc );
					continue;
				}
			}
			$snippet_code = $this->getPhpCodeForExecution( $post );
			if ( ! is_string( $snippet_code ) || $snippet_code === '' ) {
				continue;
			}
			$snippet_code = apply_filters( 'wpps_code_snippet_php_before_eval', $snippet_code, $post, 'merged_run_everywhere' );
			if ( ! is_string( $snippet_code ) || $snippet_code === '' ) {
				continue;
			}
			$merge_items[] = [ 'post' => $post, 'code' => $snippet_code, 'loc' => get_post_meta( $post->ID, '_wpps_location', true ) ?: $loc ];
		}
		if ( $merge_items !== [] ) {
			$built = $this->buildMergedPhpStringAndLineMap( $merge_items );
			if ( $built['code'] !== '' && $built['last_post'] ) {
				$merged = apply_filters( 'wpps_code_snippet_merged_php_code', $built['code'], 'run_everywhere_admin', $built['line_map'] );
				if ( ! is_string( $merged ) ) {
					$merged = $built['code'];
				}
				if ( $this->safeExecutePhpMerged( $merged, $built['last_post'], $built['line_map'], 'run_everywhere_admin' ) ) {
					foreach ( $merge_items as $item ) {
						do_action( 'wpps_snippet_loaded', $item['post'], $item['loc'] );
					}
				}
			}
		}

		if ( ! is_admin() ) {
			$merge_fe = (bool) self::getSetting( 'merge_frontend_only_php', true );
			$merge_fe = apply_filters( 'wpps_code_snippet_merge_frontend_only_php', $merge_fe );
			$frontend = $this->getSnippetsForLocation( 'frontend_only' );
			$fe_items = [];
			foreach ( $frontend as $post ) {
				$type = get_post_meta( $post->ID, '_wpps_code_type', true ) ?: 'html';
				if ( $type !== 'php' ) {
					continue;
				}
				if ( ! $this->passesProLicense( $post ) || ! $this->passesConditionalLogic( $post ) || ! $this->passesDeviceCheck( $post ) || ! $this->isSnippetScheduled( $post ) ) {
					continue;
				}
				$php_file = $this->getPhpSnippetFilePath( $post );
				if ( $this->shouldLoadPhpFromFile( $post ) ) {
					$this->ensurePhpSnippetFileIfNeeded( $post );
					if ( file_exists( $php_file ) ) {
						$output = $this->loadPhpFromFile( $php_file, $post );
						if ( $output !== '' ) {
							echo "\n<!-- " . esc_attr( wpps_cs_brand_name() ) . ' Code Snippet: ' . esc_attr( $post->post_title ) . " -->\n";
							echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
							echo "\n<!-- /" . esc_attr( wpps_cs_brand_name() ) . " -->\n";
						}
						do_action( 'wpps_snippet_loaded', $post, 'frontend_only' );
						continue;
					}
				}
				$snippet_code = $this->getPhpCodeForExecution( $post );
				if ( ! is_string( $snippet_code ) || $snippet_code === '' ) {
					continue;
				}
				if ( ! $merge_fe ) {
					$output = $this->executeSnippet( $post );
					if ( $output !== '' ) {
						echo "\n<!-- " . esc_attr( wpps_cs_brand_name() ) . ' Code Snippet: ' . esc_attr( $post->post_title ) . " -->\n";
						echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
						echo "\n<!-- /" . esc_attr( wpps_cs_brand_name() ) . " -->\n";
					}
					continue;
				}
				$snippet_code = apply_filters( 'wpps_code_snippet_php_before_eval', $snippet_code, $post, 'merged_frontend_only' );
				if ( ! is_string( $snippet_code ) || $snippet_code === '' ) {
					continue;
				}
				$fe_items[] = [ 'post' => $post, 'code' => $snippet_code ];
			}
			if ( $merge_fe && $fe_items !== [] ) {
				$built = $this->buildMergedPhpStringAndLineMap( $fe_items );
				if ( $built['code'] !== '' && $built['last_post'] ) {
					$merged = apply_filters( 'wpps_code_snippet_merged_php_code', $built['code'], 'merged_frontend_only', $built['line_map'] );
					if ( ! is_string( $merged ) ) {
						$merged = $built['code'];
					}
					$merged_posts = [];
					foreach ( $fe_items as $it ) {
						if ( isset( $it['post'] ) && $it['post'] instanceof \WP_Post ) {
							$merged_posts[] = $it['post'];
						}
					}
					$label = count( $fe_items ) === 1 ? $fe_items[0]['post']->post_title : __( 'Frontend Only (merged)', 'wp-powersuite' );
					echo "\n<!-- " . esc_attr( wpps_cs_brand_name() ) . ' Code Snippet: ' . esc_attr( $label ) . " -->\n";
					ob_start();
					$ok = $this->safeExecutePhpMerged( $merged, $built['last_post'], $built['line_map'], 'merged_frontend_only' );
					if ( $ok ) {
						$buf = ob_get_clean();
						$buf = apply_filters( 'wpps_code_snippet_merged_php_output', is_string( $buf ) ? $buf : '', $merged_posts, 'merged_frontend_only' );
						if ( is_string( $buf ) && $buf !== '' ) {
							echo $buf; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
						}
						foreach ( $fe_items as $it ) {
							if ( isset( $it['post'] ) && $it['post'] instanceof \WP_Post ) {
								do_action( 'wpps_snippet_loaded', $it['post'], 'frontend_only' );
							}
						}
					} else {
						ob_end_clean();
					}
					echo "\n<!-- /" . esc_attr( wpps_cs_brand_name() ) . " -->\n";
				}
			}
		}
	}

	public function outputFrontendConditionalLogicSnippets(): void {
		if ( ! is_admin() ) {
			$this->outputSnippetsForLocation( 'frontend_conditional_logic', true );
		}
	}

	public function outputAdminHeaderSnippets(): void {
		$this->outputSnippets( 'admin_header' );
	}

	public function outputAdminFooterSnippets(): void {
		$this->outputSnippets( 'admin_footer' );
	}

	public function outputWooBeforeShopLoopSnippets(): void {
		$this->outputSnippets( 'woo_before_shop_loop' );
	}

	public function outputWooAfterShopLoopSnippets(): void {
		$this->outputSnippets( 'woo_after_shop_loop' );
	}

	public function outputWooBeforeSingleProductSnippets(): void {
		$this->outputSnippets( 'woo_before_single_product' );
	}

	public function outputWooAfterSingleProductSnippets(): void {
		$this->outputSnippets( 'woo_after_single_product' );
	}

	public function outputWooBeforeAddToCartSnippets(): void {
		$this->outputSnippets( 'woo_before_add_to_cart' );
	}

	public function outputWooAfterAddToCartSnippets(): void {
		$this->outputSnippets( 'woo_after_add_to_cart' );
	}

	public function outputWooBeforeCheckoutSnippets(): void {
		$this->outputSnippets( 'woo_before_checkout' );
	}

	public function outputWooAfterCheckoutSnippets(): void {
		$this->outputSnippets( 'woo_after_checkout' );
	}

	public function outputWooBeforeCartSnippets(): void {
		$this->outputSnippets( 'woo_before_cart' );
	}

	public function outputWooAfterCartSnippets(): void {
		$this->outputSnippets( 'woo_after_cart' );
	}

	public function outputHeaderSnippets(): void {
		$this->outputSnippets( 'header' );
	}

	/**
	 * Output body snippets via wp_body_open (when theme supports it).
	 * Avoids output buffer; fallback is buffer injection in processElementBuffer.
	 */
	public function outputBodySnippetsViaHook(): void {
		$snippets = $this->getSnippetsForLocation( 'body' );
		if ( empty( $snippets ) ) {
			return;
		}
		$this->body_snippets_output_via_hook = true;
		$this->outputSnippets( 'body' );
	}

	public function outputFooterSnippets(): void {
		$this->outputSnippets( 'footer' );
	}

	/**
	 * Output element selector snippets via client-side JS injection.
	 * Avoids DOMDocument parsing of full page (causes blank page on large Elementor pages).
	 */
	public function outputElementSelectorSnippets(): void {
		$modes = [
			'before_element' => 'before',
			'after_element'  => 'after',
			'start_element'  => 'start',
			'end_element'    => 'end',
		];
		$injections = [];
		foreach ( $modes as $loc => $mode ) {
			$this->output_location_stack[] = $loc;
			$snippets = $this->getSnippetsForLocation( $loc );
			foreach ( $snippets as $post ) {
				$sel = get_post_meta( $post->ID, '_wpps_location_selector', true );
				if ( $sel === '' ) {
					continue;
				}
				$out = $this->executeSnippet( $post );
				if ( $out === '' ) {
					continue;
				}
				$idx = get_post_meta( $post->ID, '_wpps_location_element_index', true );
				$injections[] = [
					'selector' => $sel,
					'output'   => $out,
					'mode'     => $mode,
					'index'    => ( $idx === '' || $idx === false ) ? -1 : (int) $idx,
				];
			}
			array_pop( $this->output_location_stack );
		}
		if ( empty( $injections ) ) {
			return;
		}
		?>
		<script>
		(function() {
			var inj = <?php echo wp_json_encode( $injections ); ?>;
			function run() {
				inj.forEach(function(i) {
					var targets = document.querySelectorAll(i.selector);
					if (!targets.length) return;
					var nodes = i.index >= 0 && targets[i.index] ? [targets[i.index]] : Array.prototype.slice.call(targets);
					nodes.forEach(function(el) {
						var w = document.createElement('div');
						w.innerHTML = i.output;
						var f = document.createDocumentFragment();
						while (w.firstChild) f.appendChild(w.firstChild);
						if (i.mode === 'before') el.parentNode.insertBefore(f, el);
						else if (i.mode === 'after') el.parentNode.insertBefore(f, el.nextSibling);
						else if (i.mode === 'start') el.insertBefore(f, el.firstChild);
						else if (i.mode === 'end') el.appendChild(f);
					});
				});
			}
			if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run);
			else run();
		})();
		</script>
		<?php
	}

	/** @var int Loop post counter for before_post/after_post. */
	private int $loop_post_index = 0;

	public function onLoopStart( \WP_Query $query ): void {
		if ( $query->is_main_query() ) {
			$this->loop_post_index = 0;
		}
	}

	public function onThePost( \WP_Post $post, \WP_Query $query ): void {
		if ( ! $query->is_main_query() || ! in_the_loop() ) {
			return;
		}
		$this->loop_post_index++;

		// On archive/loop pages the theme often renders the_excerpt() instead of
		// the_content(), so the_content-based injection never runs. the_post fires for
		// every post regardless, so emit the loop-position locations here. Singular
		// views keep using the_content (handled in filterTheContent) for correct placement.
		if ( is_singular() ) {
			return;
		}
		$this->echoArchiveLoopSnippets( 'before_post' );
		$this->echoArchiveLoopSnippets( 'between_posts' );
	}

	/**
	 * Echo loop-position snippets (before_post / between_posts) during archive loops.
	 */
	private function echoArchiveLoopSnippets( string $location ): void {
		$snippets = $this->getSnippetsForLocation( $location );
		if ( empty( $snippets ) ) {
			return;
		}
		$this->output_location_stack[] = $location;
		foreach ( $snippets as $post ) {
			if ( $location === 'between_posts' ) {
				if ( $this->loop_post_index <= 1 ) {
					continue;
				}
			} else {
				$param = (int) ( get_post_meta( $post->ID, '_wpps_location_param', true ) ?: 1 );
				if ( $param !== $this->loop_post_index ) {
					continue;
				}
			}
			$out = $this->executeSnippet( $post );
			if ( $out === '' ) {
				continue;
			}
			echo "\n<!-- " . esc_attr( wpps_cs_brand_name() ) . ' Code Snippet: ' . esc_attr( $post->post_title ) . " -->\n";
			echo $out; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
			echo "\n<!-- /" . esc_attr( wpps_cs_brand_name() ) . " -->\n";
		}
		array_pop( $this->output_location_stack );
	}

	public function filterTheContent( string $content ): string {
		if ( ! $this->shouldInjectIntoPostContent( $content ) ) {
			return $content;
		}
		$post_id = (int) get_the_ID();
		if ( $post_id > 0 && isset( $this->content_locations_injected[ $post_id ] ) ) {
			return $content;
		}
		$locations = [
			'before_content',
			'after_content',
			'content_25',
			'content_33',
			'content_50',
			'content_66',
			'content_75',
			'content_80',
			'before_paragraph',
			'after_paragraph',
			'between_posts',
			'before_post',
			'after_post',
		];
		foreach ( $locations as $loc ) {
			// Loop-position locations are emitted on the_post for archive loops (so they
			// work with excerpt-based themes). Skip them here to avoid double output;
			// singular views still place them via the_content below.
			if ( ! is_singular() && in_array( $loc, [ 'before_post', 'between_posts' ], true ) ) {
				continue;
			}
			$snippets = $this->getSnippetsForLocation( $loc );
			if ( empty( $snippets ) ) {
				continue;
			}
			$this->output_location_stack[] = $loc;
			$prepend_locs = [ 'before_content', 'between_posts', 'before_post' ];
			if ( in_array( $loc, $prepend_locs, true ) ) {
				$snippets = array_reverse( $snippets );
			}
			foreach ( $snippets as $post ) {
				$param = (int) ( get_post_meta( $post->ID, '_wpps_location_param', true ) ?: 1 );
				$out   = $this->executeSnippet( $post );
				if ( $out === '' ) {
					continue;
				}
				switch ( $loc ) {
					case 'before_content':
						$content = $out . $content;
						break;
					case 'after_content':
						$content = $content . $out;
						break;
					case 'between_posts':
						if ( $this->loop_post_index > 1 ) {
							$content = $out . $content;
						}
						break;
					case 'before_post':
					case 'after_post':
						if ( $param === $this->loop_post_index ) {
							if ( $loc === 'after_post' && $post_id > 0 && isset( $this->after_post_injected[ $post_id ] ) ) {
								break;
							}
							$content = $loc === 'before_post' ? ( $out . $content ) : ( $content . $out );
							if ( $loc === 'after_post' && $post_id > 0 ) {
								$this->after_post_injected[ $post_id ] = true;
							}
						}
						break;
					case 'before_paragraph':
					case 'after_paragraph':
						$content = $this->insertAtParagraph( $content, $out, $param, $loc === 'before_paragraph' );
						break;
					case 'content_25':
					case 'content_33':
					case 'content_50':
					case 'content_66':
					case 'content_75':
					case 'content_80':
						$pct    = (int) str_replace( 'content_', '', $loc );
						$content = $this->insertAtPercentage( $content, $out, $pct );
						break;
				}
			}
			array_pop( $this->output_location_stack );
		}
		if ( $post_id > 0 ) {
			$this->content_locations_injected[ $post_id ] = true;
		}
		return $content;
	}

	private function insertAtParagraph( string $content, string $insert, int $paragraph_num, bool $before ): string {
		if ( $paragraph_num < 1 ) {
			$paragraph_num = 1;
		}

		if ( $before ) {
			if ( ! preg_match_all( '/<p\b[^>]*>/i', $content, $m, PREG_OFFSET_CAPTURE ) ) {
				return $content;
			}
			if ( ! isset( $m[0][ $paragraph_num - 1 ] ) ) {
				return $content;
			}
			$pos = $m[0][ $paragraph_num - 1 ][1];
			return substr( $content, 0, $pos ) . $insert . substr( $content, $pos );
		}

		if ( ! preg_match_all( '/<\/p>/i', $content, $m, PREG_OFFSET_CAPTURE ) ) {
			return $content;
		}
		if ( ! isset( $m[0][ $paragraph_num - 1 ] ) ) {
			return $content;
		}
		$match = $m[0][ $paragraph_num - 1 ];
		$pos   = $match[1] + strlen( $match[0] );
		return substr( $content, 0, $pos ) . $insert . substr( $content, $pos );
	}

	private function insertAtPercentage( string $content, string $insert, int $pct ): string {
		$len = strlen( $content );
		if ( $len < 10 ) {
			return $insert . $content;
		}
		$target = (int) floor( $len * $pct / 100 );

		// Prefer block boundaries in rendered HTML (paragraphs, headings, lists, etc.).
		if ( preg_match_all( '/<\/(?:p|h[1-6]|li|blockquote|figure)>/i', $content, $m, PREG_OFFSET_CAPTURE ) ) {
			$best  = null;
			$bestd = null;
			foreach ( $m[0] as $match ) {
				$boundary = $match[1] + strlen( $match[0] );
				$dist     = abs( $boundary - $target );
				if ( $bestd === null || $dist < $bestd ) {
					$bestd = $dist;
					$best  = $boundary;
				}
			}
			if ( $best !== null ) {
				return substr( $content, 0, $best ) . $insert . substr( $content, $best );
			}
		}

		// No block closers: insert at the next tag close at/after the target so we never
		// cut through an HTML tag. Falls back to appending if none found.
		$gt  = strpos( $content, '>', $target );
		$pos = $gt !== false ? $gt + 1 : $len;
		return substr( $content, 0, $pos ) . $insert . substr( $content, $pos );
	}

	public function filterTheExcerpt( string $excerpt, \WP_Post $post ): string {
		if ( ! in_the_loop() || ! is_main_query() ) {
			return $excerpt;
		}
		$post_id = (int) $post->ID;
		if ( $post_id > 0 && isset( $this->excerpt_locations_injected[ $post_id ] ) ) {
			return $excerpt;
		}

		$out_before = '';
		$out_after  = '';

		$before = $this->getSnippetsForLocation( 'before_excerpt' );
		$this->output_location_stack[] = 'before_excerpt';
		foreach ( $before as $p ) {
			$out_before .= $this->executeSnippet( $p );
		}
		array_pop( $this->output_location_stack );

		$after = $this->getSnippetsForLocation( 'after_excerpt' );
		$this->output_location_stack[] = 'after_excerpt';
		foreach ( $after as $p ) {
			$out_after .= $this->executeSnippet( $p );
		}
		array_pop( $this->output_location_stack );

		// Excerpt-based archive themes never run the_content; append after_post here instead.
		if ( ! is_singular() ) {
			$after_post_snippets = $this->getSnippetsForLocation( 'after_post' );
			if ( ! empty( $after_post_snippets ) && ! isset( $this->after_post_injected[ $post_id ] ) ) {
				$this->output_location_stack[] = 'after_post';
				$after_post_added              = false;
				foreach ( $after_post_snippets as $p ) {
					$param = (int) ( get_post_meta( $p->ID, '_wpps_location_param', true ) ?: 1 );
					if ( $param !== $this->loop_post_index ) {
						continue;
					}
					$out = $this->executeSnippet( $p );
					if ( $out !== '' ) {
						$out_after         .= $out;
						$after_post_added   = true;
					}
				}
				array_pop( $this->output_location_stack );
				if ( $after_post_added && $post_id > 0 ) {
					$this->after_post_injected[ $post_id ] = true;
				}
			}
		}

		if ( $out_before === '' && $out_after === '' ) {
			return $excerpt;
		}

		if ( $post_id > 0 ) {
			$this->excerpt_locations_injected[ $post_id ] = true;
		}

		return $out_before . $excerpt . $out_after;
	}

	/**
	 * Whether the_content filter should inject snippets for the current request.
	 *
	 * @param string $content Post content passed to the filter.
	 */
	private function shouldInjectIntoPostContent( string $content ): bool {
		if ( trim( $content ) === '' ) {
			return false;
		}
		if ( is_singular() ) {
			$queried_id = (int) get_queried_object_id();
			$current_id = (int) get_the_ID();
			if ( $queried_id > 0 && $current_id > 0 && $queried_id !== $current_id ) {
				return false;
			}
			return true;
		}
		return in_the_loop() && is_main_query();
	}

	/**
	 * Output snippets for a location. For PHP-only locations, only PHP snippets run.
	 *
	 * @param string $location Location slug.
	 * @param bool   $php_only If true, only output PHP snippets.
	 */
	private function outputSnippetsForLocation( string $location, bool $php_only = false ): void {
		$this->output_location_stack[] = $location;
		$snippets = $this->getSnippetsForLocation( $location );
		foreach ( $snippets as $post ) {
			$type = get_post_meta( $post->ID, '_wpps_code_type', true ) ?: 'html';
			if ( $php_only && $type !== 'php' ) {
				continue;
			}
			$output = $this->executeSnippet( $post );
			if ( $output !== '' ) {
				echo "\n<!-- " . esc_attr( wpps_cs_brand_name() ) . ' Code Snippet: ' . esc_attr( $post->post_title ) . " -->\n";
				echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
				echo "\n<!-- /" . esc_attr( wpps_cs_brand_name() ) . " -->\n";
			}
		}
		array_pop( $this->output_location_stack );
	}

	private function outputSnippets( string $location ): void {
		$this->output_location_stack[] = $location;
		$snippets = $this->getSnippetsForLocation( $location );
		foreach ( $snippets as $post ) {
			if ( $this->isLoadAsFile( $post ) ) {
				continue;
			}
			$output = $this->executeSnippet( $post );
			if ( $output !== '' ) {
				echo "\n<!-- " . esc_attr( wpps_cs_brand_name() ) . ' Code Snippet: ' . esc_attr( $post->post_title ) . " -->\n";
				echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Intentional: user code.
				echo "\n<!-- /" . esc_attr( wpps_cs_brand_name() ) . " -->\n";
			}
		}
		array_pop( $this->output_location_stack );
	}

	/**
	 * Check if snippet uses file-based loading (JS/CSS only).
	 */
	private function isLoadAsFile( \WP_Post $post ): bool {
		$type = get_post_meta( $post->ID, '_wpps_code_type', true ) ?: 'html';
		if ( ! in_array( $type, [ 'js', 'css' ], true ) ) {
			return false;
		}
		$per_snippet = get_post_meta( $post->ID, '_wpps_load_as_file', true ) === '1';
		if ( $per_snippet ) {
			return true;
		}
		return self::getSetting( 'load_snippets_as_files', false );
	}

	/**
	 * Enqueue JS/CSS snippets that use file-based loading.
	 */
	public function enqueueFileBasedSnippets(): void {
		foreach ( [ 'header', 'body', 'footer' ] as $loc ) {
			$snippets = $this->getSnippetsForLocation( $loc );
			foreach ( $snippets as $post ) {
				if ( ! $this->isLoadAsFile( $post ) ) {
					continue;
				}
				$type  = get_post_meta( $post->ID, '_wpps_code_type', true ) ?: 'html';
				$code  = $post->post_content;
				$code  = preg_replace( '/^\s*<script[^>]*>|<\/script>\s*$/i', '', $code );
				$code  = preg_replace( '/^\s*<style[^>]*>|<\/style>\s*$/i', '', $code );
				$code  = trim( $code );
				$file  = $this->getSnippetFilePath( $post, $type );
				$url   = $this->getSnippetFileUrl( $post, $type );
				if ( $file && $url && $this->ensureSnippetFile( $post, $type, $code ) ) {
					$handle    = 'wpps-snippet-' . $post->ID;
					$in_footer = in_array( $loc, [ 'footer', 'body' ], true );
					if ( $type === 'js' ) {
						wp_enqueue_script( $handle, $url, [], self::MODULE_VERSION, $in_footer );
					} else {
						wp_enqueue_style( $handle, $url, [], self::MODULE_VERSION );
					}
					// Same tracking as executeSnippet() / wpps_code_snippet_output (those paths are skipped for file-based assets).
					do_action( 'wpps_snippet_loaded', $post, $loc );
				}
			}
		}
	}

	private function getSnippetFileDir(): string {
		return $this->getPhpSnippetFileDir();
	}

	/**
	 * Get sanitized filesystem slug for snippet files.
	 *
	 * Uses sanitize_title( post_title ) first so names match the visible title, work better for
	 * non-ASCII titles, and update when the title changes. Falls back to post_name (WordPress slug),
	 * then to a generic prefix. The post ID is always appended in the path, so names stay unique.
	 */
	private function getSnippetFileSlug( \WP_Post $post ): string {
		$title_slug = sanitize_file_name( sanitize_title( $post->post_title ) );
		if ( $title_slug !== '' ) {
			return substr( $title_slug, 0, 80 );
		}
		if ( isset( $post->post_name ) && is_string( $post->post_name ) && $post->post_name !== '' ) {
			$slug = sanitize_file_name( $post->post_name );
			if ( $slug !== '' ) {
				return substr( $slug, 0, 80 );
			}
		}
		return 'snippet';
	}

	private function getSnippetFilePath( \WP_Post $post, string $type ): string {
		$ext = $type === 'js' ? 'js' : 'css';
		return $this->getSnippetFileDir() . '/' . $this->getSnippetFileSlug( $post ) . '-' . $post->ID . '.' . $ext;
	}

	private function getSnippetFileUrl( \WP_Post $post, string $type ): string {
		$ext  = $type === 'js' ? 'js' : 'css';
		$slug = $this->getSnippetFileSlug( $post );
		return content_url( 'wpps/wpps-snippets/' . $slug . '-' . $post->ID . '.' . $ext );
	}

	/**
	 * Get directory for PHP/HTML snippet files (global load-as-file setting).
	 */
	private function getPhpSnippetFileDir(): string {
		$dir = WP_CONTENT_DIR . '/wpps/wpps-snippets';
		if ( ! is_dir( $dir ) ) {
			wp_mkdir_p( $dir );
			$index = $dir . '/index.php';
			if ( ! file_exists( $index ) ) {
				file_put_contents( $index, '<?php // Silence is golden.' );
			}
		}
		$this->ensureSnippetDirSecurity( $dir );
		return $dir;
	}

	/**
	 * Block direct HTTP execution of PHP snippet files (Apache; include via filesystem still works).
	 *
	 * @param string $dir Snippet files directory.
	 */
	private function ensureSnippetDirSecurity( string $dir ): void {
		// Touch disk at most once per request; this runs on the snippet execution path.
		static $secured = false;
		if ( $secured ) {
			return;
		}
		$secured  = true;
		$htaccess = $dir . '/.htaccess';
		$rules    = '# ' . wpps_cs_brand_name() . ": block direct HTTP access to snippet PHP files\n"
			. "<FilesMatch \"\\.php$\">\n"
			. "\tRequire all denied\n"
			. "</FilesMatch>\n"
			. "<IfModule !mod_authz_core.c>\n"
			. "\t<FilesMatch \"\\.php$\">\n"
			. "\t\tOrder deny,allow\n"
			. "\t\tDeny from all\n"
			. "\t</FilesMatch>\n"
			. "</IfModule>\n";
		if ( ! file_exists( $htaccess ) || @file_get_contents( $htaccess ) !== $rules ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			@file_put_contents( $htaccess, $rules );
		}
		$nginx_readme = $dir . '/NGINX-SECURITY.txt';
		$nginx_note   = wpps_cs_brand_name() . " Code Snippet files\n\n"
			. "Apache: .htaccess in this folder blocks direct HTTP access to .php files.\n\n"
			. "Nginx: add a location block so .php files here are not executed via HTTP, for example:\n"
			. "  location ~* /wp-content/wpps/wpps-snippets/.*\\.php\$ { deny all; return 404; }\n";
		if ( ! file_exists( $nginx_readme ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
			@file_put_contents( $nginx_readme, $nginx_note );
		}
	}

	/**
	 * Get file path for PHP/HTML snippet.
	 */
	private function getPhpSnippetFilePath( \WP_Post $post ): string {
		return $this->getPhpSnippetFileDir() . '/' . $this->getSnippetFileSlug( $post ) . '-' . $post->ID . '.php';
	}

	/**
	 * Write PHP/HTML snippet to file (when Load PHP as Files is enabled).
	 */
	private function ensurePhpSnippetFile( \WP_Post $post ): bool {
		$type = get_post_meta( $post->ID, '_wpps_code_type', true ) ?: 'html';
		if ( ! in_array( $type, [ 'php', 'html' ], true ) ) {
			return false;
		}
		$file = $this->getPhpSnippetFilePath( $post );
		$dir  = dirname( $file );
		if ( ! is_writable( $dir ) ) {
			return false;
		}
		$code = $post->post_content;
		if ( $type === 'php' ) {
			$code = preg_replace( '/^\s*(?:(?:<' . '?php|<\?)\s*)+/i', '', $code );
			$close = chr( 63 ) . chr( 62 );
			$code = preg_replace( '/\s*' . preg_quote( $close, '/' ) . '\s*$/', '', $code );
			$code = '<?php' . "\n" . trim( $code ) . "\n";
		} else {
			$code = '<?php echo ' . var_export( $code, true ) . ';' . "\n";
		}
		return false !== file_put_contents( $file, $code );
	}

	/**
	 * Load and execute PHP snippet from file.
	 *
	 * @param string   $file File path.
	 * @param \WP_Post $post Snippet post.
	 * @return string Output.
	 */
	private function loadPhpFromFile( string $file, \WP_Post $post ): string {
		$resolved = $this->resolveAllowedPhpSnippetFile( $file );
		if ( $resolved === '' ) {
			return '';
		}
		$this->last_executed_php_snippet = $post;
		ob_start();
		try {
			include $resolved; // phpcs:ignore WordPressVIPMinimum.Files.IncludingFile.NotAbsolutePath
		} catch ( \Throwable $e ) {
			ob_end_clean();
			$this->logSnippetError(
				$post,
				$e,
				[
					'merge_context' => 'php_file_include',
					'eval_line'     => $e->getLine(),
					'snippet_line'  => $e->getLine(),
				]
			);
			if ( $this->shouldAutoDeactivateSnippetForPhpError( $post ) ) {
				wp_update_post( [ 'ID' => $post->ID, 'post_status' => 'draft' ] );
				update_post_meta( $post->ID, '_wpps_auto_deactivated', time() );
				update_post_meta( $post->ID, '_wpps_auto_deactivated_error', $e->getMessage() );
				$this->bustSnippetCaches();
				$this->notifyAutoDeactivateInAdmin( $post, $e->getMessage() );
			}
			return '';
		}
		$this->last_executed_php_snippet = null;
		$out = ob_get_clean();
		$out = apply_filters( 'wpps_code_snippet_after_php_eval', is_string( $out ) ? $out : '', $post, 'php_file_include' );
		return is_string( $out ) ? $out : '';
	}

	/**
	 * Check if snippet should load from file (global setting + type).
	 */
	private function shouldLoadPhpFromFile( \WP_Post $post ): bool {
		$per_snippet = get_post_meta( $post->ID, '_wpps_load_as_file', true ) === '1';
		if ( ! $per_snippet && ! self::getSetting( 'load_snippets_as_files', false ) ) {
			return false;
		}
		$type = get_post_meta( $post->ID, '_wpps_code_type', true ) ?: 'html';
		if ( ! in_array( $type, [ 'php', 'html' ], true ) ) {
			return false;
		}
		$auto = get_post_meta( $post->ID, '_wpps_auto_insert', true ) === '1';
		return $auto;
	}

	/**
	 * Write the on-disk PHP/HTML snippet file when file loading is enabled and it is missing.
	 */
	private function ensurePhpSnippetFileIfNeeded( \WP_Post $post ): bool {
		if ( ! $this->shouldLoadPhpFromFile( $post ) ) {
			return false;
		}
		$file = $this->getPhpSnippetFilePath( $post );
		if ( file_exists( $file ) ) {
			return true;
		}
		return $this->ensurePhpSnippetFile( $post );
	}

	/**
	 * Resolve a snippet file path only when it lives inside the managed snippets directory.
	 */
	private function resolveAllowedPhpSnippetFile( string $file ): string {
		$resolved_file = realpath( $file );
		$resolved_dir  = realpath( $this->getPhpSnippetFileDir() );
		if ( ! $resolved_file || ! $resolved_dir ) {
			return '';
		}
		$resolved_file = wp_normalize_path( $resolved_file );
		$resolved_dir  = wp_normalize_path( $resolved_dir );
		$prefix        = trailingslashit( $resolved_dir );
		if ( strcasecmp( substr( $resolved_file, 0, strlen( $prefix ) ), $prefix ) !== 0 ) {
			return '';
		}
		return $resolved_file;
	}

	private function ensureSnippetFile( \WP_Post $post, string $type, string $code ): bool {
		$file = $this->getSnippetFilePath( $post, $type );
		if ( ! $file || ! is_writable( dirname( $file ) ) ) {
			return false;
		}
		return false !== file_put_contents( $file, $code );
	}

	/**
	 * Start output buffering only for body snippets (when theme doesn't support wp_body_open).
	 * Element selector snippets use client-side JS injection to avoid DOMDocument issues.
	 */
	public function maybeStartElementBuffer(): void {
		$body = $this->getSnippetsForLocation( 'body' );
		if ( empty( $body ) ) {
			return;
		}
		ob_start( [ $this, 'processElementBuffer' ] );
	}

	/**
	 * Process buffered HTML and inject snippets before/after target elements.
	 *
	 * @param string $buffer Full page HTML.
	 * @return string Modified HTML.
	 */
	public function processElementBuffer( string $buffer ): string {
		try {
			return $this->processElementBufferInner( $buffer );
		} catch ( \Throwable $e ) {
			if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
				error_log(
					sprintf(
						wpps_cs_brand_name() . ' Code Snippet: processElementBuffer failed: %s in %s:%d',
						$e->getMessage(),
						$e->getFile(),
						$e->getLine()
					)
				);
			}
			return $buffer;
		}
	}

	/**
	 * Inner logic for processElementBuffer (wrapped in try-catch by caller).
	 *
	 * @param string $buffer Full page HTML.
	 * @return string Modified HTML.
	 */
	private function processElementBufferInner( string $buffer ): string {
		if ( $buffer === '' || strlen( $buffer ) < 50 ) {
			return $buffer;
		}
		$body_snippets = $this->getSnippetsForLocation( 'body' );

		// Inject body snippets right after <body> (only if wp_body_open didn't run - themes that don't support it).
		if ( ! $this->body_snippets_output_via_hook && ! empty( $body_snippets ) && preg_match( '/<body[^>]*>/i', $buffer ) ) {
			$body_out = '';
			$this->output_location_stack[] = 'body';
			foreach ( $body_snippets as $post ) {
				$out = $this->executeSnippet( $post );
				if ( $out !== '' ) {
					$body_out .= "\n<!-- " . esc_attr( wpps_cs_brand_name() ) . ' Code Snippet: ' . esc_attr( $post->post_title ) . " -->\n";
					$body_out .= $out;
					$body_out .= "\n<!-- /" . esc_attr( wpps_cs_brand_name() ) . " -->\n";
				}
			}
			if ( $body_out !== '' ) {
				$buffer = preg_replace( '/(<body[^>]*>)/i', '$1' . $body_out, $buffer, 1 );
			}
			array_pop( $this->output_location_stack );
		}

		// Element selector snippets are injected via JS in outputElementSelectorSnippets.
		return $buffer;
	}

	/**
	 * Check if a snippet is within its schedule window.
	 *
	 * @param \WP_Post $post Snippet post.
	 * @return bool True if snippet should run (no schedule or within window).
	 */
	private function isSnippetScheduled( \WP_Post $post ): bool {
		$enabled = get_post_meta( $post->ID, '_wpps_schedule_enabled', true ) === '1';
		if ( ! $enabled ) {
			return true;
		}
		$start = get_post_meta( $post->ID, '_wpps_schedule_start', true );
		$end   = get_post_meta( $post->ID, '_wpps_schedule_end', true );
		$tz    = wp_timezone();
		$now   = time();
		if ( is_string( $start ) && $start !== '' ) {
			$start_ts = $this->scheduleStringToTimestamp( $start, $tz );
			if ( $start_ts !== null && $now < $start_ts ) {
				return false;
			}
		}
		if ( is_string( $end ) && $end !== '' ) {
			$end_ts = $this->scheduleStringToTimestamp( $end, $tz );
			if ( $end_ts !== null && $now > $end_ts ) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Parse a datetime-local schedule string (e.g. "2026-06-21T19:30") in the site
	 * timezone and return its UTC epoch timestamp, comparable against time().
	 *
	 * @return int|null Timestamp, or null when the value cannot be parsed.
	 */
	private function scheduleStringToTimestamp( string $value, \DateTimeZone $tz ): ?int {
		$value = trim( str_replace( 'T', ' ', $value ) );
		if ( $value === '' ) {
			return null;
		}
		try {
			$dt = new \DateTimeImmutable( $value, $tz );
		} catch ( \Exception $e ) {
			return null;
		}
		return $dt->getTimestamp();
	}

	/**
	 * Check if snippet passes conditional logic rules.
	 *
	 * @param \WP_Post $post Snippet post.
	 * @return bool
	 */
	private function passesConditionalLogic( \WP_Post $post ): bool {
		$enabled = get_post_meta( $post->ID, '_wpps_conditional_enabled', true ) === '1';
		if ( ! $enabled ) {
			return true;
		}
		$rules = get_post_meta( $post->ID, '_wpps_conditional_rules', true );
		if ( ! is_array( $rules ) || empty( $rules ) ) {
			return true;
		}
		$show = ( $rules['show'] ?? get_post_meta( $post->ID, '_wpps_conditional_show', true ) ) !== '0';
		$pass = $this->evaluateConditionalRules( $rules );
		return $show ? $pass : ! $pass;
	}

	/**
	 * Evaluate conditional logic. Supports groups: OR between groups, AND within each group.
	 * Legacy: flat rules array treated as single group.
	 *
	 * @param array $rules Rules with optional 'groups' or flat array of rules.
	 * @return bool
	 */
	private function evaluateConditionalRules( array $rules ): bool {
		$groups = $rules['groups'] ?? null;
		if ( $groups === null ) {
			// Legacy flat rules.
			$groups = [ $this->normalizeRulesArray( $rules ) ];
		}
		if ( empty( $groups ) ) {
			return true;
		}
		foreach ( $groups as $group ) {
			if ( $this->evaluateRuleGroup( $group ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Evaluate a single rule group (AND within group).
	 *
	 * @param array $group Array of rules.
	 * @return bool
	 */
	private function evaluateRuleGroup( array $group ): bool {
		$group = $this->normalizeRulesArray( $group );
		foreach ( $group as $rule ) {
			$type     = $rule['type'] ?? '';
			$operator = $rule['operator'] ?? 'contains';
			$value    = $rule['value'] ?? '';
			if ( ! $this->evaluateRule( $type, $operator, $value ) ) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Sanitize conditional rules from POST (supports groups or flat).
	 *
	 * @param array $raw Raw rules from request.
	 * @return array Sanitized rules with 'show' and 'groups'.
	 */
	private function sanitizeConditionalRules( array $raw ): array {
		$sanitize_rule = function ( $r ) {
			return [
				'type'     => sanitize_key( $r['type'] ?? 'url' ),
				'operator' => sanitize_key( $r['operator'] ?? 'contains' ),
				'value'    => sanitize_text_field( $r['value'] ?? '' ),
			];
		};
		if ( isset( $raw['groups'] ) && is_array( $raw['groups'] ) ) {
			$groups = [];
			foreach ( array_slice( $raw['groups'], 0, 10 ) as $group ) {
				if ( ! is_array( $group ) ) {
					continue;
				}
				$rules_in_group = [];
				foreach ( array_slice( $group, 0, 20 ) as $r ) {
					if ( is_array( $r ) && isset( $r['type'] ) ) {
						$rules_in_group[] = $sanitize_rule( $r );
					}
				}
				if ( ! empty( $rules_in_group ) ) {
					$groups[] = $rules_in_group;
				}
			}
			return [
				'show'   => isset( $raw['show'] ) && $raw['show'] === '0' ? '0' : '1',
				'groups' => ! empty( $groups ) ? $groups : [ [ $sanitize_rule( [ 'type' => 'url', 'operator' => 'contains', 'value' => '' ] ) ] ],
			];
		}
		$flat = [];
		foreach ( array_slice( $raw, 0, 20 ) as $r ) {
			if ( is_array( $r ) && isset( $r['type'] ) ) {
				$flat[] = $sanitize_rule( $r );
			}
		}
		return [
			'show'   => '1',
			'groups' => ! empty( $flat ) ? [ $flat ] : [ [ $sanitize_rule( [ 'type' => 'url', 'operator' => 'contains', 'value' => '' ] ) ] ],
		];
	}

	/**
	 * Normalize rules to array of rule arrays (skip non-rule keys).
	 *
	 * @param array $rules Rules or mixed.
	 * @return array
	 */
	private function normalizeRulesArray( array $rules ): array {
		$out = [];
		foreach ( $rules as $k => $v ) {
			if ( $k === 'groups' || $k === 'show' ) {
				continue;
			}
			if ( is_array( $v ) && isset( $v['type'] ) ) {
				$out[] = $v;
			}
		}
		return $out;
	}

	/**
	 * Evaluate a single conditional rule.
	 *
	 * @param string $type     Rule type: url, user_role, logged_in, type_of_page, post_type.
	 * @param string $operator contains, not_contains, equals, not_equals.
	 * @param string $value    Rule value.
	 * @return bool
	 */
	private function evaluateRule( string $type, string $operator, string $value ): bool {
		switch ( $type ) {
			case 'url':
				$current = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
				$match   = in_array( $operator, [ 'equals', 'not_equals' ], true )
					? ( strcasecmp( $current, $value ) === 0 )
					: ( stripos( $current, $value ) !== false );
				return in_array( $operator, [ 'contains', 'equals' ], true ) ? $match : ! $match;
			case 'user_role':
				if ( ! function_exists( 'wp_get_current_user' ) || ! function_exists( 'is_user_logged_in' ) ) {
					return false;
				}
				if ( ! is_user_logged_in() ) {
					return in_array( $operator, [ 'not_equals', 'not_contains' ], true );
				}
				$user = wp_get_current_user();
				$has  = in_array( $value, $user->roles, true );
				return in_array( $operator, [ 'equals', 'contains' ], true ) ? $has : ! $has;
			case 'logged_in':
				if ( ! function_exists( 'is_user_logged_in' ) ) {
					return false;
				}
				$logged_in = is_user_logged_in();
				$wanted    = $value === '1' || $value === 'yes';
				return $logged_in === $wanted;
			case 'type_of_page':
				if ( ! did_action( 'wp' ) ) {
					return false;
				}
				$current = $this->getTypeOfPage();
				$match   = $current === $value;
				return in_array( $operator, [ 'equals', 'contains' ], true ) ? $match : ! $match;
			case 'post_type':
				if ( ! did_action( 'wp' ) ) {
					return false;
				}
				$current = $this->getCurrentPostType();
				$match   = $current === $value;
				return in_array( $operator, [ 'equals', 'contains' ], true ) ? $match : ! $match;
			default:
				return true;
		}
	}

	/**
	 * Get current page type (WordPress conditional tag).
	 *
	 * @return string
	 */
	private function getTypeOfPage(): string {
		if ( is_front_page() ) {
			return 'is_front_page';
		}
		if ( is_home() ) {
			return 'is_home';
		}
		if ( is_singular( 'page' ) ) {
			return 'is_page';
		}
		if ( is_singular() ) {
			return 'is_single';
		}
		if ( is_archive() ) {
			return 'is_archive';
		}
		if ( is_search() ) {
			return 'is_search';
		}
		if ( is_404() ) {
			return 'is_404';
		}
		if ( is_author() ) {
			return 'is_author';
		}
		return '';
	}

	/**
	 * Get current post type (for singular/archive).
	 *
	 * @return string
	 */
	private function getCurrentPostType(): string {
		if ( is_singular() ) {
			$post = get_queried_object();
			return $post instanceof \WP_Post ? $post->post_type : '';
		}
		if ( is_post_type_archive() ) {
			return get_query_var( 'post_type' ) ?: '';
		}
		return '';
	}

	/**
	 * Check if snippet passes device type restriction.
	 *
	 * @param \WP_Post $post Snippet post.
	 * @return bool
	 */
	private function passesDeviceCheck( \WP_Post $post ): bool {
		$device = get_post_meta( $post->ID, '_wpps_device_type', true ) ?: 'all';
		if ( $device === 'all' ) {
			return true;
		}
		$ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
		if ( $ua === '' ) {
			return true;
		}
		$is_mobile = (bool) preg_match( '/Mobile|Android|iPhone|iPad|iPod|webOS|IEMobile/i', $ua );
		$is_tablet = (bool) preg_match( '/iPad|Android(?!.*Mobile)|Tablet|Silk/i', $ua );
		switch ( $device ) {
			case 'mobile':
				return $is_mobile && ! $is_tablet;
			case 'tablet':
				return $is_tablet;
			case 'desktop':
				return ! $is_mobile && ! $is_tablet;
			default:
				return true;
		}
	}

	/**
	 * Execute a snippet and return its output.
	 *
	 * @param \WP_Post|int $snippet Post or ID.
	 * @return string
	 */
	public function executeSnippet( $snippet ): string {
		if ( ! apply_filters( 'wpps_do_auto_insert', true ) ) {
			return '';
		}
		$post = $snippet instanceof \WP_Post ? $snippet : get_post( $snippet );
		if ( ! $post || $post->post_type !== self::POST_TYPE || $post->post_status !== 'publish' ) {
			return '';
		}

		if ( ! $this->passesProLicense( $post ) ) {
			return '';
		}

		if ( ! $this->isSnippetScheduled( $post ) ) {
			return '';
		}

		if ( ! $this->passesConditionalLogic( $post ) ) {
			return '';
		}

		if ( ! $this->passesDeviceCheck( $post ) ) {
			return '';
		}

		$code = $post->post_content;
		$type = get_post_meta( $post->ID, '_wpps_code_type', true ) ?: 'html';

		// Set global so user PHP snippets can access current location (e.g. header, body, footer).
		$loc = $this->getCurrentOutputLocation();
		if ( $loc === '' ) {
			$loc = get_post_meta( $post->ID, '_wpps_location', true ) ?: '';
		}
		$GLOBALS['wpps_location'] = $loc;

		$output = $this->executeByType( $code, $type, $post );
		do_action( 'wpps_snippet_loaded', $post, $loc );
		return $output;
	}

	private function executeByType( string $code, string $type, \WP_Post $post ): string {
		if ( in_array( $type, [ 'php', 'html' ], true ) && $this->shouldLoadPhpFromFile( $post ) ) {
			$this->ensurePhpSnippetFileIfNeeded( $post );
			$file = $this->getPhpSnippetFilePath( $post );
			if ( file_exists( $file ) ) {
				$out = $this->loadPhpFromFile( $file, $post );
				return apply_filters( 'wpps_code_snippet_output', $out, $type, $post );
			}
		}

		$code = apply_filters( 'wpps_code_snippet_output', $code, $type, $post );
		if ( ! is_string( $code ) || trim( $code ) === '' ) {
			return '';
		}

		switch ( $type ) {
			case 'php':
				return $this->safeExecutePhp( $code, $post );
			case 'js':
				// Strip any script tags first (handles legacy/double-wrap), then wrap once.
				$code = self::stripJsTagsForStorage( $code );
				if ( $code === '' ) {
					return '';
				}
				return '<script>' . "\n" . $code . "\n" . '</script>';
			case 'css':
				return '<style>' . "\n" . $code . "\n" . '</style>';
			case 'html':
			default:
				return $code;
		}
	}

	/**
	 * Get raw PHP code for execution (stripped of tags).
	 *
	 * @param \WP_Post $post Snippet post.
	 * @return string
	 */
	private function getPhpCodeForExecution( \WP_Post $post ): string {
		$code = $post->post_content;
		$code = preg_replace( '/^\s*(?:(?:<' . '?php|<\?)\s*)+/i', '', $code );
		$close = chr( 63 ) . chr( 62 );
		$code = preg_replace( '/\s*' . preg_quote( $close, '/' ) . '\s*$/', '', $code );
		$code = trim( $code );
		if ( $code === '' || $this->isCodeNotAllowed( $code ) ) {
			return '';
		}
		$code = apply_filters( 'wpps_code_snippet_output', $code, 'php', $post );
		return is_string( $code ) ? $code : '';
	}

	/**
	 * Build one eval string and a line map (1-based lines in merged code) per snippet ID.
	 *
	 * @param array<int, array{post: \WP_Post, code: string}> $items Ordered merge items.
	 * @return array{code: string, line_map: array<int, array{start:int,end:int}>, last_post: ?\WP_Post}
	 */
	private function buildMergedPhpStringAndLineMap( array $items ): array {
		$line_map   = [];
		$parts      = [];
		$line       = 1;
		$last_post  = null;
		foreach ( $items as $item ) {
			$post = $item['post'];
			$code = $item['code'];
			if ( ! $post instanceof \WP_Post || ! is_string( $code ) || $code === '' ) {
				continue;
			}
			$last_post    = $post;
			$num_lines    = substr_count( $code, "\n" ) + 1;
			$start        = $line;
			$end          = $line + $num_lines - 1;
			$line_map[ $post->ID ] = [ 'start' => $start, 'end' => $end ];
			$parts[]      = $code;
			$line         = $end + 1;
		}
		return [
			'code'     => implode( "\n", $parts ),
			'line_map' => $line_map,
			'last_post' => $last_post,
		];
	}

	/**
	 * @param array<int, array{start:int,end:int}> $line_map
	 */
	private function resolvePostFromMergedPhpLine( int $eval_line, array $line_map ): ?\WP_Post {
		if ( $eval_line < 1 || $line_map === [] ) {
			return null;
		}
		foreach ( $line_map as $snippet_id => $range ) {
			if ( $eval_line >= $range['start'] && $eval_line <= $range['end'] ) {
				$p = get_post( (int) $snippet_id );
				return ( $p && $p->post_type === self::POST_TYPE ) ? $p : null;
			}
		}
		return null;
	}

	/**
	 * Locations where a PHP error moves the snippet to draft.
	 *
	 * @return string[]
	 */
	private function phpErrorAutoDeactivateLocations(): array {
		$default = self::getAllLocationSlugs();
		$locs    = apply_filters( 'wpps_code_snippet_php_auto_deactivate_locations', $default );
		if ( ! is_array( $locs ) ) {
			return $default;
		}
		$out = [];
		foreach ( $locs as $x ) {
			if ( is_string( $x ) && $x !== '' ) {
				$out[] = $x;
			}
		}
		return $out !== [] ? array_values( array_unique( $out ) ) : $default;
	}

	private function shouldAutoDeactivateSnippetForPhpError( \WP_Post $post ): bool {
		$type = get_post_meta( $post->ID, '_wpps_code_type', true ) ?: 'html';
		if ( $type !== 'php' ) {
			return false;
		}
		$loc = get_post_meta( $post->ID, '_wpps_location', true ) ?: '';
		return in_array( $loc, $this->phpErrorAutoDeactivateLocations(), true );
	}

	/**
	 * Return an error message when a snippet must not be activated, or null if OK.
	 *
	 * @param \WP_Post $post Snippet post.
	 * @return string|null
	 */
	private function getSnippetActivationBlockReason( \WP_Post $post ): ?string {
		if ( $post->post_type !== self::POST_TYPE ) {
			return __( 'Invalid snippet.', 'wp-powersuite' );
		}
		$pro_block = $this->getProLicenseBlockReason( $post );
		if ( $pro_block !== null ) {
			return $pro_block;
		}
		$type = get_post_meta( $post->ID, '_wpps_code_type', true ) ?: 'html';
		if ( $type !== 'php' ) {
			return null;
		}
		$code = is_string( $post->post_content ) ? $post->post_content : '';
		$syntax_err = self::checkPhpSyntax( $code );
		if ( $syntax_err !== null ) {
			return __( 'PHP syntax error:', 'wp-powersuite' ) . ' ' . $syntax_err;
		}
		$runtime_err = $this->testPhpSnippetRuntime( $code );
		if ( $runtime_err !== null ) {
			return __( 'PHP error:', 'wp-powersuite' ) . ' ' . $runtime_err;
		}
		return null;
	}

	/**
	 * Validate code type vs insertion location on save.
	 *
	 * @return string|null Error message or null.
	 */
	private function validateTypeLocationPair( string $type, string $loc ): ?string {
		if ( in_array( $loc, self::getPhpOnlyLocations(), true ) && $type !== 'php' ) {
			return __( 'This location requires a PHP snippet.', 'wp-powersuite' );
		}
		if ( in_array( $loc, self::getWooLocations(), true ) && ! class_exists( 'WooCommerce' ) ) {
			return __( 'This location requires WooCommerce.', 'wp-powersuite' );
		}
		return null;
	}

	/**
	 * Sanitize CSS selector for element-based insertion (reject script-like characters).
	 */
	private function sanitizeLocationSelector( string $selector ): string {
		$selector = trim( $selector );
		if ( $selector === '' || preg_match( '/[<>{}]/', $selector ) ) {
			return '';
		}
		return sanitize_text_field( $selector );
	}

	/**
	 * Validate custom shortcode slug uniqueness and reserved names.
	 *
	 * @return string|null Error message or null.
	 */
	private function validateCustomShortcode( string $custom, int $exclude_id = 0 ): ?string {
		if ( $custom === '' ) {
			return null;
		}
		if ( in_array( $custom, [ 'wpps_code' ], true ) ) {
			return __( 'This shortcode name is reserved.', 'wp-powersuite' );
		}
		$existing_id = $this->findSnippetIdByCustomShortcode( $custom );
		if ( $existing_id !== null && ( $exclude_id <= 0 || $existing_id !== $exclude_id ) ) {
			return __( 'Another snippet already uses this custom shortcode.', 'wp-powersuite' );
		}
		if ( shortcode_exists( $custom ) ) {
			return __( 'This shortcode name is already registered on your site.', 'wp-powersuite' );
		}
		return null;
	}

	/**
	 * Find snippet post ID by custom shortcode meta.
	 */
	private function findSnippetIdByCustomShortcode( string $custom ): ?int {
		$query = new \WP_Query(
			[
				'post_type'      => self::POST_TYPE,
				'post_status'    => 'any',
				'posts_per_page' => 1,
				'fields'         => 'ids',
				'meta_query'     => [
					[
						'key'   => '_wpps_custom_shortcode',
						'value' => $custom,
					],
				],
			]
		);
		if ( empty( $query->posts ) ) {
			return null;
		}
		return (int) $query->posts[0];
	}

	/**
	 * Store last PHP error JSON on the snippet for the editor (optional diagnostics).
	 *
	 * @param array<string, mixed> $context
	 */
	private function recordSnippetLastPhpError( \WP_Post $post, string $message, array $context = [] ): void {
		$payload = [
			'time'    => current_time( 'mysql' ),
			'message' => $message,
			'context' => $context,
		];
		update_post_meta( $post->ID, self::LAST_PHP_ERROR_META, wp_json_encode( $payload ) );
	}

	/**
	 * Execute merged PHP code (Run Everywhere + Admin Only + optional Frontend Only merge).
	 *
	 * @param string                                      $code     Merged PHP code.
	 * @param \WP_Post                                    $fallback Last snippet if line cannot be resolved.
	 * @param array<int, array{start:int,end:int}>       $line_map Snippet ID => line range in merged string.
	 * @param string                                      $context  Log / meta context.
	 * @return bool True if eval completed without Throwable.
	 */
	/**
	 * Run user snippet PHP via the global eval helper, guaranteeing the helper is loaded.
	 *
	 * The helper is defined in snippet-php-eval.php and required at the top of this file.
	 * We re-check here so a stale OPcache copy, partial module load, or unusual class-load
	 * order can never surface as "Call to undefined function wp_powersuite_code_snippet_eval()".
	 */
	private function evalSnippetPhp( string $code ): void {
		if ( ! function_exists( 'wp_powersuite_code_snippet_eval' ) ) {
			$eval_file = __DIR__ . '/snippet-php-eval.php';
			if ( is_readable( $eval_file ) ) {
				// Plain require (not require_once): when the module class is served from a
				// compile-only OPcache/preload image, the top-level require_once never ran,
				// yet the path can be flagged "included". Plain require re-runs the body; the
				// file guards its own definition with function_exists, so it stays safe.
				require $eval_file;
			}
		}
		if ( ! function_exists( 'wp_powersuite_code_snippet_eval' ) ) {
			// Last-resort guarantee. Defines the global helper inline so a missing or
			// unreadable eval file can never surface as a fatal "undefined function".
			// phpcs:ignore Squiz.PHP.Eval.Discouraged -- Intentional: defines the snippet eval helper.
			eval( 'namespace { if ( ! function_exists( "wp_powersuite_code_snippet_eval" ) ) { function wp_powersuite_code_snippet_eval( string $code ): void { if ( $code !== "" ) { eval( $code ); } } } }' );
		}
		\wp_powersuite_code_snippet_eval( $code );
	}

	/**
	 * Clear stored auto-deactivation / last-error state for a snippet.
	 *
	 * The "automatically deactivated due to a PHP error" notice is driven entirely by
	 * stored post meta and persists until cleared. We call this when a snippet is
	 * successfully (re)activated so a resolved error stops nagging the user.
	 */
	private function clearSnippetErrorState( int $post_id ): void {
		delete_post_meta( $post_id, '_wpps_auto_deactivated' );
		delete_post_meta( $post_id, '_wpps_auto_deactivated_error' );
		delete_post_meta( $post_id, self::LAST_PHP_ERROR_META );
	}

	private function safeExecutePhpMerged( string $code, \WP_Post $fallback, array $line_map, string $context = 'merged' ): bool {
		if ( ! is_string( $code ) || trim( $code ) === '' ) {
			return true;
		}
		$this->merged_php_line_map        = $line_map;
		$this->last_executed_php_snippet  = $fallback;
		try {
			$this->evalSnippetPhp( $code );
		} catch ( \Throwable $e ) {
			$at_line = $e->getLine();
			$post    = $this->resolvePostFromMergedPhpLine( $at_line, $line_map ) ?? $fallback;
			$rel     = null;
			if ( isset( $line_map[ $post->ID ] ) ) {
				$rel = $at_line - $line_map[ $post->ID ]['start'] + 1;
			}
			$this->logSnippetError(
				$post,
				$e,
				[
					'merge_context' => $context,
					'eval_line'     => $at_line,
					'snippet_line'  => $rel,
				]
			);
			if ( $this->shouldAutoDeactivateSnippetForPhpError( $post ) ) {
				wp_update_post( [ 'ID' => $post->ID, 'post_status' => 'draft' ] );
				update_post_meta( $post->ID, '_wpps_auto_deactivated', time() );
				update_post_meta( $post->ID, '_wpps_auto_deactivated_error', $e->getMessage() );
				$this->bustSnippetCaches();
				$this->notifyAutoDeactivateInAdmin( $post, $e->getMessage() );
			}
			$this->merged_php_line_map       = [];
			$this->last_executed_php_snippet = null;
			return false;
		}
		$this->merged_php_line_map        = [];
		$this->last_executed_php_snippet  = null;
		return true;
	}

	private function safeExecutePhp( string $code, \WP_Post $post ): string {
		// Strip PHP opening/closing tags — eval expects raw PHP only.
		$code = preg_replace( '/^\s*(?:(?:<' . '?php|<\?)\s*)+/i', '', $code );
		$close = chr( 63 ) . chr( 62 );
		$code = preg_replace( '/\s*' . preg_quote( $close, '/' ) . '\s*$/', '', $code );
		$code = trim( $code );
		if ( $code === '' ) {
			return '';
		}

		// Block dangerous patterns
		if ( $this->isCodeNotAllowed( $code ) ) {
			return '';
		}

		$code = apply_filters( 'wpps_code_snippet_php_before_eval', $code, $post, 'single' );
		if ( ! is_string( $code ) || trim( $code ) === '' ) {
			return '';
		}

		$this->last_executed_php_snippet = $post;
		ob_start();
		try {
			$this->evalSnippetPhp( $code );
		} catch ( \Throwable $e ) {
			ob_end_clean();
			$this->logSnippetError(
				$post,
				$e,
				[
					'merge_context' => 'single',
					'eval_line'     => $e->getLine(),
					'snippet_line'  => $e->getLine(),
				]
			);
			if ( $this->shouldAutoDeactivateSnippetForPhpError( $post ) ) {
				wp_update_post( [ 'ID' => $post->ID, 'post_status' => 'draft' ] );
				update_post_meta( $post->ID, '_wpps_auto_deactivated', time() );
				update_post_meta( $post->ID, '_wpps_auto_deactivated_error', $e->getMessage() );
				$this->bustSnippetCaches();
				$this->notifyAutoDeactivateInAdmin( $post, $e->getMessage() );
			}
			if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
				return '<!-- ' . esc_html( wpps_cs_brand_name() ) . ' PHP Error: ' . esc_html( $e->getMessage() ) . ' -->';
			}
			return '';
		}
		$this->last_executed_php_snippet = null;
		$out = ob_get_clean();
		$out = apply_filters( 'wpps_code_snippet_after_php_eval', $out, $post, 'single' );
		return apply_filters( 'wpps_code_snippet_output', is_string( $out ) ? $out : '', 'php', $post );
	}

	/**
	 * Shutdown handler: catch fatal errors from PHP snippets and auto-deactivate.
	 *
	 * @return void
	 */
	public function maybeDisableSnippetFromShutdown(): void {
		$error = error_get_last();
		if ( ! $error || ! isset( $error['type'], $error['message'] ) ) {
			return;
		}
		if ( $error['type'] === E_NOTICE ) {
			return;
		}
		$fatal_types = $this->shutdownFatalErrorTypes();
		if ( ! in_array( $error['type'], $fatal_types, true ) ) {
			return;
		}
		if ( ! $this->isErrorFromSnippet( $error ) ) {
			return;
		}
		$eval_line = isset( $error['line'] ) ? (int) $error['line'] : 0;
		$post      = null;
		if ( $this->merged_php_line_map !== [] && $eval_line > 0 ) {
			$post = $this->resolvePostFromMergedPhpLine( $eval_line, $this->merged_php_line_map );
		}
		if ( ! $post instanceof \WP_Post ) {
			$post = $this->last_executed_php_snippet;
		}
		if ( ! $post || $post->post_type !== self::POST_TYPE ) {
			return;
		}
		if ( ! $this->shouldAutoDeactivateSnippetForPhpError( $post ) ) {
			return;
		}
		$extra = [
			'merge_context' => $this->merged_php_line_map !== [] ? 'merged_fatal' : 'single_fatal',
			'eval_line'     => $eval_line,
			'snippet_line'  => null,
		];
		if ( isset( $this->merged_php_line_map[ $post->ID ] ) && $eval_line > 0 ) {
			$extra['snippet_line'] = $eval_line - $this->merged_php_line_map[ $post->ID ]['start'] + 1;
		}
		wp_update_post( [ 'ID' => $post->ID, 'post_status' => 'draft' ] );
		update_post_meta( $post->ID, '_wpps_auto_deactivated', time() );
		update_post_meta( $post->ID, '_wpps_auto_deactivated_error', $error['message'] ?? '' );
		$this->bustSnippetCaches();
		$this->notifyAutoDeactivateInAdmin( $post, (string) ( $error['message'] ?? '' ) );
		$this->logSnippetErrorFromShutdown( $post, $error, $extra );
		$this->merged_php_line_map = [];
	}

	/**
	 * Check if error originated from our eval'd snippet code.
	 *
	 * @param array $error Error from error_get_last().
	 * @return bool
	 */
	private function isErrorFromSnippet( array $error ): bool {
		$msg = $error['message'] ?? '';
		$file = $error['file'] ?? '';
		$pattern = '/\bwp-powersuite\b.*\bmodule\.php\b/';
		if ( preg_match( $pattern, $file ) ) {
			return true;
		}
		if ( str_contains( $file, 'snippet-php-eval.php' ) ) {
			return true;
		}
		// eval()'d code often reports as "eval()'d code" or similar.
		if ( str_contains( $msg, 'eval()' ) || str_contains( $file, 'eval()' ) ) {
			return true;
		}
		$dir = realpath( $this->getPhpSnippetFileDir() );
		if ( $dir && $file !== '' ) {
			$errf = realpath( $file );
			if ( $errf && strpos( $errf, $dir ) === 0 ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Log snippet error from shutdown (fatal) - no Throwable.
	 *
	 * @param \WP_Post             $post  Snippet post.
	 * @param array                $error Error array.
	 * @param array<string, mixed> $extra merge_context, eval_line, snippet_line, etc.
	 */
	private function logSnippetErrorFromShutdown( \WP_Post $post, array $error, array $extra = [] ): void {
		$msg = $error['message'] ?? '';
		$this->recordSnippetLastPhpError(
			$post,
			$msg,
			array_merge(
				[
					'source' => 'shutdown',
					'file'   => $error['file'] ?? '',
					'line'   => $error['line'] ?? 0,
				],
				$extra
			)
		);
		if ( ! get_option( 'wpps_cs_error_logging', false ) ) {
			return;
		}
		$logs = get_option( self::ERROR_LOG_OPTION, [] );
		if ( ! is_array( $logs ) ) {
			$logs = [];
		}
		$row = [
			'time'     => current_time( 'mysql' ),
			'snippet'  => $post->ID,
			'title'    => $post->post_title,
			'message'  => $msg,
			'file'     => $error['file'] ?? '',
			'line'     => $error['line'] ?? 0,
			'location' => get_post_meta( $post->ID, '_wpps_location', true ) ?: '',
		];
		if ( $extra !== [] ) {
			$row['merge_context'] = $extra['merge_context'] ?? '';
			$row['snippet_line']  = $extra['snippet_line'] ?? null;
		}
		$logs[] = $row;
		$logs = array_slice( $logs, -$this->getErrorLogMaxEntries() );
		update_option( self::ERROR_LOG_OPTION, $logs, false );
	}

	/**
	 * Log snippet error if error logging is enabled.
	 *
	 * @param \WP_Post             $post  Snippet post.
	 * @param \Throwable           $e     Exception.
	 * @param array<string, mixed> $extra merge_context, eval_line, snippet_line, location overrides.
	 */
	private function logSnippetError( \WP_Post $post, \Throwable $e, array $extra = [] ): void {
		$this->recordSnippetLastPhpError(
			$post,
			$e->getMessage(),
			array_merge(
				[
					'source' => 'catch',
					'file'   => $e->getFile(),
					'line'   => $e->getLine(),
				],
				$extra
			)
		);
		if ( ! get_option( 'wpps_cs_error_logging', false ) ) {
			return;
		}
		$logs = get_option( self::ERROR_LOG_OPTION, [] );
		if ( ! is_array( $logs ) ) {
			$logs = [];
		}
		$row = [
			'time'     => current_time( 'mysql' ),
			'snippet'  => $post->ID,
			'title'    => $post->post_title,
			'message'  => $e->getMessage(),
			'file'     => $e->getFile(),
			'line'     => $e->getLine(),
			'location' => get_post_meta( $post->ID, '_wpps_location', true ) ?: '',
		];
		if ( $extra !== [] ) {
			$row['merge_context'] = $extra['merge_context'] ?? '';
			$row['snippet_line']  = $extra['snippet_line'] ?? null;
		}
		$logs[] = $row;
		$logs = array_slice( $logs, -$this->getErrorLogMaxEntries() );
		update_option( self::ERROR_LOG_OPTION, $logs, false );
	}

	/**
	 * Strip leading PHP tags from code for storage. Ensures we never save <?php in DB.
	 *
	 * @param string $code Raw code from editor.
	 * @return string Code with leading <?php/<? stripped.
	 */
	public static function stripPhpTagsForStorage( string $code ): string {
		$code = preg_replace( '/^\s*(?:(?:<' . '?php|<\?)\s*)+/i', '', $code );
		$close = chr( 63 ) . chr( 62 );
		$code = preg_replace( '/\s*' . preg_quote( $close, '/' ) . '\s*$/', '', $code );
		return trim( $code );
	}

	/**
	 * Strip script tags from JS code for storage. Ensures we never save <script> in DB.
	 * Handles multiple outer wrappers (e.g. <script><script>code</script></script>).
	 *
	 * @param string $code Raw code from editor.
	 * @return string Code with script tags stripped.
	 */
	public static function stripJsTagsForStorage( string $code ): string {
		$code = trim( $code );
		while ( preg_match( '/^\s*<script[^>]*>/i', $code ) && preg_match( '/<\/script>\s*$/i', $code ) ) {
			$code = preg_replace( '/^\s*<script[^>]*>\s*/i', '', $code );
			$code = preg_replace( '/\s*<\/script>\s*$/i', '', $code );
			$code = trim( $code );
		}
		return $code;
	}

	/**
	 * Resolve PHP CLI binary for `php -l` (syntax check). Web SAPIs (e.g. php-fpm on Plesk) often
	 * have no `php` on PATH; PHP_BINDIR usually points at the matching CLI.
	 *
	 * @return string Executable path or `php` to rely on PATH.
	 */
	private static function resolvePhpCliBinary(): string {
		$filtered = apply_filters( 'wpps_code_snippet_php_binary', null );
		if ( is_string( $filtered ) && $filtered !== '' && is_file( $filtered ) && is_executable( $filtered ) ) {
			return $filtered;
		}

		$candidates = [];
		if ( defined( 'PHP_BINARY' ) && is_string( PHP_BINARY ) && PHP_BINARY !== '' ) {
			$candidates[] = PHP_BINARY;
		}
		if ( defined( 'PHP_BINDIR' ) && is_string( PHP_BINDIR ) && PHP_BINDIR !== '' ) {
			$bindir = rtrim( PHP_BINDIR, '/\\' );
			$sep    = DIRECTORY_SEPARATOR;
			if ( PHP_OS_FAMILY === 'Windows' ) {
				$candidates[] = $bindir . $sep . 'php.exe';
				$candidates[] = $bindir . $sep . 'php-cli.exe';
			} else {
				$candidates[] = $bindir . $sep . 'php';
				$candidates[] = $bindir . $sep . 'php-cli';
			}
		}

		foreach ( $candidates as $bin ) {
			if ( ! is_string( $bin ) || $bin === '' ) {
				continue;
			}
			$base = strtolower( pathinfo( $bin, PATHINFO_FILENAME ) );
			if ( $base === 'php-fpm' || str_starts_with( $base, 'php-fpm' ) ) {
				continue;
			}
			if ( is_file( $bin ) && is_executable( $bin ) ) {
				return $bin;
			}
		}

		return 'php';
	}

	/**
	 * True when `php -l` failed because the host could not run the PHP CLI, not because of invalid code.
	 */
	private static function isPhpLintEnvironmentFailure( string $combined_output, int $exit_code ): bool {
		// Typical shell: 127 = command not found, 126 = not executable.
		if ( in_array( $exit_code, [ 126, 127 ], true ) ) {
			return true;
		}
		$out = $combined_output;
		// Real lint failures from PHP itself (always treat as syntax/code issues, not environment).
		if ( preg_match( '/PHP\s+Parse\s+error|Parse\s+error\s*:|syntax error,\s*unexpected|syntax error, unexpected|Errors parsing/i', $out ) ) {
			return false;
		}
		// Could not invoke PHP CLI (Plesk, hardened PHP-FPM, Windows PATH, SELinux, etc.).
		return (bool) preg_match(
			'/\bnot found\b|No such file or directory|not recognized as an internal or external command|Cannot run|Operation not permitted|Permission denied|Exec format error|bad ELF interpreter|failed to map segment/i',
			$out
		);
	}

	/**
	 * Check PHP code for syntax errors before saving.
	 *
	 * @param string $code Raw PHP code (without <?php tags).
	 * @return string|null Error message if invalid, null if valid.
	 */
	public static function checkPhpSyntax( string $code ): ?string {
		if ( apply_filters( 'wpps_code_snippet_skip_php_syntax_check', false ) ) {
			return null;
		}
		$code = trim( $code );
		if ( $code === '' ) {
			return null;
		}
		$full = '<?php ' . $code;
		$tmp  = tempnam( sys_get_temp_dir(), 'wpps_php_' );
		if ( ! $tmp ) {
			return null;
		}
		$ok = file_put_contents( $tmp, $full );
		if ( $ok === false ) {
			@unlink( $tmp );
			return null;
		}
		$output = [];
		$binary = self::resolvePhpCliBinary();
		$cmd    = sprintf( '%s -l %s 2>&1', escapeshellarg( $binary ), escapeshellarg( $tmp ) );
		if ( ! function_exists( 'exec' ) || in_array( 'exec', array_map( 'trim', explode( ',', (string) ini_get( 'disable_functions' ) ) ), true ) ) {
			@unlink( $tmp );
			return null;
		}
		exec( $cmd, $output, $ret );
		@unlink( $tmp );
		if ( $ret !== 0 && ! empty( $output ) ) {
			$msg = implode( ' ', $output );
			if ( self::isPhpLintEnvironmentFailure( $msg, $ret ) ) {
				return null;
			}
			return preg_replace( '/^.*?:\s*/', '', $msg );
		}
		return null;
	}

	/**
	 * Hooks to restore after snippet test eval. Output hooks are the usual targets of
	 * add_action() in snippets; leaving them registered confuses some admin-side code that
	 * walks $wp_filter (e.g. method_exists() on callbacks) during admin_notices.
	 *
	 * @var string[]
	 */
	private const SNIPPET_TEST_HOOK_ROLLBACK_TAGS = [
		'wp_footer',
		'wp_head',
		'wp_body_open',
		'admin_footer',
		'admin_head',
	];

	/**
	 * Undo hook mutations on high-traffic output hooks after test eval (see self::SNIPPET_TEST_HOOK_ROLLBACK_TAGS).
	 *
	 * @param array<string, \WP_Hook> $snapshots Cloned WP_Hook instances keyed by tag (only rollback tags).
	 * @param string[]                $had_tag   Tags that had a WP_Hook before eval (same list).
	 */
	private function rollbackHookStateAfterSnippetTest( array $snapshots, array $had_tag ): void {
		global $wp_filter;
		foreach ( self::SNIPPET_TEST_HOOK_ROLLBACK_TAGS as $tag ) {
			if ( isset( $snapshots[ $tag ] ) ) {
				$wp_filter[ $tag ] = $snapshots[ $tag ];
				continue;
			}
			if ( ! isset( $had_tag[ $tag ] ) && isset( $wp_filter[ $tag ] ) ) {
				unset( $wp_filter[ $tag ] );
			}
		}
	}

	/**
	 * Test-run PHP snippet to catch runtime errors (e.g. fatal on first line).
	 *
	 * Snippets that call add_action/add_filter are skipped here (see regex below); syntax still
	 * runs via checkPhpSyntax(). We do not call do_action( 'admin_notices' ) after eval — several
	 * popular admin plugins throw TypeError (method_exists null) during that hook in admin-ajax.
	 * Optional: add_filter( 'wpps_code_snippet_runtime_test_fire_admin_notices', '__return_true' ).
	 * Force hook-registration test: add_filter( 'wpps_code_snippet_force_runtime_test_with_hooks', '__return_true' ).
	 *
	 * @param string $code Raw PHP code (without <?php tags).
	 * @return string|null Error message if runtime error, null if ok.
	 */
	private function testPhpSnippetRuntime( string $code ): ?string {
		$code = trim( $code );
		if ( $code === '' ) {
			return null;
		}

		$blocked = SnippetThreatScanner::getRuntimeBlockReason( $code );
		if ( $blocked !== null ) {
			return $blocked;
		}
		$code = preg_replace( '/^\s*(?:(?:<' . '?php|<\?)\s*)+/i', '', $code );
		$close = chr( 63 ) . chr( 62 );
		$code = preg_replace( '/\s*' . preg_quote( $close, '/' ) . '\s*$/', '', $code );
		$code = trim( $code );
		if ( $code === '' ) {
			return null;
		}

		if ( ! apply_filters( 'wpps_code_snippet_force_runtime_test_with_hooks', false, $code )
			&& preg_match( '/\badd_(?:action|filter)\s*\(/', $code ) ) {
			return null;
		}

		global $wp_filter;
		$snapshots = [];
		$had_tag   = [];
		foreach ( self::SNIPPET_TEST_HOOK_ROLLBACK_TAGS as $tag ) {
			if ( isset( $wp_filter[ $tag ] ) && $wp_filter[ $tag ] instanceof \WP_Hook ) {
				$snapshots[ $tag ] = clone $wp_filter[ $tag ];
				$had_tag[ $tag ]   = true;
			}
		}

		ob_start();
		try {
			$this->evalSnippetPhp( $code );
		} catch ( \Throwable $e ) {
			ob_end_clean();
			$this->rollbackHookStateAfterSnippetTest( $snapshots, $had_tag );
			return $e->getMessage();
		}
		$this->rollbackHookStateAfterSnippetTest( $snapshots, $had_tag );

		if ( apply_filters( 'wpps_code_snippet_runtime_test_fire_admin_notices', false, $code ) ) {
			try {
				do_action( 'admin_notices' );
			} catch ( \Throwable $e ) {
				ob_end_clean();
				return $e->getMessage();
			}
		}
		ob_end_clean();
		return null;
	}

	private function isCodeNotAllowed( string $code ): bool {
		return SnippetThreatScanner::isRuntimeBlocked( $code );
	}

	public function shortcodeHandler( array $atts ): string {
		$atts = shortcode_atts( [ 'id' => 0 ], $atts, 'wpps_code' );
		$id   = absint( $atts['id'] );
		if ( $id === 0 ) {
			return '';
		}
		$this->output_location_stack[] = 'shortcode';
		$out = $this->executeSnippet( $id );
		array_pop( $this->output_location_stack );
		return $out;
	}

	/**
	 * Register custom shortcodes for snippets that use them.
	 */
	public function registerCustomShortcodes(): void {
		$map = get_transient( self::SHORTCODE_CACHE_OPTION );
		if ( ! is_array( $map ) ) {
			$map = $this->buildCustomShortcodeMap();
			set_transient( self::SHORTCODE_CACHE_OPTION, $map, HOUR_IN_SECONDS );
		}
		foreach ( $map as $custom => $snippet_id ) {
			$custom     = (string) $custom;
			$snippet_id = (int) $snippet_id;
			if ( $custom === '' || $snippet_id <= 0 ) {
				continue;
			}
			add_shortcode(
				$custom,
				function ( $atts ) use ( $snippet_id ) {
					$this->output_location_stack[] = 'shortcode';
					$out = $this->executeSnippet( $snippet_id );
					array_pop( $this->output_location_stack );
					return $out;
				}
			);
		}
	}

	/**
	 * Build map of custom shortcode slug => snippet post ID.
	 *
	 * @return array<string, int>
	 */
	private function buildCustomShortcodeMap(): array {
		$map      = [];
		$snippets = $this->getAllSnippets( [ 'post_status' => 'publish' ] );
		foreach ( $snippets as $post ) {
			$auto_insert = get_post_meta( $post->ID, '_wpps_auto_insert', true ) === '1';
			if ( $auto_insert ) {
				continue;
			}
			$custom = get_post_meta( $post->ID, '_wpps_custom_shortcode', true );
			if ( ! is_string( $custom ) || $custom === '' ) {
				continue;
			}
			$map[ $custom ] = $post->ID;
		}
		return $map;
	}

	/**
	 * Get snippet counts (all, active, inactive) in a single query.
	 *
	 * @return array{all: int, active: int, inactive: int}
	 */
	public function getSnippetCounts(): array {
		global $wpdb;
		$table = $wpdb->posts;
		$type  = self::POST_TYPE;
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT post_status, COUNT(*) as cnt FROM {$table} WHERE post_type = %s AND post_status IN ('publish','draft','private','pending','trash') GROUP BY post_status",
				$type
			),
			ARRAY_A
		);
		$counts = [ 'all' => 0, 'active' => 0, 'inactive' => 0 ];
		foreach ( $rows as $row ) {
			$cnt = (int) $row['cnt'];
			$counts['all'] += $cnt;
			if ( $row['post_status'] === 'publish' ) {
				$counts['active'] = $cnt;
			} else {
				$counts['inactive'] += $cnt;
			}
		}
		return $counts;
	}

	/**
	 * Get all snippets (for admin).
	 *
	 * @param array $args Query args.
	 * @return \WP_Post[]
	 */
	public function getAllSnippets( array $args = [] ): array {
		$defaults = [
			'post_type'      => self::POST_TYPE,
			'post_status'    => 'any',
			'posts_per_page' => -1,
			'orderby'        => 'modified',
			'order'          => 'DESC',
		];
		$args = array_merge( $defaults, $args );
		if ( isset( $args['orderby'] ) && $args['orderby'] === 'priority' ) {
			$args['meta_key'] = '_wpps_priority';
			$args['orderby']  = 'meta_value_num';
		}
		$query = new \WP_Query( $args );
		return $query->posts;
	}

	public function addAdminMenu(): void {
		add_submenu_page(
			'powersuite',
			__( 'Code Snippets', 'wp-powersuite' ),
			__( 'Code Snippets', 'wp-powersuite' ),
			'manage_options',
			'powersuite-code-snippet',
			[ $this, 'renderAdminPage' ]
		);
	}

	public function enqueueCodeSnippetAssets( string $hook_suffix ): void {
		$page   = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
		$action = isset( $_GET['action'] ) ? sanitize_key( wp_unslash( $_GET['action'] ) ) : '';
		if ( $page !== 'powersuite-code-snippet' ) {
			return;
		}
		$base    = defined( 'WP_POWERSUITE_PLUGIN_URL' ) ? WP_POWERSUITE_PLUGIN_URL : '';
		$css_path = __DIR__ . '/assets/cs-admin.css';
		$css_ver  = is_readable( $css_path ) ? (string) filemtime( $css_path ) : self::MODULE_VERSION;
		wp_enqueue_style( 'wpps-cs-admin', $base . 'modules/code-snippet/assets/cs-admin.css', [], $css_ver );
		$module_settings_ui = defined( 'WP_POWERSUITE_SRC_DIR' )
			? WP_POWERSUITE_SRC_DIR . 'Admin' . DIRECTORY_SEPARATOR . 'ModuleSettingsUi.php'
			: '';
		if ( $module_settings_ui !== '' && is_readable( $module_settings_ui ) ) {
			require_once $module_settings_ui;
			wpps_enqueue_accordion_tooltip_script();
		}
		// Only load CodeMirror on add/edit pages, not list (faster list page)
		if ( $action === 'add' || ( $action === 'edit' && isset( $_GET['id'] ) && absint( $_GET['id'] ) > 0 ) ) {
			wp_enqueue_code_editor( [ 'type' => 'text/html' ] );
			wp_enqueue_code_editor( [ 'type' => 'text/css' ] );
			wp_enqueue_code_editor( [ 'type' => 'text/javascript' ] );
			wp_enqueue_code_editor( [ 'type' => 'application/x-httpd-php' ] );
		}
	}

	public function renderAdminPage(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'wp-powersuite' ) );
		}
		$view   = isset( $_GET['view'] ) ? sanitize_key( wp_unslash( $_GET['view'] ) ) : '';
		$action = isset( $_GET['action'] ) ? sanitize_key( wp_unslash( $_GET['action'] ) ) : '';
		$id     = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0;

		if ( $view === 'settings' ) {
			$this->renderSettingsPage();
			return;
		}
		if ( $view === 'logs' ) {
			$this->renderLogsPage();
			return;
		}
		if ( $view === 'revisions' ) {
			$this->renderRevisionsPage();
			return;
		}

		if ( $id > 0 && in_array( $action, [ 'activate', 'deactivate', 'delete' ], true ) ) {
			$nonce = isset( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : '';
			$nonce_action = $action . '-snippet_' . $id;
			if ( wp_verify_nonce( $nonce, $nonce_action ) ) {
				$post = get_post( $id );
				if ( $post && $post->post_type === self::POST_TYPE ) {
					if ( $action === 'delete' ) {
						wp_delete_post( $id, true );
						$this->bustSnippetCaches();
					} else {
						$new_status = $action === 'activate' ? 'publish' : 'draft';
						if ( $new_status === 'publish' ) {
							$block = $this->getSnippetActivationBlockReason( $post );
							if ( $block !== null ) {
								wp_safe_redirect(
									add_query_arg(
										'cs_error',
										rawurlencode( $block ),
										remove_query_arg( [ 'action', 'id', '_wpnonce' ], wp_get_referer() ?: admin_url( 'admin.php?page=powersuite-code-snippet' ) )
									)
								);
								exit;
							}
							$this->clearSnippetErrorState( $id );
						}
						wp_update_post( [ 'ID' => $id, 'post_status' => $new_status ] );
						$this->bustSnippetCaches();
					}
				}
				$redirect = $action === 'delete'
					? admin_url( 'admin.php?page=powersuite-code-snippet' )
					: remove_query_arg( [ 'action', 'id', '_wpnonce' ], wp_get_referer() ?: admin_url( 'admin.php?page=powersuite-code-snippet' ) );
				wp_safe_redirect( $redirect );
				exit;
			}
		}

		$bulk_action = isset( $_POST['action'] ) && $_POST['action'] !== '-1' ? sanitize_key( wp_unslash( $_POST['action'] ) ) : ( isset( $_POST['action2'] ) && $_POST['action2'] !== '-1' ? sanitize_key( wp_unslash( $_POST['action2'] ) ) : '' );
		$bulk_ids = isset( $_POST['snippet'] ) && is_array( $_POST['snippet'] ) ? array_map( 'absint', wp_unslash( $_POST['snippet'] ) ) : [];
		$bulk_ids = array_filter( $bulk_ids );
		if ( $bulk_action && ! empty( $bulk_ids ) && check_admin_referer( 'bulk-snippets' ) ) {
			$done    = 0;
			$skipped = 0;
			foreach ( $bulk_ids as $sid ) {
				$post = get_post( $sid );
				if ( ! $post || $post->post_type !== self::POST_TYPE ) {
					continue;
				}
				if ( $bulk_action === 'delete' ) {
					wp_delete_post( $sid, true );
					$done++;
				} elseif ( $bulk_action === 'activate' ) {
					if ( $this->getSnippetActivationBlockReason( $post ) !== null ) {
						$skipped++;
						continue;
					}
					$this->clearSnippetErrorState( $sid );
					wp_update_post( [ 'ID' => $sid, 'post_status' => 'publish' ] );
					$done++;
				} elseif ( $bulk_action === 'deactivate' ) {
					wp_update_post( [ 'ID' => $sid, 'post_status' => 'draft' ] );
					$done++;
				}
			}
			if ( $done > 0 || $skipped > 0 ) {
				$this->bustSnippetCaches();
			}
			$redirect = add_query_arg( 'page', 'powersuite-code-snippet', admin_url( 'admin.php' ) );
			$status_param = isset( $_POST['status'] ) ? sanitize_key( wp_unslash( $_POST['status'] ) ) : ( isset( $_GET['status'] ) ? sanitize_key( wp_unslash( $_GET['status'] ) ) : 'all' );
			$redirect = add_query_arg( 'status', $status_param, $redirect );
			if ( isset( $_POST['s'] ) && $_POST['s'] !== '' ) {
				$redirect = add_query_arg( 's', sanitize_text_field( wp_unslash( $_POST['s'] ) ), $redirect );
			}
			if ( $done > 0 ) {
				$redirect = add_query_arg( 'cs_done', $done . '_' . $bulk_action, $redirect );
			}
			if ( $skipped > 0 ) {
				$redirect = add_query_arg( 'cs_skipped', $skipped, $redirect );
			}
			wp_safe_redirect( $redirect );
			exit;
		}

		// Revisions page is handled by renderRevisionsPage() above.
		$is_pro = function_exists( 'wp_powersuite_is_license_valid' ) && wp_powersuite_is_license_valid();
		if ( $action === 'add' ) {
			$snippet_data = null;
			$is_edit      = false;
			include __DIR__ . '/edit.php';
		} elseif ( $action === 'edit' && $id > 0 ) {
			$snippet_data = $this->getSnippetData( $id );
			if ( $snippet_data === null ) {
				wp_safe_redirect( admin_url( 'admin.php?page=powersuite-code-snippet' ) );
				exit;
			}
			$is_edit = true;
			include __DIR__ . '/edit.php';
		} else {
			$module_id = self::MODULE_ID;
			include __DIR__ . '/admin.php';
		}
	}

	/**
	 * Get Code Snippet settings.
	 *
	 * @return array
	 */
	public static function getSettings(): array {
		$defaults = [
			'error_logging'            => false,
			'editor_height'            => 300,
			'editor_height_auto'       => false,
			'admin_bar_info'           => true,
			'load_snippets_as_files'   => false,
			'revisions_to_keep'        => 10,
			'merge_frontend_only_php'  => true,
			'global_safe_mode'         => false,
		];
		$opts = get_option( self::SETTINGS_OPTION, [] );
		$opts = is_array( $opts ) ? $opts : [];
		// Migrate from old separate options to single option.
		if ( ! isset( $opts['load_snippets_as_files'] ) && ( ! empty( $opts['load_php_as_files'] ) || ! empty( $opts['load_js_css_as_files'] ) ) ) {
			$opts['load_snippets_as_files'] = true;
		}
		return array_merge( $defaults, $opts );
	}

	/**
	 * Get a single setting.
	 *
	 * @param string $key Setting key.
	 * @param mixed  $default Default value.
	 * @return mixed
	 */
	public static function getSetting( string $key, $default = null ) {
		$opts = self::getSettings();
		return $opts[ $key ] ?? $default;
	}

	/**
	 * Render the settings page.
	 */
	public function renderSettingsPage(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'You do not have sufficient permissions.', 'wp-powersuite' ) );
		}
		$settings = self::getSettings();
		if ( isset( $_POST['wpps_cs_settings'] ) && check_admin_referer( 'wpps_cs_settings' ) ) {
			$settings = [
				'error_logging'           => isset( $_POST['wpps_cs_error_logging'] ),
				'editor_height'           => ! empty( $_POST['wpps_cs_editor_height_auto'] )
					? ( (int) ( $settings['editor_height'] ?? 300 ) )
					: max( 100, min( 800, (int) ( $_POST['wpps_cs_editor_height'] ?? 300 ) ) ),
				'editor_height_auto'      => isset( $_POST['wpps_cs_editor_height_auto'] ),
				'admin_bar_info'          => isset( $_POST['wpps_cs_admin_bar_info'] ),
				'load_snippets_as_files'  => isset( $_POST['wpps_cs_load_snippets_as_files'] ),
				'revisions_to_keep'       => max( 1, min( 100, (int) ( $_POST['wpps_cs_revisions_to_keep'] ?? 10 ) ) ),
				'merge_frontend_only_php' => isset( $_POST['wpps_cs_merge_frontend_only_php'] ),
				'global_safe_mode'        => isset( $_POST['wpps_cs_global_safe_mode'] ),
			];
			update_option( self::SETTINGS_OPTION, $settings, false );
			update_option( 'wpps_cs_error_logging', $settings['error_logging'], false );
			update_option( 'wpps_cs_global_safe_mode', $settings['global_safe_mode'], false );
			if ( ! empty( $settings['load_snippets_as_files'] ) ) {
				$all = $this->getAllSnippets( [ 'post_status' => 'publish' ] );
				foreach ( $all as $post ) {
					$type = get_post_meta( $post->ID, '_wpps_code_type', true ) ?: 'html';
					$auto = get_post_meta( $post->ID, '_wpps_auto_insert', true ) === '1';
					if ( $auto && in_array( $type, [ 'php', 'html' ], true ) ) {
						$this->ensurePhpSnippetFile( $post );
					}
				}
				foreach ( [ 'header', 'body', 'footer' ] as $loc ) {
					$snippets = $this->getSnippetsForLocation( $loc );
					foreach ( $snippets as $post ) {
						$type = get_post_meta( $post->ID, '_wpps_code_type', true ) ?: 'html';
						if ( in_array( $type, [ 'js', 'css' ], true ) ) {
							$code = $post->post_content;
							$code = preg_replace( '/^\s*<script[^>]*>|<\/script>\s*$/i', '', $code );
							$code = preg_replace( '/^\s*<style[^>]*>|<\/style>\s*$/i', '', $code );
							$this->ensureSnippetFile( $post, $type, trim( $code ) );
						}
					}
				}
				$this->bustSnippetCaches();
			}
			echo '<div class="notice notice-success is-dismissible"><p>' . esc_html__( 'Settings saved.', 'wp-powersuite' ) . '</p></div>';
		}
		?>
		<div class="wrap">
			<h1><?php esc_html_e( 'Code Snippet Settings', 'wp-powersuite' ); ?></h1>
			<p><a href="<?php echo esc_url( admin_url( 'admin.php?page=powersuite-code-snippet' ) ); ?>">&larr; <?php esc_html_e( 'Back to Snippets', 'wp-powersuite' ); ?></a></p>
			<form method="post">
				<?php wp_nonce_field( 'wpps_cs_settings' ); ?>
				<table class="form-table">
					<tr>
						<th scope="row"><?php esc_html_e( 'Error Logging', 'wp-powersuite' ); ?></th>
						<td>
							<label>
								<input type="checkbox" name="wpps_cs_error_logging" value="1" <?php checked( ! empty( $settings['error_logging'] ) ); ?>>
								<?php esc_html_e( 'Log PHP errors from snippets (view in Error Logs)', 'wp-powersuite' ); ?>
							</label>
						</td>
					</tr>
					<tr>
						<th scope="row"><?php esc_html_e( 'Global Safe Mode', 'wp-powersuite' ); ?></th>
						<td>
							<label>
								<input type="checkbox" name="wpps_cs_global_safe_mode" value="1" <?php checked( ! empty( $settings['global_safe_mode'] ) ); ?>>
								<?php esc_html_e( 'Disable all snippet execution site-wide (emergency recovery)', 'wp-powersuite' ); ?>
							</label>
							<p class="description">
								<?php esc_html_e( 'When enabled, no snippets run for any visitor until you turn this off. You can also define WPPS_CS_DISABLE in wp-config.php.', 'wp-powersuite' ); ?>
							</p>
						</td>
					</tr>
					<tr>
						<th scope="row"><?php esc_html_e( 'Frontend Only PHP', 'wp-powersuite' ); ?></th>
						<td>
							<label>
								<input type="checkbox" name="wpps_cs_merge_frontend_only_php" value="1" <?php checked( ! empty( $settings['merge_frontend_only_php'] ) ); ?>>
								<?php esc_html_e( 'Merge Frontend Only PHP snippets (run together in one step)', 'wp-powersuite' ); ?>
							</label>
							<p class="description">
								<?php esc_html_e( 'When enabled, all active Frontend Only PHP snippets that load from the database run in a single pass. When disabled, each snippet runs on its own—usually easier to fix when one snippet causes a problem.', 'wp-powersuite' ); ?>
							</p>
						</td>
					</tr>
					<tr>
						<th scope="row"><?php esc_html_e( 'Editor Height', 'wp-powersuite' ); ?></th>
						<td>
							<p class="description"><?php esc_html_e( 'Set the editor height in pixels or enable auto-grow so the code editor automatically grows in height with the code.', 'wp-powersuite' ); ?></p>
							<p>
								<label><?php esc_html_e( 'Fixed height (px):', 'wp-powersuite' ); ?>
									<?php
									$eh = max( 100, min( 800, (int) ( $settings['editor_height'] ?? 300 ) ) );
									$eh_auto = ! empty( $settings['editor_height_auto'] );
									?>
									<?php if ( $eh_auto ) : ?>
									<input type="hidden" name="wpps_cs_editor_height" value="<?php echo $eh; ?>">
									<?php endif; ?>
									<input type="number" name="wpps_cs_editor_height" value="<?php echo $eh; ?>" data-wpps-min="100" data-wpps-max="800" data-wpps-step="1" style="width:80px" <?php echo $eh_auto ? 'disabled' : ''; ?>>
								</label>
								<label style="margin-left:16px;">
									<input type="checkbox" name="wpps_cs_editor_height_auto" value="1" <?php checked( $eh_auto ); ?>>
									<?php esc_html_e( 'Auto height', 'wp-powersuite' ); ?>
								</label>
							</p>
						</td>
					</tr>
					<tr>
						<th scope="row"><?php esc_html_e( 'Admin Bar Info', 'wp-powersuite' ); ?></th>
						<td>
							<label>
								<input type="checkbox" name="wpps_cs_admin_bar_info" value="1" <?php checked( ! empty( $settings['admin_bar_info'] ) ); ?>>
								<?php esc_html_e( 'Enable the admin bar menu that shows info about which snippets & scripts are loaded on the current page.', 'wp-powersuite' ); ?>
							</label>
							<p class="description">
								<?php esc_html_e( 'Requires the WordPress admin bar to be visible. Check your profile (Show toolbar when viewing site) and the Hide Admin Bar module if enabled.', 'wp-powersuite' ); ?>
							</p>
						</td>
					</tr>
					<tr>
						<th scope="row"><?php esc_html_e( 'Revisions to Keep', 'wp-powersuite' ); ?></th>
						<td>
							<?php
							$rev_keep = max( 1, min( 100, (int) ( $settings['revisions_to_keep'] ?? 10 ) ) );
							?>
							<input type="number" name="wpps_cs_revisions_to_keep" value="<?php echo $rev_keep; ?>" data-wpps-min="1" data-wpps-max="100" data-wpps-step="1" style="width:80px">
							<p class="description"><?php esc_html_e( 'Maximum number of revisions to keep per snippet. Older revisions are automatically deleted.', 'wp-powersuite' ); ?></p>
						</td>
					</tr>
					<tr>
						<th scope="row"><?php esc_html_e( 'Load Snippets as Files', 'wp-powersuite' ); ?></th>
						<td>
							<label>
								<input type="checkbox" name="wpps_cs_load_snippets_as_files" value="1" <?php checked( ! empty( $settings['load_snippets_as_files'] ) ); ?>>
								<?php
								$path = str_replace( ABSPATH, '', WP_CONTENT_DIR . '/wpps/wpps-snippets/' );
								printf(
									/* translators: %s: path to snippet files */
									esc_html__( 'When enabled, all active PHP, HTML, JS, and CSS snippets will be loaded from files instead of the database. Path: %s', 'wp-powersuite' ),
									'<code>' . esc_html( $path ) . '</code>'
								);
								?>
							</label>
							<p class="description">
								<?php esc_html_e( 'Per-snippet overrides are available in the snippet editor. On Nginx, block direct HTTP access to .php files in this folder (see NGINX-SECURITY.txt inside the directory after the first save).', 'wp-powersuite' ); ?>
							</p>
						</td>
					</tr>
					<tr>
						<th></th>
						<td>
							<button type="submit" name="wpps_cs_settings" class="button button-primary"><?php esc_html_e( 'Save Changes', 'wp-powersuite' ); ?></button>
							<a href="<?php echo esc_url( admin_url( 'admin.php?page=powersuite-code-snippet&view=logs' ) ); ?>" class="button"><?php esc_html_e( 'Error Logs', 'wp-powersuite' ); ?></a>
						</td>
					</tr>
				</table>
			</form>
			<script>
			document.querySelector('input[name="wpps_cs_editor_height_auto"]')?.addEventListener('change', function() {
				var num = document.querySelector('input[type="number"][name="wpps_cs_editor_height"]');
				var wrap = this.closest('form');
				if (num && wrap) {
					num.disabled = this.checked;
					var hidden = wrap.querySelector('input[type="hidden"][name="wpps_cs_editor_height"]');
					if (this.checked) {
						if (!hidden) {
							hidden = document.createElement('input');
							hidden.type = 'hidden';
							hidden.name = 'wpps_cs_editor_height';
							num.parentNode.insertBefore(hidden, num);
						}
						hidden.value = num.value;
					} else if (hidden) {
						hidden.remove();
					}
				}
			});
			</script>
		</div>
		<?php
	}

	/**
	 * Render the error logs page.
	 */
	/**
	 * Render the revisions compare page.
	 */
	public function renderRevisionsPage(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'You do not have sufficient permissions.', 'wp-powersuite' ) );
		}
		if ( ! self::isProLicensed() ) {
			$license_page_url = admin_url( 'admin.php?page=powersuite-license' );
			echo '<div class="wrap wpps-cs-wrap"><h1>' . esc_html__( 'Code Revisions', 'wp-powersuite' ) . '</h1>';
			echo '<div class="notice notice-warning inline"><p>';
			echo esc_html__( 'Code Revisions is a Premium feature. Activate your license to compare and restore snippet history.', 'wp-powersuite' );
			echo ' <a href="' . esc_url( $license_page_url ) . '">' . esc_html__( 'Activate license', 'wp-powersuite' ) . '</a>';
			echo '</p></div></div>';
			return;
		}
		$revisions = new Wpps_Revisions();
		$snippet_id = isset( $_GET['snippet_id'] ) ? absint( $_GET['snippet_id'] ) : 0;
		$revision_id = isset( $_GET['revision'] ) ? absint( $_GET['revision'] ) : 0;
		$compare = isset( $_GET['compare'] ) && $_GET['compare'] === '1';

		if ( $revision_id > 0 && $snippet_id <= 0 ) {
			$snippet_id = $revisions->get_revision_parent_id( $revision_id );
		}
		if ( $snippet_id <= 0 ) {
			echo '<div class="wrap"><p>' . esc_html__( 'Invalid snippet.', 'wp-powersuite' ) . '</p><p><a href="' . esc_url( admin_url( 'admin.php?page=powersuite-code-snippet' ) ) . '">&larr; ' . esc_html__( 'Back to Snippets', 'wp-powersuite' ) . '</a></p></div>';
			return;
		}
		$post = get_post( $snippet_id );
		if ( ! $post || $post->post_type !== self::POST_TYPE ) {
			echo '<div class="wrap"><p>' . esc_html__( 'Snippet not found.', 'wp-powersuite' ) . '</p><p><a href="' . esc_url( admin_url( 'admin.php?page=powersuite-code-snippet' ) ) . '">&larr; ' . esc_html__( 'Back to Snippets', 'wp-powersuite' ) . '</a></p></div>';
			return;
		}

		// Restore returns to the edit screen, not the revisions list.
		$restore_id = isset( $_GET['restore'] ) ? absint( $_GET['restore'] ) : 0;
		if ( $restore_id > 0 && isset( $_GET['_wpnonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ), 'wpps-restore-revision' ) ) {
			$rev_data = $revisions->get_revision_data( $restore_id );
			if ( ! empty( $rev_data ) ) {
				$rev_row = $revisions->get_revision( $restore_id );
				if ( $rev_row && (int) $rev_row->snippet_id === $snippet_id ) {
					$this->restoreSnippetFromRevisionData( $snippet_id, $rev_data, $revisions );
					$this->bustSnippetCaches();
					wp_cache_delete( 'wpps_revisions_' . $snippet_id, 'wpps' );
					wp_safe_redirect( add_query_arg( [ 'restored' => 1 ], admin_url( 'admin.php?page=powersuite-code-snippet&action=edit&id=' . $snippet_id ) ) );
					exit;
				}
			}
		}

		$compare_title      = $post->post_title ?: __( '(no title)', 'wp-powersuite' );
		$back_url           = admin_url( 'admin.php?page=powersuite-code-snippet&action=edit&id=' . $snippet_id );
		$revisions_instance = $revisions;
		include __DIR__ . '/revisions.php';
	}

	/**
	 * Restore snippet from revision data (post + meta).
	 * Creates a new revision so the restored state becomes the current version.
	 *
	 * @param int             $snippet_id Snippet ID.
	 * @param array           $data       Revision data.
	 * @param Wpps_Revisions  $revisions  Revisions instance (optional, for creating revision).
	 */
	private function restoreSnippetFromRevisionData( int $snippet_id, array $data, ?Wpps_Revisions $revisions = null ): void {
		$post = get_post( $snippet_id );
		if ( ! $post || $post->post_type !== self::POST_TYPE ) {
			return;
		}

		$type = isset( $data['code_type'] ) ? sanitize_key( (string) $data['code_type'] ) : ( get_post_meta( $snippet_id, '_wpps_code_type', true ) ?: 'html' );
		$code = isset( $data['code'] ) ? (string) $data['code'] : $post->post_content;
		if ( $type === 'php' ) {
			$code = self::stripPhpTagsForStorage( $code );
			$syntax_err = self::checkPhpSyntax( $code );
			if ( $syntax_err !== null ) {
				wp_die( esc_html__( 'PHP syntax error:', 'wp-powersuite' ) . ' ' . esc_html( $syntax_err ) );
			}
			$blocked = SnippetThreatScanner::getRuntimeBlockReason( $code );
			if ( $blocked !== null ) {
				wp_die( esc_html( $blocked ) );
			}
		}

		$active = array_key_exists( 'active', $data ) ? (bool) $data['active'] : ( $post->post_status === 'publish' );
		if ( $active ) {
			if ( ! self::isProLicensed() ) {
				if ( self::isProCodeType( $type ) ) {
					wp_die( esc_html__( 'JavaScript and PHP code types require a PRO license. Please upgrade to use this feature.', 'wp-powersuite' ) );
				}
				$device = isset( $data['device_type'] ) ? (string) $data['device_type'] : ( get_post_meta( $snippet_id, '_wpps_device_type', true ) ?: 'all' );
				$cond   = array_key_exists( 'conditional_enabled', $data )
					? (bool) $data['conditional_enabled']
					: ( get_post_meta( $snippet_id, '_wpps_conditional_enabled', true ) === '1' );
				if ( $device !== 'all' || $cond ) {
					wp_die( esc_html__( 'Device targeting and conditional logic require a PRO license. Please upgrade to use this feature.', 'wp-powersuite' ) );
				}
			}
			if ( $type === 'php' ) {
				$runtime_err = $this->testPhpSnippetRuntime( $code );
				if ( $runtime_err !== null ) {
					wp_die( esc_html__( 'PHP error:', 'wp-powersuite' ) . ' ' . esc_html( $runtime_err ) );
				}
			}
		}

		kses_remove_filters();
		remove_all_filters( 'content_save_pre' );
		wp_update_post( [
			'ID'           => $snippet_id,
			'post_title'   => $data['title'] ?? '',
			'post_content' => $code,
			'post_status'  => $active ? 'publish' : 'draft',
		] );
		kses_init_filters();

		$this->applySnippetMetaFromRevisionData( $snippet_id, $data );

		if ( $revisions && get_post_meta( $snippet_id, '_wpps_revisions_enabled', true ) === '1' ) {
			$revisions->create_revision( $snippet_id, Wpps_Revisions::get_snippet_revision_data( $snippet_id ) );
		}

		$this->syncSnippetFilesAfterSave( get_post( $snippet_id ) );
	}

	/**
	 * Apply revision/snapshot meta fields onto a snippet post.
	 *
	 * @param array<string, mixed> $data Revision or snapshot data.
	 */
	private function applySnippetMetaFromRevisionData( int $snippet_id, array $data ): void {
		$meta_map = [
			'code_type'              => '_wpps_code_type',
			'location'               => '_wpps_location',
			'location_param'         => '_wpps_location_param',
			'location_selector'      => '_wpps_location_selector',
			'location_element_index' => '_wpps_location_element_index',
			'custom_shortcode'       => '_wpps_custom_shortcode',
			'auto_insert'            => '_wpps_auto_insert',
			'priority'               => '_wpps_priority',
			'notes'                  => '_wpps_notes',
			'schedule_enabled'       => '_wpps_schedule_enabled',
			'schedule_start'         => '_wpps_schedule_start',
			'schedule_end'           => '_wpps_schedule_end',
			'device_type'            => '_wpps_device_type',
			'conditional_enabled'    => '_wpps_conditional_enabled',
			'conditional_show'       => '_wpps_conditional_show',
			'revisions_enabled'      => '_wpps_revisions_enabled',
			'load_as_file'           => '_wpps_load_as_file',
		];
		foreach ( $meta_map as $key => $meta_key ) {
			if ( ! array_key_exists( $key, $data ) ) {
				continue;
			}
			$val = $data[ $key ];
			if ( in_array( $meta_key, [ '_wpps_auto_insert', '_wpps_schedule_enabled', '_wpps_conditional_enabled', '_wpps_revisions_enabled', '_wpps_load_as_file' ], true ) ) {
				$val = $val ? '1' : '0';
			}
			if ( $meta_key === '_wpps_location_element_index' ) {
				$val = ( $val === '' || $val === null ) ? '' : (string) max( 0, (int) $val );
			}
			update_post_meta( $snippet_id, $meta_key, $val );
		}
		if ( isset( $data['conditional_rules'] ) && is_array( $data['conditional_rules'] ) ) {
			update_post_meta( $snippet_id, '_wpps_conditional_rules', $data['conditional_rules'] );
		}
	}

	/**
	 * Write snippet asset files after save or restore when file loading is enabled.
	 */
	private function syncSnippetFilesAfterSave( ?\WP_Post $post ): void {
		if ( ! $post || $post->post_type !== self::POST_TYPE ) {
			return;
		}
		$type = get_post_meta( $post->ID, '_wpps_code_type', true ) ?: 'html';
		$auto = get_post_meta( $post->ID, '_wpps_auto_insert', true ) === '1';
		$load_file = get_post_meta( $post->ID, '_wpps_load_as_file', true ) === '1'
			|| self::getSetting( 'load_snippets_as_files', false );

		if ( $load_file && in_array( $type, [ 'js', 'css' ], true ) ) {
			$raw_code = $post->post_content;
			$raw_code = preg_replace( '/^\s*<script[^>]*>|<\/script>\s*$/i', '', $raw_code );
			$raw_code = preg_replace( '/^\s*<style[^>]*>|<\/style>\s*$/i', '', $raw_code );
			$this->ensureSnippetFile( $post, $type, trim( $raw_code ) );
		}
		if ( $load_file && $auto && in_array( $type, [ 'php', 'html' ], true ) ) {
			$this->ensurePhpSnippetFile( $post );
		}
	}

	public function renderLogsPage(): void {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'You do not have sufficient permissions.', 'wp-powersuite' ) );
		}
		$settings = self::getSettings();
		if ( isset( $_POST['wpps_cs_clear_logs'] ) && check_admin_referer( 'wpps_cs_logs' ) ) {
			update_option( self::ERROR_LOG_OPTION, [], false );
			echo '<div class="notice notice-success"><p>' . esc_html__( 'Error logs cleared.', 'wp-powersuite' ) . '</p></div>';
		}
		$enabled = ! empty( $settings['error_logging'] );
		$logs = get_option( self::ERROR_LOG_OPTION, [] );
		$logs = is_array( $logs ) ? array_reverse( $logs ) : [];
		?>
		<div class="wrap">
			<h1><?php esc_html_e( 'Code Snippet Error Logs', 'wp-powersuite' ); ?></h1>
			<p><a href="<?php echo esc_url( admin_url( 'admin.php?page=powersuite-code-snippet' ) ); ?>">&larr; <?php esc_html_e( 'Back to Snippets', 'wp-powersuite' ); ?></a></p>
			<p>
				<?php if ( $enabled ) : ?>
					<?php esc_html_e( 'Error logging is enabled.', 'wp-powersuite' ); ?>
				<?php else : ?>
					<?php esc_html_e( 'Error logging is disabled.', 'wp-powersuite' ); ?>
				<?php endif; ?>
				<a href="<?php echo esc_url( admin_url( 'admin.php?page=powersuite-code-snippet&view=settings' ) ); ?>"><?php esc_html_e( 'Change in Settings', 'wp-powersuite' ); ?></a>
			</p>
			<form method="post">
				<?php wp_nonce_field( 'wpps_cs_logs' ); ?>
				<p>
					<button type="submit" name="wpps_cs_clear_logs" value="1" class="button"><?php esc_html_e( 'Clear Logs', 'wp-powersuite' ); ?></button>
				</p>
			</form>
			<h2><?php esc_html_e( 'Recent Errors', 'wp-powersuite' ); ?></h2>
			<?php if ( empty( $logs ) ) : ?>
				<p><?php esc_html_e( 'No errors logged.', 'wp-powersuite' ); ?></p>
			<?php else : ?>
				<table class="widefat striped">
					<thead>
					<tr>
						<th><?php esc_html_e( 'Time', 'wp-powersuite' ); ?></th>
						<th><?php esc_html_e( 'Snippet', 'wp-powersuite' ); ?></th>
						<th><?php esc_html_e( 'Location', 'wp-powersuite' ); ?></th>
						<th><?php esc_html_e( 'Context', 'wp-powersuite' ); ?></th>
						<th><?php esc_html_e( 'Error', 'wp-powersuite' ); ?></th>
					</tr>
					</thead>
					<tbody>
					<?php foreach ( $logs as $log ) : ?>
						<tr>
							<td><?php echo esc_html( $log['time'] ?? '' ); ?></td>
							<td><a href="<?php echo esc_url( admin_url( 'admin.php?page=powersuite-code-snippet&action=edit&id=' . ( $log['snippet'] ?? 0 ) ) ); ?>"><?php echo esc_html( $log['title'] ?? $log['snippet'] ?? '' ); ?></a></td>
							<td><?php echo esc_html( (string) ( $log['location'] ?? '—' ) ); ?></td>
							<td><?php echo esc_html( (string) ( $log['merge_context'] ?? '—' ) ); ?><?php
							if ( isset( $log['snippet_line'] ) && $log['snippet_line'] !== null && $log['snippet_line'] !== '' ) :
								?>
								<br><span class="description"><?php
								printf(
									/* translators: %d: line number inside snippet source */
									esc_html__( 'Snippet line: %d', 'wp-powersuite' ),
									(int) $log['snippet_line']
								);
								?></span>
							<?php endif; ?></td>
							<td><?php echo esc_html( $log['message'] ?? '' ); ?><?php if ( ! empty( $log['line'] ) ) : ?> <span class="description">(eval <?php echo (int) $log['line']; ?>)</span><?php endif; ?></td>
						</tr>
					<?php endforeach; ?>
					</tbody>
				</table>
			<?php endif; ?>
		</div>
		<?php
	}

	// AJAX handlers
	public function ajaxSaveSnippet(): void {
		check_ajax_referer( 'wpps_cs_nonce', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}

		$is_pro = function_exists( 'wp_powersuite_is_license_valid' ) && wp_powersuite_is_license_valid();

		$id     = isset( $_POST['id'] ) ? absint( $_POST['id'] ) : 0;
		$title  = isset( $_POST['title'] ) ? sanitize_text_field( wp_unslash( $_POST['title'] ) ) : '';
		$code   = isset( $_POST['code'] ) ? wp_unslash( $_POST['code'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		$type   = isset( $_POST['code_type'] ) ? sanitize_key( $_POST['code_type'] ) : 'html';
		$loc    = isset( $_POST['location'] ) ? sanitize_key( $_POST['location'] ) : 'footer';
		$loc_param    = isset( $_POST['location_param'] ) ? max( 1, absint( $_POST['location_param'] ) ) : 1;
		$loc_selector = isset( $_POST['location_selector'] ) ? $this->sanitizeLocationSelector( wp_unslash( $_POST['location_selector'] ) ) : '';
		$loc_element_index = isset( $_POST['location_element_index'] ) ? max( 0, absint( $_POST['location_element_index'] ) ) : '';
		$device_type  = isset( $_POST['device_type'] ) ? sanitize_key( $_POST['device_type'] ) : 'all';
		if ( ! in_array( $device_type, [ 'all', 'desktop', 'mobile', 'tablet' ], true ) ) {
			$device_type = 'all';
		}
		$auto   = isset( $_POST['auto_insert'] ) && $_POST['auto_insert'] === '1';
		$active = isset( $_POST['active'] ) && $_POST['active'] === '1';
		$prio   = isset( $_POST['priority'] ) ? absint( $_POST['priority'] ) : 10;
		$sched_enabled = isset( $_POST['schedule_enabled'] ) && $_POST['schedule_enabled'] === '1';
		$sched_start   = isset( $_POST['schedule_start'] ) ? sanitize_text_field( wp_unslash( $_POST['schedule_start'] ) ) : '';
		$sched_end     = isset( $_POST['schedule_end'] ) ? sanitize_text_field( wp_unslash( $_POST['schedule_end'] ) ) : '';

		$allowed_types = [ 'html', 'css', 'js', 'php' ];
		$allowed_locs  = self::getAllLocationSlugs();
		if ( ! in_array( $type, $allowed_types, true ) ) {
			$type = 'html';
		}
		if ( ! in_array( $loc, $allowed_locs, true ) ) {
			$loc = 'footer';
		}

		$type_loc_err = $this->validateTypeLocationPair( $type, $loc );
		if ( $type_loc_err !== null ) {
			wp_send_json_error( [ 'message' => $type_loc_err ] );
		}

		if ( in_array( $type, [ 'html', 'js', 'php' ], true ) && ! current_user_can( 'unfiltered_html' ) ) {
			wp_send_json_error( [ 'message' => __( 'Saving HTML, JavaScript, or PHP snippets requires the unfiltered_html capability.', 'wp-powersuite' ) ] );
		}

		// PRO gating: reject JS/PHP code types when not licensed.
		if ( ! $is_pro && in_array( $type, [ 'js', 'php' ], true ) ) {
			wp_send_json_error( [ 'message' => __( 'JavaScript and PHP code types require a PRO license. Please upgrade to use this feature.', 'wp-powersuite' ) ] );
		}

		// PRO gating: downgrade PRO features when not licensed.
		if ( ! $is_pro ) {
			$device_type = 'all';
		}

		// Strip wrappers before saving - we never store <?php or <script> in DB.
		if ( $type === 'php' ) {
			$code = self::stripPhpTagsForStorage( $code );
			$syntax_err = self::checkPhpSyntax( $code );
			if ( $syntax_err !== null ) {
				wp_send_json_error( [ 'message' => __( 'PHP syntax error:', 'wp-powersuite' ) . ' ' . $syntax_err ] );
			}
			$blocked = SnippetThreatScanner::getRuntimeBlockReason( $code );
			if ( $blocked !== null ) {
				wp_send_json_error( [ 'message' => $blocked ] );
			}
			if ( $active ) {
				$runtime_err = $this->testPhpSnippetRuntime( $code );
				if ( $runtime_err !== null ) {
					wp_send_json_error( [ 'message' => __( 'PHP error:', 'wp-powersuite' ) . ' ' . $runtime_err ] );
				}
			}
		} elseif ( $type === 'js' ) {
			$code = self::stripJsTagsForStorage( $code );
		}

		$custom_shortcode = isset( $_POST['custom_shortcode'] ) ? preg_replace( '/[^a-z0-9_]/', '', strtolower( sanitize_key( wp_unslash( $_POST['custom_shortcode'] ) ) ) ) : '';
		$shortcode_err    = $this->validateCustomShortcode( $custom_shortcode, $id );
		if ( $shortcode_err !== null ) {
			wp_send_json_error( [ 'message' => $shortcode_err ] );
		}

		$notes = isset( $_POST['notes'] ) ? sanitize_textarea_field( wp_unslash( $_POST['notes'] ) ) : '';

		$post_data = [
			'post_type'    => self::POST_TYPE,
			'post_title'   => $title ?: __( 'Untitled Snippet', 'wp-powersuite' ),
			'post_content' => $code,
			'post_status'  => $active ? 'publish' : 'draft',
		];
		if ( $id > 0 ) {
			$post_data['ID'] = $id;
		}

		// Prevent WordPress KSES and content_save_pre from stripping/modifying PHP, script, style tags.
		kses_remove_filters();
		remove_all_filters( 'content_save_pre' );

		$rev_enabled = isset( $_POST['revisions_enabled'] ) && $_POST['revisions_enabled'] === '1';
		if ( ! $is_pro ) {
			$rev_enabled = false;
		}
		$revisions = new Wpps_Revisions();
		if ( $id > 0 ) {
			$existing = get_post( $id );
			if ( ! $existing || $existing->post_type !== self::POST_TYPE ) {
				kses_init_filters();
				wp_send_json_error( [ 'message' => __( 'Invalid snippet.', 'wp-powersuite' ) ] );
			}
			if ( $rev_enabled ) {
				$revisions->capture_snippet_data( $id );
			}
			$new_id = wp_update_post( $post_data );
		} else {
			$new_id = wp_insert_post( $post_data );
		}

		kses_init_filters();

		if ( is_wp_error( $new_id ) ) {
			wp_send_json_error( [ 'message' => $new_id->get_error_message() ] );
		}

		update_post_meta( $new_id, '_wpps_code_type', $type );
		update_post_meta( $new_id, '_wpps_location', $loc );
		update_post_meta( $new_id, '_wpps_location_param', $loc_param );
		update_post_meta( $new_id, '_wpps_location_selector', $loc_selector );
		update_post_meta( $new_id, '_wpps_location_element_index', $loc_element_index === '' ? '' : (string) $loc_element_index );
		update_post_meta( $new_id, '_wpps_device_type', $device_type );
		update_post_meta( $new_id, '_wpps_custom_shortcode', $custom_shortcode );
		update_post_meta( $new_id, '_wpps_notes', $notes );
		update_post_meta( $new_id, '_wpps_auto_insert', $auto ? '1' : '0' );
		$cond_enabled = isset( $_POST['conditional_enabled'] ) && $_POST['conditional_enabled'] === '1';
		if ( ! $is_pro ) {
			$cond_enabled = false;
		}
		$cond_show    = isset( $_POST['conditional_show'] ) && $_POST['conditional_show'] === '0' ? '0' : '1';
		$cond_rules   = isset( $_POST['conditional_rules'] ) && is_array( $_POST['conditional_rules'] ) ? $_POST['conditional_rules'] : [];
		$cond_rules   = $this->sanitizeConditionalRules( $cond_rules );
		update_post_meta( $new_id, '_wpps_conditional_enabled', $cond_enabled ? '1' : '0' );
		update_post_meta( $new_id, '_wpps_conditional_show', $cond_show );
		update_post_meta( $new_id, '_wpps_conditional_rules', $cond_rules );
		update_post_meta( $new_id, '_wpps_revisions_enabled', $rev_enabled ? '1' : '0' );
		update_post_meta( $new_id, '_wpps_priority', $prio );
		update_post_meta( $new_id, '_wpps_schedule_enabled', $sched_enabled ? '1' : '0' );
		update_post_meta( $new_id, '_wpps_schedule_start', $sched_start );
		update_post_meta( $new_id, '_wpps_schedule_end', $sched_end );
		$load_as_file = isset( $_POST['load_as_file'] ) && $_POST['load_as_file'] === '1';
		update_post_meta( $new_id, '_wpps_load_as_file', $load_as_file ? '1' : '0' );

		// Saving as active clears any prior auto-deactivation notice for this snippet.
		if ( $active ) {
			$this->clearSnippetErrorState( (int) $new_id );
		}

		$this->bustSnippetCaches();

		if ( $id > 0 && $new_id > 0 && $rev_enabled ) {
			$new_data = Wpps_Revisions::get_snippet_revision_data( $new_id );
			$revisions->maybe_create_revision( $new_id, $new_data );
		}

		delete_post_meta( $new_id, self::LAST_PHP_ERROR_META );

		$this->syncSnippetFilesAfterSave( get_post( $new_id ) );

		wp_send_json_success( [
			'id'      => $new_id,
			'message' => __( 'Snippet saved successfully.', 'wp-powersuite' ),
		] );
	}

	public function ajaxDeleteSnippet(): void {
		check_ajax_referer( 'wpps_cs_nonce', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}
		$id = isset( $_POST['id'] ) ? absint( $_POST['id'] ) : 0;
		if ( $id === 0 ) {
			wp_send_json_error( [ 'message' => __( 'Invalid ID.', 'wp-powersuite' ) ] );
		}
		$post = get_post( $id );
		if ( ! $post || $post->post_type !== self::POST_TYPE ) {
			wp_send_json_error( [ 'message' => __( 'Snippet not found.', 'wp-powersuite' ) ] );
		}
		wp_delete_post( $id, true );
		$this->bustSnippetCaches();
		wp_send_json_success( [ 'message' => __( 'Snippet deleted.', 'wp-powersuite' ) ] );
	}

	public function ajaxToggleSnippet(): void {
		check_ajax_referer( 'wpps_cs_nonce', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}
		$id = isset( $_POST['id'] ) ? absint( $_POST['id'] ) : 0;
		if ( $id === 0 ) {
			wp_send_json_error( [ 'message' => __( 'Invalid ID.', 'wp-powersuite' ) ] );
		}
		$post = get_post( $id );
		if ( ! $post || $post->post_type !== self::POST_TYPE ) {
			wp_send_json_error( [ 'message' => __( 'Snippet not found.', 'wp-powersuite' ) ] );
		}
		$new_status = $post->post_status === 'publish' ? 'draft' : 'publish';

		if ( $new_status === 'publish' ) {
			$block = $this->getSnippetActivationBlockReason( $post );
			if ( $block !== null ) {
				wp_send_json_error( [ 'message' => $block ] );
			}
			$this->clearSnippetErrorState( $id );
		}

		wp_update_post( [ 'ID' => $id, 'post_status' => $new_status ] );
		$this->bustSnippetCaches();
		wp_send_json_success( [
			'active'  => $new_status === 'publish',
			'message' => $new_status === 'publish' ? __( 'Snippet activated.', 'wp-powersuite' ) : __( 'Snippet deactivated.', 'wp-powersuite' ),
		] );
	}

	public function ajaxGetSnippet(): void {
		check_ajax_referer( 'wpps_cs_nonce', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}
		$id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0;
		if ( $id === 0 ) {
			wp_send_json_error( [ 'message' => __( 'Invalid ID.', 'wp-powersuite' ) ] );
		}
		$snippet = $this->getSnippetData( $id );
		if ( $snippet === null ) {
			wp_send_json_error( [ 'message' => __( 'Snippet not found.', 'wp-powersuite' ) ] );
		}
		wp_send_json_success( $snippet );
	}

	/**
	 * AJAX: Load compare markup for two revisions (compare any two).
	 */
	public function ajaxLoadRevisionsCompare(): void {
		check_ajax_referer( 'wpps_cs_nonce', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}
		if ( ! self::isProLicensed() ) {
			wp_send_json_error( [ 'message' => __( 'Code Revisions is a Premium feature. Activate your license to compare revisions.', 'wp-powersuite' ) ], 403 );
		}
		$ids = isset( $_POST['ids'] ) && is_array( $_POST['ids'] ) ? array_map( 'absint', wp_unslash( $_POST['ids'] ) ) : [];
		$snippet_id = isset( $_POST['snippet_id'] ) ? absint( $_POST['snippet_id'] ) : 0;
		if ( count( $ids ) !== 2 || $snippet_id <= 0 ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ] );
		}
		$revisions = new Wpps_Revisions();
		$data1 = $revisions->get_revision_data( $ids[0] );
		$data2 = $revisions->get_revision_data( $ids[1] );
		if ( empty( $data1 ) || empty( $data2 ) ) {
			wp_send_json_error( [ 'message' => __( 'Revision not found.', 'wp-powersuite' ) ] );
		}
		$rev1 = $revisions->get_revision( $ids[0] );
		$rev2 = $revisions->get_revision( $ids[1] );
		if ( ! $rev1 || ! $rev2 || (int) $rev1->snippet_id !== $snippet_id || (int) $rev2->snippet_id !== $snippet_id ) {
			wp_send_json_error( [ 'message' => __( 'Invalid revision.', 'wp-powersuite' ) ] );
		}
		$created1 = strtotime( $rev1->created );
		$created2 = strtotime( $rev2->created );
		$left_data  = $created1 <= $created2 ? $data1 : $data2;
		$right_data = $created1 <= $created2 ? $data2 : $data1;
		$markup = Wpps_Revisions_Display::render_compare_any_two( $left_data, $right_data );
		wp_send_json_success( [ 'markup' => $markup ] );
	}

	/**
	 * AJAX: Get revisions list for edit page.
	 */
	public function ajaxGetRevisionsList(): void {
		check_ajax_referer( 'wpps_cs_nonce', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}
		if ( ! self::isProLicensed() ) {
			wp_send_json_error( [ 'message' => __( 'Code Revisions is a Premium feature. Activate your license to view revision history.', 'wp-powersuite' ) ], 403 );
		}
		$snippet_id = isset( $_GET['snippet_id'] ) ? absint( $_GET['snippet_id'] ) : 0;
		if ( $snippet_id <= 0 ) {
			wp_send_json_error( [ 'message' => __( 'Invalid snippet.', 'wp-powersuite' ) ] );
		}
		$post = get_post( $snippet_id );
		if ( ! $post || $post->post_type !== self::POST_TYPE ) {
			wp_send_json_error( [ 'message' => __( 'Snippet not found.', 'wp-powersuite' ) ] );
		}
		$revisions_enabled = get_post_meta( $snippet_id, '_wpps_revisions_enabled', true ) === '1';
		if ( ! $revisions_enabled ) {
			wp_send_json_success( [
				'revisions' => [],
				'current_version' => null,
				'compare_url' => admin_url( 'admin.php?page=powersuite-code-snippet&view=revisions&snippet_id=' . $snippet_id ),
				'has_more' => false,
			] );
			return;
		}
		$limit  = isset( $_GET['limit'] ) ? max( 1, min( 50, (int) $_GET['limit'] ) ) : 5;
		$offset = isset( $_GET['offset'] ) ? max( 0, (int) $_GET['offset'] ) : 0;
		$revisions = new Wpps_Revisions();
		$total  = $revisions->count_snippet_revisions( $snippet_id );
		$rows   = $revisions->get_snippet_revisions_page( $snippet_id, $limit, $offset );
		$items = [];
		foreach ( $rows as $rev ) {
			$items[] = [
				'id'       => (int) $rev->revision_id,
				'date'     => $rev->created,
				'date_fmt' => wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), strtotime( $rev->created ) ),
				'author'   => get_the_author_meta( 'display_name', $rev->author_id ),
				'avatar'   => get_avatar_url( $rev->author_id, [ 'size' => 32 ] ),
			];
		}
		$has_more = ( $offset + count( $items ) ) < $total;
		$latest_row = $offset === 0 && ! empty( $rows ) ? $rows[0] : null;
		if ( $latest_row === null && $total > 0 ) {
			$latest_rows = $revisions->get_snippet_revisions_page( $snippet_id, 1, 0 );
			$latest_row  = ! empty( $latest_rows ) ? $latest_rows[0] : null;
		}
		$current = [
			'author'   => get_the_author_meta( 'display_name', (int) $post->post_author ),
			'avatar'   => get_avatar_url( (int) $post->post_author, [ 'size' => 32 ] ),
			'date_fmt' => wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), strtotime( $post->post_modified ) ),
			'is_current' => true,
		];
		$latest_revision_id = $latest_row ? (int) $latest_row->revision_id : 0;

		wp_send_json_success( [
			'revisions'        => $items,
			'current_version'  => $current,
			'compare_url'      => admin_url( 'admin.php?page=powersuite-code-snippet&view=revisions&snippet_id=' . $snippet_id ),
			'has_more'         => $has_more,
			'latest_revision_id' => $latest_revision_id,
		] );
	}

	/**
	 * AJAX: Restore revision (stays on edit page, no redirect to revisions page).
	 */
	public function ajaxRestoreRevision(): void {
		check_ajax_referer( 'wpps_cs_nonce', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( [ 'message' => __( 'Permission denied.', 'wp-powersuite' ) ] );
		}
		if ( ! self::isProLicensed() ) {
			wp_send_json_error( [ 'message' => __( 'Code Revisions is a Premium feature. Activate your license to restore revisions.', 'wp-powersuite' ) ], 403 );
		}
		$revision_id = isset( $_POST['revision_id'] ) ? absint( $_POST['revision_id'] ) : 0;
		$snippet_id  = isset( $_POST['snippet_id'] ) ? absint( $_POST['snippet_id'] ) : 0;
		if ( $revision_id <= 0 || $snippet_id <= 0 ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'wp-powersuite' ) ] );
		}
		$revisions = new Wpps_Revisions();
		$rev_data  = $revisions->get_revision_data( $revision_id );
		if ( empty( $rev_data ) ) {
			wp_send_json_error( [ 'message' => __( 'Revision not found.', 'wp-powersuite' ) ] );
		}
		$rev_row = $revisions->get_revision( $revision_id );
		if ( ! $rev_row || (int) $rev_row->snippet_id !== $snippet_id ) {
			wp_send_json_error( [ 'message' => __( 'Invalid revision.', 'wp-powersuite' ) ] );
		}
		$this->restoreSnippetFromRevisionData( $snippet_id, $rev_data, $revisions );
		$this->bustSnippetCaches();
		wp_cache_delete( 'wpps_revisions_' . $snippet_id, 'wpps' );
		$snippet = $this->getSnippetData( $snippet_id );
		wp_send_json_success( [
			'message' => __( 'Revision restored successfully.', 'wp-powersuite' ),
			'snippet' => $snippet,
		] );
	}

	/**
	 * Get snippet data by ID for edit page.
	 *
	 * @param int $id Snippet post ID.
	 * @return array|null Snippet data or null if not found.
	 */
	public function getSnippetData( int $id ): ?array {
		$post = get_post( $id );
		if ( ! $post || $post->post_type !== self::POST_TYPE ) {
			return null;
		}
		$cond_rules = get_post_meta( $post->ID, '_wpps_conditional_rules', true );
		return [
			'id'                    => $post->ID,
			'title'                 => $post->post_title,
			'code'                  => $post->post_content,
			'code_type'             => get_post_meta( $post->ID, '_wpps_code_type', true ) ?: 'html',
			'location'              => get_post_meta( $post->ID, '_wpps_location', true ) ?: 'footer',
			'location_param'        => (int) ( get_post_meta( $post->ID, '_wpps_location_param', true ) ?: 1 ),
			'location_selector'     => get_post_meta( $post->ID, '_wpps_location_selector', true ) ?: '',
			'location_element_index' => get_post_meta( $post->ID, '_wpps_location_element_index', true ) ?: '',
			'custom_shortcode'      => get_post_meta( $post->ID, '_wpps_custom_shortcode', true ) ?: '',
			'notes'                 => get_post_meta( $post->ID, '_wpps_notes', true ) ?: '',
			'auto_insert'           => ( get_post_meta( $post->ID, '_wpps_auto_insert', true ) ?: '0' ) === '1',
			'priority'              => (int) ( get_post_meta( $post->ID, '_wpps_priority', true ) ?: 10 ),
			'active'                => $post->post_status === 'publish',
			'schedule_enabled'      => ( get_post_meta( $post->ID, '_wpps_schedule_enabled', true ) ?: '0' ) === '1',
			'schedule_start'        => get_post_meta( $post->ID, '_wpps_schedule_start', true ) ?: '',
			'schedule_end'          => get_post_meta( $post->ID, '_wpps_schedule_end', true ) ?: '',
			'device_type'           => get_post_meta( $post->ID, '_wpps_device_type', true ) ?: 'all',
			'conditional_enabled'   => ( get_post_meta( $post->ID, '_wpps_conditional_enabled', true ) ?: '0' ) === '1',
			'conditional_show'      => get_post_meta( $post->ID, '_wpps_conditional_show', true ) ?: '1',
			'conditional_rules'     => is_array( $cond_rules ) ? $cond_rules : [],
			'revisions_enabled'     => ( get_post_meta( $post->ID, '_wpps_revisions_enabled', true ) ?: '0' ) === '1',
			'load_as_file'           => ( get_post_meta( $post->ID, '_wpps_load_as_file', true ) ?: '0' ) === '1',
		];
	}

	public function getDataLocations(): array {
		return [
			'post_type' => self::POST_TYPE,
			'transient' => self::CACHE_OPTION,
		];
	}

}
