<?php
/**
 * Manage Code Snippet Ability
 *
 * Create, read, update, delete, enable/disable code snippets.
 * Each snippet is a folder in wp-content/zip-ai-snippets/{slug}/
 * containing snippet.php, snippet.js, snippet.css as needed.
 *
 * Execution is configurable per type:
 * - PHP  → included on a configured WordPress action, including safe custom hooks
 * - JS   → enqueued via WordPress enqueue actions
 * - CSS  → enqueued via WordPress enqueue actions
 *
 * Guardrails:
 * - Path traversal protection via validate_slug()
 * - PHP blocklist (dangerous functions rejected at create/update time)
 * - File integrity hashes (SHA-256) — tampered files won't execute
 * - Snippets created DISABLED by default (require explicit enable)
 * - Backup on update (old code saved as snippet.{type}.bak)
 * - Max code size limit (100KB)
 * - Audit trail (created_by, updated_by user IDs)
 *
 * @since 0.0.5
 * @package zip-ai
 */

namespace ZipAI\MCP\Classes\Abilities\Zipai\System;

use ZipAI\MCP\Classes\Abilities\Abstract_Ability;
use ZipAI\MCP\Classes\Core\Tool_Types;
use ZipAI\MCP\Classes\Core\Response;
use ZipAI\MCP\Classes\Core\Snippet_Store;
use ZipAI\MCP\Classes\Core\Snippet_Versions;
use ZipAI\MCP\Classes\Core\Snippet_Lint;
use ZipAI\MCP\Classes\Core\Snippet_Conditions;

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

class ManageCodeSnippet extends Abstract_Ability {

	protected $is_destructive = true;

	/**
	 * Sub-actions that only READ from the snippet store — listed here so the
	 * brain's writes-require-approval gate doesn't block dedup lookups
	 * (`list`/`get`), diagnostics (`simulate_match`/`trace`), target
	 * resolution (`resolve_target`), or version-history reads.
	 *
	 * @var array<int,string>
	 */
	protected $read_only_actions = array(
		'list',
		'get',
		'list_versions',
		'get_version',
		'diff_versions',
		'simulate_match',
		'resolve_target',
		'trace',
	);

	/**
	 * Get allowed file types. Filterable via 'zip_ai_snippets_types'.
	 *
	 * @since 0.0.5
	 */
	private function get_types() {
		return apply_filters( 'zip_ai_snippets_types', array( 'php', 'js', 'css', 'html' ) );
	}

	/**
	 * Get max code size in bytes. Filterable via 'zip_ai_snippets_max_code_size'.
	 *
	 * @since 0.0.5
	 */
	private function get_max_code_size() {
		$size = apply_filters( 'zip_ai_snippets_max_code_size', 102400 ); // 100KB default
		return max( 1024, (int) $size ); // Minimum 1KB
	}

	public function configure() {
		$this->id          = 'zipai/run-snippet';
		$this->label       = 'Manage Code Snippet';
		$this->description = 'Site-wide PHP/JS/CSS/HTML snippets. '
			. 'MANDATORY FIRST STEP for ANY add/create/install/install-tag/install-snippet request: call `action:"list"` BEFORE anything else (no smart-form, no `resolve_target`, no `create`). If a matching snippet already exists, switch to `action:"update"` on that slug and skip the smart-form intake. NEVER create a duplicate. '
			. 'Conditions model: at most ONE row per type. Multi-value via `operator:"in"|"not_in"`/`starts_with`/`contains`/`ends_with`/`regex` (OR within row). Rows AND together. '
			. 'DO NOT use for page content — use editor__apply_change with the page open in the block editor, and a core/html block for raw HTML embedded in a page. '
			. 'Before authoring conditions for a named target ("shop page", "checkout", "blog"), call action=`resolve_target` to get the real post_id + post_type — never guess. '
			. 'After any conditions change, call action=`simulate_match` against the target post to verify. '
			. 'Every write returns `evaluated_targeting` (e.g. "Runs when post in [2800,2801] AND post_type=product") — read it to confirm intent. '
			. 'Full rules in "Snippet Generator" prompt section.';
		$this->capability  = 'manage_options';
	}

	public function get_tool_type() {
		return Tool_Types::ACTION;
	}

	public function get_input_schema() {
		// Per-file-type hook enums — each file type supports a different set.
		// Schema carries the contract so the prompt doesn't have to list them.
		$php_hooks  = Snippet_Store::VALID_PHP_HOOKS;
		$js_hooks   = Snippet_Store::VALID_JS_HOOKS;
		$css_hooks  = Snippet_Store::VALID_CSS_HOOKS;
		$html_hooks = Snippet_Store::VALID_HTML_HOOKS;
		$scope      = array( 'frontend', 'admin', 'everywhere', 'login' );

		// Common execution config shape — reused per file type with a different hook enum.
		$exec_entry = static function ( array $hook_enum, $allow_custom = false ) use ( $scope ): array {
			$hook_schema = array(
				'type'        => 'string',
				'enum'        => $hook_enum,
				'description' => 'WordPress action hook to attach this file to.',
			);
			if ( $allow_custom ) {
				$hook_schema = array(
					'type'        => 'string',
					'pattern'     => '^[A-Za-z_][A-Za-z0-9_./:-]{0,127}$',
					'description' => 'WordPress action hook for PHP execution. Prefer known hooks: ' . implode( ', ', $hook_enum ) . '. Safe custom plugin hooks are allowed, e.g. woocommerce_before_cart or acf/save_post.',
				);
			}

			$entry = array(
				'type'                 => 'object',
				'additionalProperties' => false,
				'properties'           => array(
					'hook'     => $hook_schema,
					'priority' => array(
						'type'        => 'integer',
						'minimum'     => 1,
						'maximum'     => 99,
						'description' => 'Hook priority (1-99). Default 10.',
					),
					'scope'    => array(
						'type'        => 'string',
						'enum'        => $scope,
						'description' => 'Where this file runs: frontend, admin, everywhere, or login pages.',
					),
				),
			);
			if ( $allow_custom ) {
				$entry['properties']['accepted_args'] = array(
					'type'        => 'integer',
					'minimum'     => 0,
					'maximum'     => 10,
					'description' => 'PHP only. Number of hook/filter arguments WordPress should pass into the snippet. Defaults to 1. Snippet code may read $args, $zip_ai_hook_value, or $zip_ai_hook_args and may return a filter value.',
				);
			}
			return $entry;
		};

		return array(
			'type'                 => 'object',
			'required'             => array( 'action' ),
			// Top-level loose — callers may carry audit metadata we don't want to block.
			'additionalProperties' => true,
			'properties'           => array(
				'action'             => array(
					'type'        => 'string',
					'enum'        => array(
						'create',
						'list',
						'get',
						'update',
						'delete',
						'enable',
						'disable',
						'list_versions',
						'get_version',
						'diff_versions',
						'restore_version',
						'create_manual_version',
						'simulate_match',
						'resolve_target',
						'trace',
					),
					'description' => 'Snippet lifecycle action. Versioning: list_versions, get_version, diff_versions, restore_version (destructive), create_manual_version. Diagnostics: `simulate_match` (does this snippet fire against a given post/url/user?), `resolve_target` (turn "shop page"/slug into concrete post_id+post_type before authoring conditions), `trace` (read last evaluation traces for a slug). Always call `resolve_target` before authoring conditions for a named page, and `simulate_match` after any conditions change to verify before claiming success.',
				),
				'slug'               => array(
					'type'        => 'string',
					'description' => 'Snippet slug (folder name). Required for get/update/delete/enable/disable.',
				),
				'title'              => array(
					'type'        => 'string',
					'description' => 'Human-readable snippet title.',
				),
				'code'               => array(
					'type'        => 'string',
					'description' => 'Code content. For PHP: do NOT include opening tag.',
				),
				'type'               => array(
					'type'        => 'string',
					'enum'        => $this->get_types(),
					'description' => 'File type. php = server-side, runs on a WP action. js = pure JavaScript body (NO `<script>` wrappers, NO HTML comments — executor wraps it in an inline `<script>` automatically; including your own tags double-wraps and breaks). css = pure CSS body (NO `<style>` wrappers). html = verbatim head/footer markup emitted as-is on `wp_head`/`wp_footer`/`login_head` — use for external `<script src="...">` loaders (Google Analytics gtag, Tag Manager, FB Pixel, Hotjar), JSON-LD blocks, OG/meta tags, `<noscript>` pixels. If your payload literally contains `<script>` or `<link rel="stylesheet">`, the answer is `type:"html"`.',
				),
				'replace_files'      => array(
					'type'        => 'boolean',
					'description' => 'Update only. Set true when the new code replaces the whole snippet and old file types must be removed after the update. Use for conversions such as changing an old HTML WhatsApp button into a PHP Facebook button so stale files cannot keep rendering.',
				),
				'remove_types'       => array(
					'type'        => 'array',
					'description' => 'Update only. Explicit file types to remove from this snippet after a successful update. Use to clean up stale old outputs (for example remove_types:["html"] after replacing old HTML with PHP). Cannot remove the same type being updated.',
					'items'       => array(
						'type' => 'string',
						'enum'  => $this->get_types(),
					),
				),
				'description'        => array( 'type' => 'string' ),
				'version_id'         => array(
					'type'        => 'string',
					'description' => 'Target version. Accepts either the raw id (e.g. "1778074991-7b9b29") OR the user-facing label (e.g. "v3"). Always prefer the raw id from a recent list_versions call — labels (vN) are derived from index position and may shift if older versions are GC\'d.',
				),
				'against_version_id' => array(
					'type'        => 'string',
					'description' => 'Comparison version for diff_versions. Accepts raw id or label. Defaults to current HEAD.',
				),
				'against'            => array(
					'type'        => 'object',
					'description' => '`simulate_match` only. Context to evaluate conditions against. Keys: post_id (preferred — server resolves post_type/page-type from it), url (REQUEST_URI to test), user_role, device ("mobile"|"desktop"). Server synthesizes runtime context from these and returns pass/fail + per-row trace.',
				),
				'query'              => array(
					'type'        => 'string',
					'description' => '`resolve_target` only. Natural-language label of a target page or archive (e.g. "shop page", "checkout", "blog", "product archive"). Server returns concrete matches (post ID + post_type, post-type archive, WooCommerce page mapping) and suggested condition rows. Always call this before authoring conditions for a named page.',
				),
				'reason'             => array(
					'type'        => 'string',
					'description' => 'Human-readable change message. Required for create_manual_version, and recommended on update when changing code.',
				),
				'execution'          => array(
					// Strict per-file-type. Wrong hook for a type (e.g. "init" on a JS file)
					// is rejected here rather than discovered at runtime.
					'type'                 => 'object',
					'description'          => 'Execution config per file type. Each file type has its own allowed hook set — schema enforces the valid combinations.',
					'additionalProperties' => false,
					'properties'           => array(
						'php'  => $exec_entry( $php_hooks, true ),
						'js'   => $exec_entry( $js_hooks ),
						'css'  => $exec_entry( $css_hooks ),
						'html' => $exec_entry( $html_hooks ),
					),
				),
				'conditions'         => array(
					'type'        => 'array',
					'description' => 'Targeting rows — FULL REPLACEMENT on update. Row shape: {type, operator, value?|values?, connector?}. `connector` is "and" (default) or "or" — "or" marks the start of a new OR chain. Consecutive AND rows form a chain; chains OR together. Standard precedence: AND binds tighter than OR. One row per type WITHIN a chain (same type across different chains is allowed). Multi-value via `operator:"in"`/`"not_in"` (or pattern operators for url_pattern). Empty array = runs everywhere (only after explicit user confirmation). Server returns `evaluated_targeting` like "Runs when (post in [2051] AND user_role=admin) OR device=mobile" — verify it matches intent.',
					'items'       => array( 'type' => 'object' ),
				),
			),
			// Per-action required-field enforcement. Each branch fires when
			// `action` matches the literal `const`, then asserts the fields
			// that action needs. Schema validators (incl. the ADK adapter)
			// surface these failures BEFORE the call hits the server, so the
			// brain learns the contract from the schema rather than from a
			// runtime "version_id is required" error string.
			'allOf'                => array(
				array(
					'if'   => array(
						'properties' => array( 'action' => array( 'const' => 'create' ) ),
						'required'   => array( 'action' ),
					),
					'then' => array( 'required' => array( 'action', 'title', 'code', 'type' ) ),
				),
				array(
					'if'   => array(
						'properties' => array( 'action' => array( 'const' => 'get' ) ),
						'required'   => array( 'action' ),
					),
						'then' => array( 'required' => array( 'action', 'slug', 'reason' ) ),
				),
				array(
					'if'   => array(
						'properties' => array( 'action' => array( 'const' => 'update' ) ),
						'required'   => array( 'action' ),
					),
					'then' => array( 'required' => array( 'action', 'slug' ) ),
				),
				array(
					'if'   => array(
						'properties' => array( 'action' => array( 'const' => 'delete' ) ),
						'required'   => array( 'action' ),
					),
					'then' => array( 'required' => array( 'action', 'slug' ) ),
				),
				array(
					'if'   => array(
						'properties' => array( 'action' => array( 'const' => 'enable' ) ),
						'required'   => array( 'action' ),
					),
					'then' => array( 'required' => array( 'action', 'slug' ) ),
				),
				array(
					'if'   => array(
						'properties' => array( 'action' => array( 'const' => 'disable' ) ),
						'required'   => array( 'action' ),
					),
					'then' => array( 'required' => array( 'action', 'slug' ) ),
				),
				array(
					'if'   => array(
						'properties' => array( 'action' => array( 'const' => 'list_versions' ) ),
						'required'   => array( 'action' ),
					),
					'then' => array( 'required' => array( 'action', 'slug' ) ),
				),
				array(
					'if'   => array(
						'properties' => array( 'action' => array( 'const' => 'get_version' ) ),
						'required'   => array( 'action' ),
					),
					'then' => array( 'required' => array( 'action', 'slug', 'version_id' ) ),
				),
				array(
					'if'   => array(
						'properties' => array( 'action' => array( 'const' => 'diff_versions' ) ),
						'required'   => array( 'action' ),
					),
					'then' => array( 'required' => array( 'action', 'slug', 'version_id' ) ),
				),
				array(
					'if'   => array(
						'properties' => array( 'action' => array( 'const' => 'restore_version' ) ),
						'required'   => array( 'action' ),
					),
					'then' => array( 'required' => array( 'action', 'slug', 'version_id' ) ),
				),
				array(
					'if'   => array(
						'properties' => array( 'action' => array( 'const' => 'create_manual_version' ) ),
						'required'   => array( 'action' ),
					),
					'then' => array( 'required' => array( 'action', 'slug' ) ),
				),
				array(
					'if'   => array(
						'properties' => array( 'action' => array( 'const' => 'simulate_match' ) ),
						'required'   => array( 'action' ),
					),
					'then' => array( 'required' => array( 'action', 'slug' ) ),
				),
				array(
					'if'   => array(
						'properties' => array( 'action' => array( 'const' => 'resolve_target' ) ),
						'required'   => array( 'action' ),
					),
					'then' => array( 'required' => array( 'action', 'query' ) ),
				),
				array(
					'if'   => array(
						'properties' => array( 'action' => array( 'const' => 'trace' ) ),
						'required'   => array( 'action' ),
					),
					'then' => array( 'required' => array( 'action', 'slug' ) ),
				),
			),
		);
	}

	/**
	 * Output schema — common response shape across all actions.
	 *
	 * @since 0.0.5
	 * @return array
	 */
	public function get_output_schema() {
		return array(
			'type'                 => 'object',
			'required'             => array( 'success' ),
			'additionalProperties' => true,
			'properties'           => array(
				'success' => array( 'type' => 'boolean' ),
				'slug'    => array( 'type' => 'string' ),
				'message' => array( 'type' => 'string' ),
				'data'    => array(
					'description' => 'Action-specific data: snippet record (create/get/update), list of snippets (list), or status flags (enable/disable/delete).',
				),
			),
		);
	}

	public function execute( $input ) {
		$this->ensure_base_dir();

		$action = $input['action'] ?? '';

		switch ( $action ) {
			case 'create':
				return $this->create_snippet( $input );
			case 'list':
				return $this->list_snippets();
			case 'get':
				return $this->get_snippet( $input );
			case 'update':
				return $this->update_snippet( $input );
			case 'delete':
				return $this->delete_snippet( $input );
			case 'enable':
				return $this->toggle_snippet( $input, true );
			case 'disable':
				return $this->toggle_snippet( $input, false );
			case 'list_versions':
				return $this->list_versions_action( $input );
			case 'get_version':
				return $this->get_version_action( $input );
			case 'diff_versions':
				return $this->diff_versions_action( $input );
			case 'restore_version':
				return $this->restore_version_action( $input );
			case 'create_manual_version':
				return $this->create_manual_version_action( $input );
			case 'simulate_match':
				return $this->simulate_match_action( $input );
			case 'resolve_target':
				return $this->resolve_target_action( $input );
			case 'trace':
				return $this->trace_action( $input );
			default:
				return Response::error( "Unknown action: {$action}" );
		}
	}

	private function agent_context() {
		$user_id = $this->current_user_id();
		$user    = $user_id ? get_user_by( 'id', $user_id ) : null;
		return array(
			'author_type' => 'agent',
			'author_id'   => $user_id ?: null,
			'author_name' => $user && $user->display_name ? 'ZIP AI (' . $user->display_name . ')' : 'ZIP AI',
		);
	}

	// ── Version actions (agent surface) ─────────────────────────────────

	private function list_versions_action( $input ) {
		$slug = Snippet_Store::validate_slug( $input['slug'] ?? '' );
		if ( ! $slug ) {
			return Response::error( 'Invalid slug.' );
		}
		$versions = Snippet_Versions::list_versions( $slug );
		return Response::success(
			count( $versions ) . ' version(s) found.',
			array(
				'slug'     => $slug,
				'versions' => $versions,
			)
		);
	}

	private function get_version_action( $input ) {
		$slug = Snippet_Store::validate_slug( $input['slug'] ?? '' );
		if ( ! $slug ) {
			return Response::error( 'Invalid slug.' );
		}
		$version_id = sanitize_text_field( $input['version_id'] ?? '' );
		if ( empty( $version_id ) ) {
			return Response::error( 'version_id is required. Pass either the raw id or the user-facing label (e.g. "v3"). Call list_versions first to map labels to ids.' );
		}
		$result = Snippet_Versions::get( $slug, $version_id );
		if ( is_wp_error( $result ) ) {
			return Response::error( $result->get_error_message() );
		}
		return Response::success( "Version '{$version_id}' loaded.", $result );
	}

	private function diff_versions_action( $input ) {
		$slug = Snippet_Store::validate_slug( $input['slug'] ?? '' );
		if ( ! $slug ) {
			return Response::error( 'Invalid slug.' );
		}
		$a = sanitize_text_field( $input['version_id'] ?? '' );
		if ( empty( $a ) ) {
			return Response::error( 'version_id is required.' );
		}
		$b = sanitize_text_field( $input['against_version_id'] ?? '' ) ?: null;
		return Response::success( 'Diff computed.', array( 'diff' => Snippet_Versions::diff( $slug, $a, $b ) ) );
	}

	private function restore_version_action( $input ) {
		$slug = Snippet_Store::validate_slug( $input['slug'] ?? '' );
		if ( ! $slug ) {
			return Response::error( 'Invalid slug.' );
		}
		$version_id = sanitize_text_field( $input['version_id'] ?? '' );
		if ( empty( $version_id ) ) {
			return Response::error( 'version_id is required. Pass either the raw id or the user-facing label (e.g. "v3"). Call list_versions first to map labels to ids.' );
		}
		$entry = Snippet_Versions::restore( $slug, $version_id, $this->agent_context() );
		if ( is_wp_error( $entry ) ) {
			return Response::error( $entry->get_error_message() );
		}
		if ( ! $entry ) {
			return Response::error( "Failed to restore version '{$version_id}'." );
		}
		// Stamp updated_by_type so future readers know an agent did this.
		$manifest = Snippet_Store::load();
		if ( isset( $manifest[ $slug ] ) ) {
			$manifest[ $slug ]['updated_by_type'] = 'agent';
			$manifest[ $slug ]['updated']         = current_time( 'mysql' );
			$manifest[ $slug ]['updated_by']      = $this->current_user_id();
			Snippet_Store::save( $manifest );
		}
		// Restore intentionally bypasses lint (escape hatch for legitimately
		// older code), but report warnings so the agent can self-correct.
		$lint_warnings = array();
		$files         = $manifest[ $slug ]['files'] ?? array();
		if ( in_array( 'php', $files, true ) ) {
			$file = Snippet_Store::snippet_file( $slug, 'php' );
			if ( file_exists( $file ) ) {
				$code           = file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions
				$execution_hook = $manifest[ $slug ]['execution']['php']['hook'] ?? '';
				$lint           = Snippet_Lint::check( $code, $execution_hook );
				if ( is_wp_error( $lint ) ) {
					$lint_warnings[] = array(
						'code'    => $lint->get_error_code(),
						'message' => $lint->get_error_message(),
					);
				}
			}
		}
		// HEAD changed — drop the executor trace ring buffer so stale entries
		// from the prior code path don't surface on the next `trace` call.
		delete_transient( 'zip_ai_snippets_trace_' . $slug );
		return Response::success(
			"Snippet '{$slug}' restored to version '{$version_id}'.",
			array(
				'version'       => $entry,
				'lint_warnings' => $lint_warnings,
			)
		);
	}

		private function create_manual_version_action( $input ) {
			$slug = Snippet_Store::validate_slug( $input['slug'] ?? '' );
			if ( ! $slug ) {
				return Response::error( 'Invalid slug.' );
			}
			$reason = sanitize_text_field( $input['reason'] ?? '' );
			if ( '' === $reason ) {
				return Response::error( 'reason is required for create_manual_version.' );
			}
			$ctx           = $this->agent_context();
			$ctx['reason'] = $reason;
			$ctx['manual'] = true;
		$entry         = Snippet_Versions::create( $slug, $ctx );
		if ( ! $entry ) {
			return Response::error( 'Failed to create manual version.' );
		}
		return Response::success( "Manual snapshot saved for '{$slug}'.", array( 'version' => $entry ) );
	}

	// ── Diagnostic actions (agent surface) ──────────────────────────────

	/**
	 * Simulate whether a snippet would fire against a synthesized request
	 * context (post_id, url, user_role, device). Agent calls this after any
	 * conditions change to verify before claiming success; admin UI can wire
	 * it to a "Test on this page" button.
	 */
	private function simulate_match_action( $input ) {
		$slug = Snippet_Store::validate_slug( $input['slug'] ?? '' );
		if ( ! $slug ) {
			return Response::error( 'Invalid slug.' );
		}
		$manifest = Snippet_Store::load();
		if ( ! isset( $manifest[ $slug ] ) ) {
			return Response::error( Snippet_Store::not_found_message( $slug ) );
		}
		$normalized = Snippet_Store::normalize_snippet( $manifest[ $slug ] );
		$conditions = $normalized['conditions'];
		$against    = is_array( $input['against'] ?? null ) ? $input['against'] : array();

		$cleanup = $this->push_simulation_context( $against );
		try {
			$trace = Snippet_Conditions::evaluate_with_trace( $conditions, false );
		} finally {
			$cleanup();
		}

		return Response::success(
			sprintf(
				"Simulation for '%s' against %s: %s",
				$slug,
				$this->describe_against( $against ),
				! empty( $trace['result'] ) ? 'WOULD FIRE' : 'would not fire'
			),
			array(
				'slug'                => $slug,
				'would_match'         => ! empty( $trace['result'] ),
				'against'             => $against,
				'evaluated_targeting' => Snippet_Store::describe_conditions( $conditions ),
				'trace'               => $trace,
			)
		);
	}

	/**
	 * Set up a transient WP_Query context matching the requested target so
	 * Snippet_Conditions evaluators see the correct globals/queried object.
	 * Returns a cleanup callable that must be invoked in a finally block.
	 */
	private function push_simulation_context( array $against ) {
		global $wp_query;

		$saved_query = $wp_query;
		$saved_post  = isset( $GLOBALS['post'] ) ? $GLOBALS['post'] : null;
		$saved_uri   = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : null;
		$saved_user  = get_current_user_id();

		if ( ! empty( $against['url'] ) ) {
			$_SERVER['REQUEST_URI'] = esc_url_raw( (string) $against['url'] );
		}

		if ( ! empty( $against['post_id'] ) ) {
			$post = get_post( (int) $against['post_id'] );
			if ( $post ) {
				$wp_query                    = new \WP_Query();
				$wp_query->queried_object    = $post;
				$wp_query->queried_object_id = $post->ID;
				$wp_query->is_single         = ( 'page' !== $post->post_type );
				$wp_query->is_singular       = true;
				$wp_query->is_page           = ( 'page' === $post->post_type );
				$wp_query->post              = $post;
				$wp_query->posts             = array( $post );
				$wp_query->post_count        = 1;
				$GLOBALS['post']             = $post;
				setup_postdata( $post );
			}
		}

		// Track whether we registered a synthetic-role filter so cleanup can
		// remove it without poking globals.
		$role_filter = null;
		if ( ! empty( $against['user_role'] ) ) {
			$role  = sanitize_key( $against['user_role'] );
			$users = get_users(
				array(
					'role'   => $role,
					'number' => 1,
					'fields' => 'ID',
				)
			);
			if ( ! empty( $users ) ) {
				wp_set_current_user( (int) $users[0] );
			} else {
				// No real user has this role. Synthesize the role via filter so
				// the evaluator sees the requested role on `wp_get_current_user()`
				// — silently no-oping (the old behavior) made `simulate_match`
				// report incorrect pass/fail using whatever role the calling
				// agent's session user happened to have.
				$role_filter = static function ( $user ) use ( $role ) {
					if ( $user instanceof \WP_User ) {
						$user->roles = array( $role );
					}
					return $user;
				};
				add_filter( 'wp_get_current_user', $role_filter, 1000 );
				// Also ensure is_user_logged_in() returns true for the synthetic
				// session — the evaluator gates on it before reading roles.
				wp_set_current_user( $saved_user > 0 ? $saved_user : 1 );
			}
		}

		$mobile_filter = null;
		if ( ! empty( $against['device'] ) ) {
			$want_mobile   = ( 'mobile' === $against['device'] );
			$mobile_filter = static function () use ( $want_mobile ) {
				return $want_mobile;
			};
			add_filter( 'pre_wp_is_mobile', $mobile_filter, 1000 );
		}

		return function () use ( $saved_query, $saved_post, $saved_uri, $saved_user, $mobile_filter, $role_filter ) {
			global $wp_query;
			$wp_query = $saved_query;
			if ( null === $saved_post ) {
				unset( $GLOBALS['post'] );
				wp_reset_postdata();
			} else {
				$GLOBALS['post'] = $saved_post;
				setup_postdata( $saved_post );
			}
			if ( null === $saved_uri ) {
				unset( $_SERVER['REQUEST_URI'] );
			} else {
				$_SERVER['REQUEST_URI'] = $saved_uri;
			}
			wp_set_current_user( (int) $saved_user );
			if ( $mobile_filter ) {
				remove_filter( 'pre_wp_is_mobile', $mobile_filter, 1000 );
			}
			if ( $role_filter ) {
				remove_filter( 'wp_get_current_user', $role_filter, 1000 );
			}
		};
	}

	private function describe_against( array $against ) {
		if ( empty( $against ) ) {
			return 'current request';
		}
		$bits = array();
		foreach ( $against as $k => $v ) {
			$bits[] = $k . '=' . (string) $v;
		}
		return implode( ',', $bits );
	}

	/**
	 * Resolve a natural-language target ("shop page", "checkout", "blog",
	 * "product archive") into concrete IDs + post_type + suggested condition
	 * rows. Agent calls this BEFORE authoring conditions on a named page so
	 * it never guesses (the celebration snippet failed exactly because the
	 * agent assumed "shop page" meant WooCommerce post_type=product).
	 */
	private function resolve_target_action( $input ) {
		$query = trim( sanitize_text_field( (string) ( $input['query'] ?? '' ) ) );
		if ( '' === $query ) {
			return Response::error( '`query` is required for resolve_target.' );
		}

		$matches = array();
		$slug    = sanitize_title( $query );
		$needle  = strtolower( $query );

		// 1) WooCommerce-specific aliases — `shop`, `cart`, `checkout`, `myaccount`/`my-account`.
		if ( function_exists( 'wc_get_page_id' ) ) {
			$wc_map = array(
				'shop'      => array( 'shop', 'shop page', 'shop pages', 'store', 'store page', 'products page' ),
				'cart'      => array( 'cart', 'cart page', 'shopping cart' ),
				'checkout'  => array( 'checkout', 'checkout page' ),
				'myaccount' => array( 'myaccount', 'my account', 'account', 'account page' ),
				'terms'     => array( 'terms', 'terms and conditions' ),
			);
			foreach ( $wc_map as $wc_key => $aliases ) {
				if ( in_array( $needle, $aliases, true ) ) {
					$wc_id = (int) wc_get_page_id( $wc_key );
					if ( $wc_id > 0 ) {
						$matches[] = $this->build_post_match( $wc_id, sprintf( 'WooCommerce %s page', $wc_key ) );
					}
				}
			}
		}

		// 2) Exact slug match across public post types.
		$by_slug = get_posts(
			array(
				'name'             => $slug,
				'post_type'        => 'any',
				'post_status'      => array( 'publish', 'draft', 'private' ),
				'posts_per_page'   => 5,
				'no_found_rows'    => true,
				'suppress_filters' => true,
			)
		);
		foreach ( $by_slug as $p ) {
			$matches[] = $this->build_post_match( (int) $p->ID, sprintf( 'Post matched by slug=%s', $p->post_name ) );
		}

		// 3) Title LIKE match.
		$by_title = get_posts(
			array(
				's'                => $query,
				'post_type'        => 'any',
				'post_status'      => array( 'publish', 'draft', 'private' ),
				'posts_per_page'   => 5,
				'no_found_rows'    => true,
				'suppress_filters' => true,
			)
		);
		foreach ( $by_title as $p ) {
			$matches[] = $this->build_post_match( (int) $p->ID, sprintf( 'Post matched by title="%s"', $p->post_title ) );
		}

		// 4) Post-type archive — e.g. query "product archive" / "products".
		$archive_query = preg_replace( '/\s+(archive|archives|page)$/i', '', $needle );
		$archive_query = trim( $archive_query );
		if ( '' !== $archive_query ) {
			$post_types = get_post_types(
				array(
					'has_archive' => true,
					'public'      => true,
				),
				'objects'
			);
			foreach ( $post_types as $pt ) {
				$name_match = strtolower( $pt->name ) === $archive_query
					|| strtolower( $pt->name ) . 's' === $archive_query
					|| ( ! empty( $pt->labels->name ) && strtolower( $pt->labels->name ) === $archive_query );
				if ( $name_match ) {
					$matches[] = array(
						'kind'                 => 'post_type_archive',
						'post_type'            => $pt->name,
						'label'                => sprintf( '%s archive (post type %s)', $pt->labels->name ?? $pt->name, $pt->name ),
						'suggested_conditions' => array(
							array(
								'type'     => 'page',
								'operator' => 'is',
								'value'    => 'archive',
							),
							array(
								'type'     => 'post_type',
								'operator' => 'is',
								'value'    => $pt->name,
							),
						),
						'suggested_join'       => 'all of the above (AND)',
					);
				}
			}
		}

		$matches = $this->dedupe_target_matches( $matches );

		return Response::success(
			sprintf(
				'%d match(es) for "%s".',
				count( $matches ),
				$query
			),
			array(
				'query'   => $query,
				'matches' => $matches,
				'hint'    => $this->resolve_hint( $matches ),
			)
		);
	}

	private function build_post_match( $post_id, $why ) {
		$post = get_post( (int) $post_id );
		if ( ! $post ) {
			return null;
		}
		return array(
			'kind'                 => 'post',
			'id'                   => (int) $post->ID,
			'post_type'            => $post->post_type,
			'slug'                 => $post->post_name,
			'title'                => $post->post_title,
			'status'               => $post->post_status,
			'url'                  => get_permalink( $post ) ?: null,
			'why'                  => $why,
			'suggested_conditions' => array(
				array(
					'type'     => 'post',
					'operator' => 'is',
					'value'    => (string) $post->ID,
				),
			),
		);
	}

	private function dedupe_target_matches( array $matches ) {
		$seen = array();
		$out  = array();
		foreach ( $matches as $m ) {
			if ( ! $m ) {
				continue;
			}
			$key = $m['kind'] . ':' . ( $m['id'] ?? $m['post_type'] ?? '' );
			if ( isset( $seen[ $key ] ) ) {
				continue;
			}
			$seen[ $key ] = true;
			$out[]        = $m;
		}
		return $out;
	}

	private function resolve_hint( array $matches ) {
		if ( empty( $matches ) ) {
			return 'no matches — ask the user to clarify (page slug, URL, or post ID).';
		}
		if ( count( $matches ) === 1 ) {
			return 'single match — safe to use its `suggested_conditions` directly.';
		}
		return 'multiple matches — confirm with the user which one (do not guess).';
	}

	/**
	 * Read the executor's recent evaluation trace for a slug. Returns the
	 * last N entries from the ring buffer so the agent or admin can diagnose
	 * "snippet enabled but didn't fire" without trial-and-error.
	 */
	private function trace_action( $input ) {
		$slug = Snippet_Store::validate_slug( $input['slug'] ?? '' );
		if ( ! $slug ) {
			return Response::error( 'Invalid slug.' );
		}
		$trace = get_transient( 'zip_ai_snippets_trace_' . $slug );
		if ( ! is_array( $trace ) ) {
			$trace = array();
		}
		return Response::success(
			sprintf( '%d trace entr(y/ies) for "%s".', count( $trace ), $slug ),
			array(
				'slug'  => $slug,
				'count' => count( $trace ),
				'trace' => $trace,
				'hint'  => empty( $trace )
					? 'no traces yet — enable trace mode by appending ?zip_ai_snippet_trace=1 (admin only) to a frontend URL, or call simulate_match for synthetic context.'
					: 'most recent entry is last in array; each entry records request context + per-row pass/fail.',
			)
		);
	}

	// ── Security ───────────────────────────────────────────────────────

	/**
	 * Compute file hash for integrity verification.
	 *
	 * @since 0.0.5
	 */
	private function file_hash( $filepath ) {
		return hash_file( 'sha256', $filepath );
	}

	/**
	 * Get current WordPress user ID for audit trail.
	 *
	 * @since 0.0.5
	 */
	private function current_user_id() {
		return get_current_user_id() ?: 0;
	}

	// ── Directory setup ────────────────────────────────────────────────

	private function ensure_base_dir() {
		$base = Snippet_Store::base_dir();
		wp_mkdir_p( $base );

		// Per-process belt-and-braces protection. Snippet_Store::write_dir_protection
		// writes `.htaccess` + `index.php` if missing. Idempotent.
		Snippet_Store::write_dir_protection( $base );

		// Doc-only nginx config snippet — admins on Nginx can paste it into
		// their server block. `.htaccess` is inert on Nginx, so this is the
		// recommended hardening path.
		$nginx_path = $base . '/nginx.conf.example';
		if ( ! file_exists( $nginx_path ) ) {
			file_put_contents( $nginx_path, Snippet_Store::nginx_config_content() ); // phpcs:ignore WordPress.WP.AlternativeFunctions
		}

		if ( ! file_exists( Snippet_Store::manifest_path() ) ) {
			Snippet_Store::save( array() );
		}
	}

	private function protect_snippet_dir( $slug ) {
		// Belt-and-braces: drop both `.htaccess` and `index.php` into each
		// per-snippet folder so a misconfigured server can't list contents
		// or serve raw source even if the base-dir `.htaccess` is ignored.
		Snippet_Store::write_dir_protection( Snippet_Store::snippet_dir( $slug ) );
	}

	// ── Helpers ─────────────────────────────────────────────────────────

	private function slugify( $title ) {
		return sanitize_file_name( sanitize_title( $title ) );
	}

	/**
	 * Thin pass-through to the central Snippet_Store sanitizer. Single
	 * source of truth for execution + condition sanitization.
	 */
	private function sanitize_execution( $execution ) {
		return Snippet_Store::sanitize_execution( $execution );
	}

	private function sanitize_conditions( $conditions ) {
		return Snippet_Store::sanitize_conditions( $conditions );
	}

	private function detect_files( $slug ) {
		$found = array();
		foreach ( $this->get_types() as $type ) {
			if ( file_exists( Snippet_Store::snippet_file( $slug, $type ) ) ) {
				$found[] = $type;
			}
		}
		return $found;
	}

	/**
	 * Write code to a snippet file with validation and hashing.
	 *
	 * @since 0.0.5
	 * @return array{filepath: string, hash: string}|string Error string on failure.
	 */
	private function write_snippet_file( $slug, $type, $code, $title, $execution_hook = '' ) {
		// Strip null bytes to prevent injection.
		$code = str_replace( "\0", '', $code );

		if ( strlen( $code ) > $this->get_max_code_size() ) {
			return 'Code exceeds maximum size of 100KB.';
		}

		// PHP blocklist check.
		if ( $type === 'php' ) {
			$blocked = Snippet_Store::check_php_blocklist( $code );
			if ( $blocked ) {
				return "Blocked: '{$blocked}' is not allowed in PHP snippets for security reasons.";
			}

			// Inline-targeting lint — block routing checks baked into the
			// snippet body. Forces the agent to use the `conditions[]`
			// argument instead of `if (is_home()) return;` wrappers.
			$lint = Snippet_Lint::check( $code, $execution_hook );
			if ( is_wp_error( $lint ) ) {
				return $lint->get_error_message();
			}

			$code    = preg_replace( '/^<\?(php)?\s*/', '', $code );
			$content = "<?php\nif ( ! defined( 'ABSPATH' ) ) { exit; }\n// Snippet: {$title}\n" . $code . "\n";
		} else {
			// JS/CSS bodies must not contain `<script>`/`<style>` wrappers or
			// HTML comments — the executor already wraps them via
			// wp_add_inline_script/style. HTML type is verbatim and bypasses
			// this check intentionally.
			$non_php_lint = Snippet_Lint::check_non_php( $code, $type );
			if ( is_wp_error( $non_php_lint ) ) {
				return $non_php_lint->get_error_message();
			}
			$content = $code;
		}

		$filepath = Snippet_Store::snippet_file( $slug, $type );
		if ( ! Snippet_Store::is_safe_path( $filepath ) ) {
			return 'Refused unsafe write path.';
		}

		// .bak files are no longer needed — Snippet_Versions snapshots HEAD on
		// every code change (auto for diffs, manual via create_manual_version).
		// phpcs:ignore WordPress.WP.AlternativeFunctions
		if ( false === file_put_contents( $filepath, $content ) ) {
			return 'Failed to write the snippet file to disk. Check filesystem permissions and available disk space on wp-content.';
		}
		Snippet_Store::invalidate_opcache( $filepath );

		return array(
			'filepath' => $filepath,
			'hash'     => $this->file_hash( $filepath ),
		);
	}

	/**
	 * Persist the manifest, returning a ready-to-return Response::error array
	 * when the write fails (disk full / permissions) so callers surface the
	 * failure instead of reporting a phantom success. Returns null on success.
	 *
	 * Snippet_Store::save() returns false when its file_put_contents() fails;
	 * the CRUD paths previously ignored that and answered "done" while nothing
	 * was persisted.
	 *
	 * @param array  $manifest        Manifest to persist.
	 * @param string $failure_message Optional override for the failure copy — used
	 *                                by the delete paths, where files are removed
	 *                                from disk BEFORE the manifest write, so the
	 *                                generic "nothing was persisted" wording would
	 *                                be the inverse of the truth.
	 * @return array|null Response::error array on failure, null on success.
	 */
	private function save_manifest( array $manifest, string $failure_message = '' ) {
		if ( Snippet_Store::save( $manifest ) ) {
			return null;
		}
		return Response::error(
			'' !== $failure_message
				? $failure_message
				: 'Failed to save the snippet to disk — the change was not persisted. Check filesystem permissions and available disk space on wp-content.'
		);
	}

	// ── CRUD ────────────────────────────────────────────────────────────

	private function create_snippet( $input ) {
		$title = sanitize_text_field( $input['title'] ?? '' );
		$code  = $input['code'] ?? '';
		$type  = $input['type'] ?? 'php';

		if ( empty( $code ) ) {
			return Response::error( 'Code is required.' );
		}
		if ( ! in_array( $type, $this->get_types(), true ) ) {
			return Response::error( 'Invalid type. Use: ' . implode( ', ', $this->get_types() ) );
		}

		$manifest = Snippet_Store::load();
		$raw_slug = ! empty( $input['slug'] ) ? $input['slug'] : $this->slugify( $title ?: 'snippet' );
		$slug     = Snippet_Store::validate_slug( $raw_slug );

		if ( ! $slug ) {
			return Response::error( 'Invalid slug. Use only letters, numbers, and hyphens.' );
		}

		$is_new    = ! isset( $manifest[ $slug ] );
		$execution = $this->sanitize_execution( $input['execution'] ?? ( $manifest[ $slug ]['execution'] ?? array() ) );

		if ( ! $is_new && file_exists( Snippet_Store::snippet_file( $slug, $type ) ) ) {
			return Response::error( "Snippet '{$slug}' already has a {$type} file. Use update action to modify it." );
		}
		if ( $is_new && empty( $title ) ) {
			return Response::error( 'Title is required for new snippets.' );
		}

		// For new snippets, mint a randomized on-disk dir name so the
		// filesystem path is not guessable from the title alone. Existing
		// snippets keep their current dir (manifest `dir` field or slug
		// fallback resolved by Snippet_Store::resolve_dir_name).
		if ( $is_new ) {
			$dir_name = Snippet_Store::mint_storage_dir( $slug );
			Snippet_Store::set_dir_cache( $slug, $dir_name );
		}

		wp_mkdir_p( Snippet_Store::snippet_dir( $slug ) );
		$this->protect_snippet_dir( $slug );

		$snippet_title = $title ?: ( $manifest[ $slug ]['title'] ?? $slug );
		$result        = $this->write_snippet_file( $slug, $type, $code, $snippet_title, $execution[ $type ]['hook'] ?? '' );

		if ( is_string( $result ) ) {
			return Response::error( $result );
		}

		if ( $is_new ) {
			$manifest[ $slug ] = array(
				'title'           => $title,
				'dir'             => $dir_name, // randomized on-disk basename
				'files'           => array( $type ),
				'file_hashes'     => array( $type => $result['hash'] ),
				'enabled'         => false,
				'description'     => sanitize_text_field( $input['description'] ?? '' ),
				'execution'       => $execution,
				'conditions'      => $this->sanitize_conditions( $input['conditions'] ?? array() ),
				'created'         => current_time( 'mysql' ),
				'created_by'      => $this->current_user_id(),
				'updated_by_type' => 'agent',
			);
		} else {
			$files = $manifest[ $slug ]['files'] ?? array();
			if ( ! in_array( $type, $files, true ) ) {
				$files[] = $type;
			}
			$manifest[ $slug ]['files']                = $files;
			$manifest[ $slug ]['file_hashes'][ $type ] = $result['hash'];
			$manifest[ $slug ]['updated']              = current_time( 'mysql' );
			$manifest[ $slug ]['updated_by']           = $this->current_user_id();
			$manifest[ $slug ]['updated_by_type']      = 'agent';
		}

		$save_error = $this->save_manifest( $manifest );
		if ( null !== $save_error ) {
			return $save_error;
		}

		// Snapshot a new version (agent author). Reason falls back to a
		// generic message so the version row carries useful intent.
		$ctx           = $this->agent_context();
		$ctx['reason'] = sanitize_text_field(
			$input['reason'] ?? ( $is_new ? 'Initial version' : "Updated {$type} via ZIP AI" )
		);
		$ctx['manual'] = false;
		Snippet_Versions::create( $slug, $ctx );

		// Snippet_Versions::create rewrites the manifest to update
		// current_version_id / versions_count / last_version_at — reload so the
		// response payload reflects current state instead of a pre-version copy.
		$manifest = Snippet_Store::load();

		$label = $is_new ? 'created (disabled — call enable to activate)' : "updated (added {$type} file)";

		// applied_fields lets the caller verify each mutating field actually
		// landed in the manifest. Without this, agents have no way to detect
		// silent drops (which is how the conditions-field regression went
		// unnoticed before).
		$applied = array( 'slug', 'type', 'files' );
		if ( $is_new ) {
			$applied[] = 'title';
			$applied[] = 'description';
			$applied[] = 'execution';
			$applied[] = 'conditions';
		}
		if ( isset( $input['description'] ) ) {
			$applied[] = 'description';
		}

		$saved_conditions = $manifest[ $slug ]['conditions'] ?? array();
		$secret_warnings  = Snippet_Lint::detect_secrets( $code );
		return Response::success(
			"Snippet '{$slug}' {$label}.",
			array(
				'slug'                => $slug,
				'type'                => $type,
				'files'               => $manifest[ $slug ]['files'],
				'enabled'             => $manifest[ $slug ]['enabled'],
				'secret_warnings'     => $secret_warnings,
				'execution'           => $manifest[ $slug ]['execution'] ?? null,
				'conditions'          => $saved_conditions,
				'evaluated_targeting' => Snippet_Store::describe_conditions( $saved_conditions ),
				'applied_fields'      => array_values( array_unique( $applied ) ),
			)
		);
	}

	private function list_snippets() {
		$manifest = Snippet_Store::load();
		$snippets = array();

		foreach ( $manifest as $slug => $meta ) {
			if ( $slug === '_version' ) {
				continue;
			}
			$normalized = Snippet_Store::normalize_snippet( $meta );
			$snippets[] = array(
				'slug'                => $slug,
				'title'               => $normalized['title'],
				'files'               => $normalized['files'],
				'enabled'             => $normalized['enabled'],
				'description'         => $normalized['description'],
				'execution'           => $normalized['execution'],
				'conditions'          => $normalized['conditions'],
				'evaluated_targeting' => Snippet_Store::describe_conditions( $normalized['conditions'] ),
			);
		}

		return Response::success(
			count( $snippets ) . ' snippets found.',
			array(
				'count'    => count( $snippets ),
				'snippets' => $snippets,
			)
		);
	}

	private function get_snippet( $input ) {
		$slug = Snippet_Store::validate_slug( $input['slug'] ?? '' );
		if ( ! $slug ) {
			return Response::error( 'Invalid slug.' );
		}

		$manifest = Snippet_Store::load();
		if ( ! isset( $manifest[ $slug ] ) ) {
			return Response::error( Snippet_Store::not_found_message( $slug ) );
		}

		$meta  = $manifest[ $slug ];
		$files = $meta['files'] ?? array();
		$code  = array();

		foreach ( $files as $type ) {
			$filepath = Snippet_Store::snippet_file( $slug, $type );
			if ( file_exists( $filepath ) ) {
				$code[ $type ] = file_get_contents( $filepath ); // phpcs:ignore WordPress.WP.AlternativeFunctions
			}
		}

		$saved_conditions = $meta['conditions'] ?? array();
		return Response::success(
			"Snippet: {$meta['title']}",
			array_merge(
				$meta,
				array(
					'slug'                => $slug,
					'code'                => $code,
					'evaluated_targeting' => Snippet_Store::describe_conditions( $saved_conditions ),
				)
			)
		);
	}

	private function sanitize_remove_types( $types ) {
		if ( ! is_array( $types ) ) {
			return array();
		}
		return array_values( array_unique( array_intersect( $this->get_types(), $types ) ) );
	}

	private function remove_snippet_file_types( $slug, &$meta, array $types ) {
		$removed             = array();
		$meta['files']       = array_values( array_intersect( $this->get_types(), $meta['files'] ?? array() ) );
		$meta['file_hashes'] = isset( $meta['file_hashes'] ) && is_array( $meta['file_hashes'] ) ? $meta['file_hashes'] : array();

		foreach ( $types as $type ) {
			if ( ! in_array( $type, $meta['files'], true ) ) {
				continue;
			}

			$filepath = Snippet_Store::snippet_file( $slug, $type );
			if ( Snippet_Store::is_safe_path( $filepath ) && file_exists( $filepath ) ) {
				unlink( $filepath ); // phpcs:ignore WordPress.WP.AlternativeFunctions
				Snippet_Store::invalidate_opcache( $filepath );
			}

			$meta['files'] = array_values( array_diff( $meta['files'], array( $type ) ) );
			unset( $meta['file_hashes'][ $type ] );
			$removed[] = $type;
		}

		return $removed;
	}

	private function update_snippet( $input ) {
		$slug = Snippet_Store::validate_slug( $input['slug'] ?? '' );
		if ( ! $slug ) {
			return Response::error( 'Invalid slug.' );
		}

		$manifest = Snippet_Store::load();
		if ( ! isset( $manifest[ $slug ] ) ) {
			return Response::error( Snippet_Store::not_found_message( $slug ) );
		}

		$meta              = $manifest[ $slug ];
		$conditions_before = is_array( $meta['conditions'] ?? null ) ? $meta['conditions'] : array();
		$applied           = array(); // Tracks which fields the caller's payload actually mutated.
		$snapshot_needed   = false;
		$updated_type      = null;
		$remove_types      = $this->sanitize_remove_types( $input['remove_types'] ?? array() );

		if ( isset( $input['code'] ) && ! isset( $input['type'] ) ) {
			$files = array_values( array_intersect( $this->get_types(), $meta['files'] ?? array() ) );
			if ( empty( $files ) ) {
				$files = $this->detect_files( $slug );
			}
			if ( 1 === count( $files ) ) {
				$input['type'] = $files[0];
			} else {
				return Response::error(
					sprintf(
						'Type is required when updating code. Pass one of: %s.',
						implode( ', ', $this->get_types() )
					)
				);
			}
		}

		if ( isset( $input['code'] ) && isset( $input['type'] ) ) {
			$type = $input['type'];
			if ( ! in_array( $type, $this->get_types(), true ) ) {
				return Response::error( 'Invalid type.' );
			}
			if ( empty( $input['replace_files'] ) && in_array( $type, $remove_types, true ) ) {
				return Response::error( 'remove_types cannot include the same type being updated.' );
			}

			$execution      = $this->sanitize_execution( $input['execution'] ?? ( $meta['execution'] ?? array() ) );
			$execution_hook = $execution[ $type ]['hook'] ?? '';
			$result         = $this->write_snippet_file( $slug, $type, $input['code'], $meta['title'], $execution_hook );
			if ( is_string( $result ) ) {
				return Response::error( $result );
			}

			$files = array_values( array_intersect( $this->get_types(), $meta['files'] ?? array() ) );
			if ( ! in_array( $type, $files, true ) ) {
				if ( ! isset( $meta['file_hashes'] ) || ! is_array( $meta['file_hashes'] ) ) {
					$meta['file_hashes'] = array();
				}
				// Updating a single-file snippet to a different type is a
				// conversion, not an additive multi-file edit. Without this, a
				// request like "change the WhatsApp HTML snippet to a Facebook PHP
				// snippet" leaves the old HTML file active underneath the new one.
				if ( 1 === count( $files ) ) {
					$old_type = $files[0];
					$old_path = Snippet_Store::snippet_file( $slug, $old_type );
					if ( Snippet_Store::is_safe_path( $old_path ) && file_exists( $old_path ) ) {
						unlink( $old_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions
						Snippet_Store::invalidate_opcache( $old_path );
					}
					unset( $meta['file_hashes'][ $old_type ] );
					$files       = array( $type );
					$applied[]   = 'files';
				} else {
					$files[] = $type;
				}
				$meta['files'] = $files;
			}
			$meta['file_hashes']          = $meta['file_hashes'] ?? array();
			$meta['file_hashes'][ $type ] = $result['hash'];
			$applied[]                    = 'code';
			$snapshot_needed              = true;
			$updated_type                 = $type;
		}

		if ( ! empty( $input['replace_files'] ) ) {
			if ( ! $updated_type ) {
				return Response::error( 'replace_files requires code and type so the replacement file is explicit.' );
			}
			$remove_types = array_values( array_diff( array_intersect( $this->get_types(), $meta['files'] ?? array() ), array( $updated_type ) ) );
		}

		if ( $updated_type && in_array( $updated_type, $remove_types, true ) ) {
			return Response::error( 'remove_types cannot include the same type being updated.' );
		}

		if ( ! empty( $remove_types ) ) {
			$files_before = array_values( array_intersect( $this->get_types(), $meta['files'] ?? array() ) );
			$files_after  = array_values( array_diff( $files_before, $remove_types ) );
			if ( empty( $files_after ) ) {
				return Response::error( 'Refusing to remove every file from a snippet via update. Use delete action instead.' );
			}

			$removed = $this->remove_snippet_file_types( $slug, $meta, $remove_types );
			if ( ! empty( $removed ) ) {
				$applied[]       = 'files';
				$snapshot_needed = true;
			}
		}

		if ( isset( $input['title'] ) ) {
			$title = sanitize_text_field( $input['title'] );
			if ( ( $meta['title'] ?? '' ) !== $title ) {
				$snapshot_needed = true;
			}
			$meta['title'] = $title;
			$applied[]     = 'title';
		}
		if ( isset( $input['description'] ) ) {
			$description = sanitize_text_field( $input['description'] );
			if ( ( $meta['description'] ?? '' ) !== $description ) {
				$snapshot_needed = true;
			}
			$meta['description'] = $description;
			$applied[]           = 'description';
		}
		// Allow execution + conditions to be updated via the agent. Without
		// these branches the ability silently ignored targeting changes —
		// agent thought the call succeeded while the manifest stayed empty.
		if ( isset( $input['execution'] ) && is_array( $input['execution'] ) ) {
			$execution = $this->sanitize_execution( $input['execution'] );
			if ( ( $meta['execution'] ?? array() ) !== $execution ) {
				$snapshot_needed = true;
			}
			$meta['execution'] = $execution;
			$applied[]         = 'execution';
		}
		if ( isset( $input['conditions'] ) && is_array( $input['conditions'] ) ) {
			$conditions = $this->sanitize_conditions( $input['conditions'] );
			if ( ( $meta['conditions'] ?? array() ) !== $conditions ) {
				$snapshot_needed = true;
			}
			$meta['conditions'] = $conditions;
			$applied[]          = 'conditions';
		}

		// Reject no-op updates so callers don't silently treat a missed
		// payload (e.g. a typo'd field name like `condition` instead of
		// `conditions`) as a successful mutation.
		if ( empty( $applied ) ) {
			return Response::error(
				'No mutating field provided. Pass at least one of: code (with type), title, description, execution, conditions, remove_types.'
			);
		}

		$meta['updated']         = current_time( 'mysql' );
		$meta['updated_by']      = $this->current_user_id();
		$meta['updated_by_type'] = 'agent';

		$manifest[ $slug ] = $meta;

		$save_error = $this->save_manifest( $manifest );
		if ( null !== $save_error ) {
			return $save_error;
		}

		if ( $snapshot_needed ) {
			$ctx           = $this->agent_context();
			$ctx['reason'] = sanitize_text_field( $input['reason'] ?? 'Updated snippet via ZIP AI: ' . implode( ', ', $applied ) );
			$ctx['manual'] = false;
			Snippet_Versions::create( $slug, $ctx );
			// Reload so $meta reflects manifest fields rewritten by Versions::create
			// (current_version_id, versions_count, last_version_at).
			$manifest = Snippet_Store::load();
			$meta     = $manifest[ $slug ] ?? $meta;
		}

		$saved_conditions = $meta['conditions'] ?? array();
		$secret_warnings  = isset( $input['code'] ) ? Snippet_Lint::detect_secrets( (string) $input['code'] ) : array();
		return Response::success(
			sprintf( "Snippet '%s' updated. Applied: %s.", $slug, implode( ', ', $applied ) ),
			array(
				'secret_warnings'            => $secret_warnings,
				'slug'                       => $slug,
				'applied_fields'             => $applied,
				'execution'                  => $meta['execution'] ?? null,
				'conditions'                 => $saved_conditions,
				'evaluated_targeting'        => Snippet_Store::describe_conditions( $saved_conditions ),
				'evaluated_targeting_before' => Snippet_Store::describe_conditions( $conditions_before ),
			)
		);
	}

	private function delete_snippet( $input ) {
		$slug = Snippet_Store::validate_slug( $input['slug'] ?? '' );
		if ( ! $slug ) {
			return Response::error( 'Invalid slug.' );
		}

		$manifest = Snippet_Store::load();
		if ( ! isset( $manifest[ $slug ] ) ) {
			return Response::error( Snippet_Store::not_found_message( $slug ) );
		}

		if ( ! empty( $input['dry_run'] ) ) {
			return Response::success( "Dry run — would delete: {$manifest[ $slug ]['title']}" );
		}

		// Delete specific file type if specified.
		if ( ! empty( $input['type'] ) ) {
			$type     = $input['type'];
			$filepath = Snippet_Store::snippet_file( $slug, $type );

			if ( file_exists( $filepath ) ) {
				unlink( $filepath ); // phpcs:ignore WordPress.WP.AlternativeFunctions
			}

			$files = array_values( array_diff( $manifest[ $slug ]['files'] ?? array(), array( $type ) ) );
			unset( $manifest[ $slug ]['file_hashes'][ $type ] );
			$manifest[ $slug ]['files']   = $files;
			$manifest[ $slug ]['updated'] = current_time( 'mysql' );

			if ( empty( $files ) ) {
				$this->remove_snippet_dir( $slug );
				unset( $manifest[ $slug ] );
				$save_error = $this->save_manifest( $manifest, "Snippet '{$slug}' files were removed, but the snippet list could not be updated (manifest write failed) — it may still appear until the delete is retried." );
				if ( null !== $save_error ) {
					return $save_error;
				}
				return Response::success( "Snippet '{$slug}' deleted (last file removed)." );
				}

				$save_error = $this->save_manifest( $manifest, "The {$type} file was removed, but the snippet list could not be updated (manifest write failed) — retry to reconcile it." );
				if ( null !== $save_error ) {
					return $save_error;
				}
				$ctx           = $this->agent_context();
				$ctx['reason'] = sanitize_text_field( $input['reason'] ?? "Removed {$type} file via ZIP AI" );
				$ctx['manual'] = false;
				Snippet_Versions::create( $slug, $ctx );
				return Response::success( "Removed {$type} file from snippet '{$slug}'." );
			}

		$this->remove_snippet_dir( $slug );
		unset( $manifest[ $slug ] );
		$save_error = $this->save_manifest( $manifest, "Snippet '{$slug}' files were removed, but the snippet list could not be updated (manifest write failed) — it may still appear until the delete is retried." );
		if ( null !== $save_error ) {
			return $save_error;
		}
		// Clear the executor's evaluation ring buffer for this slug so a
		// re-created snippet with the same slug doesn't surface stale traces.
		delete_transient( 'zip_ai_snippets_trace_' . $slug );
		// Drop the cached dir lookup so a re-created snippet with the same
		// slug doesn't accidentally inherit the deleted snippet's dir name
		// from the in-process cache.
		Snippet_Store::clear_dir_cache();

		return Response::success( "Snippet '{$slug}' deleted." );
	}

	private function remove_snippet_dir( $slug ) {
		$dir = Snippet_Store::snippet_dir( $slug );
		if ( ! is_dir( $dir ) ) {
			return;
		}
		// Snippet dirs hold subdirs (e.g. `.versions/{id}/snippet.{ext}` written
		// by Snippet_Versions). The old top-level scan skipped those and tripped
		// `rmdir(): Directory not empty`. Walk the tree bottom-up so every file
		// and child directory is gone before we remove the parent. Don't follow
		// symlinks — unlink the link itself instead of recursing into its target.
		self::recursive_delete( $dir );
	}

	/**
	 * Recursively delete a directory and all of its contents.
	 * Skips symlink traversal so a stray link can never escape the snippet root.
	 */
	private static function recursive_delete( $dir ) {
		if ( ! is_dir( $dir ) || is_link( $dir ) ) {
			if ( is_link( $dir ) || is_file( $dir ) ) {
				unlink( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions
			}
			return;
		}
		$entries = @scandir( $dir );
		if ( is_array( $entries ) ) {
			foreach ( $entries as $entry ) {
				if ( '.' === $entry || '..' === $entry ) {
					continue;
				}
				$path = $dir . '/' . $entry;
				if ( is_link( $path ) || is_file( $path ) ) {
					unlink( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions
					continue;
				}
				if ( is_dir( $path ) ) {
					self::recursive_delete( $path );
				}
			}
		}
		rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions
	}

	/**
	 * Toggle a snippet's enabled state.
	 *
	 * SECURITY — accepted residual (audit zip-ai#314 item #4): enabling a `php`
	 * snippet causes author-written PHP to run on its hook (see
	 * Snippet_Executor::include_php_snippet's `include`), i.e. it is RCE-capable.
	 * There is intentionally NO server-side human-approval gate here. The
	 * human-in-the-loop is the brain's writes-require-approval card: this ability
	 * is `is_destructive` and `enable` is not in `read_only_actions`, so the
	 * client renders an explicit approval the user must accept before the brain
	 * calls this (hardened by #12 — a write can no longer self-classify as a read
	 * and skip the card). This relies on the brain-trust model: the caller is the
	 * connected admin (App Password owner with `manage_options`) who can already
	 * author/run PHP. The residual — a prompt-injected brain bypassing its own
	 * approval — is ACCEPTED for the current threat model. If that model tightens,
	 * gate php-enable on a human-only surface the brain cannot reach (the Snippets
	 * admin page + cookie/nonce), per #4 option A1.
	 */
	private function toggle_snippet( $input, $enable ) {
		$slug = Snippet_Store::validate_slug( $input['slug'] ?? '' );
		if ( ! $slug ) {
			return Response::error( 'Invalid slug.' );
		}

		$manifest = Snippet_Store::load();
		if ( ! isset( $manifest[ $slug ] ) ) {
			return Response::error( Snippet_Store::not_found_message( $slug ) );
		}

		$manifest[ $slug ]['enabled']    = $enable;
		$manifest[ $slug ]['updated']    = current_time( 'mysql' );
		$manifest[ $slug ]['updated_by'] = $this->current_user_id();

		$save_error = $this->save_manifest( $manifest );
		if ( null !== $save_error ) {
			return $save_error;
		}

		$label = $enable ? 'enabled' : 'disabled';
		return Response::success( sprintf( "Snippet '%s' %s.", $manifest[ $slug ]['title'], $label ) );
	}
}
