<?php
/**
 * Widget Store — source-of-truth + sandbox for AI-generated Elementor widgets.
 *
 * Generated widgets are NEVER written into the plugin, theme, or core. The
 * canonical record is a private `emcp_widget` custom post type whose
 * `_emcp_spec` meta holds the structured spec the PHP is compiled from (so the
 * PHP is always regenerable). The compiled `Widget_Base` subclass lives in an
 * isolated sandbox under `wp-content/uploads/emcp-widgets/`, guarded from direct
 * web access by an `index.php` + `.htaccess`. A derived `manifest.json` lists
 * only ACTIVE widgets; the loader reads it (fast, no DB query per request) while
 * the CPT stays authoritative.
 *
 * Post status is the activation flag: `publish` = active (loaded into Elementor),
 * `draft` = inactive.
 *
 * @package EMCP_Tools
 * @since   1.9.0
 */

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

/**
 * Stores, compiles, and tracks AI-generated custom Elementor widgets.
 *
 * @since 1.9.0
 */
class EMCP_Tools_Widget_Store {

	const POST_TYPE        = 'emcp_widget';
	const META_SPEC        = '_emcp_spec';
	const META_WIDGET_NAME = '_emcp_widget_name';
	const META_CLASS_NAME  = '_emcp_class_name';
	const META_PHP_HASH    = '_emcp_php_hash';
	const META_CSS_HASH    = '_emcp_css_hash';
	const META_JS_HASH     = '_emcp_js_hash';
	const META_LAST_ERROR  = '_emcp_last_error';

	/**
	 * Sandbox directory name under the uploads basedir.
	 *
	 * @var string
	 */
	const SANDBOX_DIR = 'emcp-widgets';

	/**
	 * Registers the CPT. Hooked on `init`.
	 *
	 * @since 1.9.0
	 */
	public static function register_post_type(): void {
		register_post_type(
			self::POST_TYPE,
			array(
				'public'              => false,
				'publicly_queryable'  => false,
				'show_ui'             => false,
				'show_in_menu'        => false,
				'show_in_rest'        => false,
				'exclude_from_search' => true,
				'has_archive'         => false,
				'rewrite'             => false,
				'query_var'           => false,
				'capability_type'     => 'page',
				'map_meta_cap'        => true,
				'supports'            => array( 'title', 'author' ),
				'labels'              => array(
					'name' => __( 'EMCP Custom Widgets', 'emcp-tools' ),
				),
			)
		);
	}

	/**
	 * Whether the current user/site may manage generated widgets. Requires the
	 * Pro gate plus `manage_options`.
	 *
	 * @since 1.9.0
	 *
	 * @return bool
	 */
	public static function user_has_access(): bool {
		if ( ! function_exists( 'emcp_tools_fs' ) || ! emcp_tools_fs()->can_use_premium_code() ) {
			return false;
		}
		return current_user_can( 'manage_options' );
	}

	// -------------------------------------------------------------------------
	// Sandbox directory
	// -------------------------------------------------------------------------

	/**
	 * Absolute path to the sandbox directory (no trailing slash).
	 *
	 * @since 1.9.0
	 *
	 * @return string
	 */
	public static function sandbox_dir(): string {
		$uploads = wp_upload_dir();
		return trailingslashit( $uploads['basedir'] ) . self::SANDBOX_DIR;
	}

	/**
	 * Absolute path to the per-widget PHP file directory.
	 *
	 * @since 1.9.0
	 *
	 * @return string
	 */
	public static function widgets_dir(): string {
		return self::sandbox_dir() . '/widgets';
	}

	/**
	 * Absolute path to the manifest file.
	 *
	 * @since 1.9.0
	 *
	 * @return string
	 */
	public static function manifest_path(): string {
		return self::sandbox_dir() . '/manifest.json';
	}

	/**
	 * Ensures the sandbox directory tree exists with web-access guards.
	 *
	 * @since 1.9.0
	 *
	 * @return true|WP_Error
	 */
	public static function ensure_sandbox() {
		$dir         = self::sandbox_dir();
		$widgets_dir = self::widgets_dir();

		if ( ! wp_mkdir_p( $widgets_dir ) ) {
			return new WP_Error(
				'sandbox_unwritable',
				__( 'Could not create the widget sandbox directory under uploads. Check filesystem permissions.', 'emcp-tools' )
			);
		}

		// Block direct web access to the generated PHP, but allow per-widget
		// style.css / script.js to be served (they're enqueued on the front end).
		self::write_file( $dir . '/index.php', "<?php\n// Silence is golden.\n" );
		self::write_file( $widgets_dir . '/index.php', "<?php\n// Silence is golden.\n" );
		self::write_file(
			$dir . '/.htaccess',
			"# Generated by MCP Tools for Elementor — block direct PHP; allow css/js assets.\n<FilesMatch \"\\.php$\">\n<IfModule mod_authz_core.c>\nRequire all denied\n</IfModule>\n<IfModule !mod_authz_core.c>\ndeny from all\n</IfModule>\n</FilesMatch>\n"
		);

		return true;
	}

	/**
	 * Writes a file to disk, creating parent dirs as needed.
	 *
	 * @since 1.9.0
	 *
	 * @param string $path     Absolute path.
	 * @param string $contents File contents.
	 * @return bool True on success.
	 */
	private static function write_file( string $path, string $contents ): bool {
		wp_mkdir_p( dirname( $path ) );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
		$ok = false !== file_put_contents( $path, $contents );
		if ( $ok && function_exists( 'opcache_invalidate' ) && '.php' === substr( $path, -4 ) ) {
			opcache_invalidate( $path, true );
		}
		return $ok;
	}

	/**
	 * Relative directory (under the sandbox) holding a widget's files.
	 *
	 * @since 1.9.0
	 *
	 * @param int $post_id Widget post ID.
	 * @return string
	 */
	public static function relative_widget_dir( int $post_id ): string {
		return 'widgets/' . $post_id;
	}

	/**
	 * Absolute path to a widget's own directory.
	 *
	 * @since 1.9.0
	 *
	 * @param int $post_id Widget post ID.
	 * @return string
	 */
	public static function widget_dir( int $post_id ): string {
		return self::sandbox_dir() . '/' . self::relative_widget_dir( $post_id );
	}

	/**
	 * Relative PHP path (under the sandbox) for a widget.
	 *
	 * @since 1.9.0
	 *
	 * @param int $post_id Widget post ID.
	 * @return string
	 */
	public static function relative_php_path( int $post_id ): string {
		return self::relative_widget_dir( $post_id ) . '/widget.php';
	}

	/**
	 * Absolute PHP path for a widget.
	 *
	 * @since 1.9.0
	 *
	 * @param int $post_id Widget post ID.
	 * @return string
	 */
	public static function php_path( int $post_id ): string {
		return self::sandbox_dir() . '/' . self::relative_php_path( $post_id );
	}

	/**
	 * Absolute path to a widget's CSS file.
	 *
	 * @param int $post_id Widget post ID.
	 * @return string
	 */
	public static function css_path( int $post_id ): string {
		return self::widget_dir( $post_id ) . '/style.css';
	}

	/**
	 * Absolute path to a widget's JS file.
	 *
	 * @param int $post_id Widget post ID.
	 * @return string
	 */
	public static function js_path( int $post_id ): string {
		return self::widget_dir( $post_id ) . '/script.js';
	}

	/**
	 * Public URL to a widget's directory (no trailing slash).
	 *
	 * @param int $post_id Widget post ID.
	 * @return string
	 */
	public static function widget_url( int $post_id ): string {
		$uploads = wp_upload_dir();
		return trailingslashit( $uploads['baseurl'] ) . self::SANDBOX_DIR . '/' . self::relative_widget_dir( $post_id );
	}

	/**
	 * Base asset handle for a widget; '-style' / '-script' are appended.
	 *
	 * @param int $post_id Widget post ID.
	 * @return string
	 */
	public static function asset_handle( int $post_id ): string {
		return 'emcp-widget-' . $post_id;
	}

	// -------------------------------------------------------------------------
	// CRUD
	// -------------------------------------------------------------------------

	/**
	 * Creates a new generated widget from a validated spec.
	 *
	 * @since 1.9.0
	 *
	 * @param array $spec   The structured widget spec (already schema-valid).
	 * @param bool  $active Whether to activate immediately (default true).
	 * @return array|WP_Error Widget summary on success.
	 */
	public static function create( array $spec, bool $active = true ) {
		if ( ! self::user_has_access() ) {
			return new WP_Error( 'forbidden', __( 'You do not have permission to create widgets.', 'emcp-tools' ) );
		}

		$ensured = self::ensure_sandbox();
		if ( is_wp_error( $ensured ) ) {
			return $ensured;
		}

		$title = isset( $spec['meta']['title'] ) ? sanitize_text_field( (string) $spec['meta']['title'] ) : __( 'Custom Widget', 'emcp-tools' );

		// Insert the post first so the ID can seed the unique class/widget names.
		$post_id = wp_insert_post(
			array(
				'post_type'   => self::POST_TYPE,
				'post_status' => $active ? 'publish' : 'draft',
				'post_title'  => $title,
				'post_author' => get_current_user_id(),
			),
			true
		);

		if ( is_wp_error( $post_id ) ) {
			return $post_id;
		}

		$written = self::write_widget( (int) $post_id, $spec );
		if ( is_wp_error( $written ) ) {
			wp_delete_post( (int) $post_id, true );
			return $written;
		}

		// Never let a widget that would fatal Elementor go live.
		if ( $active ) {
			self::safeguard_active( (int) $post_id );
		}

		self::rebuild_manifest();

		return self::summary( (int) $post_id );
	}

	/**
	 * Updates an existing widget's spec and regenerates its PHP.
	 *
	 * @since 1.9.0
	 *
	 * @param int   $post_id Widget post ID.
	 * @param array $spec    New spec.
	 * @return array|WP_Error
	 */
	public static function update( int $post_id, array $spec ) {
		if ( ! self::user_has_access() ) {
			return new WP_Error( 'forbidden', __( 'You do not have permission to update widgets.', 'emcp-tools' ) );
		}

		$post = get_post( $post_id );
		if ( ! $post || self::POST_TYPE !== $post->post_type ) {
			return new WP_Error( 'not_found', __( 'Widget not found.', 'emcp-tools' ) );
		}

		$ensured = self::ensure_sandbox();
		if ( is_wp_error( $ensured ) ) {
			return $ensured;
		}

		$title = isset( $spec['meta']['title'] ) ? sanitize_text_field( (string) $spec['meta']['title'] ) : $post->post_title;
		wp_update_post(
			array(
				'ID'         => $post_id,
				'post_title' => $title,
			)
		);

		// A successful regenerate clears any prior fatal flag.
		delete_post_meta( $post_id, self::META_LAST_ERROR );

		$written = self::write_widget( $post_id, $spec );
		if ( is_wp_error( $written ) ) {
			return $written;
		}

		// If it was active, re-check the regenerated code; demote if now broken.
		if ( 'publish' === get_post_status( $post_id ) ) {
			self::safeguard_active( $post_id );
		}

		self::rebuild_manifest();

		return self::summary( $post_id );
	}

	/**
	 * Compiles + writes the widget PHP file and persists its meta. Shared by
	 * create() and update().
	 *
	 * @since 1.9.0
	 *
	 * @param int   $post_id Widget post ID.
	 * @param array $spec    The widget spec.
	 * @return true|WP_Error
	 */
	private static function write_widget( int $post_id, array $spec ) {
		// The generator ships in the private Pro overlay. Guard defensively so a
		// free build (pro/ absent) returns a clean error instead of fataling.
		if ( ! class_exists( 'EMCP_Tools_Widget_Generator' ) ) {
			return new WP_Error( 'emcp_pro_required', __( 'Widget Builder requires EMCP Pro.', 'emcp-tools' ) );
		}
		$class_name  = 'EMCP_Widget_' . $post_id;
		$widget_name = 'emcp_custom_' . $post_id;

		// Optional per-widget assets (full CSS/JS files, enqueued on the front end).
		$css     = ( isset( $spec['styles'] ) && is_string( $spec['styles'] ) ) ? trim( $spec['styles'] ) : '';
		$js      = ( isset( $spec['scripts'] ) && is_string( $spec['scripts'] ) ) ? trim( $spec['scripts'] ) : '';
		$has_css = '' !== $css;
		$has_js  = '' !== $js;

		$php = EMCP_Tools_Widget_Generator::generate(
			$spec,
			$class_name,
			$widget_name,
			array(
				'style_handle'  => $has_css ? self::asset_handle( $post_id ) . '-style' : '',
				'script_handle' => $has_js ? self::asset_handle( $post_id ) . '-script' : '',
			)
		);
		if ( is_wp_error( $php ) ) {
			return $php;
		}

		$path = self::php_path( $post_id );
		if ( ! self::write_file( $path, $php ) ) {
			return new WP_Error( 'write_failed', __( 'Could not write the widget file to the sandbox.', 'emcp-tools' ) );
		}

		// CSS file (defensively strip any PHP open tag — assets are served static).
		// Strip <?php, <?= and bare <? so a .css/.js asset can never be executed as
		// PHP on a server with short_open_tag enabled.
		if ( $has_css ) {
			$css_out = preg_replace( '/<\?(?:php|=)?/i', '', $css );
			self::write_file( self::css_path( $post_id ), $css_out );
			update_post_meta( $post_id, self::META_CSS_HASH, hash( 'sha256', $css_out ) );
		} else {
			self::delete_file( self::css_path( $post_id ) );
			delete_post_meta( $post_id, self::META_CSS_HASH );
		}

		// JS file (same defensive PHP-open-tag strip as CSS above).
		if ( $has_js ) {
			$js_out = preg_replace( '/<\?(?:php|=)?/i', '', $js );
			self::write_file( self::js_path( $post_id ), $js_out );
			update_post_meta( $post_id, self::META_JS_HASH, hash( 'sha256', $js_out ) );
		} else {
			self::delete_file( self::js_path( $post_id ) );
			delete_post_meta( $post_id, self::META_JS_HASH );
		}

		update_post_meta( $post_id, self::META_SPEC, wp_slash( wp_json_encode( $spec ) ) );
		update_post_meta( $post_id, self::META_WIDGET_NAME, $widget_name );
		update_post_meta( $post_id, self::META_CLASS_NAME, $class_name );
		update_post_meta( $post_id, self::META_PHP_HASH, hash( 'sha256', $php ) );

		return true;
	}

	/**
	 * Deletes a file if it exists.
	 *
	 * @param string $path Absolute path.
	 */
	private static function delete_file( string $path ): void {
		if ( file_exists( $path ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
			@unlink( $path );
		}
	}

	/**
	 * Runtime safety check: instantiate the generated widget and force its
	 * control stack to build (the same thing Elementor's editor does), so a
	 * widget that would fatal Elementor is caught here instead of white-screening
	 * the editor panel. Best-effort — returns true when Elementor isn't present.
	 *
	 * @since 1.9.0
	 *
	 * @param int    $post_id    Widget post ID.
	 * @param string $class_name Generated class name.
	 * @return true|WP_Error
	 */
	private static function runtime_validate( int $post_id, string $class_name ) {
		if ( '' === $class_name || ! class_exists( '\\Elementor\\Widget_Base' ) ) {
			return true;
		}
		$path = self::php_path( $post_id );
		if ( ! class_exists( $class_name ) && is_file( $path ) ) {
			include_once $path;
		}
		if ( ! class_exists( $class_name ) ) {
			return new WP_Error( 'class_missing', __( 'The generated widget class did not load.', 'emcp-tools' ) );
		}
		try {
			$instance = new $class_name();
			// Forces init_controls() -> register_controls(); surfaces bad control args.
			$instance->get_stack();
		} catch ( \Throwable $e ) {
			return new WP_Error( 'runtime_error', $e->getMessage() );
		}
		return true;
	}

	/**
	 * If an active widget fails the runtime check, demote it to draft and record
	 * the error so it never reaches the manifest (and never breaks the editor).
	 *
	 * @since 1.9.0
	 *
	 * @param int $post_id Widget post ID.
	 */
	private static function safeguard_active( int $post_id ): void {
		$rt = self::runtime_validate( $post_id, 'EMCP_Widget_' . $post_id );
		if ( is_wp_error( $rt ) ) {
			wp_update_post( array( 'ID' => $post_id, 'post_status' => 'draft' ) );
			update_post_meta( $post_id, self::META_LAST_ERROR, sanitize_text_field( $rt->get_error_message() ) );
		}
	}

	/**
	 * Activates or deactivates a widget.
	 *
	 * @since 1.9.0
	 *
	 * @param int    $post_id Widget post ID.
	 * @param string $status  'active' or 'draft'.
	 * @return array|WP_Error
	 */
	public static function set_status( int $post_id, string $status ) {
		if ( ! self::user_has_access() ) {
			return new WP_Error( 'forbidden', __( 'You do not have permission to change widget status.', 'emcp-tools' ) );
		}

		$post = get_post( $post_id );
		if ( ! $post || self::POST_TYPE !== $post->post_type ) {
			return new WP_Error( 'not_found', __( 'Widget not found.', 'emcp-tools' ) );
		}

		$post_status = ( 'active' === $status ) ? 'publish' : 'draft';

		// Re-activating: confirm the file exists AND the widget builds cleanly
		// before going live, so a broken widget can never reach the manifest.
		if ( 'publish' === $post_status ) {
			if ( ! file_exists( self::php_path( $post_id ) ) ) {
				return new WP_Error( 'missing_file', __( 'The generated widget file is missing; update the widget to regenerate it.', 'emcp-tools' ) );
			}
			$rt = self::runtime_validate( $post_id, (string) get_post_meta( $post_id, self::META_CLASS_NAME, true ) );
			if ( is_wp_error( $rt ) ) {
				update_post_meta( $post_id, self::META_LAST_ERROR, sanitize_text_field( $rt->get_error_message() ) );
				return new WP_Error(
					'activation_blocked',
					sprintf(
						/* translators: %s: error message */
						__( 'Cannot activate — the widget errors when Elementor builds it: %s. Fix the spec and update the widget.', 'emcp-tools' ),
						$rt->get_error_message()
					)
				);
			}
			delete_post_meta( $post_id, self::META_LAST_ERROR );
		}

		wp_update_post(
			array(
				'ID'          => $post_id,
				'post_status' => $post_status,
			)
		);

		self::rebuild_manifest();

		return self::summary( $post_id );
	}

	/**
	 * Deletes a widget: removes the CPT post, sandbox file, and manifest entry.
	 *
	 * @since 1.9.0
	 *
	 * @param int $post_id Widget post ID.
	 * @return array|WP_Error
	 */
	public static function delete( int $post_id ) {
		if ( ! self::user_has_access() ) {
			return new WP_Error( 'forbidden', __( 'You do not have permission to delete widgets.', 'emcp-tools' ) );
		}

		$post = get_post( $post_id );
		if ( ! $post || self::POST_TYPE !== $post->post_type ) {
			return new WP_Error( 'not_found', __( 'Widget not found.', 'emcp-tools' ) );
		}

		// Remove the widget's whole directory (widget.php + any css/js).
		self::rmdir_recursive( self::widget_dir( $post_id ) );

		wp_delete_post( $post_id, true );
		self::rebuild_manifest();

		return array(
			'success'   => true,
			'widget_id' => $post_id,
		);
	}

	/**
	 * Flags a widget as having crashed during load and deactivates it. Called by
	 * the loader's shutdown handler.
	 *
	 * @since 1.9.0
	 *
	 * @param int    $post_id Widget post ID.
	 * @param string $error   The captured error message.
	 */
	public static function mark_error( int $post_id, string $error ): void {
		update_post_meta( $post_id, self::META_LAST_ERROR, sanitize_text_field( $error ) );
		wp_update_post(
			array(
				'ID'          => $post_id,
				'post_status' => 'draft',
			)
		);
		self::rebuild_manifest();
	}

	// -------------------------------------------------------------------------
	// Read
	// -------------------------------------------------------------------------

	/**
	 * Returns the decoded spec for a widget.
	 *
	 * @since 1.9.0
	 *
	 * @param int $post_id Widget post ID.
	 * @return array|null
	 */
	public static function get_spec( int $post_id ): ?array {
		$raw = get_post_meta( $post_id, self::META_SPEC, true );
		if ( empty( $raw ) ) {
			return null;
		}
		$spec = json_decode( $raw, true );
		return is_array( $spec ) ? $spec : null;
	}

	/**
	 * Returns a summary array for one widget.
	 *
	 * @since 1.9.0
	 *
	 * @param int $post_id Widget post ID.
	 * @return array|WP_Error
	 */
	public static function summary( int $post_id ) {
		$post = get_post( $post_id );
		if ( ! $post || self::POST_TYPE !== $post->post_type ) {
			return new WP_Error( 'not_found', __( 'Widget not found.', 'emcp-tools' ) );
		}

		return array(
			'widget_id'   => (int) $post_id,
			'title'       => (string) $post->post_title,
			'widget_name' => (string) get_post_meta( $post_id, self::META_WIDGET_NAME, true ),
			'class_name'  => (string) get_post_meta( $post_id, self::META_CLASS_NAME, true ),
			'status'      => ( 'publish' === $post->post_status ) ? 'active' : 'draft',
			'last_error'  => (string) get_post_meta( $post_id, self::META_LAST_ERROR, true ),
			'updated'     => (string) $post->post_modified,
		);
	}

	/**
	 * Returns the generated PHP source for a widget (read from disk).
	 *
	 * @since 1.9.0
	 *
	 * @param int $post_id Widget post ID.
	 * @return string
	 */
	public static function get_php( int $post_id ): string {
		return self::read_file( self::php_path( $post_id ) );
	}

	/**
	 * Returns a widget's CSS (empty if none).
	 *
	 * @param int $post_id Widget post ID.
	 * @return string
	 */
	public static function get_css( int $post_id ): string {
		return self::read_file( self::css_path( $post_id ) );
	}

	/**
	 * Returns a widget's JS (empty if none).
	 *
	 * @param int $post_id Widget post ID.
	 * @return string
	 */
	public static function get_js( int $post_id ): string {
		return self::read_file( self::js_path( $post_id ) );
	}

	/**
	 * Reads a file's contents, or '' if absent.
	 *
	 * @param string $path Absolute path.
	 * @return string
	 */
	private static function read_file( string $path ): string {
		if ( ! file_exists( $path ) ) {
			return '';
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		return (string) file_get_contents( $path );
	}

	/**
	 * Lists generated widgets.
	 *
	 * @since 1.9.0
	 *
	 * @param string $status Optional 'active' | 'draft' | 'any' (default 'any').
	 * @return array<int, array>
	 */
	public static function list_widgets( string $status = 'any' ): array {
		$post_status = 'any';
		if ( 'active' === $status ) {
			$post_status = 'publish';
		} elseif ( 'draft' === $status ) {
			$post_status = 'draft';
		}

		$query = new WP_Query(
			array(
				'post_type'      => self::POST_TYPE,
				'post_status'    => $post_status,
				'posts_per_page' => 200,
				'orderby'        => 'modified',
				'order'          => 'DESC',
				'no_found_rows'  => true,
			)
		);

		$out = array();
		foreach ( $query->posts as $post ) {
			$summary = self::summary( (int) $post->ID );
			if ( ! is_wp_error( $summary ) ) {
				$out[] = $summary;
			}
		}
		return $out;
	}

	// -------------------------------------------------------------------------
	// Manifest
	// -------------------------------------------------------------------------

	/**
	 * Rebuilds manifest.json from the active (published) widgets.
	 *
	 * @since 1.9.0
	 */
	public static function rebuild_manifest(): void {
		$query = new WP_Query(
			array(
				'post_type'      => self::POST_TYPE,
				'post_status'    => 'publish',
				'posts_per_page' => 200,
				'no_found_rows'  => true,
			)
		);

		$entries = array();
		foreach ( $query->posts as $post ) {
			$post_id = (int) $post->ID;
			$entries[] = array(
				'post_id'     => $post_id,
				'class_name'  => (string) get_post_meta( $post_id, self::META_CLASS_NAME, true ),
				'widget_name' => (string) get_post_meta( $post_id, self::META_WIDGET_NAME, true ),
				'php_path'    => self::relative_php_path( $post_id ),
				'hash'        => (string) get_post_meta( $post_id, self::META_PHP_HASH, true ),
				'css'         => (string) get_post_meta( $post_id, self::META_CSS_HASH, true ),
				'js'          => (string) get_post_meta( $post_id, self::META_JS_HASH, true ),
			);
		}

		self::ensure_sandbox();
		self::write_file( self::manifest_path(), (string) wp_json_encode( $entries ) );
	}

	/**
	 * Reads the manifest. Returns an array of active-widget entries.
	 *
	 * @since 1.9.0
	 *
	 * @return array<int, array>
	 */
	public static function read_manifest(): array {
		$path = self::manifest_path();
		if ( ! file_exists( $path ) ) {
			return array();
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
		$raw = (string) file_get_contents( $path );
		$data = json_decode( $raw, true );
		return is_array( $data ) ? $data : array();
	}

	// -------------------------------------------------------------------------
	// Uninstall
	// -------------------------------------------------------------------------

	/**
	 * Deletes all generated widgets and removes the sandbox tree. Called from
	 * the plugin's uninstall handler — generated executable code must not
	 * survive uninstall.
	 *
	 * @since 1.9.0
	 */
	public static function uninstall_cleanup(): void {
		$query = new WP_Query(
			array(
				'post_type'      => self::POST_TYPE,
				'post_status'    => 'any',
				'posts_per_page' => -1,
				'fields'         => 'ids',
				'no_found_rows'  => true,
			)
		);
		foreach ( $query->posts as $id ) {
			wp_delete_post( (int) $id, true );
		}

		self::rmdir_recursive( self::sandbox_dir() );
	}

	/**
	 * Recursively removes a directory tree.
	 *
	 * @since 1.9.0
	 *
	 * @param string $dir Absolute directory path.
	 */
	private static function rmdir_recursive( string $dir ): void {
		if ( ! is_dir( $dir ) ) {
			return;
		}
		$items = scandir( $dir );
		if ( false === $items ) {
			return;
		}
		foreach ( $items as $item ) {
			if ( '.' === $item || '..' === $item ) {
				continue;
			}
			$path = $dir . '/' . $item;
			if ( is_dir( $path ) ) {
				self::rmdir_recursive( $path );
			} else {
				// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
				@unlink( $path );
			}
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.rmdir_rmdir
		@rmdir( $dir );
	}
}
