<?php
/**
 * Snippet Store — single source of truth for ZIP AI snippet manifest I/O.
 *
 * All classes that read or write the snippet manifest MUST use this class.
 * Never read/write manifest.php directly.
 *
 * Storage format: manifest.php with `<?php exit;` guard on line 1, JSON on line 2+.
 * This prevents HTTP access on ALL servers (Apache, Nginx, LiteSpeed).
 *
 * @since 0.1.0
 * @package zip-ai
 */

namespace ZipAI\MCP\Classes\Core;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

class Snippet_Store {

	/**
	 * PHP exit guard prepended to manifest file.
	 *
	 * @var string
	 */
	private const GUARD = "<?php exit; // ZIP AI Snippets manifest. ?>\n";

	/**
	 * Allowed file types for snippets.
	 *
	 * @since 0.0.5
	 * @var array
	 */
	const ALLOWED_TYPES = array( 'php', 'js', 'css', 'html' );

	/**
	 * Valid PHP execution hooks.
	 *
	 * @since 0.0.5
	 * @var array
	 */
	const VALID_PHP_HOOKS = array(
		'plugins_loaded',
		'init',
		'wp_loaded',
		'wp_enqueue_scripts',
		'wp_head',
		'wp_footer',
		'template_redirect',
		'admin_init',
		'admin_head',
		'admin_footer',
		'shutdown',
	);

	/**
	 * Valid JS execution hooks.
	 *
	 * @since 0.0.5
	 * @var array
	 */
	const VALID_JS_HOOKS = array( 'wp_head', 'wp_footer', 'admin_head', 'admin_footer', 'login_head' );

	/**
	 * Valid CSS execution hooks.
	 *
	 * @since 0.0.5
	 * @var array
	 */
	const VALID_CSS_HOOKS = array( 'wp_head', 'admin_head', 'login_head', 'wp_footer' );

	/**
	 * Valid HTML execution hooks. HTML snippets emit verbatim markup —
	 * they're for raw `<script>` loaders (GA, GTM, FB Pixel), JSON-LD,
	 * `<noscript>` blocks, OG tags — anything that JS/CSS can't express
	 * without being wrapped in another script/style tag by the executor.
	 *
	 * @since 0.0.5
	 * @var array
	 */
	const VALID_HTML_HOOKS = array( 'wp_head', 'wp_footer', 'admin_head', 'admin_footer', 'login_head' );

	/**
	 * Valid execution scopes.
	 *
	 * @since 0.0.5
	 * @var array
	 */
	const VALID_SCOPES = array( 'frontend', 'admin', 'everywhere', 'login' );

	/**
	 * Valid condition types — single source consumed by REST sanitizer,
	 * agent-ability sanitizer, and the Conditions evaluator.
	 *
	 * @since 0.0.5
	 * @var array
	 */
	const VALID_CONDITION_TYPES = array(
		'page',
		'user',
		'user_role',
		'post_type',
		'post',
		'device',
		'url_pattern',
	);

	/**
	 * Valid condition operators — single source.
	 *
	 * @since 0.0.5
	 * @var array
	 */
	const VALID_CONDITION_OPERATORS = array(
		'is',
		'is_not',
		'in',
		'not_in',
		'contains',
		'starts_with',
		'ends_with',
		'regex',
	);

	/**
	 * Default blocked PHP function names. Filterable via
	 * `zip_ai_snippets_blocked_functions` (additive only — defaults can never
	 * be removed).
	 *
	 * @since 0.0.5
	 * @return array
	 */
	public static function default_blocked_functions() {
		return array(
			// Shell execution
			'exec',
			'shell_exec',
			'system',
			'passthru',
			'popen',
			'proc_open',
			'pcntl_exec',
			// Destructive WordPress
			'wp_delete_site',
			'wp_uninitialize_site',
			'drop_tables',
			// Dynamic code execution
			'eval',
			'assert',
			'create_function',
			// Filesystem permissions
			'chmod',
			'chown',
			'chgrp',
			'symlink',
			'link',
			// Environment manipulation
			'putenv',
			'dl',
			// PHP internals exposure
			'phpinfo',
			'show_source',
			'highlight_file',
			// Process manipulation
			'proc_nice',
			'proc_terminate',
			// Low-level sockets — use wp_remote_* instead
			'fsockopen',
			'stream_socket_client',
		);
	}

	/**
	 * Get effective blocked-functions list (defaults + user filter additions).
	 * Defaults are never removable.
	 *
	 * @since 0.0.5
	 * @return array
	 */
	public static function blocked_functions() {
		$defaults = self::default_blocked_functions();
		$custom   = apply_filters( 'zip_ai_snippets_blocked_functions', $defaults );
		return array_values( array_unique( array_merge( $defaults, (array) $custom ) ) );
	}

	/**
	 * Detect a PHP shell-execution backtick operator in `$code`.
	 *
	 * A naïve `preg_match('/`/', ...)` false-positives on backticks that live
	 * inside embedded HTML / JS / CSS blocks of a PHP file (JS template
	 * literals such as `` `hello ${name}` ``), which are never evaluated by
	 * PHP and pose no risk. PHP's own tokenizer is the only reliable way to
	 * distinguish the shell-exec operator from those benign occurrences.
	 *
	 * @since 0.0.5
	 * @param string $code Snippet body as authored by the user (no opening tag).
	 * @return bool True iff a real PHP shell-exec backtick token exists.
	 */
	public static function has_php_shell_exec_backticks( $code ) {
		if ( ! is_string( $code ) || strpos( $code, '`' ) === false ) {
			return false;
		}

		$prefixed = ( strncmp( ltrim( $code ), '<?', 2 ) === 0 ) ? $code : "<?php\n" . $code;
		$tokens   = @token_get_all( $prefixed );

		if ( ! is_array( $tokens ) ) {
			// Tokenizer unavailable / failed — fail closed.
			return true;
		}

		foreach ( $tokens as $token ) {
			// token_get_all returns either [id, text, line] arrays for named
			// tokens or raw single-character strings for punctuation. The
			// shell-exec operator surfaces as the bare '`' character; backticks
			// inside T_INLINE_HTML / T_CONSTANT_ENCAPSED_STRING /
			// T_ENCAPSED_AND_WHITESPACE / comments are absorbed into the
			// surrounding token's text and never appear at this layer.
			if ( is_string( $token ) && $token === '`' ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Scan PHP code for blocked dangerous functions / patterns.
	 *
	 * Single source — replaces the previously-duplicated private
	 * `check_php_blocklist()` methods in `ManageCodeSnippet` and the
	 * snippet REST controller. Returns the offending function or pattern
	 * name (suitable for use in an error message) or null when clean.
	 *
	 * @since 0.0.5
	 * @param string $code Snippet body as authored by the user.
	 * @return string|null Blocked name, or null when clean.
	 */
	public static function check_php_blocklist( $code ) {
		foreach ( self::blocked_functions() as $func ) {
			if ( preg_match( '/\b' . preg_quote( $func, '/' ) . '\s*\(/i', $code ) ) {
				return $func;
			}
			if ( preg_match( '/[\'"]' . preg_quote( $func, '/' ) . '[\'"]/i', $code ) ) {
				return "{$func} (as string reference)";
			}
		}

		if ( self::has_php_shell_exec_backticks( $code ) ) {
			return 'shell execution (backticks)';
		}

		if ( preg_match( '/preg_replace\s*\(\s*[\'"].*\/[a-z]*e[a-z]*[\'"]/i', $code ) ) {
			return 'preg_replace with /e modifier';
		}

		return null;
	}

	/**
	 * Per-file-type valid hook list lookup.
	 *
	 * @since 0.0.5
	 * @param string $type
	 * @return array
	 */
	public static function valid_hooks_for( $type ) {
		switch ( $type ) {
			case 'php':
				return self::VALID_PHP_HOOKS;
			case 'js':
				return self::VALID_JS_HOOKS;
			case 'css':
				return self::VALID_CSS_HOOKS;
			case 'html':
				return self::VALID_HTML_HOOKS;
			default:
				return array();
		}
	}

	/**
	 * Whether a hook name is safe to persist and execute.
	 *
	 * PHP snippets may run directly on third-party plugin hooks. JS/CSS keep a
	 * curated list because they are mapped onto WordPress enqueue actions.
	 *
	 * @since 0.0.5
	 * @param string $type File type.
	 * @param string $hook Hook name.
	 * @return bool
	 */
	public static function is_valid_execution_hook( $type, $hook ) {
		$hook = (string) $hook;
		if ( in_array( $hook, self::valid_hooks_for( $type ), true ) ) {
			return true;
		}
		if ( 'php' !== $type ) {
			return false;
		}
		return self::is_safe_custom_php_hook( $hook );
	}

	/**
	 * Validate custom PHP hook names.
	 *
	 * WordPress hook names are arbitrary strings, but snippets should only
	 * persist names that are readable, bounded, and free of whitespace/control
	 * characters. This allows common third-party hooks like
	 * `woocommerce_before_cart`, `elementor/frontend/after_render`, and
	 * `acf/save_post`.
	 *
	 * @since 0.0.5
	 * @param string $hook Hook name.
	 * @return bool
	 */
	public static function is_safe_custom_php_hook( $hook ) {
		$hook = trim( (string) $hook );
		if ( '' === $hook || strlen( $hook ) > 128 ) {
			return false;
		}
		return (bool) preg_match( '/^[A-Za-z_][A-Za-z0-9_\\.\\/:\\-]*$/', $hook );
	}

	/**
	 * Sanitize one execution hook while preserving valid custom PHP hooks.
	 *
	 * @since 0.0.5
	 * @param string $type    File type.
	 * @param mixed  $hook    Raw hook value.
	 * @param string $default Default hook.
	 * @return string
	 */
	public static function sanitize_execution_hook( $type, $hook, $default ) {
		$hook = trim( sanitize_text_field( (string) $hook ) );
		return self::is_valid_execution_hook( $type, $hook ) ? $hook : $default;
	}

	/**
	 * Sanitize execution config. Single source consumed by REST + Ability.
	 * Always returns a fully-populated array (php/js/css keys), with invalid
	 * input replaced by defaults.
	 *
	 * @since 0.0.5
	 * @param array $execution Raw execution config.
	 * @return array
	 */
	public static function sanitize_execution( $execution ) {
		$defaults = self::default_execution();
		if ( ! is_array( $execution ) ) {
			return $defaults;
		}

		$result = array();
		foreach ( $defaults as $type => $type_defaults ) {
			$input = $execution[ $type ] ?? array();
			if ( ! is_array( $input ) ) {
				$result[ $type ] = $type_defaults;
				continue;
			}
			$result[ $type ] = array(
				'hook'     => self::sanitize_execution_hook( $type, $input['hook'] ?? '', $type_defaults['hook'] ),
				'priority' => max( 1, min( 99, (int) ( $input['priority'] ?? $type_defaults['priority'] ) ) ),
				'scope'    => in_array( $input['scope'] ?? '', self::VALID_SCOPES, true )
					? $input['scope']
					: $type_defaults['scope'],
			);
			if ( 'php' === $type ) {
				$result[ $type ]['accepted_args'] = max( 0, min( 10, (int) ( $input['accepted_args'] ?? $type_defaults['accepted_args'] ?? 1 ) ) );
			}
		}
		return $result;
	}

	/**
	 * Sanitize a conditions array under the one-row-per-type model.
	 *
	 * Data model:
	 *   - Each row is one constraint: { type, operator, value?, values?, meta? }
	 *   - At most ONE row per `type` (post, post_type, page, user, user_role,
	 *     device, url_pattern). Same-type rows submitted by clients are merged.
	 *   - All rows AND together at evaluation time.
	 *   - Multi-value operators (`in`/`not_in`/`starts_with`/`contains`/
	 *     `ends_with`/`regex`) OR across their values inside the row.
	 *
	 * Merge rules for duplicate-type rows in the input:
	 *   - All positive operators (`is`/`in`) with same type → single row with
	 *     operator `in` and unique merged values.
	 *   - All negative operators (`is_not`/`not_in`) with same type → single
	 *     row with operator `not_in` and unique merged values.
	 *   - All same single-operator on string-matchers (`starts_with`/`contains`/
	 *     `ends_with`/`regex`) → single row with that operator and unique
	 *     merged values (evaluator ORs across values).
	 *   - Mixed direction (positive + negative) or mixed string-matchers for
	 *     the same type → the FIRST sanitized row wins, the rest are dropped.
	 *     Authors should express alternates inside a single row.
	 *
	 * @since 0.0.5
	 * @param array $conditions Raw conditions.
	 * @return array
	 */
	public static function sanitize_conditions( $conditions ) {
		if ( ! is_array( $conditions ) ) {
			return array();
		}

		$normalized = array();
		foreach ( $conditions as $cond ) {
			if ( ! is_array( $cond ) ) {
				continue;
			}
			$type = sanitize_key( $cond['type'] ?? '' );
			if ( ! in_array( $type, self::VALID_CONDITION_TYPES, true ) ) {
				continue;
			}

			$operator = in_array( $cond['operator'] ?? 'is', self::VALID_CONDITION_OPERATORS, true )
				? $cond['operator']
				: 'is';

			$values = array();
			if ( isset( $cond['values'] ) && is_array( $cond['values'] ) ) {
				foreach ( $cond['values'] as $v ) {
					$v = sanitize_text_field( (string) $v );
					if ( '' !== $v ) {
						$values[] = $v;
					}
				}
			}
			if ( isset( $cond['value'] ) && '' !== $cond['value'] ) {
				$v = sanitize_text_field( (string) $cond['value'] );
				if ( '' !== $v ) {
					$values[] = $v;
				}
			}
			if ( empty( $values ) ) {
				continue; // Empty row is meaningless under this model.
			}

			// Login-state normalization. `logged_in` / `logged_out` are login
			// STATES, not roles — but the model routinely emits them under
			// `type:"user_role"` (e.g. `user_role is_not logged_in` for a
			// "logged-out only" snippet). The role evaluator then reads that as
			// "doesn't have a role NAMED logged_in" → true for EVERY user → the
			// gate is a silent no-op (a logged-out-only snippet shows for
			// logged-in users too). Route any single login-state token to the
			// `user` type, which the evaluator resolves via is_user_logged_in(),
			// and fold `logged_out` into `is_not logged_in` (the only value the
			// `user` type understands). Operators in/not_in collapse to is/is_not.
			if ( 1 === count( $values ) && in_array( $values[0], array( 'logged_in', 'logged_out' ), true ) ) {
				$negated = in_array( $operator, array( 'is_not', 'not_in' ), true );
				if ( 'logged_out' === $values[0] ) {
					$negated = ! $negated; // logged_out === NOT logged_in
				}
				$type      = 'user';
				$values[0] = 'logged_in';
				$operator  = $negated ? 'is_not' : 'is';
			}

			// Multi-value login-state in a ROLE LIST (e.g. `user_role not_in
			// [logged_in, editor]`). The login token is dead under the role
			// evaluator (no role is named that), so the login intent is silently
			// lost. Strip login-state tokens from the role list; for a NEGATIVE
			// operator (is_not/not_in — which AND-composes) emit a separate
			// `user` row carrying the login intent. For a POSITIVE operator the
			// value-level OR can't be split into AND rows, so we only strip the
			// already-dead token (preserving runtime behaviour, cleaning the shape).
			$extra_login_row = null;
			if ( 'user_role' === $type && count( $values ) > 1 ) {
				$login_tokens = array_values( array_intersect( $values, array( 'logged_in', 'logged_out' ) ) );
				if ( ! empty( $login_tokens ) ) {
					$values = array_values( array_diff( $values, array( 'logged_in', 'logged_out' ) ) );
					// Compose the login row only for a single, unambiguous token
					// under a negative operator; a contradictory pair just strips.
					if ( 1 === count( $login_tokens ) && in_array( $operator, array( 'is_not', 'not_in' ), true ) ) {
						$negated = true; // negative operator
						if ( 'logged_out' === $login_tokens[0] ) {
							$negated = ! $negated; // logged_out === NOT logged_in
						}
						$extra_login_row = array(
							'type'     => 'user',
							'operator' => $negated ? 'is_not' : 'is',
							'values'   => array( 'logged_in' ),
						);
					}
					// Stripping emptied the role list — promote the login row into
					// this row (or drop the row if there is no login intent left).
					if ( empty( $values ) ) {
						if ( null !== $extra_login_row ) {
							$type            = 'user';
							$operator        = $extra_login_row['operator'];
							$values          = array( 'logged_in' );
							$extra_login_row = null;
						} else {
							continue;
						}
					}
				}
			}

			// `meta` is reserved for future per-row flags (e.g. case_sensitive
			// on url_pattern). Currently no flag is consumed by the evaluator,
			// so we strip the entire meta block at sanitize time — accepting it
			// would mislead authors into thinking it works. Re-add to the
			// allowlist here when an evaluator codepath consumes the flag.
			$meta = array();

			$connector = isset( $cond['connector'] ) ? strtolower( (string) $cond['connector'] ) : 'and';
			if ( 'or' !== $connector ) {
				$connector = 'and';
			}

			$normalized[] = array(
				'type'      => $type,
				'operator'  => $operator,
				'values'    => $values,
				'connector' => $connector,
				'meta'      => $meta,
			);

			// A login row split out of a multi-value role list ANDs with the
			// role row in the same chain (different type, so it survives merge).
			if ( null !== $extra_login_row ) {
				$normalized[] = array(
					'type'      => $extra_login_row['type'],
					'operator'  => $extra_login_row['operator'],
					'values'    => $extra_login_row['values'],
					'connector' => 'and',
					'meta'      => array(),
				);
			}
		}

		return self::merge_by_type( $normalized );
	}

	/**
	 * Merge same-type rows into one per type — but PER CHAIN, not globally.
	 * Chains are separated by `connector:"or"` on a row. Cross-chain same-
	 * type rows are legitimate (e.g. `post=2051 AND role=admin` OR
	 * `post=2052 AND role=editor`), so each chain is merged independently.
	 *
	 * @param array $rows Normalized rows.
	 * @return array Flat list preserving chain order; connector field marks
	 *               chain boundaries.
	 */
	private static function merge_by_type( array $rows ) {
		// Split into chains by `or` connector.
		$chains  = array();
		$current = array();
		foreach ( $rows as $row ) {
			if ( 'or' === ( $row['connector'] ?? 'and' ) && ! empty( $current ) ) {
				$chains[] = $current;
				$current  = array();
			}
			$current[] = $row;
		}
		if ( ! empty( $current ) ) {
			$chains[] = $current;
		}

		$out = array();
		foreach ( $chains as $chain_index => $chain ) {
			$merged_chain = self::merge_chain( $chain );
			foreach ( $merged_chain as $i => $row ) {
				// First row of the FIRST chain → connector "and" (implicit).
				// First row of subsequent chains → connector "or".
				// Subsequent rows in any chain → connector "and".
				$row['connector'] = ( 0 === $i && $chain_index > 0 ) ? 'or' : 'and';
				$out[]            = self::finalize_row( $row );
			}
		}
		return $out;
	}

	/**
	 * Merge same-type rows within a single AND-chain. Same rules as before:
	 * all positive → `in`; all negative → `not_in`; same string-matcher →
	 * that matcher with multi-value; mixed → first row wins.
	 *
	 * @param array $chain
	 * @return array
	 */
	private static function merge_chain( array $chain ) {
		$positive_set = array( 'is', 'in' );
		$negative_set = array( 'is_not', 'not_in' );
		$pattern_set  = array( 'starts_with', 'contains', 'ends_with', 'regex' );

		$by_type = array();
		$order   = array();
		foreach ( $chain as $row ) {
			if ( ! isset( $by_type[ $row['type'] ] ) ) {
				$by_type[ $row['type'] ] = array();
				$order[]                 = $row['type'];
			}
			$by_type[ $row['type'] ][] = $row;
		}

		$out = array();
		foreach ( $order as $type ) {
			$bucket = $by_type[ $type ];
			if ( count( $bucket ) === 1 ) {
				$out[] = $bucket[0];
				continue;
			}
			$first             = $bucket[0];
			$ops               = array_values(
				array_unique(
					array_map(
						static function ( $r ) {
							return $r['operator'];
						},
						$bucket 
					) 
				) 
			);
			$all_positive      = ! array_diff( $ops, $positive_set );
			$all_negative      = ! array_diff( $ops, $negative_set );
			$single_pattern_op = 1 === count( $ops ) && in_array( $ops[0], $pattern_set, true );

			if ( $all_positive ) {
				$out[] = array(
					'type'      => $type,
					'operator'  => 'in',
					'values'    => self::merge_values( $bucket ),
					'connector' => $first['connector'],
					'meta'      => $first['meta'],
				);
				continue;
			}
			if ( $all_negative ) {
				$out[] = array(
					'type'      => $type,
					'operator'  => 'not_in',
					'values'    => self::merge_values( $bucket ),
					'connector' => $first['connector'],
					'meta'      => $first['meta'],
				);
				continue;
			}
			if ( $single_pattern_op ) {
				$out[] = array(
					'type'      => $type,
					'operator'  => $ops[0],
					'values'    => self::merge_values( $bucket ),
					'connector' => $first['connector'],
					'meta'      => $first['meta'],
				);
				continue;
			}
			$out[] = $first;
		}
		return $out;
	}

	private static function merge_values( array $bucket ) {
		$seen = array();
		$out  = array();
		foreach ( $bucket as $row ) {
			foreach ( $row['values'] as $v ) {
				if ( ! isset( $seen[ $v ] ) ) {
					$seen[ $v ] = true;
					$out[]      = $v;
				}
			}
		}
		return $out;
	}

	/**
	 * Convert the internal normalized row shape into the persistence shape:
	 * single-value rows use `value`, multi-value rows use `values`. Drops
	 * empty meta. Promotes `is`→`in` / `is_not`→`not_in` when multi-value.
	 *
	 * @param array $row
	 * @return array
	 */
	private static function finalize_row( array $row ) {
		$values = isset( $row['values'] ) && is_array( $row['values'] ) ? $row['values'] : array();
		$op     = $row['operator'] ?? 'is';

		$persisted = array(
			'type'     => $row['type'],
			'operator' => $op,
		);

		if ( count( $values ) > 1 ) {
			// Multi-value: promote single-value operators to their list form.
			if ( 'is' === $op ) {
				$persisted['operator'] = 'in';
			} elseif ( 'is_not' === $op ) {
				$persisted['operator'] = 'not_in';
			}
			$persisted['values'] = array_values( $values );
		} else {
			// Single-value: demote list operators back to their scalar form so
			// the persisted shape is canonical. Without this, `describe_row`
			// renders `post in [2051]` instead of `post=2051` after a merge
			// collapses to one value.
			if ( 'in' === $op ) {
				$persisted['operator'] = 'is';
			} elseif ( 'not_in' === $op ) {
				$persisted['operator'] = 'is_not';
			}
			$persisted['value'] = (string) ( $values[0] ?? '' );
		}

		// Connector is significant: "or" marks the start of a new OR chain.
		// "and" is the default; we still persist it explicitly so JSON shape
		// is uniform across rows (avoid silent client-side drift).
		$connector              = isset( $row['connector'] ) ? strtolower( (string) $row['connector'] ) : 'and';
		$persisted['connector'] = ( 'or' === $connector ) ? 'or' : 'and';

		if ( ! empty( $row['meta'] ) && is_array( $row['meta'] ) ) {
			$persisted['meta'] = $row['meta'];
		}
		return $persisted;
	}

	/**
	 * Human-readable sentence describing when a snippet will run.
	 *
	 * Output examples:
	 *   "Runs everywhere"
	 *   "Runs when post_type=product"
	 *   "Runs when post in [2800,2801] AND post_type=product"
	 *
	 * Under the one-row-per-type model the description is always a flat AND
	 * of row strings — no parenthesization, no group rendering.
	 *
	 * @since 0.0.5
	 * @param array $conditions
	 * @return string
	 */
	public static function describe_conditions( $conditions ) {
		if ( empty( $conditions ) || ! is_array( $conditions ) ) {
			return 'Runs everywhere';
		}

		// Split into chains: each chain is rows joined by AND, chains OR together.
		$chains  = array();
		$current = array();
		foreach ( $conditions as $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}
			if ( 'or' === ( $row['connector'] ?? 'and' ) && ! empty( $current ) ) {
				$chains[] = $current;
				$current  = array();
			}
			$current[] = $row;
		}
		if ( ! empty( $current ) ) {
			$chains[] = $current;
		}
		if ( empty( $chains ) ) {
			return 'Runs everywhere';
		}

		$rendered = array();
		foreach ( $chains as $chain ) {
			$row_strings = array();
			foreach ( $chain as $row ) {
				$row_strings[] = self::describe_row( $row );
			}
			if ( empty( $row_strings ) ) {
				continue;
			}
			$expr = implode( ' AND ', $row_strings );
			// Parenthesize multi-row chains when there is more than one chain
			// so AND/OR precedence reads correctly.
			if ( count( $row_strings ) > 1 && count( $chains ) > 1 ) {
				$expr = '(' . $expr . ')';
			}
			$rendered[] = $expr;
		}
		if ( empty( $rendered ) ) {
			return 'Runs everywhere';
		}
		return 'Runs when ' . implode( ' OR ', $rendered );
	}

	/**
	 * Render a single condition row as `type[op]value` short form. Operator
	 * is omitted for the common `is` case to keep the sentence readable.
	 *
	 * @since 0.0.5
	 * @param array $row
	 * @return string
	 */
	private static function describe_row( $row ) {
		$type = $row['type'] ?? '?';
		$op   = $row['operator'] ?? 'is';
		if ( isset( $row['values'] ) && is_array( $row['values'] ) && ! empty( $row['values'] ) ) {
			$val = implode( ',', array_map( 'strval', $row['values'] ) );
		} else {
			$val = (string) ( $row['value'] ?? '' );
		}

		switch ( $op ) {
			case 'is':
				return $type . '=' . $val;
			case 'is_not':
				return $type . '!=' . $val;
			case 'in':
				return $type . ' in [' . $val . ']';
			case 'not_in':
				return $type . ' not in [' . $val . ']';
			case 'contains':
				return $type . ' contains [' . $val . ']';
			case 'starts_with':
				return $type . ' starts_with [' . $val . ']';
			case 'ends_with':
				return $type . ' ends_with [' . $val . ']';
			case 'regex':
				return $type . ' matches [' . $val . ']';
			default:
				return $type . ' ' . $op . ' ' . $val;
		}
	}

	/**
	 * Return all valid snippet slugs from the manifest. Used to enrich
	 * "not found" errors so the agent can self-correct on a typo or stale
	 * slug reference.
	 *
	 * @since 0.0.5
	 * @return array
	 */
	public static function known_slugs() {
		$manifest = self::load();
		return array_values(
			array_filter(
				array_keys( $manifest ),
				static function ( $k ) {
					return $k !== '_version'; }
			) 
		);
	}

	/**
	 * Format a "not found" diagnostic with up to N known slugs for the agent
	 * to fuzzy-match against.
	 *
	 * @since 0.0.5
	 * @param string $requested Slug the caller asked for.
	 * @return string
	 */
	public static function not_found_message( $requested ) {
		$known = self::known_slugs();
		if ( empty( $known ) ) {
			return sprintf( 'Snippet "%s" not found. No snippets exist yet.', (string) $requested );
		}
		$preview = array_slice( $known, 0, 20 );
		$suffix  = count( $known ) > 20 ? sprintf( ' (and %d more)', count( $known ) - 20 ) : '';
		return sprintf(
			'Snippet "%s" not found. Known slugs: %s%s. Call action=list to inspect all snippets.',
			(string) $requested,
			implode( ', ', $preview ),
			$suffix
		);
	}

	/**
	 * Default execution settings per file type.
	 *
	 * @since 0.0.5
	 * @return array
	 */
	public static function default_execution() {
		return array(
			'php'  => array(
				'hook'          => 'init',
				'priority'      => 10,
				'scope'         => 'everywhere',
				'accepted_args' => 1,
			),
			'js'   => array(
				'hook'     => 'wp_footer',
				'priority' => 10,
				'scope'    => 'frontend',
			),
			'css'  => array(
				'hook'     => 'wp_head',
				'priority' => 10,
				'scope'    => 'frontend',
			),
			'html' => array(
				'hook'     => 'wp_head',
				'priority' => 10,
				'scope'    => 'frontend',
			),
		);
	}

	/**
	 * Normalize a snippet's metadata by filling missing fields with defaults.
	 *
	 * Ensures backward compatibility — old manifests without execution/conditions
	 * fields get sensible defaults so downstream code never needs null-checks.
	 *
	 * @since 0.0.5
	 * @param array $meta Raw snippet metadata from manifest.
	 * @return array Normalized metadata with all fields present.
	 */
	public static function normalize_snippet( $meta ) {
		$defaults = array(
			'title'              => '',
			'description'        => '',
			'files'              => array(),
			'file_hashes'        => array(),
			'enabled'            => false,
			'auto_disabled'      => false,
			'disabled_reason'    => null,
			'disabled_at'        => null,
			'execution'          => self::default_execution(),
			'conditions'         => array(),
			'sandbox'            => false,
			'sandbox_user'       => null,
			'created'            => null,
			'created_by'         => null,
			'updated'            => null,
			'updated_by'         => null,
			'updated_by_type'    => null,  // user|agent|system — author type of last edit
			'current_version_id' => null,  // pointer into .versions/index.json
			'versions_count'     => 0,     // denormalized for list-page badge
			'last_version_at'    => null,  // ISO 8601 timestamp of latest version
		);

		$merged = array_merge( $defaults, $meta );

		// Ensure execution has entries for all file types with defaults.
		$default_exec = self::default_execution();
		foreach ( $default_exec as $type => $type_defaults ) {
			if ( ! isset( $merged['execution'][ $type ] ) ) {
				$merged['execution'][ $type ] = $type_defaults;
			} else {
				$merged['execution'][ $type ] = array_merge( $type_defaults, $merged['execution'][ $type ] );
			}
		}

		// Validate execution hooks and scopes.
		foreach ( array_keys( $default_exec ) as $type ) {
			$merged['execution'][ $type ]['hook'] = self::sanitize_execution_hook(
				$type,
				$merged['execution'][ $type ]['hook'] ?? '',
				$default_exec[ $type ]['hook']
			);
			if ( ! in_array( $merged['execution'][ $type ]['scope'], self::VALID_SCOPES, true ) ) {
				$merged['execution'][ $type ]['scope'] = $default_exec[ $type ]['scope'];
			}
			$merged['execution'][ $type ]['priority'] = max( 1, min( 99, (int) $merged['execution'][ $type ]['priority'] ) );
			if ( 'php' === $type ) {
				$merged['execution'][ $type ]['accepted_args'] = max( 0, min( 10, (int) ( $merged['execution'][ $type ]['accepted_args'] ?? 1 ) ) );
			}
		}

		// Ensure conditions is an array.
		if ( ! is_array( $merged['conditions'] ) ) {
			$merged['conditions'] = array();
		}

		return $merged;
	}

	/**
	 * Get the base snippets directory.
	 *
	 * Filterable via 'zip_ai_snippets_base_dir'. Always validated to be under WP_CONTENT_DIR.
	 *
	 * @since 0.1.0
	 * @return string Absolute directory path.
	 */
	public static function base_dir() {
		$dir = apply_filters( 'zip_ai_snippets_base_dir', WP_CONTENT_DIR . '/zip-ai-snippets' );

		$real = realpath( $dir ) ?: $dir;
		$base = realpath( WP_CONTENT_DIR ) ?: WP_CONTENT_DIR;
		if ( strpos( $real, $base ) !== 0 ) {
			return WP_CONTENT_DIR . '/zip-ai-snippets';
		}

		return $dir;
	}

	/**
	 * Get the manifest file path.
	 *
	 * @since 0.1.0
	 * @return string
	 */
	public static function manifest_path() {
		return self::base_dir() . '/manifest.php';
	}

	/**
	 * Load the manifest as an array.
	 *
	 * @since 0.1.0
	 * @return array Manifest data, or empty array if missing/corrupt.
	 */
	public static function load() {
		$path = self::manifest_path();
		if ( ! file_exists( $path ) ) {
			return array();
		}

		$raw = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions
		if ( false === $raw ) {
			return array();
		}

		$newline = strpos( $raw, "\n" );
		if ( false === $newline ) {
			return array();
		}

		$json = substr( $raw, $newline + 1 );
		$data = json_decode( $json, true );

		return is_array( $data ) ? $data : array();
	}

	/**
	 * Save manifest data to disk.
	 *
	 * @since 0.1.0
	 * @param array $data Manifest data.
	 * @return bool True on success.
	 */
	public static function save( array $data ) {
		$content = self::GUARD . wp_json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE );

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		return false !== file_put_contents( self::manifest_path(), $content, LOCK_EX );
	}

	/**
	 * Run a callback with an exclusive lock held across the manifest's full
	 * read-modify-write window. Solves the lost-update race where parallel
	 * REST requests (bulk delete, concurrent MCP tool calls) each `load()`
	 * the same baseline, each `unset()` their slug, each `save()` — second
	 * writer wins, the first deletion silently comes back, the UI surfaces
	 * the resurrected entry as a "leftover".
	 *
	 * Usage:
	 *   Snippet_Store::with_lock( function ( &$manifest ) use ( $slug ) {
	 *       unset( $manifest[ $slug ] );
	 *   } );
	 *
	 * Best-effort: when `flock()` is unavailable on the host (some hosted
	 * filesystems don't support advisory locking), falls back to a plain
	 * load/save sequence so callers don't break — same behavior as before
	 * this helper existed.
	 *
	 * @param callable $callback function ( array &$manifest ): void
	 * @return bool True on save success.
	 */
	public static function with_lock( callable $callback ) {
		$path = self::manifest_path();

		if ( ! file_exists( $path ) ) {
			// First-write case — there is nothing to read-lock. Hand the
			// callback an empty array, then save() (which already takes
			// LOCK_EX for the write).
			$manifest = array();
			$callback( $manifest );
			return self::save( $manifest );
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions
		$fp = @fopen( $path, 'r+' );
		if ( false === $fp ) {
			// Cannot open for read+write — fall back to the unsafe path so
			// callers degrade rather than fail.
			$manifest = self::load();
			$callback( $manifest );
			return self::save( $manifest );
		}

		$locked = @flock( $fp, LOCK_EX ); // phpcs:ignore WordPress.WP.AlternativeFunctions
		try {
			// phpcs:ignore WordPress.WP.AlternativeFunctions
			$raw     = stream_get_contents( $fp );
			$newline = is_string( $raw ) ? strpos( $raw, "\n" ) : false;
			$json    = false === $newline ? '' : substr( $raw, $newline + 1 );
			$data    = json_decode( $json, true );
			$manifest = is_array( $data ) ? $data : array();

			$callback( $manifest );

			$content = self::GUARD . wp_json_encode( $manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE );

			rewind( $fp );
			ftruncate( $fp, 0 );
			$written = fwrite( $fp, $content );
			fflush( $fp );

			return false !== $written;
		} finally {
			if ( $locked ) {
				@flock( $fp, LOCK_UN ); // phpcs:ignore WordPress.WP.AlternativeFunctions
			}
			fclose( $fp ); // phpcs:ignore WordPress.WP.AlternativeFunctions
		}
	}

	/**
	 * Validate and sanitize a snippet slug.
	 *
	 * Prevents path traversal, reserved names, and special characters.
	 *
	 * @since 0.1.0
	 * @param string $slug Raw slug input.
	 * @return string|false Sanitized slug, or false if invalid.
	 */
	public static function validate_slug( $slug ) {
		$slug = sanitize_file_name( $slug );

		if ( empty( $slug ) || '_version' === $slug || '.' === $slug || '..' === $slug ) {
			return false;
		}

		if ( preg_match( '/[\/\\\\]|\.\./', $slug ) ) {
			return false;
		}

		$base   = realpath( self::base_dir() ) ?: self::base_dir();
		$target = $base . '/' . $slug;
		if ( strpos( $target, $base . '/' ) !== 0 ) {
			return false;
		}

		return $slug;
	}

	/**
	 * Check if a file path is safely within the snippets base directory.
	 *
	 * @since 0.1.0
	 * @param string $filepath Absolute file path to check.
	 * @return bool
	 */
	public static function is_safe_path( $filepath ) {
		$real_base = realpath( self::base_dir() );
		if ( ! $real_base ) {
			return false;
		}

		// For new files that don't exist yet, check the parent directory.
		$check = file_exists( $filepath ) ? $filepath : dirname( $filepath );
		$real  = realpath( $check );
		if ( ! $real ) {
			return false;
		}

		return strpos( $real, $real_base . DIRECTORY_SEPARATOR ) === 0
			|| $real === $real_base;
	}

	/**
	 * Per-process cache of slug → on-disk dir-name lookups. Avoids reloading
	 * the manifest for every snippet_dir/snippet_file call within a request.
	 *
	 * @var array<string,string>
	 */
	private static $dir_cache = array();

	/**
	 * Mint a randomized on-disk directory name for a new snippet.
	 *
	 * Format: `{slug}-{8 hex chars}`. Pure-random suffix means an attacker
	 * can't guess the path from the title alone — defense against direct-URL
	 * fetching on web servers where `.htaccess` is inert (Nginx, Caddy, IIS).
	 * The human-readable slug is preserved as a prefix for filesystem
	 * inspection.
	 *
	 * @since 0.0.5
	 * @param string $slug Validated slug.
	 * @return string Storage dir basename.
	 */
	public static function mint_storage_dir( $slug ) {
		$suffix = function_exists( 'wp_generate_password' )
			? strtolower( wp_generate_password( 8, false, false ) )
			: bin2hex( random_bytes( 4 ) );
		// Strip any non [a-z0-9] just in case (wp_generate_password without
		// special chars already gives alphanumeric — belt-and-braces).
		$suffix = preg_replace( '/[^a-z0-9]/', '', $suffix );
		if ( strlen( $suffix ) < 8 ) {
			$suffix = str_pad( $suffix, 8, '0' );
		}
		return $slug . '-' . substr( $suffix, 0, 8 );
	}

	/**
	 * Resolve a slug to its on-disk directory basename via the manifest.
	 * New snippets carry an explicit `dir` field (randomized prefix +
	 * suffix). Legacy snippets without `dir` fall back to the slug, so
	 * existing folders keep working without migration.
	 *
	 * @since 0.0.5
	 * @param string $slug
	 * @return string
	 */
	public static function resolve_dir_name( $slug ) {
		if ( isset( self::$dir_cache[ $slug ] ) ) {
			return self::$dir_cache[ $slug ];
		}
		$manifest = self::load();
		$dir      = isset( $manifest[ $slug ]['dir'] ) && is_string( $manifest[ $slug ]['dir'] ) && '' !== $manifest[ $slug ]['dir']
			? $manifest[ $slug ]['dir']
			: $slug;
		self::$dir_cache[ $slug ] = $dir;
		return $dir;
	}

	/**
	 * Reset the dir cache. Call after a write that may have changed the
	 * `dir` field — primarily create / restore-from-version paths.
	 *
	 * @since 0.0.5
	 */
	public static function clear_dir_cache() {
		self::$dir_cache = array();
	}

	/**
	 * Stash a slug → dir-name binding into the cache. Used by the create
	 * flow to expose a freshly-minted random dir to `snippet_dir()` before
	 * the manifest is persisted (otherwise the fallback would point at
	 * `base_dir()/{slug}` instead of `base_dir()/{slug}-{hex}`).
	 *
	 * @since 0.0.5
	 * @param string $slug
	 * @param string $dir
	 */
	public static function set_dir_cache( $slug, $dir ) {
		self::$dir_cache[ $slug ] = $dir;
	}

	/**
	 * Get the full path to a snippet's directory.
	 *
	 * @since 0.1.0
	 * @param string $slug Validated snippet slug.
	 * @return string
	 */
	public static function snippet_dir( $slug ) {
		return self::base_dir() . '/' . self::resolve_dir_name( $slug );
	}

	/**
	 * Get the full path to a snippet file.
	 *
	 * @since 0.1.0
	 * @param string $slug Validated snippet slug.
	 * @param string $type File type (php, js, css).
	 * @return string
	 */
	public static function snippet_file( $slug, $type ) {
		return self::snippet_dir( $slug ) . '/snippet.' . $type;
	}

	/**
	 * Apache/LiteSpeed `.htaccess` body that denies all direct HTTP access
	 * to the snippets dir tree. Idempotent — safe to write at any time.
	 *
	 * @since 0.0.5
	 * @return string
	 */
	public static function htaccess_content() {
		return "# ZIP AI snippets — block all direct HTTP access.\n"
			. "<IfModule mod_authz_core.c>\n  Require all denied\n</IfModule>\n"
			. "<IfModule !mod_authz_core.c>\n  Order Deny,Allow\n  Deny from all\n</IfModule>\n";
	}

	/**
	 * Recommended Nginx location block for hosts where `.htaccess` is inert.
	 * Written as a documentation file inside the base dir so admins can
	 * paste the snippet into their server config.
	 *
	 * @since 0.0.5
	 * @return string
	 */
	public static function nginx_config_content() {
		$rel = '/wp-content/' . basename( self::base_dir() ) . '/';
		return "# ZIP AI snippets — paste this into your Nginx server block.\n"
			. "# Blocks direct HTTP access to snippet source on hosts where\n"
			. "# .htaccess is inert. The plugin's PHP executor still includes\n"
			. "# these files server-side; this only blocks the public URL path.\n"
			. "\n"
			. "location ~* ^{$rel} {\n"
			. "    deny all;\n"
			. "    return 403;\n"
			. "}\n";
	}

	/**
	 * Idempotent .htaccess + index.php protection at a directory path.
	 * Used for both the base dir and each per-snippet subdir.
	 *
	 * @since 0.0.5
	 * @param string $dir Absolute path.
	 * @return void
	 */
	public static function write_dir_protection( $dir ) {
		if ( ! is_dir( $dir ) ) {
			return;
		}
		$htaccess = $dir . '/.htaccess';
		if ( ! file_exists( $htaccess ) ) {
			file_put_contents( $htaccess, self::htaccess_content() ); // phpcs:ignore WordPress.WP.AlternativeFunctions
		}
		$index = $dir . '/index.php';
		if ( ! file_exists( $index ) ) {
			file_put_contents( $index, '<?php // Silence is golden.' ); // phpcs:ignore WordPress.WP.AlternativeFunctions
		}
	}

	/**
	 * Invalidate PHP opcache for a snippet file after rewrite.
	 *
	 * Critical on hosts with `opcache.validate_timestamps=0` — without this
	 * call, `include` keeps executing the previously-compiled bytecode even
	 * after the file content changes, so frontend never picks up snippet
	 * edits until opcache is flushed manually.
	 *
	 * @since 0.0.5
	 * @param string $filepath Absolute path to the file just written.
	 * @return void
	 */
	public static function invalidate_opcache( $filepath ) {
		if ( ! function_exists( 'opcache_invalidate' ) ) {
			return;
		}
		// Only PHP files get cached; skip JS/CSS to avoid noise.
		if ( substr( $filepath, -4 ) !== '.php' ) {
			return;
		}
		// `force = true` so we drop the entry even if its timestamp matches.
		@opcache_invalidate( $filepath, true ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
	}
}
