<?php
/**
 * Snippet REST API - Manage ZIP AI code snippets via REST endpoints.
 *
 * Endpoints:
 *   GET    /snippets              — List all snippets
 *   POST   /snippets              — Create new snippet
 *   GET    /snippets/{slug}       — Get single snippet with file contents
 *   POST   /snippets/{slug}       — Update snippet
 *   DELETE /snippets/{slug}       — Delete snippet
 *   POST   /snippets/{slug}/toggle — Toggle enable/disable
 *   GET    /snippets/export       — Export selected snippets as JSON
 *   POST   /snippets/import       — Import snippets from JSON
 *
 * @since 0.1.0
 * @package zip-ai
 */

namespace ZipAI\MCP\Classes\Api;

use ZipAI\MCP\Classes\Core\Snippet_Store;
use ZipAI\MCP\Classes\Core\Snippet_Versions;
use ZipAI\MCP\Classes\Core\Snippet_Lint;

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

class Snippet_REST_API {

	private $namespace = 'zip-ai/v1';

	private const MAX_CODE_SIZE = 102400; // 100KB

	public function __construct() {
		add_action( 'rest_api_init', array( $this, 'register_routes' ) );
	}

	// ── Routes ─────────────────────────────────────────────────────────

	public function register_routes() {
		$slug_args = array(
			'slug' => array(
				'required'          => true,
				'type'              => 'string',
				'sanitize_callback' => 'sanitize_file_name',
				'validate_callback' => function ( $value ) {
					return (bool) preg_match( '/^[a-zA-Z0-9_-]+$/', $value );
				},
			),
		);

		// List all.
		register_rest_route(
			$this->namespace,
			'/snippets',
			array(
				'methods'             => 'GET',
				'callback'            => array( $this, 'get_snippets' ),
				'permission_callback' => array( $this, 'check_permission' ),
			)
		);

		// Create new.
		register_rest_route(
			$this->namespace,
			'/snippets',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'create_snippet' ),
				'permission_callback' => array( $this, 'check_permission' ),
			)
		);

		// Export.
		register_rest_route(
			$this->namespace,
			'/snippets/export',
			array(
				'methods'             => 'GET',
				'callback'            => array( $this, 'export_snippets' ),
				'permission_callback' => array( $this, 'check_permission' ),
			)
		);

		// Import.
		register_rest_route(
			$this->namespace,
			'/snippets/import',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'import_snippets' ),
				'permission_callback' => array( $this, 'check_permission' ),
			)
		);

		// Safe mode toggle.
		register_rest_route(
			$this->namespace,
			'/snippets/safe-mode',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'toggle_safe_mode' ),
				'permission_callback' => array( $this, 'check_permission' ),
			)
		);

		// Test run.
		register_rest_route(
			$this->namespace,
			'/snippets/(?P<slug>[a-zA-Z0-9_-]+)/test',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'test_snippet' ),
				'permission_callback' => array( $this, 'check_permission' ),
				'args'                => $slug_args,
			)
		);

		// Sandbox mode toggle.
		register_rest_route(
			$this->namespace,
			'/snippets/(?P<slug>[a-zA-Z0-9_-]+)/sandbox',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'toggle_sandbox' ),
				'permission_callback' => array( $this, 'check_permission' ),
				'args'                => $slug_args,
			)
		);

		// Toggle.
		register_rest_route(
			$this->namespace,
			'/snippets/(?P<slug>[a-zA-Z0-9_-]+)/toggle',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'toggle_snippet' ),
				'permission_callback' => array( $this, 'check_permission' ),
				'args'                => $slug_args,
			)
		);

		// Targeting lookups — autocomplete posts/pages for the post-picker condition.
		register_rest_route(
			$this->namespace,
			'/snippets/lookup/posts',
			array(
				'methods'             => 'GET',
				'callback'            => array( $this, 'lookup_posts' ),
				'permission_callback' => array( $this, 'check_permission' ),
			)
		);

		// Versions — list / create manual snapshot.
		register_rest_route(
			$this->namespace,
			'/snippets/(?P<slug>[a-zA-Z0-9_-]+)/versions',
			array(
				array(
					'methods'             => 'GET',
					'callback'            => array( $this, 'list_versions' ),
					'permission_callback' => array( $this, 'check_permission' ),
					'args'                => $slug_args,
				),
				array(
					'methods'             => 'POST',
					'callback'            => array( $this, 'create_manual_version' ),
					'permission_callback' => array( $this, 'check_permission' ),
					'args'                => $slug_args,
				),
			)
		);

		// Versions — diff against another version (or current HEAD by default).
		register_rest_route(
			$this->namespace,
			'/snippets/(?P<slug>[a-zA-Z0-9_-]+)/versions/(?P<id>[a-zA-Z0-9_-]+)/diff',
			array(
				'methods'             => 'GET',
				'callback'            => array( $this, 'diff_version' ),
				'permission_callback' => array( $this, 'check_permission' ),
				'args'                => $slug_args,
			)
		);

		// Versions — restore.
		register_rest_route(
			$this->namespace,
			'/snippets/(?P<slug>[a-zA-Z0-9_-]+)/versions/(?P<id>[a-zA-Z0-9_-]+)/restore',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'restore_version' ),
				'permission_callback' => array( $this, 'check_permission' ),
				'args'                => $slug_args,
			)
		);

		// Versions — get / delete one.
		register_rest_route(
			$this->namespace,
			'/snippets/(?P<slug>[a-zA-Z0-9_-]+)/versions/(?P<id>[a-zA-Z0-9_-]+)',
			array(
				array(
					'methods'             => 'GET',
					'callback'            => array( $this, 'get_version' ),
					'permission_callback' => array( $this, 'check_permission' ),
					'args'                => $slug_args,
				),
				array(
					'methods'             => 'DELETE',
					'callback'            => array( $this, 'delete_version' ),
					'permission_callback' => array( $this, 'check_permission' ),
					'args'                => $slug_args,
				),
			)
		);

		// Get / Update / Delete single.
		register_rest_route(
			$this->namespace,
			'/snippets/(?P<slug>[a-zA-Z0-9_-]+)',
			array(
				array(
					'methods'             => 'GET',
					'callback'            => array( $this, 'get_snippet' ),
					'permission_callback' => array( $this, 'check_permission' ),
					'args'                => $slug_args,
				),
				array(
					'methods'             => 'POST',
					'callback'            => array( $this, 'update_snippet' ),
					'permission_callback' => array( $this, 'check_permission' ),
					'args'                => $slug_args,
				),
				array(
					'methods'             => 'DELETE',
					'callback'            => array( $this, 'delete_snippet' ),
					'permission_callback' => array( $this, 'check_permission' ),
					'args'                => $slug_args,
				),
			)
		);
	}

	public function check_permission() {
		if ( current_user_can( 'manage_options' ) ) {
			return true;
		}
		return new \WP_Error( 'rest_forbidden', __( 'You do not have permission to manage snippets.', 'zip-ai' ), array( 'status' => 403 ) );
	}

	// ── List ───────────────────────────────────────────────────────────

	public function get_snippets() {
		$manifest = Snippet_Store::load();
		$snippets = array();

		foreach ( $manifest as $slug => $meta ) {
			if ( '_version' === $slug ) {
				continue;
			}
			$snippets[] = $this->format_snippet( $slug, $meta );
		}

		return new \WP_REST_Response( array( 'snippets' => $snippets ), 200 );
	}

	// ── Get Single ─────────────────────────────────────────────────────

	public function get_snippet( $request ) {
		$slug = Snippet_Store::validate_slug( $request->get_param( 'slug' ) );
		if ( ! $slug ) {
			return new \WP_REST_Response( array( 'error' => 'Invalid slug.' ), 400 );
		}

		$manifest = Snippet_Store::load();
		if ( ! isset( $manifest[ $slug ] ) ) {
			return new \WP_REST_Response( array( 'error' => 'Snippet not found.' ), 404 );
		}

		$snippet                  = $this->format_snippet( $slug, $manifest[ $slug ] );
		$snippet['file_contents'] = $this->read_snippet_files( $slug );

		return new \WP_REST_Response( $snippet, 200 );
	}

	// ── Create ─────────────────────────────────────────────────────────

	/**
	 * Create a new snippet.
	 *
	 * @since 0.0.5
	 */
	public function create_snippet( $request ) {
		$body  = $request->get_json_params();
		$title = sanitize_text_field( $body['title'] ?? '' );

		if ( empty( $title ) ) {
			return new \WP_REST_Response( array( 'error' => 'Title is required.' ), 400 );
		}

		// Generate slug from title.
		$slug = sanitize_title( $title );
		$slug = Snippet_Store::validate_slug( $slug );
		if ( ! $slug ) {
			return new \WP_REST_Response( array( 'error' => 'Could not generate valid slug from title.' ), 400 );
		}

		$manifest = Snippet_Store::load();

		// Handle slug conflicts.
		$original_slug = $slug;
		$counter       = 1;
		while ( isset( $manifest[ $slug ] ) ) {
			$slug = $original_slug . '-' . $counter;
			++$counter;
		}

		// Mint randomized on-disk dir name so the filesystem path is not
		// guessable from the title alone. Cache it BEFORE calling
		// snippet_dir() — resolve_dir_name() would otherwise fall back to
		// the slug since the manifest entry doesn't exist yet.
		$dir_name = Snippet_Store::mint_storage_dir( $slug );
		Snippet_Store::set_dir_cache( $slug, $dir_name );

		// Create directory + drop `.htaccess` and `index.php` for
		// belt-and-braces protection on Apache + dir-listing fallback.
		$snippet_dir = Snippet_Store::snippet_dir( $slug );
		if ( ! is_dir( $snippet_dir ) ) {
			wp_mkdir_p( $snippet_dir );
		}
		Snippet_Store::write_dir_protection( $snippet_dir );

		// Write files.
		$file_contents = $body['file_contents'] ?? array();
		$files         = array();
		$file_hashes   = array();
		$execution     = $this->sanitize_execution( $body['execution'] ?? array() );

		foreach ( $file_contents as $type => $content ) {
			$type = sanitize_key( $type );
			if ( ! in_array( $type, Snippet_Store::ALLOWED_TYPES, true ) ) {
				continue;
			}

			$validation = $this->validate_code( $type, $content, $execution[ $type ]['hook'] ?? '' );
			if ( is_wp_error( $validation ) ) {
				return new \WP_REST_Response( array( 'error' => $validation->get_error_message() ), 400 );
			}

			$filepath = $snippet_dir . '/snippet.' . $type;
			if ( ! Snippet_Store::is_safe_path( $filepath ) ) {
				return new \WP_REST_Response( array( 'error' => 'Refused unsafe write path.' ), 400 );
			}
			file_put_contents( $filepath, $content, LOCK_EX ); // phpcs:ignore WordPress.WP.AlternativeFunctions
			Snippet_Store::invalidate_opcache( $filepath );
			$files[]              = $type;
			$file_hashes[ $type ] = hash_file( 'sha256', $filepath );
		}

		// Build manifest entry.
		$manifest[ $slug ] = array(
			'title'           => $title,
			'dir'             => $dir_name, // randomized on-disk basename
			'description'     => sanitize_text_field( $body['description'] ?? '' ),
			'files'           => $files,
			'file_hashes'     => $file_hashes,
			'enabled'         => false,
			'execution'       => $execution,
			'conditions'      => $this->sanitize_conditions( $body['conditions'] ?? array() ),
			'created'         => gmdate( 'Y-m-d H:i:s' ),
			'created_by'      => get_current_user_id(),
			'updated'         => gmdate( 'Y-m-d H:i:s' ),
			'updated_by'      => get_current_user_id(),
			'updated_by_type' => 'user',
		);

		Snippet_Store::save( $manifest );

		// Seed v1 from the just-written HEAD files.
		Snippet_Versions::create(
			$slug,
			array(
				'author_type' => 'user',
				'author_id'   => get_current_user_id(),
				'author_name' => $this->current_user_display_name(),
				'reason'      => 'Initial version',
				'manual'      => false,
			)
		);

		// Re-load manifest because Snippet_Versions::create() updated the version pointer.
		$manifest = Snippet_Store::load();

		return new \WP_REST_Response(
			array(
				'success' => true,
				'slug'    => $slug,
				'snippet' => $this->format_snippet( $slug, $manifest[ $slug ] ),
			),
			201
		);
	}

	// ── Update ─────────────────────────────────────────────────────────

	public function update_snippet( $request ) {
		$slug = Snippet_Store::validate_slug( $request->get_param( 'slug' ) );
		if ( ! $slug ) {
			return new \WP_REST_Response( array( 'error' => 'Invalid slug.' ), 400 );
		}

		$manifest = Snippet_Store::load();
		if ( ! isset( $manifest[ $slug ] ) ) {
			return new \WP_REST_Response( array( 'error' => 'Snippet not found.' ), 404 );
		}

		$body        = $request->get_json_params();
		$snippet_dir = Snippet_Store::snippet_dir( $slug );
		$applied     = array();
		$snapshot_needed = false;

		// Update description if provided.
		if ( isset( $body['description'] ) ) {
			$description = sanitize_text_field( $body['description'] );
			if ( ( $manifest[ $slug ]['description'] ?? '' ) !== $description ) {
				$snapshot_needed = true;
			}
			$manifest[ $slug ]['description'] = $description;
			$applied[]                        = 'description';
		}

		// Update execution if provided.
		if ( isset( $body['execution'] ) && is_array( $body['execution'] ) ) {
			$execution = $this->sanitize_execution( $body['execution'] );
			if ( ( $manifest[ $slug ]['execution'] ?? array() ) !== $execution ) {
				$snapshot_needed = true;
			}
			$manifest[ $slug ]['execution'] = $execution;
			$applied[]                      = 'execution';
		}

		// Update conditions if provided.
		if ( isset( $body['conditions'] ) && is_array( $body['conditions'] ) ) {
			$conditions = $this->sanitize_conditions( $body['conditions'] );
			if ( ( $manifest[ $slug ]['conditions'] ?? array() ) !== $conditions ) {
				$snapshot_needed = true;
			}
			$manifest[ $slug ]['conditions'] = $conditions;
			$applied[]                       = 'conditions';
		}

		// Delete files for types removed from the intended set. The React
		// editor sends `files` as the post-save desired list — anything in
		// the manifest's existing `files` but missing from this payload gets
		// dropped from disk + manifest. This lets the UI uncheck a file type
		// and have the actual file removed on save.
		if ( isset( $body['files'] ) && is_array( $body['files'] ) ) {
			$desired = array_values(
				array_intersect(
					array_map( 'sanitize_key', $body['files'] ),
					Snippet_Store::ALLOWED_TYPES
				)
			);
			$current = $manifest[ $slug ]['files'] ?? array();
			$removed = array_diff( $current, $desired );
			foreach ( $removed as $type ) {
				$filepath = $snippet_dir . '/snippet.' . $type;
				if ( Snippet_Store::is_safe_path( $filepath ) && file_exists( $filepath ) ) {
					unlink( $filepath ); // phpcs:ignore WordPress.WP.AlternativeFunctions
				}
				if ( isset( $manifest[ $slug ]['file_hashes'][ $type ] ) ) {
					unset( $manifest[ $slug ]['file_hashes'][ $type ] );
				}
			}
			if ( ! empty( $removed ) ) {
				$manifest[ $slug ]['files'] = array_values( array_diff( $current, $removed ) );
				$applied[]                  = 'files_removed';
				$snapshot_needed            = true;
			}
		}

		// Update file contents if provided. Track whether any file content
		// actually changed so we can record a new version snapshot.
		$content_changed = false;
		if ( ! empty( $body['file_contents'] ) && is_array( $body['file_contents'] ) ) {
			foreach ( $body['file_contents'] as $type => $content ) {
				$type = sanitize_key( $type );
				if ( ! in_array( $type, Snippet_Store::ALLOWED_TYPES, true ) ) {
					continue;
				}

				$execution_hook = $manifest[ $slug ]['execution'][ $type ]['hook'] ?? '';
				$validation     = $this->validate_code( $type, $content, $execution_hook );
				if ( is_wp_error( $validation ) ) {
					return new \WP_REST_Response( array( 'error' => $validation->get_error_message() ), 400 );
				}

				$filepath = $snippet_dir . '/snippet.' . $type;
				if ( ! Snippet_Store::is_safe_path( $filepath ) ) {
					continue;
				}

				$prev_hash = $manifest[ $slug ]['file_hashes'][ $type ] ?? null;

				file_put_contents( $filepath, $content, LOCK_EX ); // phpcs:ignore WordPress.WP.AlternativeFunctions
				Snippet_Store::invalidate_opcache( $filepath );

				// Update hash + files list.
				if ( ! isset( $manifest[ $slug ]['file_hashes'] ) ) {
					$manifest[ $slug ]['file_hashes'] = array();
				}
				$new_hash                                  = hash_file( 'sha256', $filepath );
				$manifest[ $slug ]['file_hashes'][ $type ] = $new_hash;

				if ( $new_hash !== $prev_hash ) {
					$content_changed = true;
					$snapshot_needed = true;
				}

				if ( ! in_array( $type, $manifest[ $slug ]['files'] ?? array(), true ) ) {
					$manifest[ $slug ]['files'][] = $type;
				}
			}
			if ( $content_changed ) {
				$applied[] = 'file_contents';
			}
		}

		$manifest[ $slug ]['updated']         = gmdate( 'Y-m-d H:i:s' );
		$manifest[ $slug ]['updated_by']      = get_current_user_id();
		$manifest[ $slug ]['updated_by_type'] = 'user';

		Snippet_Store::save( $manifest );

		// Auto-snapshot new version when the saved snippet behavior changes.
		// Versions include both files and snippet metadata, so rollback must
		// cover targeting/execution/file-removal edits as well as code changes.
		if ( $snapshot_needed ) {
			Snippet_Versions::create(
				$slug,
				array(
					'author_type' => 'user',
					'author_id'   => get_current_user_id(),
					'author_name' => $this->current_user_display_name(),
					'reason'      => sanitize_text_field( $body['version_reason'] ?? '' ),
					'manual'      => false,
				)
			);
			$manifest = Snippet_Store::load();
		}

		return new \WP_REST_Response(
			array(
				'success'        => true,
				'snippet'        => $this->format_snippet( $slug, $manifest[ $slug ] ),
				'applied_fields' => array_values( array_unique( $applied ) ),
			),
			200
		);
	}

	// ── Test Run (Sandbox) ─────────────────────────────────────────────

	/**
	 * Execute a PHP snippet in an isolated child process.
	 * Catches runtime errors without crashing the site.
	 *
	 * @since 0.0.5
	 */
	public function test_snippet( $request ) {
		$slug = Snippet_Store::validate_slug( $request->get_param( 'slug' ) );
		if ( ! $slug ) {
			return new \WP_REST_Response( array( 'error' => 'Invalid slug.' ), 400 );
		}

		$body = $request->get_json_params();
		$code = $body['code'] ?? null;

		// If code not provided in body, read from disk.
		if ( null === $code ) {
			$filepath = Snippet_Store::snippet_file( $slug, 'php' );
			if ( ! file_exists( $filepath ) ) {
				return new \WP_REST_Response( array( 'error' => 'No PHP file found for this snippet.' ), 404 );
			}
			$code = file_get_contents( $filepath ); // phpcs:ignore WordPress.WP.AlternativeFunctions
		}

		if ( empty( trim( $code ) ) ) {
			return new \WP_REST_Response(
				array(
					'success'           => true,
					'output'            => '',
					'error'             => '',
					'execution_time_ms' => 0,
				),
				200
			);
		}

		// Ensure opening PHP tag.
		$needs_tag = stripos( trim( $code ), '<?php' ) !== 0 && stripos( trim( $code ), '<?' ) !== 0;
		$test_code = $needs_tag ? "<?php\n" . $code : $code;

		// Write to temp file.
		$tmp = wp_tempnam( 'zip_ai_snippet_test_' );
		if ( ! $tmp ) {
			return new \WP_REST_Response( array( 'error' => 'Failed to create temp file.' ), 500 );
		}
		file_put_contents( $tmp, $test_code, LOCK_EX ); // phpcs:ignore WordPress.WP.AlternativeFunctions

		$php_binary = defined( 'PHP_BINARY' ) ? PHP_BINARY : 'php';
		$timeout    = 3; // seconds

		// Build command with timeout.
		$cmd = sprintf(
			'timeout %d %s %s 2>&1',
			$timeout,
			escapeshellarg( $php_binary ),
			escapeshellarg( $tmp )
		);

		$start  = microtime( true );
		$output = array();
		$exit   = 0;
		exec( $cmd, $output, $exit ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec
		$elapsed = round( ( microtime( true ) - $start ) * 1000 );

		unlink( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions

		$output_str = implode( "\n", $output );

		// timeout command returns 124 on timeout.
		if ( 124 === $exit ) {
			return new \WP_REST_Response(
				array(
					'success'           => false,
					'output'            => '',
					'error'             => "Execution timed out after {$timeout} seconds. Possible infinite loop.",
					'execution_time_ms' => $elapsed,
				),
				200
			);
		}

		if ( 0 !== $exit ) {
			// Clean temp file paths from error messages.
			$clean_output = preg_replace( '/in\s+\/[^\s:]+/', '', $output_str );
			$clean_output = trim( preg_replace( '/\s+/', ' ', $clean_output ) );

			// Adjust line numbers if we added <?php tag.
			if ( $needs_tag && preg_match( '/on line (\d+)/', $clean_output, $m ) ) {
				$adjusted     = max( 1, (int) $m[1] - 1 );
				$clean_output = preg_replace( '/on line \d+/', 'on line ' . $adjusted, $clean_output );
			}

			return new \WP_REST_Response(
				array(
					'success'           => false,
					'output'            => '',
					'error'             => $clean_output,
					'execution_time_ms' => $elapsed,
				),
				200
			);
		}

		return new \WP_REST_Response(
			array(
				'success'           => true,
				'output'            => $output_str,
				'error'             => '',
				'execution_time_ms' => $elapsed,
			),
			200
		);
	}

	// ── Sandbox Mode ───────────────────────────────────────────────────

	/**
	 * Toggle sandbox mode for a snippet.
	 *
	 * Actions:
	 *   activate   — enable sandbox for current user (snippet runs only for you)
	 *   deactivate — turn off sandbox (snippet stops running)
	 *   go_live    — promote sandbox to live (enable for everyone, clear sandbox)
	 *
	 * @since 0.0.5
	 */
	public function toggle_sandbox( $request ) {
		$slug = Snippet_Store::validate_slug( $request->get_param( 'slug' ) );
		if ( ! $slug ) {
			return new \WP_REST_Response( array( 'error' => 'Invalid slug.' ), 400 );
		}

		$manifest = Snippet_Store::load();
		if ( ! isset( $manifest[ $slug ] ) ) {
			return new \WP_REST_Response( array( 'error' => 'Snippet not found.' ), 404 );
		}

		$body   = $request->get_json_params();
		$action = $body['action'] ?? 'activate';

		switch ( $action ) {
			case 'activate':
				// No subprocess test needed — just enable sandbox for this user.
				// The executor will include the file ONLY for this user.
				// If it crashes, only their session is affected.
				$manifest[ $slug ]['sandbox']      = true;
				$manifest[ $slug ]['sandbox_user'] = get_current_user_id();
				$manifest[ $slug ]['enabled']      = false; // Not live — only sandbox.
				break;

			case 'deactivate':
				$manifest[ $slug ]['sandbox']      = false;
				$manifest[ $slug ]['sandbox_user'] = null;
				break;

			case 'go_live':
				// Promote from sandbox to live — you already tested it in sandbox.
				$manifest[ $slug ]['sandbox']         = false;
				$manifest[ $slug ]['sandbox_user']    = null;
				$manifest[ $slug ]['enabled']         = true;
				$manifest[ $slug ]['auto_disabled']   = false;
				$manifest[ $slug ]['disabled_reason'] = null;
				break;

			default:
				return new \WP_REST_Response( array( 'error' => 'Invalid action.' ), 400 );
		}

		Snippet_Store::save( $manifest );

		return new \WP_REST_Response(
			array(
				'success' => true,
				'sandbox' => $manifest[ $slug ]['sandbox'],
				'enabled' => $manifest[ $slug ]['enabled'],
			),
			200
		);
	}

	// ── Safe Mode ──────────────────────────────────────────────────────

	public function toggle_safe_mode( $request ) {
		$body     = $request->get_json_params();
		$action   = $body['action'] ?? 'toggle';
		$duration = min( max( (int) ( $body['duration'] ?? 30 ), 1 ), 1440 ); // 1 min to 24 hours, default 30 min.
		$ttl      = $duration * MINUTE_IN_SECONDS;

		if ( 'activate' === $action ) {
			set_transient( 'zip_ai_snippets_safe_mode', time() + $ttl, $ttl );
			return new \WP_REST_Response(
				array(
					'success'  => true,
					'active'   => true,
					'duration' => $duration,
				),
				200
			);
		}

		if ( 'deactivate' === $action ) {
			delete_transient( 'zip_ai_snippets_safe_mode' );
			return new \WP_REST_Response(
				array(
					'success' => true,
					'active'  => false,
				),
				200
			);
		}

		// Toggle.
		$is_active = (bool) get_transient( 'zip_ai_snippets_safe_mode' );
		if ( $is_active ) {
			delete_transient( 'zip_ai_snippets_safe_mode' );
		} else {
			set_transient( 'zip_ai_snippets_safe_mode', time() + $ttl, $ttl );
		}

		return new \WP_REST_Response(
			array(
				'success'  => true,
				'active'   => ! $is_active,
				'duration' => $duration,
			),
			200
		);
	}

	// ── Toggle ─────────────────────────────────────────────────────────

	public function toggle_snippet( $request ) {
		$slug = Snippet_Store::validate_slug( $request->get_param( 'slug' ) );
		if ( ! $slug ) {
			return new \WP_REST_Response( array( 'error' => 'Invalid slug.' ), 400 );
		}

		$manifest = Snippet_Store::load();
		if ( ! isset( $manifest[ $slug ] ) ) {
			return new \WP_REST_Response( array( 'error' => 'Snippet not found.' ), 404 );
		}

		$will_enable = ! $manifest[ $slug ]['enabled'];

		$manifest[ $slug ]['enabled'] = $will_enable;

		if ( $will_enable ) {
			$manifest[ $slug ]['auto_disabled']   = false;
			$manifest[ $slug ]['disabled_reason'] = null;
			$manifest[ $slug ]['disabled_at']     = null;
		}

		Snippet_Store::save( $manifest );

		return new \WP_REST_Response(
			array(
				'success' => true,
				'enabled' => $manifest[ $slug ]['enabled'],
			),
			200
		);
	}

	// ── Delete ─────────────────────────────────────────────────────────

	public function delete_snippet( $request ) {
		$slug = Snippet_Store::validate_slug( $request->get_param( 'slug' ) );
		if ( ! $slug ) {
			return new \WP_REST_Response( array( 'error' => 'Invalid slug.' ), 400 );
		}

		// Pre-flight existence check using an un-locked read. The
		// authoritative existence check runs again under the lock below.
		$pre = Snippet_Store::load();
		if ( ! isset( $pre[ $slug ] ) ) {
			return new \WP_REST_Response( array( 'error' => 'Snippet not found.' ), 404 );
		}

		// Remove the on-disk snippet directory BEFORE touching the manifest
		// so that on a crash between rmdir and save() the manifest still
		// describes a real directory (worst case: a stale entry pointing at
		// a vanished dir, which the loader / runtime already tolerate). The
		// inverse order — manifest first, files second — could orphan files
		// the manifest no longer knows about.
		$snippet_dir = Snippet_Store::snippet_dir( $slug );
		if ( is_dir( $snippet_dir ) ) {
			$this->remove_directory( $snippet_dir );
		}

		// Atomically drop the slug from the manifest. Lock spans the full
		// read-modify-write window, so parallel bulk-delete requests cannot
		// each load the same baseline and overwrite each other's deletions.
		Snippet_Store::with_lock(
			static function ( &$manifest ) use ( $slug ) {
				unset( $manifest[ $slug ] );
			}
		);

		return new \WP_REST_Response( array( 'success' => true ), 200 );
	}

	// ── Export ─────────────────────────────────────────────────────────

	/**
	 * Export selected snippets as JSON.
	 *
	 * @since 0.0.5
	 */
	public function export_snippets( $request ) {
		$slugs_param = $request->get_param( 'slugs' );
		$manifest    = Snippet_Store::load();
		$export      = array();

		$slugs = $slugs_param ? array_map( 'sanitize_key', explode( ',', $slugs_param ) ) : array_keys( $manifest );

		foreach ( $slugs as $slug ) {
			if ( '_version' === $slug || ! isset( $manifest[ $slug ] ) ) {
				continue;
			}

			$meta  = Snippet_Store::normalize_snippet( $manifest[ $slug ] );
			$files = array();

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

			$export[] = array(
				'slug'        => $slug,
				'title'       => $meta['title'],
				'description' => $meta['description'],
				'execution'   => $meta['execution'],
				'conditions'  => $meta['conditions'],
				'files'       => $files,
			);
		}

		return new \WP_REST_Response(
			array(
				'version'     => 1,
				'exported_at' => gmdate( 'c' ),
				'snippets'    => $export,
			),
			200
		);
	}

	// ── Import ─────────────────────────────────────────────────────────

	/**
	 * Import snippets from JSON.
	 *
	 * @since 0.0.5
	 */
	public function import_snippets( $request ) {
		$body    = $request->get_json_params();
		$preview = ! empty( $request->get_param( 'preview' ) );

		if ( empty( $body['snippets'] ) || ! is_array( $body['snippets'] ) ) {
			return new \WP_REST_Response( array( 'error' => 'Invalid import format. Expected { snippets: [...] }' ), 400 );
		}

		$manifest = Snippet_Store::load();
		$results  = array();

		foreach ( $body['snippets'] as $item ) {
			$slug = sanitize_title( $item['slug'] ?? $item['title'] ?? '' );
			$slug = Snippet_Store::validate_slug( $slug );

			if ( ! $slug ) {
				$results[] = array(
					'slug'    => $item['slug'] ?? '?',
					'success' => false,
					'error'   => 'Invalid slug.',
				);
				continue;
			}

			// Handle conflicts.
			$original = $slug;
			$counter  = 1;
			while ( isset( $manifest[ $slug ] ) ) {
				$slug = $original . '-imported-' . $counter;
				++$counter;
			}

			if ( $preview ) {
				$results[] = array(
					'slug'        => $slug,
					'title'       => $item['title'] ?? $slug,
					'files'       => array_keys( $item['files'] ?? array() ),
					'conflict'    => $slug !== $original,
					'will_create' => true,
				);
				continue;
			}

			// Create directory.
			$snippet_dir = Snippet_Store::snippet_dir( $slug );
			wp_mkdir_p( $snippet_dir );
			file_put_contents( $snippet_dir . '/index.php', "<?php\n// Silence is golden.\n", LOCK_EX ); // phpcs:ignore WordPress.WP.AlternativeFunctions

			// Write files.
			$files       = array();
			$file_hashes = array();
			$execution   = $this->sanitize_execution( $item['execution'] ?? array() );
			foreach ( ( $item['files'] ?? array() ) as $type => $encoded ) {
				$type = sanitize_key( $type );
				if ( ! in_array( $type, Snippet_Store::ALLOWED_TYPES, true ) ) {
					continue;
				}

				$content = base64_decode( $encoded ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
				if ( false === $content ) {
					continue;
				}

				$validation = $this->validate_code( $type, $content, $execution[ $type ]['hook'] ?? '' );
				if ( is_wp_error( $validation ) ) {
					$results[] = array(
						'slug'    => $slug,
						'success' => false,
						'error'   => $validation->get_error_message(),
					);
					continue 2;
				}

				$filepath = $snippet_dir . '/snippet.' . $type;
				if ( ! Snippet_Store::is_safe_path( $filepath ) ) {
					$results[] = array(
						'slug'    => $slug,
						'success' => false,
						'error'   => 'Refused unsafe write path.',
					);
					continue 2;
				}
				file_put_contents( $filepath, $content, LOCK_EX ); // phpcs:ignore WordPress.WP.AlternativeFunctions
				Snippet_Store::invalidate_opcache( $filepath );
				$files[]              = $type;
				$file_hashes[ $type ] = hash_file( 'sha256', $filepath );
			}

			// Build manifest entry — always disabled on import.
			$manifest[ $slug ] = array(
				'title'       => sanitize_text_field( $item['title'] ?? $slug ),
				'description' => sanitize_text_field( $item['description'] ?? '' ),
				'files'       => $files,
				'file_hashes' => $file_hashes,
				'enabled'     => false,
				'execution'   => $execution,
				'conditions'  => $this->sanitize_conditions( $item['conditions'] ?? array() ),
				'created'     => gmdate( 'Y-m-d H:i:s' ),
				'created_by'  => get_current_user_id(),
				'updated'     => gmdate( 'Y-m-d H:i:s' ),
				'updated_by'  => get_current_user_id(),
			);

			$results[] = array(
				'slug'    => $slug,
				'success' => true,
			);
		}

		if ( ! $preview ) {
			Snippet_Store::save( $manifest );
		}

		return new \WP_REST_Response(
			array(
				'success' => true,
				'preview' => $preview,
				'results' => $results,
			),
			200
		);
	}

	// ── Targeting lookups ──────────────────────────────────────────────

	/**
	 * Search posts/pages by title for the post-picker condition. Returns up to
	 * 20 matches across publish + draft (the caller filters status client-side
	 * if needed).
	 */
	public function lookup_posts( $request ) {
		$q = sanitize_text_field( (string) $request->get_param( 'q' ) );
		if ( strlen( $q ) < 1 ) {
			return new \WP_REST_Response( array( 'results' => array() ), 200 );
		}

		$post_types = get_post_types( array( 'public' => true ), 'names' );
		$query      = new \WP_Query(
			array(
				'post_type'      => array_values( $post_types ),
				'post_status'    => array( 'publish', 'draft', 'private' ),
				'perm'           => 'readable',
				's'              => $q,
				'posts_per_page' => 20,
				'orderby'        => 'date',
				'order'          => 'DESC',
				'no_found_rows'  => true,
			)
		);

		$results = array();
		foreach ( $query->posts as $post ) {
			$results[] = array(
				'id'    => (int) $post->ID,
				'title' => $post->post_title !== '' ? $post->post_title : sprintf( '(no title #%d)', $post->ID ),
				'type'  => $post->post_type,
				'slug'  => $post->post_name,
			);
		}

		return new \WP_REST_Response( array( 'results' => $results ), 200 );
	}

	// ── Versions ───────────────────────────────────────────────────────

	private function current_user_display_name() {
		$user = wp_get_current_user();
		return $user && $user->ID ? $user->display_name : 'You';
	}

	private function require_snippet( $request ) {
		$raw  = (string) $request->get_param( 'slug' );
		$slug = Snippet_Store::validate_slug( $raw );
		if ( ! $slug ) {
			return new \WP_REST_Response(
				array( 'error' => sprintf( 'Invalid slug "%s". Slugs must contain only letters, numbers, and hyphens.', $raw ) ),
				400
			);
		}
		$manifest = Snippet_Store::load();
		if ( ! isset( $manifest[ $slug ] ) ) {
			return new \WP_REST_Response(
				array( 'error' => Snippet_Store::not_found_message( $slug ) ),
				404
			);
		}
		return $slug;
	}

	public function list_versions( $request ) {
		$slug = $this->require_snippet( $request );
		if ( ! is_string( $slug ) ) {
			return $slug;
		}
		return new \WP_REST_Response(
			array(
				'success'  => true,
				'versions' => Snippet_Versions::list_versions( $slug ),
			),
			200
		);
	}

	public function get_version( $request ) {
		$slug = $this->require_snippet( $request );
		if ( ! is_string( $slug ) ) {
			return $slug;
		}
		$version = Snippet_Versions::get( $slug, $request->get_param( 'id' ) );
		if ( is_wp_error( $version ) ) {
			$status = $version->get_error_code() === 'version_not_found' ? 404 : 400;
			return new \WP_REST_Response(
				array(
					'error' => $version->get_error_message(),
					'code'  => $version->get_error_code(),
				),
				$status
			);
		}
		return new \WP_REST_Response( array( 'success' => true ) + $version, 200 );
	}

	public function diff_version( $request ) {
		$slug = $this->require_snippet( $request );
		if ( ! is_string( $slug ) ) {
			return $slug;
		}
		$against = $request->get_param( 'against' ) ?: null;
		$diff    = Snippet_Versions::diff( $slug, $request->get_param( 'id' ), $against );
		return new \WP_REST_Response(
			array(
				'success' => true,
				'diff'    => $diff,
			),
			200
		);
	}

		public function create_manual_version( $request ) {
			$slug = $this->require_snippet( $request );
			if ( ! is_string( $slug ) ) {
				return $slug;
			}
			$body   = $request->get_json_params();
			$reason = sanitize_text_field( $body['reason'] ?? '' );
			if ( '' === $reason ) {
				return new \WP_REST_Response( array( 'error' => 'reason is required.' ), 400 );
			}
			$entry = Snippet_Versions::create(
				$slug,
				array(
					'author_type' => 'user',
					'author_id'   => get_current_user_id(),
					'author_name' => $this->current_user_display_name(),
					'reason'      => $reason,
					'manual'      => true,
				)
		);
		if ( ! $entry ) {
			return new \WP_REST_Response( array( 'error' => 'Failed to create version.' ), 500 );
		}
		return new \WP_REST_Response(
			array(
				'success' => true,
				'version' => $entry,
			),
			201
		);
	}

	public function restore_version( $request ) {
		$slug = $this->require_snippet( $request );
		if ( ! is_string( $slug ) ) {
			return $slug;
		}
		$entry = Snippet_Versions::restore(
			$slug,
			$request->get_param( 'id' ),
			array(
				'author_type' => 'user',
				'author_id'   => get_current_user_id(),
				'author_name' => $this->current_user_display_name(),
			)
		);
		if ( is_wp_error( $entry ) ) {
			return new \WP_REST_Response(
				array(
					'error' => $entry->get_error_message(),
					'code'  => $entry->get_error_code(),
				),
				400
			);
		}
		if ( ! $entry ) {
			return new \WP_REST_Response( array( 'error' => 'Failed to restore version.' ), 500 );
		}
		// Restore is a deliberate escape hatch — old code may pre-date current
		// lint rules. Surface warnings in the response so the caller can act
		// on them, but never block the restore itself.
		$lint_warnings = $this->lint_head_php( $slug );
		return new \WP_REST_Response(
			array(
				'success'       => true,
				'version'       => $entry,
				'lint_warnings' => $lint_warnings,
			),
			200
		);
	}

	/**
	 * Run lint on the current HEAD PHP file of a snippet (post-restore).
	 * Returns an array of warning records or an empty array when clean.
	 */
	private function lint_head_php( $slug ) {
		$manifest = Snippet_Store::load();
		if ( ! isset( $manifest[ $slug ] ) ) {
			return array();
		}
		$files = $manifest[ $slug ]['files'] ?? array();
		if ( ! in_array( 'php', $files, true ) ) {
			return array();
		}
		$file = Snippet_Store::snippet_file( $slug, 'php' );
		if ( ! file_exists( $file ) ) {
			return array();
		}
		$code           = file_get_contents( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions
		$execution_hook = $manifest[ $slug ]['execution']['php']['hook'] ?? '';
		$result         = Snippet_Lint::check( $code, $execution_hook );
		if ( ! is_wp_error( $result ) ) {
			return array();
		}
		return array(
			array(
				'code'    => $result->get_error_code(),
				'message' => $result->get_error_message(),
			),
		);
	}

	public function delete_version( $request ) {
		$slug = $this->require_snippet( $request );
		if ( ! is_string( $slug ) ) {
			return $slug;
		}
		$ok = Snippet_Versions::delete( $slug, $request->get_param( 'id' ) );
		if ( is_wp_error( $ok ) ) {
			$status = $ok->get_error_code() === 'version_not_found' ? 404 : 400;
			return new \WP_REST_Response(
				array(
					'error' => $ok->get_error_message(),
					'code'  => $ok->get_error_code(),
				),
				$status
			);
		}
		return new \WP_REST_Response( array( 'success' => true ), 200 );
	}

	// ── Validation ─────────────────────────────────────────────────────

	/**
	 * Validate code content (size limit + PHP blocklist).
	 *
	 * @since 0.0.5
	 * @param string $type    File type.
	 * @param string $content Code content.
	 * @return true|\WP_Error
	 */
	private function validate_code( $type, $content, $execution_hook = '' ) {
		$content = str_replace( "\0", '', $content );

		if ( strlen( $content ) > self::MAX_CODE_SIZE ) {
			return new \WP_Error( 'too_large', 'Code exceeds maximum size of 100KB.' );
		}

		if ( 'php' === $type ) {
			$blocked = Snippet_Store::check_php_blocklist( $content );
			if ( $blocked ) {
				return new \WP_Error( 'blocked', "Blocked: '{$blocked}' is not allowed in PHP snippets." );
			}

			// Inline-targeting lint — production gate that forces routing
			// checks into conditions[] instead of `if (is_home()) return;`.
			$lint = Snippet_Lint::check( $content, $execution_hook );
			if ( is_wp_error( $lint ) ) {
				return $lint;
			}

			// Syntax check — prevents fatal errors on next page load.
			$syntax_error = $this->check_php_syntax( $content );
			if ( $syntax_error ) {
				return new \WP_Error( 'syntax_error', $syntax_error );
			}
		} else {
			// JS/CSS bodies must not contain HTML wrapper tags (the executor
			// already wraps them via wp_add_inline_script/style — an inner
			// `</script>`/`</style>` terminates the wrapper early). HTML
			// bodies must not contain `<?php`/`<?=` open tags (emitted via
			// echo, never executed). Same gate the agent ability runs.
			$non_php_lint = Snippet_Lint::check_non_php( $content, $type );
			if ( is_wp_error( $non_php_lint ) ) {
				return $non_php_lint;
			}
		}

		return true;
	}

	/**
	 * Check PHP code for syntax errors using php -l.
	 *
	 * Writes code to a temp file, runs php -l, parses output.
	 * Returns error message string on failure, null on success.
	 *
	 * @since 0.0.5
	 * @param string $code PHP code to check.
	 * @return string|null Error message or null if valid.
	 */
	private function check_php_syntax( $code ) {
		// Delegate to the shared parse-only validator (token_get_all with
		// TOKEN_PARSE). It validates the body as a complete file and throws on
		// invalid syntax, without the if(false){...} eval wrapper's two failure
		// modes: false-positives on a correct body that closes the PHP tag and
		// ends in trailing HTML, and the risk of an eval parse error escaping
		// as a fatal that takes down the request.
		$error = Snippet_Lint::detect_syntax_error( $code );
		return null === $error ? null : 'PHP Syntax Error: ' . $error;
	}


	/**
	 * Thin pass-through to the central Snippet_Store sanitizer. Kept as a
	 * private method so call sites stay readable.
	 */
	private function sanitize_execution( $execution ) {
		return Snippet_Store::sanitize_execution( $execution );
	}

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

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

	private function format_snippet( $slug, $meta ) {
		$normalized     = Snippet_Store::normalize_snippet( $meta );
		$manifest_files = $normalized['files'];
		$disk_files     = $this->scan_snippet_files( $slug );
		$all_files      = array_values( array_unique( array_merge( $manifest_files, $disk_files ) ) );

		return array(
			'slug'               => $slug,
			'title'              => $normalized['title'],
			'description'        => $normalized['description'],
			'enabled'            => $normalized['enabled'],
			'files'              => $all_files,
			'execution'          => $normalized['execution'],
			'conditions'         => $normalized['conditions'],
			'sandbox'            => $normalized['sandbox'],
			'sandbox_user'       => $normalized['sandbox_user'],
			'auto_disabled'      => $normalized['auto_disabled'],
			'disabled_reason'    => $normalized['disabled_reason'],
			'disabled_at'        => $normalized['disabled_at'],
			'created'            => $normalized['created'],
			'updated'            => $normalized['updated'],
			'updated_by_type'    => $normalized['updated_by_type'],
			'current_version_id' => $normalized['current_version_id'],
			'versions_count'     => $normalized['versions_count'],
			'last_version_at'    => $normalized['last_version_at'],
		);
	}

	private function scan_snippet_files( $slug ) {
		$snippet_dir = Snippet_Store::snippet_dir( $slug );
		$types       = array();

		foreach ( Snippet_Store::ALLOWED_TYPES as $type ) {
			$filepath = $snippet_dir . '/snippet.' . $type;
			if ( Snippet_Store::is_safe_path( $filepath ) && file_exists( $filepath ) ) {
				$types[] = $type;
			}
		}

		return $types;
	}

	private function read_snippet_files( $slug ) {
		$contents = array();

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

		return $contents;
	}

	private function remove_directory( $dir ) {
		$real_base = realpath( Snippet_Store::base_dir() );
		$real_dir  = realpath( $dir );
		if ( ! $real_base || ! $real_dir || strpos( $real_dir, $real_base . DIRECTORY_SEPARATOR ) !== 0 ) {
			return;
		}

		$files = new \RecursiveIteratorIterator(
			new \RecursiveDirectoryIterator( $dir, \RecursiveDirectoryIterator::SKIP_DOTS ),
			\RecursiveIteratorIterator::CHILD_FIRST
		);

		foreach ( $files as $file ) {
			if ( $file->isDir() ) {
				rmdir( $file->getRealPath() ); // phpcs:ignore WordPress.WP.AlternativeFunctions
			} else {
				unlink( $file->getRealPath() ); // phpcs:ignore WordPress.WP.AlternativeFunctions
			}
		}

		rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions
	}
}
