<?php
/**
 * User (imported/editor) blocks REST controller.
 *
 * @package JOCBM
 */

namespace JOCBM\Rest;

use JOCBM\Database\Schema;
use JOCBM\Services\AttrSanitizer;
use JOCBM\Services\BlockCategories;
use JOCBM\Services\CssVars;
use JOCBM\Services\CssWriter;
use JOCBM\Services\TemplateLibrary;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

defined( 'ABSPATH' ) || exit;

/**
 * Class UserBlocksController
 */
final class UserBlocksController {

	use RestPermissions;

	/**
	 * Register routes.
	 */
	public function register_routes(): void {
		register_rest_route(
			RestApi::NAMESPACE,
			'/user-blocks',
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'index' ),
					'permission_callback' => array( $this, 'can_manage' ),
				),
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => array( $this, 'create' ),
					'permission_callback' => array( $this, 'can_manage' ),
				),
			)
		);

		register_rest_route(
			RestApi::NAMESPACE,
			'/button-library',
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'button_library' ),
					'permission_callback' => array( $this, 'can_manage' ),
				),
			)
		);

		register_rest_route(
			RestApi::NAMESPACE,
			'/user-blocks/check-name',
			array(
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => array( $this, 'check_name' ),
				'permission_callback' => array( $this, 'can_manage' ),
				'args'                => array(
					'name' => array(
						'required'          => true,
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_text_field',
					),
				),
			)
		);

		register_rest_route(
			RestApi::NAMESPACE,
			'/user-blocks/(?P<id>\d+)',
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'show' ),
					'permission_callback' => array( $this, 'can_manage' ),
				),
				array(
					'methods'             => WP_REST_Server::EDITABLE,
					'callback'            => array( $this, 'update' ),
					'permission_callback' => array( $this, 'can_manage' ),
				),
				array(
					'methods'             => WP_REST_Server::DELETABLE,
					'callback'            => array( $this, 'delete' ),
					'permission_callback' => array( $this, 'can_manage' ),
				),
			)
		);

		register_rest_route(
			RestApi::NAMESPACE,
			'/user-blocks/(?P<id>\d+)/toggle',
			array(
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'toggle' ),
				'permission_callback' => array( $this, 'can_manage' ),
			)
		);

		register_rest_route(
			RestApi::NAMESPACE,
			'/user-blocks/(?P<id>\d+)/duplicate',
			array(
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'duplicate' ),
				'permission_callback' => array( $this, 'can_manage' ),
			)
		);

		register_rest_route(
			RestApi::NAMESPACE,
			'/user-blocks/(?P<id>\d+)/sync-template',
			array(
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'sync_template' ),
				'permission_callback' => array( $this, 'can_manage' ),
			)
		);

		register_rest_route(
			RestApi::NAMESPACE,
			'/user-blocks/sync-templates',
			array(
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'sync_templates' ),
				'permission_callback' => array( $this, 'can_manage' ),
				'args'                => array(
					'ids' => array(
						'type'              => 'array',
						'items'             => array( 'type' => 'integer' ),
						'sanitize_callback' => static function ( $value ) {
							if ( ! is_array( $value ) ) {
								return array();
							}
							return array_values(
								array_unique(
									array_filter(
										array_map( 'absint', $value )
									)
								)
							);
						},
						'default'           => array(),
					),
					'all' => array(
						'type'    => 'boolean',
						'default' => false,
					),
				),
			)
		);

		register_rest_route(
			RestApi::NAMESPACE,
			'/user-blocks/(?P<id>\d+)/template-diff',
			array(
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => array( $this, 'template_diff' ),
				'permission_callback' => array( $this, 'can_manage' ),
			)
		);
	}

	/**
	 * Button styles for Style reference pickers (fresh — not page-load localize).
	 */
	public function button_library(): WP_REST_Response {
		$registry = new \JOCBM\Blocks\BlockRegistry();
		return new WP_REST_Response(
			array(
				'items' => $registry->get_button_library(),
			),
			200
		);
	}

	/**
	 * List imported editor blocks.
	 */
	public function index(): WP_REST_Response {
		global $wpdb;

		$table  = Schema::table( 'user_blocks' );
		$system = Schema::table( 'system_blocks' );
		$cats   = Schema::table( 'categories' );

		$rows = $wpdb->get_results(
			// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- %i table identifiers are not counted by PHPCS.
			$wpdb->prepare(
				"SELECT u.*,
				s.name AS system_name,
				s.variant,
				s.slug AS system_slug,
				s.default_attrs AS system_default_attrs,
				s.css_vars AS system_css_vars,
				s.markup AS library_markup,
				s.field_schema AS library_field_schema,
				s.style_schema AS library_style_schema,
				s.template_hash AS library_template_hash,
				c.slug AS category_slug,
				c.name AS category_name
			FROM %i u
			INNER JOIN %i s ON s.id = u.system_block_id
			LEFT JOIN %i c ON c.id = s.category_id
			ORDER BY u.updated_at DESC, u.id DESC",
				$table,
				$system,
				$cats
			),
			ARRAY_A
		);

		$items = array_map( array( $this, 'format' ), $rows ?: array() );

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

	/**
	 * Get one user block.
	 *
	 * @param WP_REST_Request $request Request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function show( WP_REST_Request $request ) {
		$row = $this->find( (int) $request['id'] );

		if ( ! $row ) {
			return new WP_Error( 'jocbm_not_found', __( 'User block not found.', 'joc-blockora-morph' ), array( 'status' => 404 ) );
		}

		return new WP_REST_Response( $this->format( $row ), 200 );
	}

	/**
	 * Validate a proposed block display name before import/save.
	 *
	 * @param WP_REST_Request $request Request.
	 */
	public function check_name( WP_REST_Request $request ): WP_REST_Response {
		$name = sanitize_text_field( (string) $request->get_param( 'name' ) );

		if ( '' === $name ) {
			return new WP_REST_Response(
				array(
					'valid'   => false,
					'code'    => 'required',
					'message' => __( 'Block name is required.', 'joc-blockora-morph' ),
				),
				200
			);
		}

		if ( $this->name_exists( $name ) ) {
			return new WP_REST_Response(
				array(
					'valid'   => false,
					'code'    => 'exists',
					'message' => __( 'That block name is already in use. Choose a unique name.', 'joc-blockora-morph' ),
				),
				200
			);
		}

		return new WP_REST_Response(
			array(
				'valid' => true,
				'code'  => 'ok',
			),
			200
		);
	}

	/**
	 * Import a system block into My Blocks.
	 *
	 * @param WP_REST_Request $request Request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function create( WP_REST_Request $request ) {
		global $wpdb;

		$system_id     = (int) $request->get_param( 'systemBlockId' );
		$owner_slug    = sanitize_key( (string) $request->get_param( 'ownerSlug' ) );
		$owner_variant = sanitize_key( (string) $request->get_param( 'ownerVariant' ) ) ?: 'default';
		$name          = sanitize_text_field( (string) $request->get_param( 'name' ) );
		$icon          = $this->sanitize_icon( (string) $request->get_param( 'icon' ) );
		$css_vars      = CssVars::normalize( $this->json_param( $request, 'cssVars', array() ) );
		$attrs         = $this->json_param( $request, 'defaultAttrs', array() );
		$post_types    = $this->json_param( $request, 'postTypes', array( 'post', 'page' ) );

		$system_table = Schema::table( 'system_blocks' );
		$system       = null;

		// Owner packs: resolve from disk, upsert a linkage row only at import time.
		if ( '' !== $owner_slug ) {
			$pack = TemplateLibrary::find( $owner_slug, $owner_variant );
			if ( ! $pack || empty( $pack['is_owner'] ) ) {
				return new WP_Error(
					'jocbm_not_found',
					__( 'Owner pack not found on disk.', 'joc-blockora-morph' ),
					array( 'status' => 404 )
				);
			}
			if ( 'ready' !== sanitize_key( (string) ( $pack['publisher_status'] ?? '' ) ) ) {
				return new WP_Error(
					'jocbm_owner_draft',
					__( 'Only published owner packs can be imported.', 'joc-blockora-morph' ),
					array( 'status' => 400 )
				);
			}
			$system_id = $this->upsert_owner_system_row( $pack );
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Plugin tables from Schema::table().
			$system    = $wpdb->get_row(
				$wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', $system_table, $system_id ),
				ARRAY_A
			);
		} else {
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Plugin tables from Schema::table().
			$system = $wpdb->get_row(
				$wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', $system_table, $system_id ),
				ARRAY_A
			);
		}

		if ( ! $system ) {
			return new WP_Error( 'jocbm_not_found', __( 'System block not found.', 'joc-blockora-morph' ), array( 'status' => 404 ) );
		}

		$is_pro_block = ! empty( $system['is_pro'] );
		/**
		 * Whether a system block may be imported into My Blocks.
		 *
		 * @param bool                $allowed Whether import is allowed.
		 * @param array<string,mixed> $system  System block row.
		 */
		$can_import = (bool) apply_filters(
			'jocbm_can_import_system_block',
			! $is_pro_block || jocbm_is_pro(),
			$system
		);

		if ( ! $can_import ) {
			return new WP_Error(
				'jocbm_pro_required',
				__( 'This block requires JOC Blockora Morph Pro.', 'joc-blockora-morph' ),
				array( 'status' => 403 )
			);
		}

		if ( '' === $name ) {
			return new WP_Error(
				'jocbm_name_required',
				__( 'A unique block name is required.', 'joc-blockora-morph' ),
				array( 'status' => 400 )
			);
		}

		if ( $this->name_exists( $name ) ) {
			return new WP_Error(
				'jocbm_name_exists',
				__( 'That block name is already in use. Choose a unique name.', 'joc-blockora-morph' ),
				array( 'status' => 409 )
			);
		}

		if ( ! CssVars::has_any( $css_vars ) ) {
			$css_vars = CssVars::normalize( json_decode( $system['css_vars'] ?: '{}', true ) ?: array() );
		}

		if ( empty( $attrs ) ) {
			$attrs = json_decode( $system['default_attrs'] ?: '{}', true ) ?: array();
		}
		$field_schema = json_decode( $system['field_schema'] ?: '[]', true ) ?: array();
		$attrs        = AttrSanitizer::sanitize( (array) $attrs, is_array( $field_schema ) ? $field_schema : array() );

		$slug  = $this->unique_pending_slug();
		$table = Schema::table( 'user_blocks' );
		$hash  = (string) ( $system['template_hash'] ?? '' );
		if ( '' === $hash ) {
			$hash = TemplateLibrary::hash( $system );
		}

		$wpdb->insert(
			$table,
			array(
				'system_block_id' => $system_id,
				'user_id'         => get_current_user_id(),
				'name'            => $name,
				'slug'            => $slug,
				'icon'            => $icon,
				'markup'          => $system['markup'],
				'field_schema'    => $system['field_schema'],
				'style_schema'    => $system['style_schema'],
				'template_hash'   => $hash,
				'css_vars'        => wp_json_encode( $css_vars ),
				'default_attrs'   => wp_json_encode( $attrs ),
				'post_types'      => wp_json_encode( array_values( array_map( 'sanitize_key', (array) $post_types ) ) ),
				'is_enabled'      => 1,
			),
			array( '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d' )
		);

		$id = (int) $wpdb->insert_id;
		if ( ! $id ) {
			return new WP_Error(
				'jocbm_create_failed',
				__( 'Could not import block.', 'joc-blockora-morph' ),
				array( 'status' => 500 )
			);
		}

		// Gutenberg identity is the block name → slug. Bind slug to DB id so
		// delete + re-import with the same display name cannot revive old placements.
		$slug = $this->assign_id_slug( $id );

		$css_file = CssWriter::write_user_block( $id, $slug, $css_vars, is_array( $attrs ) ? $attrs : array() );
		$wpdb->update( $table, array( 'css_file' => $css_file ), array( 'id' => $id ), array( '%s' ), array( '%d' ) );

		$row = $this->find( $id );

		return new WP_REST_Response( $this->format( $row ), 201 );
	}

	/**
	 * Upsert a system_blocks linkage row from an Owner disk pack (import only).
	 *
	 * Publish/unpublish never touches the DB — only import materializes a row
	 * so My Blocks update-template can keep resolving the source.
	 *
	 * @param array<string,mixed> $pack TemplateLibrary owner pack.
	 */
	private function upsert_owner_system_row( array $pack ): int {
		global $wpdb;

		$table     = Schema::table( 'system_blocks' );
		$cat_table = Schema::table( 'categories' );
		$slug      = sanitize_key( (string) ( $pack['slug'] ?? '' ) );
		$variant   = sanitize_key( (string) ( $pack['variant'] ?? 'default' ) ) ?: 'default';
		$category  = sanitize_key( (string) ( $pack['category'] ?? 'misc' ) ) ?: 'misc';
		list( $category, $category_slugs ) = BlockCategories::from_meta( $pack );
		if ( '' === $category ) {
			$category = 'misc';
			$category_slugs = array( 'misc' );
		}

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Plugin tables from Schema::table().
		$category_id = (int) $wpdb->get_var(
			$wpdb->prepare( 'SELECT id FROM %i WHERE slug = %s', $cat_table, $category )
		);
		if ( ! $category_id ) {
			$wpdb->insert(
				$cat_table,
				array(
					'slug'        => $category,
					'name'        => ucwords( str_replace( '-', ' ', $category ) ),
					'description' => '',
					'parent_slug' => '',
					'is_pro'      => 0,
					'sort_order'  => 100,
				),
				array( '%s', '%s', '%s', '%s', '%d', '%d' )
			);
			$category_id = (int) $wpdb->insert_id;
		}

		$data = array(
			'category_id'   => $category_id,
			'slug'          => $slug,
			'name'          => sanitize_text_field( (string) ( $pack['name'] ?? $slug ) ),
			'description'   => sanitize_text_field( (string) ( $pack['description'] ?? '' ) ),
			'variant'       => $variant,
			'markup'        => (string) ( $pack['markup'] ?? '' ),
			'css_vars'      => wp_json_encode( $pack['css_vars'] ?? array() ),
			'default_attrs' => wp_json_encode( $pack['default_attrs'] ?? array() ),
			'field_schema'  => wp_json_encode( $pack['field_schema'] ?? array() ),
			'style_schema'  => wp_json_encode( $pack['style_schema'] ?? array() ),
			'template_hash' => (string) ( $pack['template_hash'] ?? TemplateLibrary::hash( $pack ) ),
			'is_pro'        => ! empty( $pack['is_pro'] ) ? 1 : 0,
			'is_featured'   => ! empty( $pack['is_featured'] ) ? 1 : 0,
			'source'        => sanitize_key( (string) ( $pack['source'] ?? 'core' ) ) ?: 'core',
			// Keep inactive — JBM lists Owner packs from disk, not this row.
			'is_active'     => 0,
			'sort_order'    => (int) ( $pack['sort_order'] ?? 100 ),
		);
		$formats = array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%d', '%d' );

		$existing_id = (int) $wpdb->get_var(
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Plugin tables from Schema::table().
			$wpdb->prepare(
				'SELECT id FROM %i WHERE slug = %s AND variant = %s',
				$table,
				$slug,
				$variant
			)
		);

		if ( $existing_id ) {
			$wpdb->update( $table, $data, array( 'id' => $existing_id ), $formats, array( '%d' ) );
			BlockCategories::sync( $existing_id, $category_slugs, $category );
			return $existing_id;
		}

		$wpdb->insert( $table, $data, $formats );
		$inserted = (int) $wpdb->insert_id;
		if ( $inserted ) {
			BlockCategories::sync( $inserted, $category_slugs, $category );
		}
		return $inserted;
	}

	/**
	 * Update user block styles / settings.
	 *
	 * @param WP_REST_Request $request Request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function update( WP_REST_Request $request ) {
		global $wpdb;

		$id  = (int) $request['id'];
		$row = $this->find( $id );

		if ( ! $row ) {
			return new WP_Error( 'jocbm_not_found', __( 'User block not found.', 'joc-blockora-morph' ), array( 'status' => 404 ) );
		}

		$table = Schema::table( 'user_blocks' );
		$data  = array();
		$fmt   = array();

		if ( null !== $request->get_param( 'name' ) ) {
			$name = sanitize_text_field( (string) $request->get_param( 'name' ) );
			if ( '' === $name ) {
				return new WP_Error(
					'jocbm_name_required',
					__( 'A unique block name is required.', 'joc-blockora-morph' ),
					array( 'status' => 400 )
				);
			}
			if ( $this->name_exists( $name, $id ) ) {
				return new WP_Error(
					'jocbm_name_exists',
					__( 'That block name is already in use. Choose a unique name.', 'joc-blockora-morph' ),
					array( 'status' => 409 )
				);
			}
			$data['name'] = $name;
			$fmt[]        = '%s';
		}

		if ( null !== $request->get_param( 'icon' ) ) {
			$data['icon'] = $this->sanitize_icon( (string) $request->get_param( 'icon' ) );
			$fmt[]        = '%s';
		}

		$css_vars = $this->json_param( $request, 'cssVars', null );
		if ( null !== $css_vars ) {
			$css_vars         = CssVars::normalize( $css_vars );
			$data['css_vars'] = wp_json_encode( $css_vars );
			$fmt[]            = '%s';
		}

		$attrs = $this->json_param( $request, 'defaultAttrs', null );

		$field_schema_param = $this->json_param( $request, 'fieldSchema', null );
		$field_schema_for_attrs = null;
		if ( null !== $field_schema_param ) {
			$sanitized_schema = $this->sanitize_field_schema( $field_schema_param );
			$data['field_schema'] = wp_json_encode( $sanitized_schema );
			$fmt[]                = '%s';
			$field_schema_for_attrs = $sanitized_schema;
		}

		if ( null !== $attrs ) {
			if ( null === $field_schema_for_attrs ) {
				$field_schema_for_attrs = json_decode( $row['field_schema'] ?: '[]', true ) ?: array();
			}
			$data['default_attrs'] = wp_json_encode(
				AttrSanitizer::sanitize(
					(array) $attrs,
					is_array( $field_schema_for_attrs ) ? $field_schema_for_attrs : array()
				)
			);
			$fmt[] = '%s';
		} elseif ( null !== $field_schema_for_attrs ) {
			// Re-sanitize stored attrs against the new schema when schema changes alone.
			$existing_attrs        = (array) ( json_decode( $row['default_attrs'] ?: '{}', true ) ?: array() );
			$data['default_attrs'] = wp_json_encode(
				AttrSanitizer::sanitize( $existing_attrs, $field_schema_for_attrs )
			);
			$fmt[] = '%s';
		}

		if ( null !== $css_vars || null !== $attrs || null !== $field_schema_param ) {
			$write_vars = null !== $css_vars
				? $css_vars
				: CssVars::normalize( json_decode( $row['css_vars'] ?: '{}', true ) ?: array() );
			$write_attrs = isset( $data['default_attrs'] )
				? (array) json_decode( $data['default_attrs'], true )
				: (array) ( json_decode( $row['default_attrs'] ?: '{}', true ) ?: array() );
			$write_schema = is_array( $field_schema_for_attrs ) ? $field_schema_for_attrs : array();
			if ( ! $write_schema ) {
				$write_schema = json_decode( $row['field_schema'] ?: '[]', true ) ?: array();
			}
			$data['css_file'] = CssWriter::write_user_block( $id, $row['slug'], $write_vars, $write_attrs, is_array( $write_schema ) ? $write_schema : array() );
			$fmt[]            = '%s';
		}

		$post_types = $this->json_param( $request, 'postTypes', null );
		if ( null !== $post_types ) {
			$data['post_types'] = wp_json_encode( array_values( array_map( 'sanitize_key', (array) $post_types ) ) );
			$fmt[]              = '%s';
		}

		if ( null !== $request->get_param( 'isEnabled' ) ) {
			$data['is_enabled'] = (int) (bool) $request->get_param( 'isEnabled' );
			$fmt[]              = '%d';
		}

		if ( $data ) {
			$wpdb->update( $table, $data, array( 'id' => $id ), $fmt, array( '%d' ) );
		}

		return new WP_REST_Response( $this->format( $this->find( $id ) ), 200 );
	}

	/**
	 * Toggle enabled state.
	 *
	 * @param WP_REST_Request $request Request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function toggle( WP_REST_Request $request ) {
		global $wpdb;

		$id  = (int) $request['id'];
		$row = $this->find( $id );

		if ( ! $row ) {
			return new WP_Error( 'jocbm_not_found', __( 'User block not found.', 'joc-blockora-morph' ), array( 'status' => 404 ) );
		}

		$enabled = 1 === (int) $row['is_enabled'] ? 0 : 1;
		$wpdb->update(
			Schema::table( 'user_blocks' ),
			array( 'is_enabled' => $enabled ),
			array( 'id' => $id ),
			array( '%d' ),
			array( '%d' )
		);

		return new WP_REST_Response( $this->format( $this->find( $id ) ), 200 );
	}

	/**
	 * Clone an imported Gutenberg block (styles, attrs, snapshot).
	 *
	 * @param WP_REST_Request $request Request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function duplicate( WP_REST_Request $request ) {
		global $wpdb;

		$id  = (int) $request['id'];
		$row = $this->find( $id );

		if ( ! $row ) {
			return new WP_Error( 'jocbm_not_found', __( 'User block not found.', 'joc-blockora-morph' ), array( 'status' => 404 ) );
		}

		$name = sanitize_text_field( (string) $request->get_param( 'name' ) );
		if ( '' === $name ) {
			return new WP_Error(
				'jocbm_name_required',
				__( 'A unique block name is required.', 'joc-blockora-morph' ),
				array( 'status' => 400 )
			);
		}

		if ( $this->name_exists( $name ) ) {
			return new WP_Error(
				'jocbm_name_exists',
				__( 'That block name is already in use. Choose a unique name.', 'joc-blockora-morph' ),
				array( 'status' => 409 )
			);
		}

		$slug     = $this->unique_pending_slug();
		$css_vars = CssVars::normalize( json_decode( $row['css_vars'] ?: '{}', true ) ?: array() );

		$table = Schema::table( 'user_blocks' );
		$wpdb->insert(
			$table,
			array(
				'system_block_id' => (int) $row['system_block_id'],
				'user_id'         => get_current_user_id(),
				'name'            => $name,
				'slug'            => $slug,
				'icon'            => $this->sanitize_icon( (string) ( $row['icon'] ?? 'screenoptions' ) ),
				'markup'          => $row['markup'],
				'field_schema'    => $row['field_schema'],
				'style_schema'    => $row['style_schema'],
				'template_hash'   => (string) ( $row['template_hash'] ?? '' ),
				'css_vars'        => wp_json_encode( $css_vars ),
				'default_attrs'   => $row['default_attrs'] ?: '{}',
				'post_types'      => $row['post_types'] ?: wp_json_encode( array( 'post', 'page' ) ),
				'is_enabled'      => 1,
			),
			array( '%d', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d' )
		);

		$new_id = (int) $wpdb->insert_id;
		if ( ! $new_id ) {
			return new WP_Error(
				'jocbm_duplicate_failed',
				__( 'Could not duplicate block.', 'joc-blockora-morph' ),
				array( 'status' => 500 )
			);
		}

		$slug = $this->assign_id_slug( $new_id );

		$dup_attrs = json_decode( $row['default_attrs'] ?: '{}', true ) ?: array();
		$css_file  = CssWriter::write_user_block(
			$new_id,
			$slug,
			$css_vars,
			is_array( $dup_attrs ) ? $dup_attrs : array()
		);
		$wpdb->update( $table, array( 'css_file' => $css_file ), array( 'id' => $new_id ), array( '%s' ), array( '%d' ) );

		$created = $this->find( $new_id );

		/**
		 * Fires after a user block is duplicated.
		 *
		 * @param array<string, mixed> $created New block row.
		 * @param array<string, mixed> $row     Source block row.
		 */
		do_action( 'jocbm_user_block_duplicated', $created, $row );

		return new WP_REST_Response( $this->format( $created ), 201 );
	}

	/**
	 * Delete user block.
	 *
	 * @param WP_REST_Request $request Request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function delete( WP_REST_Request $request ) {
		global $wpdb;

		$id  = (int) $request['id'];
		$row = $this->find( $id );

		if ( ! $row ) {
			return new WP_Error( 'jocbm_not_found', __( 'User block not found.', 'joc-blockora-morph' ), array( 'status' => 404 ) );
		}

		CssWriter::delete_user_block( $row['slug'] );
		$wpdb->delete( Schema::table( 'user_blocks' ), array( 'id' => $id ), array( '%d' ) );

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

	/**
	 * Pull latest library template into this imported block (opt-in).
	 *
	 * @param WP_REST_Request $request Request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function sync_template( WP_REST_Request $request ) {
		$result = $this->apply_template_sync( (int) $request['id'] );

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

		return new WP_REST_Response( $result, 200 );
	}

	/**
	 * Apply library template updates to many imported blocks.
	 *
	 * @param WP_REST_Request $request Request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function sync_templates( WP_REST_Request $request ) {
		$all = (bool) $request->get_param( 'all' );
		$ids = $request->get_param( 'ids' );
		if ( ! is_array( $ids ) ) {
			$ids = array();
		}
		$ids = array_values( array_unique( array_map( 'absint', $ids ) ) );

		if ( $all ) {
			$ids = $this->ids_with_update_available();
		}

		if ( empty( $ids ) ) {
			return new WP_Error(
				'jocbm_no_updates',
				__( 'No blocks selected for update.', 'joc-blockora-morph' ),
				array( 'status' => 400 )
			);
		}

		$updated = array();
		$failed  = array();

		foreach ( $ids as $id ) {
			$result = $this->apply_template_sync( $id );
			if ( is_wp_error( $result ) ) {
				$failed[] = array(
					'id'      => $id,
					'code'    => $result->get_error_code(),
					'message' => $result->get_error_message(),
				);
				continue;
			}
			$updated[] = $result;
		}

		return new WP_REST_Response(
			array(
				'updated' => $updated,
				'failed'  => $failed,
				'count'   => count( $updated ),
			),
			200
		);
	}

	/**
	 * Apply one library template sync.
	 *
	 * @param int $id User block ID.
	 * @return array<string, mixed>|WP_Error Formatted block or error.
	 */
	private function apply_template_sync( int $id ) {
		global $wpdb;

		$row = $this->find( $id );

		if ( ! $row ) {
			return new WP_Error( 'jocbm_not_found', __( 'User block not found.', 'joc-blockora-morph' ), array( 'status' => 404 ) );
		}

		$system_table = Schema::table( 'system_blocks' );
		$system       = $wpdb->get_row(
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Plugin tables from Schema::table().
			$wpdb->prepare(
				'SELECT * FROM %i WHERE id = %d',
				$system_table,
				(int) $row['system_block_id']
			),
			ARRAY_A
		);

		if ( ! $system ) {
			return new WP_Error( 'jocbm_not_found', __( 'Library block not found.', 'joc-blockora-morph' ), array( 'status' => 404 ) );
		}

		$hash = (string) ( $system['template_hash'] ?? '' );
		if ( '' === $hash ) {
			$hash = TemplateLibrary::hash( $system );
		}

		$user_attrs   = json_decode( $row['default_attrs'] ?: '{}', true ) ?: array();
		$lib_attrs    = json_decode( $system['default_attrs'] ?: '{}', true ) ?: array();
		$merged_attrs = array_merge( $lib_attrs, $user_attrs );

		$user_vars   = json_decode( $row['css_vars'] ?: '{}', true ) ?: array();
		$lib_vars    = json_decode( $system['css_vars'] ?: '{}', true ) ?: array();
		$merged_vars = CssVars::merge( $lib_vars, $user_vars );

		$table = Schema::table( 'user_blocks' );
		$wpdb->update(
			$table,
			array(
				'markup'        => $system['markup'],
				'field_schema'  => $system['field_schema'],
				'style_schema'  => $system['style_schema'],
				'template_hash' => $hash,
				'default_attrs' => wp_json_encode( $merged_attrs ),
				'css_vars'      => wp_json_encode( $merged_vars ),
				'css_file'      => CssWriter::write_user_block(
					$id,
					$row['slug'],
					$merged_vars,
					is_array( $merged_attrs ) ? $merged_attrs : array(),
					TemplateLibrary::decode_field_schema( $system['field_schema'] ?? '[]' )
				),
			),
			array( 'id' => $id ),
			array( '%s', '%s', '%s', '%s', '%s', '%s', '%s' ),
			array( '%d' )
		);

		$this->backfill_shortcode_attrs_after_sync( $id, $merged_attrs, (string) $system['field_schema'] );

		$fresh = $this->find( $id );
		if ( ! $fresh ) {
			return new WP_Error( 'jocbm_not_found', __( 'User block not found.', 'joc-blockora-morph' ), array( 'status' => 404 ) );
		}

		return $this->format( $fresh );
	}

	/**
	 * Fill newly added template fields into shortcode content attrs after a sync.
	 *
	 * @param int                  $user_block_id User block ID.
	 * @param array<string, mixed> $defaults      Merged default attrs.
	 * @param string               $field_schema  JSON field schema.
	 */
	private function backfill_shortcode_attrs_after_sync( int $user_block_id, array $defaults, string $field_schema ): void {
		global $wpdb;

		$contents = Schema::table( 'user_contents' );
		if ( ! $contents ) {
			return;
		}

		$schema = TemplateLibrary::decode_field_schema( $field_schema );
		if ( ! $schema ) {
			return;
		}

		$canonical = array();
		foreach ( $schema as $field ) {
			if ( empty( $field['key'] ) || ! is_string( $field['key'] ) ) {
				continue;
			}
			$canonical[ strtolower( $field['key'] ) ] = $field['key'];
		}

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				'SELECT id, content_attrs FROM %i WHERE user_block_id = %d',
				$contents,
				$user_block_id
			),
			ARRAY_A
		);

		foreach ( $rows ?: array() as $row ) {
			$attrs = json_decode( $row['content_attrs'] ?: '{}', true ) ?: array();
			if ( ! is_array( $attrs ) ) {
				$attrs = array();
			}

			// Align casing.
			$aligned = array();
			foreach ( $attrs as $key => $value ) {
				$lookup             = strtolower( (string) $key );
				$aligned[ $canonical[ $lookup ] ?? (string) $key ] = $value;
			}

			$changed = false;
			foreach ( $schema as $field ) {
				$key = isset( $field['key'] ) ? (string) $field['key'] : '';
				if ( '' === $key || ! array_key_exists( $key, $defaults ) ) {
					continue;
				}
				$missing   = ! array_key_exists( $key, $aligned );
				$corrupted = false;
				if ( ! $missing ) {
					$type = isset( $field['type'] ) ? (string) $field['type'] : '';
					if ( in_array( $type, array( 'repeater', 'elementsOrder', 'media', 'image', 'link' ), true ) ) {
						$corrupted = ! is_array( $aligned[ $key ] );
					} elseif ( is_string( $aligned[ $key ] ) && 'Array' === $aligned[ $key ] ) {
						$corrupted = true;
					}
				}
				if ( ! $missing && ! $corrupted ) {
					continue;
				}
				$aligned[ $key ] = $defaults[ $key ];
				$changed         = true;
			}

			foreach ( array( 'elementsVisibility' ) as $key ) {
				if ( ! array_key_exists( $key, $aligned ) ) {
					continue;
				}
				if ( is_array( $aligned[ $key ] ) ) {
					continue;
				}
				if ( array_key_exists( $key, $defaults ) && is_array( $defaults[ $key ] ) ) {
					$aligned[ $key ] = $defaults[ $key ];
				} else {
					$aligned[ $key ] = array();
				}
				$changed = true;
			}

			if ( ! $changed ) {
				continue;
			}

			$wpdb->update(
				$contents,
				array( 'content_attrs' => wp_json_encode( $aligned ) ),
				array( 'id' => (int) $row['id'] ),
				array( '%s' ),
				array( '%d' )
			);
		}
	}

	/**
	 * IDs of imported blocks whose library template hash differs.
	 *
	 * @return int[]
	 */
	private function ids_with_update_available(): array {
		global $wpdb;

		$table  = Schema::table( 'user_blocks' );
		$system = Schema::table( 'system_blocks' );

		$rows = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT u.id
			FROM %i u
			INNER JOIN %i s ON s.id = u.system_block_id
			WHERE s.template_hash IS NOT NULL
				AND s.template_hash != ''
				AND ( u.template_hash IS NULL OR u.template_hash = '' OR u.template_hash != s.template_hash )",
				$table,
				$system
			)
		);

		return array_map( 'absint', $rows ?: array() );
	}

	/**
	 * Preview current vs library template before syncing (no writes).
	 *
	 * @param WP_REST_Request $request Request.
	 * @return WP_REST_Response|WP_Error
	 */
	public function template_diff( WP_REST_Request $request ) {
		global $wpdb;

		$id  = (int) $request['id'];
		$row = $this->find( $id );

		if ( ! $row ) {
			return new WP_Error( 'jocbm_not_found', __( 'User block not found.', 'joc-blockora-morph' ), array( 'status' => 404 ) );
		}

		$system_table = Schema::table( 'system_blocks' );
		$system       = $wpdb->get_row(
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Plugin tables from Schema::table().
			$wpdb->prepare(
				'SELECT * FROM %i WHERE id = %d',
				$system_table,
				(int) $row['system_block_id']
			),
			ARRAY_A
		);

		if ( ! $system ) {
			return new WP_Error( 'jocbm_not_found', __( 'Library block not found.', 'joc-blockora-morph' ), array( 'status' => 404 ) );
		}

		$current_markup = ! empty( $row['markup'] ) ? (string) $row['markup'] : (string) ( $row['library_markup'] ?? '' );
		$current_fields = json_decode(
			! empty( $row['field_schema'] ) ? $row['field_schema'] : ( $row['library_field_schema'] ?? '[]' ),
			true
		) ?: array();
		$current_styles = json_decode(
			! empty( $row['style_schema'] ) ? $row['style_schema'] : ( $row['library_style_schema'] ?? '[]' ),
			true
		) ?: array();
		$current_attrs  = json_decode( $row['default_attrs'] ?: '{}', true ) ?: array();
		$current_vars   = CssVars::normalize( json_decode( $row['css_vars'] ?: '{}', true ) ?: array() );

		$lib_markup = (string) ( $system['markup'] ?? '' );
		$lib_fields = json_decode( $system['field_schema'] ?: '[]', true ) ?: array();
		$lib_styles = json_decode( $system['style_schema'] ?: '[]', true ) ?: array();
		$lib_attrs  = json_decode( $system['default_attrs'] ?: '{}', true ) ?: array();
		$lib_vars   = CssVars::normalize( json_decode( $system['css_vars'] ?: '{}', true ) ?: array() );

		$proposed_attrs = array_merge( $lib_attrs, $current_attrs );
		$proposed_vars  = CssVars::merge( $lib_vars, $current_vars );

		$current_field_keys  = $this->schema_field_keys( $current_fields );
		$proposed_field_keys = $this->schema_field_keys( $lib_fields );
		$current_style_keys  = $this->schema_style_keys( $current_styles );
		$proposed_style_keys = $this->schema_style_keys( $lib_styles );

		$changes = array();
		if ( $current_markup !== $lib_markup ) {
			$changes[] = array(
				'type'    => 'markup',
				'label'   => __( 'Markup / HTML structure', 'joc-blockora-morph' ),
				'detail'  => __( 'The block layout template changed.', 'joc-blockora-morph' ),
			);
		}

		$added_fields   = array_values( array_diff( $proposed_field_keys, $current_field_keys ) );
		$removed_fields = array_values( array_diff( $current_field_keys, $proposed_field_keys ) );
		if ( $added_fields || $removed_fields ) {
			$changes[] = array(
				'type'    => 'fields',
				'label'   => __( 'Content fields', 'joc-blockora-morph' ),
				'added'   => $added_fields,
				'removed' => $removed_fields,
				'detail'  => __( 'Inspector field schema changed.', 'joc-blockora-morph' ),
			);
		}

		$added_styles   = array_values( array_diff( $proposed_style_keys, $current_style_keys ) );
		$removed_styles = array_values( array_diff( $current_style_keys, $proposed_style_keys ) );
		if ( $added_styles || $removed_styles || wp_json_encode( $current_styles ) !== wp_json_encode( $lib_styles ) ) {
			$changes[] = array(
				'type'    => 'styles',
				'label'   => __( 'Style controls', 'joc-blockora-morph' ),
				'added'   => $added_styles,
				'removed' => $removed_styles,
				'detail'  => __( 'Design panel schema changed.', 'joc-blockora-morph' ),
			);
		}

		if ( wp_json_encode( $current_vars ) !== wp_json_encode( $proposed_vars ) ) {
			$changes[] = array(
				'type'   => 'cssVars',
				'label'  => __( 'Default styles', 'joc-blockora-morph' ),
				'detail' => __( 'Library defaults will merge in; your overrides are kept.', 'joc-blockora-morph' ),
			);
		}

		if ( empty( $changes ) ) {
			$changes[] = array(
				'type'   => 'hash',
				'label'  => __( 'Template revision', 'joc-blockora-morph' ),
				'detail' => __( 'Library revision differs, but no structural diff was detected.', 'joc-blockora-morph' ),
			);
		}

		return new WP_REST_Response(
			array(
				'id'       => $id,
				'name'     => $row['name'],
				'current'  => array(
					'markup'       => $current_markup,
					'defaultAttrs' => $current_attrs,
					'cssVars'      => $current_vars,
					'fieldSchema'  => $current_fields,
					'styleSchema'  => $current_styles,
				),
				'proposed' => array(
					'markup'       => $lib_markup,
					'defaultAttrs' => $proposed_attrs,
					'cssVars'      => $proposed_vars,
					'fieldSchema'  => $lib_fields,
					'styleSchema'  => $lib_styles,
				),
				'changes'  => $changes,
				'notes'    => array(
					__( 'Existing Gutenberg instances keep their saved field values and style overrides.', 'joc-blockora-morph' ),
					__( 'New markup and schemas apply on the next render after you update.', 'joc-blockora-morph' ),
				),
			),
			200
		);
	}

	/**
	 * Collect field keys from a field schema array.
	 *
	 * @param array<int, mixed> $schema Schema.
	 * @return array<int, string>
	 */
	private function schema_field_keys( array $schema ): array {
		$keys = array();
		foreach ( $schema as $field ) {
			if ( is_array( $field ) && ! empty( $field['key'] ) ) {
				$keys[] = (string) $field['key'];
			}
		}
		sort( $keys );
		return array_values( array_unique( $keys ) );
	}

	/**
	 * Collect style field keys from a style schema array.
	 *
	 * @param array<int, mixed> $schema Schema.
	 * @return array<int, string>
	 */
	private function schema_style_keys( array $schema ): array {
		$keys = array();
		foreach ( $schema as $group ) {
			if ( ! is_array( $group ) ) {
				continue;
			}
			foreach ( ( $group['fields'] ?? array() ) as $field ) {
				if ( is_array( $field ) && ! empty( $field['key'] ) ) {
					$keys[] = (string) $field['key'];
				}
			}
		}
		sort( $keys );
		return array_values( array_unique( $keys ) );
	}

	/**
	 * Find joined row.
	 *
	 * @param int $id ID.
	 * @return array<string, mixed>|null
	 */
	private function find( int $id ): ?array {
		global $wpdb;

		$table  = Schema::table( 'user_blocks' );
		$system = Schema::table( 'system_blocks' );
		$cats   = Schema::table( 'categories' );

		$row = $wpdb->get_row(
			// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- %i table identifiers are not counted by PHPCS.
			$wpdb->prepare(
				"SELECT u.*,
					s.name AS system_name,
					s.variant,
					s.slug AS system_slug,
					s.default_attrs AS system_default_attrs,
					s.css_vars AS system_css_vars,
					s.markup AS library_markup,
					s.field_schema AS library_field_schema,
					s.style_schema AS library_style_schema,
					s.template_hash AS library_template_hash,
					c.slug AS category_slug,
					c.name AS category_name
				FROM %i u
				INNER JOIN %i s ON s.id = u.system_block_id
				LEFT JOIN %i c ON c.id = s.category_id
				WHERE u.id = %d",
				$table,
				$system,
				$cats,
				$id
			),
			ARRAY_A
		);

		return $row ?: null;
	}

	/**
	 * Format row.
	 *
	 * @param array<string, mixed> $row Row.
	 * @return array<string, mixed>
	 */
	private function format( array $row ): array {
		$markup = ! empty( $row['markup'] ) ? $row['markup'] : ( $row['library_markup'] ?? '' );
		$fields = ! empty( $row['field_schema'] ) ? $row['field_schema'] : ( $row['library_field_schema'] ?? '[]' );
		$styles = ! empty( $row['style_schema'] ) ? $row['style_schema'] : ( $row['library_style_schema'] ?? '[]' );

		$field_schema = TemplateLibrary::apply_library_field_types(
			TemplateLibrary::decode_field_schema( $fields ),
			TemplateLibrary::decode_field_schema( $row['library_field_schema'] ?? '[]' )
		);

		$user_hash = (string) ( $row['template_hash'] ?? '' );
		$lib_hash  = (string) ( $row['library_template_hash'] ?? '' );
		$update    = ( '' !== $lib_hash && $user_hash !== $lib_hash );

		$system_id = (int) ( $row['system_block_id'] ?? 0 );
		$cat_slugs = $system_id ? BlockCategories::slugs_for_block( $system_id ) : array();
		if ( ! $cat_slugs ) {
			$primary = sanitize_key( (string) ( $row['category_slug'] ?? '' ) );
			$cat_slugs = $primary ? array( $primary ) : array();
		}

		return array(
			'id'              => (int) $row['id'],
			'systemBlockId'   => $system_id,
			'userId'          => (int) $row['user_id'],
			'name'            => $row['name'],
			'slug'            => $row['slug'],
			'icon'            => \JOCBM\Services\BlockIdentityIcon::sanitize(
				(string) ( $row['icon'] ?? 'screenoptions' )
			),
			'systemName'      => $row['system_name'] ?? '',
			'systemSlug'      => $row['system_slug'] ?? '',
			'categorySlug'    => $row['category_slug'] ?? '',
			'categoryName'    => $row['category_name'] ?? '',
			'categorySlugs'   => $cat_slugs,
			'variant'         => $row['variant'] ?? '',
			'markup'          => $markup,
			'cssVars'         => CssVars::normalize( json_decode( $row['css_vars'] ?: '{}', true ) ?: array() ),
			'defaultAttrs'    => json_decode( $row['default_attrs'] ?: '{}', true ) ?: array(),
			'postTypes'       => ( static function ( $raw ) {
				$types = json_decode( is_string( $raw ) ? $raw : '[]', true );
				$types = is_array( $types ) ? $types : array();
				$types = array_values(
					array_filter(
						array_map( 'sanitize_key', $types )
					)
				);
				return $types ? $types : array( 'post', 'page' );
			} )( $row['post_types'] ?? null ),
			'fieldSchema'     => $field_schema,
			'styleSchema'     => json_decode( $styles ?: '[]', true ) ?: array(),
			'templateHash'    => $user_hash,
			'libraryHash'     => $lib_hash,
			'updateAvailable' => $update,
			'isEnabled'       => (bool) $row['is_enabled'],
			'cssFile'         => $row['css_file'],
			'createdAt'       => $row['created_at'],
			'updatedAt'       => $row['updated_at'],
		);
	}

	/**
	 * Whether a display name is already taken (case-insensitive).
	 *
	 * @param string   $name       Name.
	 * @param int|null $exclude_id Optional ID to exclude (updates).
	 */
	private function name_exists( string $name, ?int $exclude_id = null ): bool {
		global $wpdb;

		$table = Schema::table( 'user_blocks' );

		if ( $exclude_id ) {
			return (bool) $wpdb->get_var(
				$wpdb->prepare(
					'SELECT id FROM %i WHERE LOWER(name) = LOWER(%s) AND id != %d LIMIT 1',
					$table,
					$name,
					$exclude_id
				)
			);
		}

		return (bool) $wpdb->get_var(
			$wpdb->prepare(
				'SELECT id FROM %i WHERE LOWER(name) = LOWER(%s) LIMIT 1',
				$table,
				$name
			)
		);
	}

	/**
	 * Field schema key/type identifiers (preserve camelCase).
	 *
	 * @param string $value Raw.
	 */
	private function sanitize_schema_ident( string $value ): string {
		$value = preg_replace( '/[^a-zA-Z0-9_]/', '', $value ) ?? '';
		return $value;
	}

	/**
	 * Sanitize a field_schema list for storage (expanded fields only).
	 *
	 * @param mixed $schema Raw schema.
	 * @return array<int, array<string, mixed>>
	 */
	private function sanitize_field_schema( $schema ): array {
		if ( ! is_array( $schema ) ) {
			return array();
		}
		$out = array();
		foreach ( $schema as $entry ) {
			if ( ! is_array( $entry ) ) {
				continue;
			}
			$key  = isset( $entry['key'] ) ? $this->sanitize_schema_ident( (string) $entry['key'] ) : '';
			$type = isset( $entry['type'] ) ? $this->sanitize_schema_ident( (string) $entry['type'] ) : '';
			if ( '' === $key || '' === $type ) {
				continue;
			}
			$clean         = $this->sanitize_field_schema_subtree( $entry );
			$clean['key']  = $key;
			$clean['type'] = $type;
			if ( isset( $clean['fields'] ) && is_array( $clean['fields'] ) ) {
				$clean['fields'] = $this->sanitize_field_schema( $clean['fields'] );
			}
			$out[] = $clean;
		}
		return $out;
	}

	/**
	 * Preserve schema property names; sanitize scalar leaf values.
	 *
	 * @param mixed $value Value.
	 * @return mixed
	 */
	private function sanitize_field_schema_subtree( $value ) {
		if ( is_bool( $value ) || is_int( $value ) || is_float( $value ) || null === $value ) {
			return $value;
		}
		if ( is_string( $value ) ) {
			return sanitize_text_field( $value );
		}
		if ( ! is_array( $value ) ) {
			return null;
		}
		$out = array();
		foreach ( $value as $k => $v ) {
			if ( is_int( $k ) ) {
				$out[] = $this->sanitize_field_schema_subtree( $v );
				continue;
			}
			$prop = (string) $k;
			// Allow camelCase schema props (styleSupports, mediaTypes, …).
			if ( ! preg_match( '/^[a-zA-Z_][a-zA-Z0-9_]*$/', $prop ) ) {
				continue;
			}
			if ( 'fields' === $prop ) {
				$out[ $prop ] = $v;
				continue;
			}
			$out[ $prop ] = $this->sanitize_field_schema_subtree( $v );
		}
		return $out;
	}

	/**
	 * Sanitize Gutenberg / My Blocks identity icon (dashicon | lucide:id | emoji:char).
	 *
	 * @param string $icon Icon ref.
	 */
	private function sanitize_icon( string $icon ): string {
		return \JOCBM\Services\BlockIdentityIcon::sanitize( $icon );
	}

	/**
	 * Gutenberg-safe slug permanently tied to the user_blocks row id.
	 *
	 * @param int $id User block id.
	 */
	public static function id_slug( int $id ): string {
		return 'ub-' . max( 0, $id );
	}

	/**
	 * Persist slug = ub-{id} after insert so block type names never reuse.
	 *
	 * @param int $id User block id.
	 * @return string
	 */
	private function assign_id_slug( int $id ): string {
		global $wpdb;

		$slug  = self::id_slug( $id );
		$table = Schema::table( 'user_blocks' );
		$wpdb->update(
			$table,
			array( 'slug' => $slug ),
			array( 'id' => $id ),
			array( '%s' ),
			array( '%d' )
		);

		return $slug;
	}

	/**
	 * Temporary unique slug used only until assign_id_slug() runs.
	 */
	private function unique_pending_slug(): string {
		return 'ub-pending-' . strtolower( wp_generate_password( 12, false, false ) );
	}

	/**
	 * Unique Gutenberg-safe slug (namespace/block-name allows a-z, 0-9, hyphens only).
	 *
	 * @param string $base Base slug or raw name.
	 * @deprecated New imports use id_slug(); kept for any legacy callers.
	 */
	private function unique_slug( string $base ): string {
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Plugin tables from Schema::table().
		global $wpdb;

		$table = Schema::table( 'user_blocks' );
		$slug  = self::block_name_slug( $base );
		$i     = 1;
		$root  = $slug;

		while ( $wpdb->get_var( $wpdb->prepare( 'SELECT id FROM %i WHERE slug = %s', $table, $slug ) ) ) {
			$slug = $root . '-' . $i;
			++$i;
		}

		return $slug;
	}

	/**
	 * Normalize a string into a WordPress block-name segment.
	 *
	 * @param string $value Raw name or slug.
	 */
	public static function block_name_slug( string $value ): string {
		$value = strtolower( $value );
		$value = str_replace( array( '_', ' ', '.' ), '-', $value );
		$value = sanitize_title( $value );
		$value = preg_replace( '/[^a-z0-9-]+/', '-', $value ) ?? '';
		$value = trim( $value, '-' );
		if ( '' === $value || ! preg_match( '/^[a-z]/', $value ) ) {
			$value = 'block-' . $value;
			$value = trim( $value, '-' );
		}
		return $value ?: 'block';
	}
}
