<?php
/**
 * Built-in form frontend renderer.
 *
 * @package SubmissionInbox
 */

namespace SUBIN\Forms;

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

/**
 * Renders built-in Submission Inbox forms.
 */
class FormRenderer {
	/**
	 * Settings repository.
	 *
	 * @var \SUBIN_SettingsRepository|null
	 */
	private $settings;

	/**
	 * Constructor.
	 *
	 * @param \SUBIN_SettingsRepository|null $settings Settings repository.
	 */
	public function __construct( ?\SUBIN_SettingsRepository $settings = null ) {
		$this->settings = $settings;
	}

	/**
	 * Render a complete form.
	 *
	 * @param array<string,mixed>       $form Form row.
	 * @param array<int,array<string,mixed>> $fields Field rows.
	 * @param array<string,mixed>            $state Submission state.
	 * @return string
	 */
	public function render( array $form, array $fields, array $state = array() ) {
		$this->enqueue_assets();

		$form_id = absint( $form['id'] ?? 0 );
		if ( $form_id <= 0 ) {
			return '';
		}

		$is_preview       = ! empty( $state['preview'] );
		$is_design_studio = 'design_studio' === sanitize_key( $state['mode'] ?? '' );
		$design = $this->design_settings( $form );
		$advanced = $this->advanced_html_settings( $form );
		$blocks = $this->design_blocks( $form );
		$layout = $this->design_layout( $form );
		$grapesjs_project = ( ! $is_preview && ! $is_design_studio ) ? $this->grapesjs_project( $form ) : array();
		$use_grapesjs_layout = ! empty( $grapesjs_project );
		$classes = array(
			'subin-form-wrap',
			'subin-form-' . $form_id,
			$this->layout_class( $form['layout_template'] ?? '' ),
			$this->style_class( $form['style_preset'] ?? '' ),
			'subin-form-button-' . $design['button_style'],
			'subin-form-width-' . $design['form_width'],
			'subin-form-spacing-' . $design['spacing'],
			'subin-form-header-' . $design['header_alignment'],
		);
		if ( $is_preview ) {
			$classes[] = 'subin-form-wrap--preview';
		}
		if ( $is_design_studio ) {
			$classes[] = 'subin-form-wrap--design-studio';
		}
		if ( '' !== $advanced['custom_wrapper_class'] ) {
			$classes = array_merge( $classes, explode( ' ', $advanced['custom_wrapper_class'] ) );
		}

		$form_classes = array( 'subin-form' );
		if ( '' !== $advanced['custom_form_class'] ) {
			$form_classes = array_merge( $form_classes, explode( ' ', $advanced['custom_form_class'] ) );
		}

		$designer            = new DesignSettings();
		$design_studio       = $designer->get_for_form( $form );
		$form_settings       = $this->raw_settings( $form );
		$design_field_styles = $designer->sanitize_field_styles( is_array( $form_settings['design_studio_field_styles'] ?? null ) ? $form_settings['design_studio_field_styles'] : array() );
		$scoped_css          = ( new ScopedCssGenerator() )->generate( $form_id, $design_studio, $design_field_styles );
		$custom_css    = $this->sanitize_scoped_custom_css( $advanced['custom_css'], $form_id );
		$grapesjs_css  = $use_grapesjs_layout ? $this->sanitize_grapesjs_css( $grapesjs_project['css'] ?? '', $form_id ) : '';
		$safe_css      = str_replace( '</style', '<\/style', $scoped_css );
		$safe_custom_css = str_replace( '</style', '<\/style', $custom_css );
		$safe_grapesjs_css = str_replace( '</style', '<\/style', $grapesjs_css );

		ob_start();
		?>
		<?php if ( '' !== $scoped_css ) : ?>
			<style id="<?php echo esc_attr( 'subin-form-design-' . $form_id ); ?>"><?php echo $safe_css; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Generated from sanitized allowlisted design settings. ?></style>
		<?php endif; ?>
		<?php if ( '' !== $custom_css ) : ?>
			<style id="<?php echo esc_attr( 'subin-form-custom-css-' . $form_id ); ?>"><?php echo $safe_custom_css; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Validated as scoped CSS for this form before output. ?></style>
		<?php endif; ?>
		<?php if ( '' !== $grapesjs_css ) : ?>
			<style id="<?php echo esc_attr( 'subin-form-grapesjs-css-' . $form_id ); ?>"><?php echo $safe_grapesjs_css; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Validated as scoped CSS for this form before output. ?></style>
		<?php endif; ?>
		<div class="<?php echo esc_attr( implode( ' ', array_filter( $classes ) ) ); ?>" data-form-id="<?php echo esc_attr( (string) $form_id ); ?>" <?php $this->part_attrs( 'form_wrapper', __( 'Form Container', 'submission-inbox' ) ); ?>>
			<?php if ( $is_preview ) : ?>
				<div class="<?php echo esc_attr( implode( ' ', array_filter( $form_classes ) ) ); ?>" role="presentation" <?php $this->part_attrs( 'form', __( 'Form', 'submission-inbox' ) ); ?>>
			<?php else : ?>
				<form class="<?php echo esc_attr( implode( ' ', array_filter( $form_classes ) ) ); ?>" method="post" action="" data-subin-ajax="1" data-subin-rest-url="<?php echo esc_url( rest_url( 'submission-inbox/v1/forms/' . absint( $form_id ) . '/submit' ) ); ?>" <?php $this->part_attrs( 'form', __( 'Form', 'submission-inbox' ) ); ?>>
			<?php endif; ?>
				<?php if ( $use_grapesjs_layout ) : ?>
					<?php $this->render_message( $state ); ?>
					<?php $this->render_grapesjs_layout( $form_id, $grapesjs_project, $fields, $state, $form ); ?>
					<?php if ( ! $this->grapesjs_has_submit_button( $grapesjs_project ) ) : ?>
						<?php $this->render_submit_button( $form, $is_preview ); ?>
					<?php endif; ?>
					<?php $this->render_internal_fields( $form_id ); ?>
					<?php $this->render_honeypot( $form_id ); ?>
				<?php else : ?>
					<?php $this->render_design_blocks( $blocks, 'before_form' ); ?>
					<?php $this->render_custom_html_block( $advanced['html_before_form'], 'before-form' ); ?>
					<?php $this->render_header( $form ); ?>
					<?php $this->render_design_blocks( $blocks, 'after_header' ); ?>
					<?php if ( $is_preview ) : ?>
						<div class="subin-form-preview-note"><?php esc_html_e( 'Preview only', 'submission-inbox' ); ?></div>
					<?php endif; ?>
					<?php $this->render_message( $state ); ?>
					<?php $this->render_design_blocks( $blocks, 'before_fields' ); ?>
					<div class="subin-form-fields" <?php echo $is_design_studio ? 'data-subin-canvas-fields="1"' : ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Static editor-only attribute. ?>>
						<?php if ( ! empty( $layout ) ) : ?>
							<?php $this->render_design_layout( $form_id, $layout, $fields, $blocks, $state, $is_design_studio ); ?>
						<?php elseif ( $is_design_studio ) : ?>
							<?php foreach ( $fields as $field ) : ?>
								<?php $this->render_design_studio_drop_zone( absint( $field['id'] ?? 0 ) ); ?>
								<?php $this->render_design_studio_field( $form_id, $field, $state ); ?>
							<?php endforeach; ?>
							<?php $this->render_design_studio_drop_zone( 0, empty( $fields ) ); ?>
						<?php else : ?>
							<?php foreach ( $fields as $field ) : ?>
								<?php $this->render_field( $form_id, $field, $state ); ?>
							<?php endforeach; ?>
						<?php endif; ?>
					</div>
					<?php $this->render_design_blocks( $blocks, 'after_fields' ); ?>
					<?php if ( ! $is_preview ) : ?>
						<?php $this->render_internal_fields( $form_id ); ?>
						<?php $this->render_honeypot( $form_id ); ?>
					<?php endif; ?>
					<?php $this->render_custom_html_block( $advanced['html_before_submit'], 'before-submit' ); ?>
					<?php $this->render_design_blocks( $blocks, 'before_submit' ); ?>
					<?php $this->render_submit_button( $form, $is_preview ); ?>
					<?php $this->render_custom_html_block( $advanced['html_after_submit'], 'after-submit' ); ?>
					<?php $this->render_design_blocks( $blocks, 'after_submit' ); ?>
					<?php $this->render_design_blocks( $blocks, 'before_footer' ); ?>
					<?php $this->render_footer( $form, $design ); ?>
				<?php endif; ?>
			<?php if ( $is_preview ) : ?>
				</div>
			<?php else : ?>
				</form>
			<?php endif; ?>
			<?php if ( ! $use_grapesjs_layout ) : ?>
				<?php $this->render_custom_html_block( $advanced['html_after_form'], 'after-form' ); ?>
				<?php $this->render_design_blocks( $blocks, 'after_form' ); ?>
			<?php endif; ?>
		</div>
		<?php

		return (string) ob_get_clean();
	}

	/**
	 * Render message container.
	 *
	 * @param array<string,mixed> $state Submission state.
	 * @return void
	 */
	private function render_message( array $state ) {
		$status  = sanitize_key( $state['status'] ?? '' );
		$message = sanitize_textarea_field( $state['message'] ?? '' );
		$class   = $status ? ' subin-form-message--' . $status : '';
		$part    = 'error' === $status ? 'error_message' : 'success_message';
		$label   = 'error' === $status ? __( 'Error Message', 'submission-inbox' ) : __( 'Success Message', 'submission-inbox' );
		?>
		<div class="<?php echo esc_attr( 'subin-form-message' . $class ); ?>" role="status" aria-live="polite" <?php $this->part_attrs( $part, $label ); ?>>
			<?php if ( '' !== $message ) : ?>
				<?php echo esc_html( $message ); ?>
			<?php endif; ?>
		</div>
		<?php
	}

	/**
	 * Enqueue frontend assets.
	 *
	 * @return void
	 */
	private function enqueue_assets() {
		$recaptcha = $this->recaptcha_config();

		wp_enqueue_style(
			'subin-frontend',
			SUBIN_PLUGIN_URL . 'assets/frontend.css',
			array(),
			(string) filemtime( SUBIN_PLUGIN_DIR . 'assets/frontend.css' )
		);
		wp_enqueue_script(
			'subin-frontend',
			SUBIN_PLUGIN_URL . 'assets/frontend.js',
			array(),
			(string) filemtime( SUBIN_PLUGIN_DIR . 'assets/frontend.js' ),
			true
		);

		wp_localize_script(
			'subin-frontend',
			'subinFrontend',
			array(
				'recaptcha' => array(
					'enabled'      => $recaptcha['enabled'],
					'siteKey'      => $recaptcha['site_key'],
					'action'       => 'submission_inbox_form',
					'errorMessage' => __( 'Could not verify the submission. Please try again.', 'submission-inbox' ),
				),
			)
		);

		if ( $recaptcha['enabled'] ) {
			wp_enqueue_script(
				'subin-google-recaptcha',
				add_query_arg( 'render', $recaptcha['site_key'], 'https://www.google.com/recaptcha/api.js' ),
				array(),
				null,
				true
			);
		}
	}

	/**
	 * Get public reCAPTCHA frontend config.
	 *
	 * @return array{enabled:bool,site_key:string}
	 */
	private function recaptcha_config() {
		if ( ! $this->settings ) {
			return array(
				'enabled'  => false,
				'site_key' => '',
			);
		}

		$settings = $this->settings->all();
		$site_key = trim( (string) ( $settings['recaptcha_site_key'] ?? '' ) );

		return array(
			'enabled'  => ! empty( $settings['recaptcha_enabled'] ) && '' !== $site_key && '' !== trim( (string) ( $settings['recaptcha_secret_key'] ?? '' ) ),
			'site_key' => sanitize_text_field( $site_key ),
		);
	}

	/**
	 * Render form header.
	 *
	 * @param array<string,mixed> $form Form row.
	 * @return void
	 */
	private function render_header( array $form ) {
		$title       = sanitize_text_field( $form['header_title'] ?? '' );
		$description = sanitize_textarea_field( $form['header_description'] ?? '' );

		if ( '' === $title && '' === $description ) {
			return;
		}
		?>
		<div class="subin-form-header" <?php $this->part_attrs( 'header', __( 'Header', 'submission-inbox' ) ); ?>>
			<?php if ( '' !== $title ) : ?>
				<h3 <?php $this->part_attrs( 'header_title', __( 'Header Title', 'submission-inbox' ) ); ?>><?php echo esc_html( $title ); ?></h3>
			<?php endif; ?>
			<?php if ( '' !== $description ) : ?>
				<p <?php $this->part_attrs( 'header_description', __( 'Header Description', 'submission-inbox' ) ); ?>><?php echo esc_html( $description ); ?></p>
			<?php endif; ?>
		</div>
		<?php
	}

	/**
	 * Render form footer.
	 *
	 * @param array<string,mixed> $form Form row.
	 * @return void
	 */
	private function render_footer( array $form, array $design ) {
		$footer_note  = sanitize_textarea_field( $form['footer_note'] ?? '' );
		$privacy_text = sanitize_textarea_field( $form['privacy_text'] ?? '' );

		if ( empty( $design['show_footer'] ) ) {
			$footer_note = '';
		}

		if ( empty( $design['show_privacy_text'] ) ) {
			$privacy_text = '';
		}

		if ( '' === $footer_note && '' === $privacy_text ) {
			return;
		}
		?>
		<div class="subin-form-footer" <?php $this->part_attrs( 'footer', __( 'Footer', 'submission-inbox' ) ); ?>>
			<?php if ( '' !== $footer_note ) : ?>
				<p><?php echo esc_html( $footer_note ); ?></p>
			<?php endif; ?>
			<?php if ( '' !== $privacy_text ) : ?>
				<p class="subin-form-privacy" <?php $this->part_attrs( 'privacy_text', __( 'Privacy Text', 'submission-inbox' ) ); ?>><?php echo esc_html( $privacy_text ); ?></p>
			<?php endif; ?>
		</div>
		<?php
	}

	/**
	 * Render the canonical Submission Inbox submit button.
	 *
	 * @param array<string,mixed> $form Form row.
	 * @param bool                $is_preview Preview mode.
	 * @return void
	 */
	private function render_submit_button( array $form, $is_preview ) {
		?>
		<div class="subin-form-actions">
			<button type="submit" class="subin-form-submit" <?php disabled( $is_preview ); ?> <?php $this->part_attrs( 'submit_button', __( 'Submit Button', 'submission-inbox' ) ); ?>><?php echo esc_html( $form['submit_button_text'] ?: __( 'Send', 'submission-inbox' ) ); ?></button>
		</div>
		<?php
	}

	/**
	 * Render a saved GrapesJS layout through controlled Submission Inbox components only.
	 *
	 * @param int                            $form_id Form id.
	 * @param array<string,mixed>            $project GrapesJS project.
	 * @param array<int,array<string,mixed>> $fields Field rows.
	 * @param array<string,mixed>            $state Submission state.
	 * @param array<string,mixed>            $form Form row.
	 * @return void
	 */
	private function render_grapesjs_layout( $form_id, array $project, array $fields, array $state, array $form ) {
		$field_map = $this->grapesjs_field_map( $fields );
		$components = is_array( $project['components'] ?? null ) ? $project['components'] : array();

		echo '<div class="subin-grapesjs-layout">';
		foreach ( $components as $component ) {
			if ( is_array( $component ) ) {
				$this->render_grapesjs_component( $form_id, $component, $field_map, $state, $form );
			}
		}
		echo '</div>';
	}

	/**
	 * Render one controlled GrapesJS component.
	 *
	 * @param int                          $form_id Form id.
	 * @param array<string,mixed>          $component Component data.
	 * @param array<string,array<string,mixed>> $field_map Fields indexed by id/key.
	 * @param array<string,mixed>          $state Submission state.
	 * @param array<string,mixed>          $form Form row.
	 * @return void
	 */
	private function render_grapesjs_component( $form_id, array $component, array $field_map, array $state, array $form ) {
		$attributes = is_array( $component['attributes'] ?? null ) ? $component['attributes'] : array();
		$type       = sanitize_key( $component['type'] ?? '' );
		$subin_type   = sanitize_key( $attributes['data-subin-component'] ?? '' );
		$children   = $component['components'] ?? array();

		if ( '' === $subin_type ) {
			$subin_type = $this->grapesjs_component_key_from_type( $type );
		}

		if ( 'form-wrapper' === $subin_type ) {
			echo '<div' . $this->grapesjs_id_attr( $component ) . ' class="' . esc_attr( 'subin-grapesjs-wrapper ' . $this->grapesjs_class_list( $component, array( 'subin-form-' . absint( $form_id ) ) ) ) . '"' . $this->grapesjs_style_attr( $component ) . '>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Attribute helper returns escaped strings.
			$this->render_grapesjs_wrapper_children( $form_id, $children, $field_map, $state, $form );
			echo '</div>';
			return;
		}

		if ( 'row' === $subin_type ) {
			if ( ! $this->grapesjs_row_has_frontend_output( $children ) && '' === $this->grapesjs_intentional_empty_row_style( $component ) ) {
				return;
			}
			echo '<div' . $this->grapesjs_id_attr( $component ) . ' class="' . esc_attr( 'subin-form-row ' . $this->grapesjs_class_list( $component ) ) . '"' . $this->grapesjs_style_attr( $component ) . '>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Attribute helper returns escaped strings.
			$this->render_grapesjs_children( $form_id, $children, $field_map, $state, $form );
			echo '</div>';
			return;
		}

		if ( 'field' === $subin_type ) {
			$field = $this->grapesjs_find_field( $attributes, $field_map );
			if ( ! $field ) {
				$field = $this->grapesjs_virtual_field( $attributes );
			} else {
				$field = $this->grapesjs_field_with_component_settings( $field, $attributes );
			}
			if ( $field ) {
				$field_key = sanitize_key( $field['field_key'] ?? '' );
				$errors    = is_array( $state['errors'] ?? null ) ? $state['errors'] : array();
				$is_error  = '' !== $field_key && ! empty( $errors[ $field_key ] );
				echo '<div' . $this->grapesjs_id_attr( $component ) . ' class="' . esc_attr( trim( 'subin-grapesjs-field subin-field-group ' . $this->grapesjs_class_list( $component ) . ( $is_error ? ' is-error' : '' ) ) ) . '"' . $this->grapesjs_style_attr( $component ) . '>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Attribute helper returns escaped strings.
				$this->render_field( $form_id, $field, $state, true );
				echo '</div>';
			}
			return;
		}

		if ( 'submit-button' === $subin_type ) {
			$this->render_grapesjs_submit_button( $form, $component );
			return;
		}

		if ( 'divider' === $subin_type ) {
			echo '<div' . $this->grapesjs_id_attr( $component ) . ' class="' . esc_attr( 'subin-grapesjs-block subin-grapesjs-block--divider ' . $this->grapesjs_class_list( $component ) ) . '"' . $this->grapesjs_style_attr( $component ) . '><hr /></div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Attribute helper returns escaped strings.
			return;
		}

		if ( 'image-block' === $subin_type ) {
			$this->render_grapesjs_image_block( $component );
			return;
		}

		if ( in_array( $subin_type, array( 'text-block', 'html-block', 'notice', 'privacy', 'section', 'spacer' ), true ) ) {
			echo '<div' . $this->grapesjs_id_attr( $component ) . ' class="' . esc_attr( 'subin-grapesjs-block subin-grapesjs-block--' . sanitize_html_class( $subin_type ) . ' ' . $this->grapesjs_class_list( $component ) ) . '"' . $this->grapesjs_style_attr( $component ) . '>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Attribute helper returns escaped strings.
			if ( 'spacer' === $subin_type ) {
				echo '<div class="subin-grapesjs-spacer" aria-hidden="true"></div>';
			} elseif ( 'section' === $subin_type ) {
				$this->render_grapesjs_children( $form_id, $children, $field_map, $state, $form );
			} elseif ( 'html-block' === $subin_type && isset( $attributes['data-subin-html-content'] ) ) {
				echo $this->grapesjs_content_attribute_html( $attributes['data-subin-html-content'], $subin_type ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Sanitized by controlled content renderer.
			} elseif ( isset( $attributes['data-subin-content'] ) ) {
				echo $this->grapesjs_content_attribute_html( $attributes['data-subin-content'], $subin_type ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Sanitized by controlled content renderer.
			} else {
				echo $this->grapesjs_safe_content( $children ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Sanitized by controlled component renderer.
			}
			echo '</div>';
		}
	}

	/**
	 * Render a real submit button with saved GrapesJS component classes/styles.
	 *
	 * @param array<string,mixed> $form Form row.
	 * @param array<string,mixed> $component Component data.
	 * @return void
	 */
	private function render_grapesjs_submit_button( array $form, array $component ) {
		$attributes = is_array( $component['attributes'] ?? null ) ? $component['attributes'] : array();
		$label = sanitize_text_field( $attributes['data-subin-button-text'] ?? '' );
		if ( '' === $label ) {
			$label = $this->grapesjs_plain_text( $component['components'] ?? '' );
		}
		if ( '' === $label ) {
			$label = sanitize_text_field( $form['submit_button_text'] ?: __( 'Send', 'submission-inbox' ) );
		}
		?>
		<button type="submit"<?php echo $this->grapesjs_id_attr( $component ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Attribute helper returns escaped strings. ?> class="<?php echo esc_attr( trim( 'subin-form-submit subin-grapesjs-submit ' . $this->grapesjs_class_list( $component ) ) ); ?>"<?php echo $this->grapesjs_style_attr( $component ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Attribute helper returns escaped strings. ?> <?php $this->part_attrs( 'submit_button', __( 'Submit Button', 'submission-inbox' ) ); ?>><?php echo esc_html( $label ); ?></button>
		<?php
	}

	/**
	 * Render explicit GrapesJS rows, wrapping legacy flat children one per row.
	 *
	 * @param int                          $form_id Form id.
	 * @param mixed                        $children Children.
	 * @param array<string,array<string,mixed>> $field_map Fields indexed by id/key.
	 * @param array<string,mixed>          $state Submission state.
	 * @param array<string,mixed>          $form Form row.
	 * @return void
	 */
	private function render_grapesjs_wrapper_children( $form_id, $children, array $field_map, array $state, array $form ) {
		if ( ! is_array( $children ) ) {
			return;
		}

		foreach ( $children as $child ) {
			if ( ! is_array( $child ) ) {
				continue;
			}

			if ( ! $this->grapesjs_component_is_frontend_renderable( $child ) ) {
				continue;
			}

			if ( $this->grapesjs_component_subin_type( $child ) === 'row' ) {
				$this->render_grapesjs_component( $form_id, $child, $field_map, $state, $form );
				continue;
			}

			echo '<div class="subin-form-row subin-form-row--legacy">';
			$this->render_grapesjs_component( $form_id, $child, $field_map, $state, $form );
			echo '</div>';
		}
	}

	/**
	 * Render child controlled GrapesJS components.
	 *
	 * @param int                          $form_id Form id.
	 * @param mixed                        $children Children.
	 * @param array<string,array<string,mixed>> $field_map Fields indexed by id/key.
	 * @param array<string,mixed>          $state Submission state.
	 * @param array<string,mixed>          $form Form row.
	 * @return void
	 */
	private function render_grapesjs_children( $form_id, $children, array $field_map, array $state, array $form ) {
		if ( ! is_array( $children ) ) {
			return;
		}

		foreach ( $children as $child ) {
			if ( is_array( $child ) && $this->grapesjs_component_is_frontend_renderable( $child ) ) {
				$this->render_grapesjs_component( $form_id, $child, $field_map, $state, $form );
			}
		}
	}

	/**
	 * Check whether a component should produce frontend output.
	 *
	 * @param array<string,mixed> $component Component data.
	 * @return bool
	 */
	private function grapesjs_component_is_frontend_renderable( array $component ) {
		return in_array(
			$this->grapesjs_component_subin_type( $component ),
			array( 'form-wrapper', 'row', 'field', 'submit-button', 'text-block', 'html-block', 'notice', 'divider', 'privacy', 'section', 'spacer', 'image-block' ),
			true
		);
	}

	/**
	 * Check whether a row has any controlled frontend-renderable children.
	 *
	 * @param mixed $children Row children.
	 * @return bool
	 */
	private function grapesjs_row_has_frontend_output( $children ) {
		if ( ! is_array( $children ) ) {
			return false;
		}

		foreach ( $children as $child ) {
			if ( ! is_array( $child ) || ! $this->grapesjs_component_is_frontend_renderable( $child ) ) {
				continue;
			}

			if ( 'row' !== $this->grapesjs_component_subin_type( $child ) || $this->grapesjs_row_has_frontend_output( $child['components'] ?? array() ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Preserve intentional empty spacing rows without editor placeholder text.
	 *
	 * @param array<string,mixed> $component Row component.
	 * @return string
	 */
	private function grapesjs_intentional_empty_row_style( array $component ) {
		$style = is_array( $component['style'] ?? null ) ? $component['style'] : array();
		return $this->sanitize_grapesjs_style_declarations(
			array_intersect_key(
				$style,
				array(
					'height'     => true,
					'min-height' => true,
					'margin'     => true,
					'margin-top' => true,
					'margin-bottom' => true,
					'padding'    => true,
					'padding-top' => true,
					'padding-bottom' => true,
				)
			)
		);
	}

	/**
	 * Build field lookup maps for GrapesJS references.
	 *
	 * @param array<int,array<string,mixed>> $fields Field rows.
	 * @return array<string,array<string,mixed>>
	 */
	private function grapesjs_field_map( array $fields ) {
		$map = array();
		foreach ( $fields as $field ) {
			$field_id = absint( $field['id'] ?? 0 );
			$key      = sanitize_key( $field['field_key'] ?? '' );
			if ( $field_id > 0 ) {
				$map[ 'id:' . $field_id ] = $field;
			}
			if ( '' !== $key ) {
				$map[ 'key:' . $key ] = $field;
			}
		}

		return $map;
	}

	/**
	 * Find a field referenced by controlled GrapesJS metadata.
	 *
	 * @param array<string,mixed>              $attributes Component attributes.
	 * @param array<string,array<string,mixed>> $field_map Field lookup map.
	 * @return array<string,mixed>|null
	 */
	private function grapesjs_find_field( array $attributes, array $field_map ) {
		$field_id = absint( $attributes['data-subin-field-id'] ?? 0 );
		$key      = sanitize_key( $attributes['data-subin-field-key'] ?? '' );

		if ( $field_id > 0 && isset( $field_map[ 'id:' . $field_id ] ) ) {
			return $field_map[ 'id:' . $field_id ];
		}

		if ( '' !== $key && isset( $field_map[ 'key:' . $key ] ) ) {
			return $field_map[ 'key:' . $key ];
		}

		return null;
	}

	/**
	 * Apply controlled GrapesJS field settings over stored field data.
	 *
	 * @param array<string,mixed> $field Field row.
	 * @param array<string,mixed> $attributes Component attributes.
	 * @return array<string,mixed>
	 */
	private function grapesjs_field_with_component_settings( array $field, array $attributes ) {
		if ( isset( $attributes['data-subin-label'] ) || isset( $attributes['data-subin-field-label'] ) ) {
			$field['field_label'] = sanitize_text_field( $attributes['data-subin-label'] ?? $attributes['data-subin-field-label'] );
		}
		if ( isset( $attributes['data-subin-placeholder'] ) ) {
			$field['placeholder'] = sanitize_text_field( $attributes['data-subin-placeholder'] );
			if ( 'select' === sanitize_key( $field['field_type'] ?? '' ) ) {
				$field['empty_option_text'] = sanitize_text_field( $attributes['data-subin-placeholder'] );
			}
		}
		if ( isset( $attributes['data-subin-help-text'] ) ) {
			$field['help_text'] = sanitize_textarea_field( $attributes['data-subin-help-text'] );
		}
		if ( isset( $attributes['data-subin-required'] ) ) {
			$field['is_required'] = '1' === (string) $attributes['data-subin-required'];
		}
		if ( 'consent' === sanitize_key( $field['field_type'] ?? '' ) && isset( $attributes['data-subin-consent-text'] ) ) {
			$field['field_label'] = sanitize_text_field( $attributes['data-subin-consent-text'] );
		}
		if ( 'hidden' === sanitize_key( $field['field_type'] ?? '' ) ) {
			if ( isset( $attributes['data-subin-field-key'] ) ) {
				$field['field_key'] = sanitize_key( $attributes['data-subin-field-key'] );
			}
			if ( isset( $attributes['data-subin-default-value'] ) ) {
				$field['default_value'] = sanitize_text_field( $attributes['data-subin-default-value'] );
			}
		}
		if ( in_array( sanitize_key( $field['field_type'] ?? '' ), array( 'select', 'radio', 'checkbox' ), true ) ) {
			$options_json = $this->grapesjs_options_json_from_attribute( $attributes['data-subin-options'] ?? '' );
			if ( '' !== $options_json ) {
				$field['field_options'] = $options_json;
			}
		}

		return $field;
	}

	/**
	 * Build a render-only field from controlled GrapesJS block metadata.
	 *
	 * @param array<string,mixed> $attributes Component attributes.
	 * @return array<string,mixed>|null
	 */
	private function grapesjs_virtual_field( array $attributes ) {
		$type = $this->sanitize_choice(
			$attributes['data-subin-field-type'] ?? 'text',
			array( 'text', 'email', 'phone', 'date', 'textarea', 'select', 'radio', 'checkbox', 'consent', 'hidden' ),
			'text'
		);
		$key  = sanitize_key( $attributes['data-subin-field-key'] ?? '' );

		if ( '' === $key ) {
			return null;
		}

		$field = array(
			'id'            => 0,
			'field_type'    => $type,
			'field_key'     => $key,
			'field_label'   => sanitize_text_field( $attributes['data-subin-label'] ?? ( $attributes['data-subin-field-label'] ?? $this->grapesjs_default_field_label( $type ) ) ),
			'placeholder'   => sanitize_text_field( $attributes['data-subin-placeholder'] ?? '' ),
			'empty_option_text' => sanitize_text_field( $attributes['data-subin-placeholder'] ?? '' ),
			'is_required'   => '1' === (string) ( $attributes['data-subin-required'] ?? '' ),
			'field_options' => '',
			'help_text'     => sanitize_textarea_field( $attributes['data-subin-help-text'] ?? '' ),
			'default_value' => sanitize_text_field( $attributes['data-subin-default-value'] ?? '' ),
		);

		if ( 'consent' === $type && isset( $attributes['data-subin-consent-text'] ) ) {
			$field['field_label'] = sanitize_text_field( $attributes['data-subin-consent-text'] );
		}

		if ( in_array( $type, array( 'select', 'radio', 'checkbox' ), true ) ) {
			$options_json = $this->grapesjs_options_json_from_attribute( $attributes['data-subin-options'] ?? '' );
			if ( '' === $options_json ) {
				$options_json = wp_json_encode( $this->grapesjs_default_choice_options() );
			}
			$field['field_options'] = is_string( $options_json ) ? $options_json : '';
		}

		return $field;
	}

	/**
	 * Decode and sanitize choice field options from controlled GrapesJS metadata.
	 *
	 * @param mixed $value Attribute value.
	 * @return string
	 */
	private function grapesjs_options_json_from_attribute( $value ) {
		if ( is_string( $value ) ) {
			$decoded = json_decode( wp_unslash( $value ), true );
			$value   = is_array( $decoded ) ? $decoded : array();
		}

		if ( ! is_array( $value ) ) {
			return '';
		}

		$options = array();
		foreach ( $value as $index => $option ) {
			if ( is_scalar( $option ) ) {
				$label = sanitize_text_field( wp_strip_all_tags( (string) $option ) );
				$raw_value = $label;
			} elseif ( is_array( $option ) ) {
				$label = sanitize_text_field( wp_strip_all_tags( (string) ( $option['label'] ?? '' ) ) );
				$raw_value = (string) ( $option['value'] ?? $label );
			} else {
				continue;
			}

			if ( preg_match( '/^\\s*(javascript|data)\\s*:/i', $raw_value ) ) {
				$raw_value = '';
			}

			$value_key = sanitize_key( '' !== $raw_value ? $raw_value : $label );
			if ( '' === $value_key ) {
				$value_key = 'option_' . ( absint( $index ) + 1 );
			}

			if ( '' === $label ) {
				$label = $value_key;
			}

			$options[] = array(
				'label' => $label,
				'value' => $value_key,
			);
		}

		if ( empty( $options ) ) {
			return '';
		}

		$encoded = wp_json_encode( $options );

		return is_string( $encoded ) ? $encoded : '';
	}

	/**
	 * Default options for render-only GrapesJS choice fields.
	 *
	 * @return array<int,array{label:string,value:string}>
	 */
	private function grapesjs_default_choice_options() {
		return array(
			array(
				'label' => __( 'Option 1', 'submission-inbox' ),
				'value' => 'option_1',
			),
			array(
				'label' => __( 'Option 2', 'submission-inbox' ),
				'value' => 'option_2',
			),
		);
	}

	/**
	 * Default labels for render-only GrapesJS fields.
	 *
	 * @param string $type Field type.
	 * @return string
	 */
	private function grapesjs_default_field_label( $type ) {
		$labels = array(
			'text'     => __( 'Text field', 'submission-inbox' ),
			'email'    => __( 'Email address', 'submission-inbox' ),
			'phone'    => __( 'Phone number', 'submission-inbox' ),
			'date'     => __( 'Date', 'submission-inbox' ),
			'textarea' => __( 'Message', 'submission-inbox' ),
			'select'   => __( 'Select field', 'submission-inbox' ),
			'radio'    => __( 'Radio field', 'submission-inbox' ),
			'checkbox' => __( 'Checkbox field', 'submission-inbox' ),
			'consent'  => __( 'I agree to be contacted about this request.', 'submission-inbox' ),
			'hidden'   => __( 'Hidden field', 'submission-inbox' ),
		);

		return $labels[ sanitize_key( $type ) ] ?? __( 'Field', 'submission-inbox' );
	}

	/**
	 * Convert a GrapesJS component type to a controlled Submission Inbox component key.
	 *
	 * @param string $type GrapesJS type.
	 * @return string
	 */
	private function grapesjs_component_key_from_type( $type ) {
		$map = array(
			'subin-form-wrapper'  => 'form-wrapper',
			'subin-row'           => 'row',
			'subin-field'         => 'field',
			'subin-submit-button' => 'submit-button',
			'subin-text-block'    => 'text-block',
			'subin-html-block'    => 'html-block',
			'subin-notice-block'  => 'notice',
			'subin-divider-block' => 'divider',
			'subin-privacy-block' => 'privacy',
			'subin-section-block' => 'section',
			'subin-image-block'   => 'image-block',
			'subin-spacer-block'  => 'spacer',
			'wrapper'           => 'form-wrapper',
		);

		return $map[ sanitize_key( $type ) ] ?? '';
	}

	/**
	 * Resolve a controlled Submission Inbox component type from stored component data.
	 *
	 * @param array<string,mixed> $component Component data.
	 * @return string
	 */
	private function grapesjs_component_subin_type( array $component ) {
		$attributes = is_array( $component['attributes'] ?? null ) ? $component['attributes'] : array();
		$type       = sanitize_key( $component['type'] ?? '' );

		return sanitize_key( $attributes['data-subin-component'] ?? $this->grapesjs_component_key_from_type( $type ) );
	}

	/**
	 * Check whether a GrapesJS project has a controlled submit button.
	 *
	 * @param array<string,mixed> $project Project data.
	 * @return bool
	 */
	private function grapesjs_has_submit_button( array $project ) {
		return $this->grapesjs_components_contain_type( is_array( $project['components'] ?? null ) ? $project['components'] : array(), 'submit-button' );
	}

	/**
	 * Recursively check component type presence.
	 *
	 * @param array<int,mixed> $components Components.
	 * @param string           $wanted Wanted Submission Inbox component key.
	 * @return bool
	 */
	private function grapesjs_components_contain_type( array $components, $wanted ) {
		foreach ( $components as $component ) {
			if ( ! is_array( $component ) ) {
				continue;
			}
			$subin_type   = $this->grapesjs_component_subin_type( $component );
			if ( $wanted === $subin_type ) {
				return true;
			}
			if ( is_array( $component['components'] ?? null ) && $this->grapesjs_components_contain_type( $component['components'], $wanted ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Check whether components contain controlled layout data even if the wrapper is missing.
	 *
	 * @param array<int,mixed> $components Components.
	 * @return bool
	 */
	private function grapesjs_components_contain_controlled_layout( array $components ) {
		foreach ( array( 'row', 'field', 'submit-button', 'text-block', 'html-block', 'notice', 'divider', 'privacy', 'section', 'spacer', 'image-block' ) as $type ) {
			if ( $this->grapesjs_components_contain_type( $components, $type ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Render a safe image block from controlled metadata.
	 *
	 * @param array<string,mixed> $component Component data.
	 * @return void
	 */
	private function render_grapesjs_image_block( array $component ) {
		$attributes = is_array( $component['attributes'] ?? null ) ? $component['attributes'] : array();
		$url        = $this->sanitize_grapesjs_image_url( $attributes['data-subin-media-url'] ?? '' );
		$alt        = sanitize_text_field( $attributes['data-subin-media-alt'] ?? '' );

		if ( '' === $url ) {
			$url = $this->grapesjs_image_url_from_children( $component['components'] ?? array() );
		}

		echo '<div' . $this->grapesjs_id_attr( $component ) . ' class="' . esc_attr( 'subin-grapesjs-block subin-grapesjs-block--image-block ' . $this->grapesjs_class_list( $component ) ) . '"' . $this->grapesjs_style_attr( $component ) . '>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Attribute helper returns escaped strings.
		if ( '' !== $url ) {
			printf( '<img src="%1$s" alt="%2$s" loading="lazy" />', esc_url( $url ), esc_attr( $alt ) );
		}
		echo '</div>';
	}

	/**
	 * Extract a safe image URL from controlled child image data.
	 *
	 * @param mixed $children Children.
	 * @return string
	 */
	private function grapesjs_image_url_from_children( $children ) {
		if ( ! is_array( $children ) ) {
			return '';
		}

		foreach ( $children as $child ) {
			if ( ! is_array( $child ) ) {
				continue;
			}
			$tag        = sanitize_key( $child['tagName'] ?? '' );
			$attributes = is_array( $child['attributes'] ?? null ) ? $child['attributes'] : array();
			if ( 'img' === $tag && 'image-control' === sanitize_key( $attributes['data-subin-control'] ?? '' ) ) {
				return $this->sanitize_grapesjs_image_url( $attributes['src'] ?? '' );
			}
		}

		return '';
	}

	/**
	 * Build an escaped id attribute for saved GrapesJS component IDs.
	 *
	 * @param array<string,mixed> $component Component data.
	 * @return string
	 */
	private function grapesjs_id_attr( array $component ) {
		$attributes = is_array( $component['attributes'] ?? null ) ? $component['attributes'] : array();
		$id         = $this->sanitize_grapesjs_component_id( $attributes['id'] ?? ( $component['id'] ?? '' ) );

		return '' !== $id ? ' id="' . esc_attr( $id ) . '"' : '';
	}

	/**
	 * Sanitize a saved GrapesJS component id.
	 *
	 * @param mixed $id Component id.
	 * @return string
	 */
	private function sanitize_grapesjs_component_id( $id ) {
		$id = trim( (string) $id );
		if ( '' === $id || ! preg_match( '/^[A-Za-z][A-Za-z0-9_-]{0,63}$/', $id ) ) {
			return '';
		}

		return $id;
	}

	/**
	 * Build a safe class list from GrapesJS component classes.
	 *
	 * @param array<string,mixed> $component Component data.
	 * @param array<int,string>   $extra Extra classes.
	 * @return string
	 */
	private function grapesjs_class_list( array $component, array $extra = array() ) {
		$classes = $extra;
		$attributes = is_array( $component['attributes'] ?? null ) ? $component['attributes'] : array();

		if ( is_string( $attributes['class'] ?? null ) ) {
			$classes = array_merge( $classes, explode( ' ', $attributes['class'] ) );
		}

		if ( is_array( $component['classes'] ?? null ) ) {
			foreach ( $component['classes'] as $class ) {
				if ( is_string( $class ) ) {
					$classes[] = $class;
				} elseif ( is_array( $class ) && is_string( $class['name'] ?? null ) ) {
					$classes[] = $class['name'];
				}
			}
		}

		return $this->sanitize_grapesjs_frontend_class_list( $classes );
	}

	/**
	 * Keep frontend-safe GrapesJS classes and strip editor/canvas helpers.
	 *
	 * @param array<int,string> $classes Classes.
	 * @return string
	 */
	private function sanitize_grapesjs_frontend_class_list( array $classes ) {
		$clean = array();
		foreach ( $classes as $class ) {
			foreach ( preg_split( '/\s+/', (string) $class ) as $part ) {
				$part = sanitize_html_class( $part );
				if ( '' === $part || $this->grapesjs_class_is_editor_only( $part ) ) {
					continue;
				}
				$clean[] = $part;
			}
		}

		return implode( ' ', array_unique( $clean ) );
	}

	/**
	 * Detect editor-only GrapesJS/Design Studio classes.
	 *
	 * @param string $class Class name.
	 * @return bool
	 */
	private function grapesjs_class_is_editor_only( $class ) {
		if ( 0 === strpos( $class, 'gjs-' ) ) {
			return true;
		}

		if ( in_array( $class, array( 'subin-gjs-form-preview', 'subin-gjs-form-flow', 'subin-gjs-row', 'subin-gjs-spacer-preview', 'subin-gjs-image-placeholder' ), true ) ) {
			return true;
		}

		if ( preg_match( '/(placeholder|drop-zone|toolbar|selected|hovered|resizer|resize-handle|canvas|editor)/i', $class ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Build an escaped style attribute from allowlisted GrapesJS inline styles.
	 *
	 * @param array<string,mixed> $component Component data.
	 * @return string
	 */
	private function grapesjs_style_attr( array $component ) {
		$style      = is_array( $component['style'] ?? null ) ? $component['style'] : array();
		$attributes = is_array( $component['attributes'] ?? null ) ? $component['attributes'] : array();
		$css        = $this->sanitize_grapesjs_style_declarations( $style );
		$layout     = $this->grapesjs_layout_style_declarations( $attributes );
		$background = $this->grapesjs_background_style_declarations( $attributes );

		if ( '' !== $layout ) {
			$css = '' !== $css ? $css . ';' . $layout : $layout;
		}
		if ( '' !== $background ) {
			$css = '' !== $css ? $css . ';' . $background : $background;
		}

		return '' !== $css ? ' style="' . esc_attr( $css ) . '"' : '';
	}

	/**
	 * Build safe width declarations from controlled flow layout attributes.
	 *
	 * @param array<string,mixed> $attributes Component attributes.
	 * @return string
	 */
	private function grapesjs_layout_style_declarations( array $attributes ) {
		$width = $this->sanitize_grapesjs_layout_width( $attributes['data-subin-layout-width'] ?? '' );
		if ( '' === $width ) {
			$span = absint( $attributes['data-subin-layout-span'] ?? 0 );
			if ( $span > 0 && $span <= 12 ) {
				$width = $span >= 12 ? '100%' : 'calc(' . round( $span / 12 * 100, 4 ) . '% - ' . round( 16 * ( 12 - $span ) / 12, 4 ) . 'px)';
			}
		}

		return '' !== $width ? 'width:' . $width : '';
	}

	/**
	 * Sanitize controlled flow width values.
	 *
	 * @param mixed $value Width value.
	 * @return string
	 */
	private function sanitize_grapesjs_layout_width( $value ) {
		$value = trim( (string) $value );
		if ( '' === $value || preg_match( '/(@import|javascript\s*:|data\s*:|expression\s*\(|url\s*\(|[<>{}])/i', $value ) ) {
			return '';
		}

		if ( preg_match( '/^(auto|0)$/i', $value ) ) {
			return strtolower( $value );
		}

		if ( preg_match( '/^\\d+(?:\\.\\d+)?(?:px|%|rem|em)$/i', $value ) ) {
			return $value;
		}

		if ( preg_match( '/^calc\\(\\s*\\d+(?:\\.\\d+)?%\\s*-\\s*\\d+(?:\\.\\d+)?px\\s*\\)$/i', $value ) ) {
			return preg_replace( '/\\s+/', ' ', $value );
		}

		return '';
	}

	/**
	 * Build safe background image declarations from controlled component attributes.
	 *
	 * @param array<string,mixed> $attributes Component attributes.
	 * @return string
	 */
	private function grapesjs_background_style_declarations( array $attributes ) {
		$url = $this->sanitize_grapesjs_image_url( $attributes['data-subin-bg-image-url'] ?? '' );
		if ( '' === $url ) {
			return '';
		}

		$size     = $this->sanitize_choice( $attributes['data-subin-bg-size'] ?? '', array( 'cover', 'contain', 'auto' ), 'cover' );
		$position = $this->sanitize_choice( $attributes['data-subin-bg-position'] ?? '', array( 'center', 'top', 'bottom', 'left', 'right' ), 'center' );
		$repeat   = $this->sanitize_choice( $attributes['data-subin-bg-repeat'] ?? '', array( 'no-repeat', 'repeat', 'repeat-x', 'repeat-y' ), 'no-repeat' );

		return 'background-image:url("' . esc_url_raw( $url, array( 'http', 'https' ) ) . '");background-size:' . $size . ';background-position:' . $position . ';background-repeat:' . $repeat;
	}

	/**
	 * Sanitize controlled GrapesJS inline style declarations.
	 *
	 * @param array<string,mixed> $style Style declarations.
	 * @return string
	 */
	private function sanitize_grapesjs_style_declarations( array $style ) {
		$allowed = array(
			'width',
			'height',
			'min-width',
			'max-width',
			'min-height',
			'max-height',
			'margin',
			'margin-top',
			'margin-right',
			'margin-bottom',
			'margin-left',
			'padding',
			'padding-top',
			'padding-right',
			'padding-bottom',
			'padding-left',
			'align-items',
			'justify-content',
			'gap',
			'font-size',
			'font-weight',
			'font-style',
			'line-height',
			'text-align',
			'color',
			'text-transform',
			'background-color',
			'background-size',
			'background-position',
			'background-repeat',
			'border-width',
			'border-top-width',
			'border-right-width',
			'border-bottom-width',
			'border-left-width',
			'border-style',
			'border-top-style',
			'border-right-style',
			'border-bottom-style',
			'border-left-style',
			'border-color',
			'border-top-color',
			'border-right-color',
			'border-bottom-color',
			'border-left-color',
			'border-radius',
			'border-top-left-radius',
			'border-top-right-radius',
			'border-bottom-right-radius',
			'border-bottom-left-radius',
			'box-shadow',
			'opacity',
			'overflow-wrap',
			'box-sizing',
		);
		$declarations = array();
		foreach ( $style as $property => $value ) {
			$property = sanitize_key( $property );
			if ( ! in_array( $property, $allowed, true ) ) {
				continue;
			}
			$value = $this->sanitize_grapesjs_css_value( $value );
			if ( '' !== $value ) {
				$declarations[] = $property . ':' . $value;
			}
		}

		return implode( ';', $declarations );
	}

	/**
	 * Render safe content for GrapesJS text/html blocks.
	 *
	 * @param mixed $content Component children/content.
	 * @return string
	 */
	private function grapesjs_safe_content( $content ) {
		if ( is_string( $content ) ) {
			return $this->sanitize_custom_html( $content );
		}

		if ( ! is_array( $content ) ) {
			return '';
		}

		$html = '';
		foreach ( $content as $child ) {
			if ( is_string( $child ) ) {
				$html .= esc_html( $child );
			} elseif ( is_array( $child ) ) {
				if ( 'textnode' === sanitize_key( $child['type'] ?? '' ) ) {
					$html .= esc_html( sanitize_text_field( $child['content'] ?? '' ) );
				} else {
					$html .= $this->grapesjs_safe_html_node( $child );
				}
			}
		}

		return $html;
	}

	/**
	 * Render sanitized content stored in controlled component attributes.
	 *
	 * @param mixed  $content Content attribute.
	 * @param string $subin_type Component type.
	 * @return string
	 */
	private function grapesjs_content_attribute_html( $content, $subin_type ) {
		$content = (string) $content;
		if ( 'html-block' === $subin_type ) {
			return $this->sanitize_custom_html( $content );
		}

		$text = sanitize_textarea_field( wp_strip_all_tags( $content ) );
		if ( '' === $text ) {
			return '';
		}

		return '<p>' . esc_html( $text ) . '</p>';
	}

	/**
	 * Extract plain text from GrapesJS content data.
	 *
	 * @param mixed $content Component content.
	 * @return string
	 */
	private function grapesjs_plain_text( $content ) {
		if ( is_string( $content ) ) {
			return sanitize_text_field( wp_strip_all_tags( $content ) );
		}

		if ( ! is_array( $content ) ) {
			return '';
		}

		$text = '';
		foreach ( $content as $child ) {
			if ( is_string( $child ) ) {
				$text .= ' ' . $child;
			} elseif ( is_array( $child ) ) {
				if ( isset( $child['content'] ) ) {
					$text .= ' ' . $child['content'];
				}
				$text .= ' ' . $this->grapesjs_plain_text( $child['components'] ?? '' );
			}
		}

		return trim( sanitize_text_field( wp_strip_all_tags( $text ) ) );
	}

	/**
	 * Render one safe HTML node from controlled GrapesJS content data.
	 *
	 * @param array<string,mixed> $node Node data.
	 * @return string
	 */
	private function grapesjs_safe_html_node( array $node ) {
		$tag = sanitize_key( $node['tagName'] ?? 'div' );
		if ( in_array( $tag, array( 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ), true ) ) {
			$tag = 'div';
		}
		if ( ! in_array( $tag, array( 'p', 'br', 'strong', 'em', 'b', 'i', 'span', 'div', 'small', 'ul', 'ol', 'li', 'a', 'hr' ), true ) ) {
			return $this->grapesjs_safe_content( $node['components'] ?? '' );
		}

		$node_attributes = is_array( $node['attributes'] ?? null ) ? $node['attributes'] : array();
		$node_classes    = $this->grapesjs_class_list( $node );
		if ( '' !== $node_classes ) {
			$node_attributes['class'] = trim( (string) ( $node_attributes['class'] ?? '' ) . ' ' . $node_classes );
		}

		$attributes = $this->grapesjs_safe_html_attrs( $tag, $node_attributes );
		if ( in_array( $tag, array( 'br', 'hr' ), true ) ) {
			return '<' . $tag . $attributes . ' />';
		}

		return '<' . $tag . $attributes . '>' . $this->grapesjs_safe_content( $node['components'] ?? '' ) . '</' . $tag . '>';
	}

	/**
	 * Render safe attributes for GrapesJS content HTML nodes.
	 *
	 * @param string              $tag Tag.
	 * @param array<string,mixed> $attributes Attributes.
	 * @return string
	 */
	private function grapesjs_safe_html_attrs( $tag, array $attributes ) {
		$out = '';
		foreach ( $attributes as $name => $value ) {
			$name = strtolower( (string) $name );
			if ( 0 === strpos( $name, 'on' ) || in_array( $name, array( 'style', 'srcdoc' ), true ) ) {
				continue;
			}
			if ( 'class' === $name ) {
				$class = $this->sanitize_class_list( $value );
				if ( '' !== $class ) {
					$out .= ' class="' . esc_attr( $class ) . '"';
				}
			} elseif ( 'aria-label' === $name ) {
				$out .= ' aria-label="' . esc_attr( sanitize_text_field( $value ) ) . '"';
			} elseif ( 'a' === $tag && in_array( $name, array( 'href', 'title', 'target', 'rel' ), true ) ) {
				if ( 'href' === $name ) {
					$url = esc_url_raw( $value, array( 'http', 'https', 'mailto', 'tel' ) );
					if ( '' !== $url && ! preg_match( '/^(javascript|data):/i', $url ) ) {
						$out .= ' href="' . esc_url( $url ) . '"';
					}
				} else {
					$out .= ' ' . $name . '="' . esc_attr( sanitize_text_field( $value ) ) . '"';
				}
			}
		}

		return $out;
	}

	/**
	 * Render one field.
	 *
	 * @param int                 $form_id Form id.
	 * @param array<string,mixed> $field Field row.
	 * @return void
	 */
	private function render_field( $form_id, array $field, array $state, $use_grapesjs_classes = false ) {
		$type = sanitize_key( $field['field_type'] ?? 'text' );
		$key  = sanitize_key( $field['field_key'] ?? '' );

		if ( '' === $key ) {
			return;
		}

		if ( 'hidden' === $type ) {
			$this->render_hidden_field( $key, $field['default_value'] ?? '' );
			return;
		}

		$field_id = $this->field_id( $form_id, $key );
		$field_db_id = absint( $field['id'] ?? 0 );
		$label    = sanitize_text_field( $field['field_label'] ?? '' );
		$help     = sanitize_textarea_field( $field['help_text'] ?? '' );
		$required = ! empty( $field['is_required'] );
		$errors   = is_array( $state['errors'] ?? null ) ? $state['errors'] : array();
		$values   = is_array( $state['values'] ?? null ) ? $state['values'] : array();
		$error    = sanitize_text_field( $errors[ $key ] ?? '' );
		$value    = $values[ $key ] ?? '';
		$field_label = $label ? $label : $key;
		?>
		<div class="<?php echo esc_attr( 'subin-field subin-field-type-' . $type . ( $error ? ' subin-field--error' : '' ) ); ?>" <?php $this->part_attrs( 'field_group', __( 'Field Group', 'submission-inbox' ), $key, $field_label, $field_db_id ); ?>>
			<?php if ( in_array( $type, array( 'radio', 'checkbox' ), true ) ) : ?>
				<fieldset>
					<?php /* translators: %s: field label. */ ?>
					<legend class="subin-field-label" <?php $this->part_attrs( 'field_label', sprintf( __( 'Field Label: %s', 'submission-inbox' ), $field_label ), $key, $field_label, $field_db_id ); ?>><?php echo esc_html( $label ); ?><?php $this->required_marker( $required ); ?></legend>
					<?php $this->render_choice_group( $field, $required, $value, $field_label, $field_db_id, $use_grapesjs_classes ); ?>
				</fieldset>
			<?php elseif ( 'consent' === $type ) : ?>
				<?php $this->render_consent_field( $field_id, $key, $label, $required, $value, $field_db_id, $use_grapesjs_classes ); ?>
			<?php else : ?>
				<?php /* translators: %s: field label. */ ?>
				<label class="subin-field-label" for="<?php echo esc_attr( $field_id ); ?>" <?php $this->part_attrs( 'field_label', sprintf( __( 'Field Label: %s', 'submission-inbox' ), $field_label ), $key, $field_label, $field_db_id ); ?>><?php echo esc_html( $label ); ?><?php $this->required_marker( $required ); ?></label>
				<?php $this->render_input( $field_id, $field, $required, $value, $field_label, $field_db_id, '' !== $error ); ?>
			<?php endif; ?>
			<?php if ( '' !== $error ) : ?>
				<p class="subin-field-error"><?php echo esc_html( $error ); ?></p>
			<?php endif; ?>
			<?php if ( '' !== $help ) : ?>
				<?php /* translators: %s: field label. */ ?>
				<p class="subin-field-help" <?php $this->part_attrs( 'field_help', sprintf( __( 'Help Text: %s', 'submission-inbox' ), $field_label ), $key, $field_label, $field_db_id ); ?>><?php echo esc_html( $help ); ?></p>
			<?php endif; ?>
		</div>
		<?php
	}

	/**
	 * Render an editor-only Design Studio field wrapper.
	 *
	 * @param int                 $form_id Form id.
	 * @param array<string,mixed> $field Field row.
	 * @param array<string,mixed> $state Preview state.
	 * @return void
	 */
	private function render_design_studio_field( $form_id, array $field, array $state ) {
		$field_db_id = absint( $field['id'] ?? 0 );
		if ( $field_db_id <= 0 ) {
			return;
		}

		$field_key   = sanitize_key( $field['field_key'] ?? '' );
		$field_label = sanitize_text_field( $field['field_label'] ?? '' );
		$field_type  = sanitize_key( $field['field_type'] ?? 'text' );

		ob_start();
		$this->render_field( $form_id, $field, $state );
		$field_markup = (string) ob_get_clean();
		?>
		<div
			class="subin-canvas-field"
			data-subin-element-type="field"
			data-subin-field-id="<?php echo esc_attr( (string) $field_db_id ); ?>"
			data-subin-field-key="<?php echo esc_attr( $field_key ); ?>"
		>
			<div class="subin-canvas-field-preview">
				<?php if ( 'hidden' === $field_type ) : ?>
					<?php /* translators: %s: hidden field label. */ ?>
					<div class="subin-canvas-hidden-field-preview" data-subin-part="field_group" data-subin-label="<?php echo esc_attr( sprintf( __( 'Hidden Field: %s', 'submission-inbox' ), $field_label ? $field_label : $field_key ) ); ?>" data-subin-field-key="<?php echo esc_attr( $field_key ); ?>" data-subin-field-id="<?php echo esc_attr( (string) $field_db_id ); ?>" data-subin-field-label="<?php echo esc_attr( $field_label ? $field_label : $field_key ); ?>">
						<?php /* translators: %s: hidden field label. */ ?>
						<?php echo esc_html( sprintf( __( 'Hidden field: %s', 'submission-inbox' ), $field_label ? $field_label : $field_key ) ); ?>
					</div>
				<?php endif; ?>
				<?php echo $field_markup; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Field renderer escapes individual values. ?>
			</div>
		</div>
		<?php
	}

	/**
	 * Render an editor-only Design Studio drop zone.
	 *
	 * @param int $before_field_id Field id that follows this zone, or zero for the final zone.
	 * @param bool $is_empty_canvas Whether this is the empty-form drop target.
	 * @return void
	 */
	private function render_design_studio_drop_zone( $before_field_id, $is_empty_canvas = false ) {
		$before_field_id = absint( $before_field_id );
		?>
		<div
			class="<?php echo esc_attr( 'subin-canvas-drop-zone' . ( $is_empty_canvas ? ' subin-canvas-drop-zone--empty' : '' ) ); ?>"
			data-subin-canvas-drop-zone="1"
			<?php if ( $before_field_id > 0 ) : ?>
				data-subin-drop-before-field-id="<?php echo esc_attr( (string) $before_field_id ); ?>"
			<?php else : ?>
				data-subin-drop-after-last-field="1"
			<?php endif; ?>
		>
			<span><?php echo esc_html( $is_empty_canvas ? __( 'Drag a field here', 'submission-inbox' ) : __( 'Drop here', 'submission-inbox' ) ); ?></span>
		</div>
		<?php
	}

	/**
	 * Render text-like inputs, textarea, and select.
	 *
	 * @param string              $field_id Field id.
	 * @param array<string,mixed> $field Field row.
	 * @param bool                $required Required.
	 * @param mixed               $value Submitted field value.
	 * @param string              $field_label Field label.
	 * @param int                 $field_db_id Field database id.
	 * @param bool                $has_error Whether the field has a validation error.
	 * @return void
	 */
	private function render_input( $field_id, array $field, $required, $value = '', $field_label = '', $field_db_id = 0, $has_error = false ) {
		$type        = sanitize_key( $field['field_type'] ?? 'text' );
		$key         = sanitize_key( $field['field_key'] ?? '' );
		$placeholder = sanitize_text_field( $field['placeholder'] ?? '' );
		$name        = 'subin_fields[' . $key . ']';
		$field_label = $field_label ? $field_label : $key;
		$input_class = ' class="subin-field-input"';
		$error_attr  = $has_error ? ' aria-invalid="true"' : '';
		/* translators: %s: field label. */
		$field_input_label = sprintf( __( 'Field Input: %s', 'submission-inbox' ), $field_label );

		if ( 'textarea' === $type ) {
			$rows = absint( $field['rows'] ?? 0 );
			?>
			<textarea id="<?php echo esc_attr( $field_id ); ?>" class="subin-field-input subin-field-textarea" name="<?php echo esc_attr( $name ); ?>" placeholder="<?php echo esc_attr( $placeholder ); ?>" <?php echo $rows > 0 ? 'rows="' . esc_attr( (string) min( 12, max( 2, $rows ) ) ) . '"' : ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Numeric attribute fragment. ?> <?php $this->required_attributes( $required ); ?><?php echo $error_attr; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Static aria-invalid fragment. ?> <?php $this->part_attrs( 'field_input', $field_input_label, $key, $field_label, $field_db_id ); ?>><?php echo esc_textarea( (string) $value ); ?></textarea>
			<?php
			return;
		}

		if ( 'select' === $type ) {
			$empty_label = sanitize_text_field( $field['empty_option_text'] ?? $placeholder );
			if ( '' === $empty_label ) {
				$empty_label = __( 'Select...', 'submission-inbox' );
			}
			?>
			<select id="<?php echo esc_attr( $field_id ); ?>"<?php echo $input_class; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Static class fragment. ?> name="<?php echo esc_attr( $name ); ?>" <?php $this->required_attributes( $required ); ?><?php echo $error_attr; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Static aria-invalid fragment. ?> <?php $this->part_attrs( 'field_input', $field_input_label, $key, $field_label, $field_db_id ); ?>>
				<option value=""><?php echo esc_html( $empty_label ); ?></option>
				<?php foreach ( $this->get_options( $field ) as $option ) : ?>
					<option value="<?php echo esc_attr( $option['value'] ); ?>" <?php selected( (string) $value, $option['value'] ); ?>><?php echo esc_html( $option['label'] ); ?></option>
				<?php endforeach; ?>
			</select>
			<?php
			return;
		}

		$input_type = 'email' === $type ? 'email' : ( 'phone' === $type ? 'tel' : ( 'date' === $type ? 'date' : 'text' ) );
		?>
		<input id="<?php echo esc_attr( $field_id ); ?>"<?php echo $input_class; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Static class fragment. ?> type="<?php echo esc_attr( $input_type ); ?>" name="<?php echo esc_attr( $name ); ?>" value="<?php echo esc_attr( (string) $value ); ?>" placeholder="<?php echo esc_attr( $placeholder ); ?>" <?php $this->required_attributes( $required ); ?><?php echo $error_attr; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Static aria-invalid fragment. ?> <?php $this->part_attrs( 'field_input', $field_input_label, $key, $field_label, $field_db_id ); ?> />
		<?php
	}

	/**
	 * Render radio or checkbox options.
	 *
	 * @param array<string,mixed> $field Field row.
	 * @param bool                $required Required.
	 * @return void
	 */
	private function render_choice_group( array $field, $required, $value = '', $field_label = '', $field_db_id = 0, $use_grapesjs_classes = false ) {
		$type    = sanitize_key( $field['field_type'] ?? 'checkbox' );
		$key     = sanitize_key( $field['field_key'] ?? '' );
		$options = $this->get_options( $field );
		$name    = 'checkbox' === $type ? 'subin_fields[' . $key . '][]' : 'subin_fields[' . $key . ']';
		$field_label = $field_label ? $field_label : $key;
		/* translators: %s: field label. */
		$field_input_label = sprintf( __( 'Field Input: %s', 'submission-inbox' ), $field_label );

		if ( empty( $options ) && 'checkbox' === $type ) {
			?>
			<label class="<?php echo esc_attr( $use_grapesjs_classes ? 'subin-choice subin-checkbox-field' : 'subin-choice' ); ?>">
				<input<?php echo $use_grapesjs_classes ? ' class="subin-field-checkbox-box"' : ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Static class fragment. ?> type="checkbox" name="<?php echo esc_attr( $name ); ?>" value="1" <?php checked( '1', (string) $value ); ?> <?php $this->required_attributes( $required ); ?> <?php $this->part_attrs( 'field_input', $field_input_label, $key, $field_label, $field_db_id ); ?> />
				<span><?php esc_html_e( 'Yes', 'submission-inbox' ); ?></span>
			</label>
			<?php
			return;
		}

		foreach ( $options as $option ) :
			$checked = is_array( $value ) ? in_array( $option['value'], $value, true ) : ( (string) $value === $option['value'] );
			?>
			<label class="<?php echo esc_attr( $use_grapesjs_classes && 'checkbox' === $type ? 'subin-choice subin-checkbox-field' : 'subin-choice' ); ?>">
				<input<?php echo $use_grapesjs_classes && 'checkbox' === $type ? ' class="subin-field-checkbox-box"' : ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Static class fragment. ?> type="<?php echo esc_attr( $type ); ?>" name="<?php echo esc_attr( $name ); ?>" value="<?php echo esc_attr( $option['value'] ); ?>" <?php checked( $checked ); ?> <?php $this->required_attributes( $required ); ?> <?php $this->part_attrs( 'field_input', $field_input_label, $key, $field_label, $field_db_id ); ?> />
				<span><?php echo esc_html( $option['label'] ); ?></span>
			</label>
			<?php
		endforeach;
	}

	/**
	 * Render consent field.
	 *
	 * @param string $field_id Field id.
	 * @param string $key Field key.
	 * @param string $label Label.
	 * @param bool   $required Required.
	 * @return void
	 */
	private function render_consent_field( $field_id, $key, $label, $required, $value = '', $field_db_id = 0, $use_grapesjs_classes = false ) {
		$label = $label ? $label : __( 'I agree to be contacted about this request.', 'submission-inbox' );
		/* translators: %s: field label. */
		$field_label_attr = sprintf( __( 'Field Label: %s', 'submission-inbox' ), $label );
		/* translators: %s: field label. */
		$field_input_label = sprintf( __( 'Field Input: %s', 'submission-inbox' ), $label );
		?>
		<label class="<?php echo esc_attr( $use_grapesjs_classes ? 'subin-consent subin-checkbox-field subin-field-checkbox' : 'subin-consent' ); ?>" for="<?php echo esc_attr( $field_id ); ?>" <?php $this->part_attrs( 'field_label', $field_label_attr, $key, $label, $field_db_id ); ?>>
			<input id="<?php echo esc_attr( $field_id ); ?>"<?php echo $use_grapesjs_classes ? ' class="subin-field-checkbox-box"' : ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Static class fragment. ?> type="checkbox" name="<?php echo esc_attr( 'subin_fields[' . sanitize_key( $key ) . ']' ); ?>" value="1" <?php checked( '1', (string) $value ); ?> <?php $this->required_attributes( $required ); ?> <?php $this->part_attrs( 'field_input', $field_input_label, $key, $label, $field_db_id ); ?> />
			<span><?php echo esc_html( $label ); ?><?php $this->required_marker( $required ); ?></span>
		</label>
		<?php
	}

	/**
	 * Render hidden field.
	 *
	 * @param string $key Field key.
	 * @return void
	 */
	private function render_hidden_field( $key, $value = '' ) {
		?>
		<input type="hidden" name="<?php echo esc_attr( 'subin_fields[' . sanitize_key( $key ) . ']' ); ?>" value="<?php echo esc_attr( sanitize_text_field( $value ) ); ?>" />
		<?php
	}

	/**
	 * Render internal hidden fields.
	 *
	 * @param int $form_id Form id.
	 * @return void
	 */
	private function render_internal_fields( $form_id ) {
		$form_id    = absint( $form_id );
		$started_at = time();
		$ajax_token = wp_hash( \SUBIN\Forms\FormSubmissionHandler::AJAX_SUBMIT_TOKEN_ACTION . '|' . $form_id . '|' . $started_at );
		?>
		<input type="hidden" name="subin_form_id" value="<?php echo esc_attr( (string) $form_id ); ?>" />
		<input type="hidden" name="subin_form_submitted" value="1" />
		<input type="hidden" name="subin_started_at" value="<?php echo esc_attr( (string) $started_at ); ?>" />
		<input type="hidden" name="subin_ajax_token" value="<?php echo esc_attr( $ajax_token ); ?>" />
		<input type="hidden" name="subin_source_url" value="<?php echo esc_url( remove_query_arg( \SUBIN\Forms\FormSubmissionHandler::RESULT_QUERY_ARG ) ); ?>" />
		<input type="hidden" name="subin_recaptcha_token" value="" />
		<input type="hidden" name="subin_recaptcha_action" value="submission_inbox_form" />
		<input type="hidden" name="_subin_form_nonce" value="<?php echo esc_attr( wp_create_nonce( 'subin_submit_form_' . $form_id ) ); ?>" />
		<?php
	}

	/**
	 * Render honeypot.
	 *
	 * @return void
	 */
	private function render_honeypot( $form_id ) {
		$field_id = 'subin-company-website-' . absint( $form_id );
		?>
		<div class="subin-hp-field" aria-hidden="true">
			<label for="<?php echo esc_attr( $field_id ); ?>"><?php esc_html_e( 'Leave this field empty', 'submission-inbox' ); ?></label>
			<input id="<?php echo esc_attr( $field_id ); ?>" type="text" name="subin_company_website" tabindex="-1" autocomplete="off" />
		</div>
		<?php
	}

	/**
	 * Get sanitized options.
	 *
	 * @param array<string,mixed> $field Field row.
	 * @return array<int,array{label:string,value:string}>
	 */
	private function get_options( array $field ) {
		if ( empty( $field['field_options'] ) || ! is_string( $field['field_options'] ) ) {
			return array();
		}

		$decoded = json_decode( $field['field_options'], true );
		if ( ! is_array( $decoded ) ) {
			return array();
		}

		$options = array();
		foreach ( $decoded as $option ) {
			if ( ! is_array( $option ) ) {
				continue;
			}

			$label = sanitize_text_field( $option['label'] ?? '' );
			$value = sanitize_key( $option['value'] ?? $label );
			if ( '' === $label || '' === $value ) {
				continue;
			}

			$options[] = array(
				'label' => $label,
				'value' => $value,
			);
		}

		return $options;
	}

	/**
	 * Print required marker.
	 *
	 * @param bool $required Required.
	 * @return void
	 */
	private function required_marker( $required ) {
		if ( $required ) {
			echo ' <span class="subin-required" aria-hidden="true">*</span>';
		}
	}

	/**
	 * Print required attributes.
	 *
	 * @param bool $required Required.
	 * @return void
	 */
	private function required_attributes( $required ) {
		if ( $required ) {
			echo ' required aria-required="true"';
		}
	}

	/**
	 * Build field id.
	 *
	 * @param int    $form_id Form id.
	 * @param string $key Field key.
	 * @return string
	 */
	private function field_id( $form_id, $key ) {
		return 'subin-form-' . absint( $form_id ) . '-' . sanitize_key( $key );
	}

	/**
	 * Print form part attributes used by scoped CSS and Design Studio previews.
	 *
	 * @param string $part Part key.
	 * @param string $label Human label.
	 * @param string $field_key Field key.
	 * @param string $field_label Field label.
	 * @return void
	 */
	private function part_attrs( $part, $label, $field_key = '', $field_label = '', $field_id = 0 ) {
		printf(
			' data-subin-part="%1$s" data-subin-label="%2$s"',
			esc_attr( sanitize_key( $part ) ),
			esc_attr( sanitize_text_field( $label ) )
		);

		if ( '' !== $field_key ) {
			printf( ' data-subin-field-key="%s"', esc_attr( sanitize_key( $field_key ) ) );
		}

		if ( absint( $field_id ) > 0 ) {
			printf( ' data-subin-field-id="%s"', esc_attr( (string) absint( $field_id ) ) );
		}

		if ( '' !== $field_label ) {
			printf( ' data-subin-field-label="%s"', esc_attr( sanitize_text_field( $field_label ) ) );
		}
	}

	/**
	 * Get layout class.
	 *
	 * @param string $layout Layout key.
	 * @return string
	 */
	private function layout_class( $layout ) {
		$allowed = array( 'clean_card', 'minimal', 'soft_business', 'compact', 'full_width', 'two_column' );
		$layout  = in_array( sanitize_key( $layout ), $allowed, true ) ? sanitize_key( $layout ) : 'clean_card';

		return 'subin-form-layout-' . str_replace( '_', '-', $layout );
	}

	/**
	 * Get style class.
	 *
	 * @param string $style Style key.
	 * @return string
	 */
	private function style_class( $style ) {
		$allowed = array( 'default', 'light', 'soft', 'simple', 'professional' );
		$style   = in_array( sanitize_key( $style ), $allowed, true ) ? sanitize_key( $style ) : 'default';

		return 'subin-form-style-' . $style;
	}

	/**
	 * Get saved design settings.
	 *
	 * @param array<string,mixed> $form Form row.
	 * @return array<string,mixed>
	 */
	private function design_settings( array $form ) {
		$settings = $this->raw_settings( $form );

		$design = is_array( $settings['design'] ?? null ) ? $settings['design'] : array();

		return array(
			'button_style'      => $this->sanitize_choice( $design['button_style'] ?? '', array( 'solid', 'outline', 'soft' ), 'solid' ),
			'form_width'        => $this->sanitize_choice( $design['form_width'] ?? '', array( 'narrow', 'standard', 'wide', 'full' ), 'standard' ),
			'spacing'           => $this->sanitize_choice( $design['spacing'] ?? '', array( 'compact', 'comfortable', 'spacious' ), 'comfortable' ),
			'header_alignment'  => $this->sanitize_choice( $design['header_alignment'] ?? '', array( 'left', 'center' ), 'left' ),
			'show_footer'       => array_key_exists( 'show_footer', $design ) ? (bool) $design['show_footer'] : true,
			'show_privacy_text' => array_key_exists( 'show_privacy_text', $design ) ? (bool) $design['show_privacy_text'] : true,
		);
	}

	/**
	 * Get saved Advanced HTML settings.
	 *
	 * @param array<string,mixed> $form Form row.
	 * @return array<string,string>
	 */
	private function advanced_html_settings( array $form ) {
		$settings = $this->raw_settings( $form );

		$advanced = is_array( $settings['advanced_html'] ?? null ) ? $settings['advanced_html'] : array();

		return array(
			'html_before_form'    => $this->sanitize_custom_html( $advanced['html_before_form'] ?? '' ),
			'html_after_form'     => $this->sanitize_custom_html( $advanced['html_after_form'] ?? '' ),
			'html_before_submit'  => $this->sanitize_custom_html( $advanced['html_before_submit'] ?? '' ),
			'html_after_submit'   => $this->sanitize_custom_html( $advanced['html_after_submit'] ?? '' ),
			'custom_wrapper_class' => $this->sanitize_class_list( $advanced['custom_wrapper_class'] ?? '' ),
			'custom_form_class'    => $this->sanitize_class_list( $advanced['custom_form_class'] ?? '' ),
			'custom_css'           => sanitize_textarea_field( $advanced['custom_css'] ?? '' ),
		);
	}

	/**
	 * Get sanitized Design Studio blocks.
	 *
	 * @param array<string,mixed> $form Form row.
	 * @return array<int,array<string,mixed>>
	 */
	private function design_blocks( array $form ) {
		$settings = $this->raw_settings( $form );

		$blocks = is_array( $settings['design_blocks'] ?? null ) ? $settings['design_blocks'] : array();
		$clean  = array();
		foreach ( $blocks as $block ) {
			if ( ! is_array( $block ) ) {
				continue;
			}
			$clean_block = $this->sanitize_design_block( $block );
			if ( '' !== $clean_block['id'] ) {
				$clean[] = $clean_block;
			}
		}

		usort(
			$clean,
			static function ( $a, $b ) {
				return absint( $a['sort_order'] ?? 0 ) <=> absint( $b['sort_order'] ?? 0 );
			}
		);

		return $clean;
	}

	/**
	 * Decode form settings JSON.
	 *
	 * @param array<string,mixed> $form Form row.
	 * @return array<string,mixed>
	 */
	private function raw_settings( array $form ) {
		if ( empty( $form['settings_json'] ) || ! is_string( $form['settings_json'] ) ) {
			return array();
		}

		$decoded = json_decode( $form['settings_json'], true );
		return is_array( $decoded ) ? $decoded : array();
	}

	/**
	 * Get saved GrapesJS project data when it contains controlled Submission Inbox components.
	 *
	 * @param array<string,mixed> $form Form row.
	 * @return array<string,mixed>
	 */
	private function grapesjs_project( array $form ) {
		$settings = $this->raw_settings( $form );
		$project  = array();

		if ( is_array( $settings['design_studio']['grapesjs_project'] ?? null ) ) {
			$project = $settings['design_studio']['grapesjs_project'];
		} elseif ( is_array( $settings['design_studio_grapesjs_project'] ?? null ) ) {
			$project = $settings['design_studio_grapesjs_project'];
		}

		if ( empty( $project ) ) {
			return array();
		}

		$components = $this->grapesjs_project_components( $project );
		if ( empty( $components ) ) {
			return array();
		}

		if ( ! $this->grapesjs_components_contain_type( $components, 'form-wrapper' ) ) {
			if ( ! $this->grapesjs_components_contain_controlled_layout( $components ) ) {
				return array();
			}

			$components = array(
				array(
					'type'       => 'wrapper',
					'attributes' => array(
						'data-subin-component' => 'form-wrapper',
					),
					'components' => $components,
				),
			);
		}

		return array(
			'components' => $components,
			'css'        => $this->grapesjs_project_css( $project, $form['id'] ?? 0, $components ),
		);
	}

	/**
	 * Extract components from either legacy or native GrapesJS project data.
	 *
	 * @param array<string,mixed> $project Project data.
	 * @return array<int,mixed>
	 */
	private function grapesjs_project_components( array $project ) {
		if ( is_array( $project['components'] ?? null ) ) {
			return $project['components'];
		}

		$pages = is_array( $project['pages'] ?? null ) ? $project['pages'] : array();
		foreach ( $pages as $page ) {
			if ( ! is_array( $page ) ) {
				continue;
			}

			$frames = is_array( $page['frames'] ?? null ) ? $page['frames'] : array();
			foreach ( $frames as $frame ) {
				if ( is_array( $frame ) && is_array( $frame['component'] ?? null ) ) {
					return array( $frame['component'] );
				}
			}
		}

		return array();
	}

	/**
	 * Extract safe scoped CSS from native or legacy GrapesJS project data.
	 *
	 * @param array<string,mixed> $project Project data.
	 * @param int                 $form_id Form id.
	 * @return string
	 */
	private function grapesjs_project_css( array $project, $form_id, array $components = array() ) {
		$css = array();
		if ( is_string( $project['css'] ?? null ) ) {
			$raw_css = $this->scope_grapesjs_raw_css( $project['css'], $form_id );
			if ( '' !== $raw_css ) {
				$css[] = $raw_css;
			}
		}

		$style_css = $this->grapesjs_css_from_styles( is_array( $project['styles'] ?? null ) ? $project['styles'] : array(), $form_id );
		if ( '' !== $style_css ) {
			$css[] = $style_css;
		}

		$field_inner_css = $this->grapesjs_field_inner_css_from_components( $components, $form_id );
		if ( '' !== $field_inner_css ) {
			$css[] = $field_inner_css;
		}

		return implode( "\n", $css );
	}

	/**
	 * Build scoped CSS for controlled field inner part settings.
	 *
	 * @param array<int,mixed> $components GrapesJS component tree.
	 * @param int              $form_id Form id.
	 * @return string
	 */
	private function grapesjs_field_inner_css_from_components( array $components, $form_id ) {
		$scope = '.subin-form-' . absint( $form_id );
		$rules = array();

		foreach ( $components as $component ) {
			if ( ! is_array( $component ) ) {
				continue;
			}

			if ( 'field' === $this->grapesjs_component_subin_type( $component ) ) {
				$rules = array_merge( $rules, $this->grapesjs_field_inner_css_rules( $component, $scope ) );
			}

			if ( is_array( $component['components'] ?? null ) ) {
				$child_css = $this->grapesjs_field_inner_css_from_components( $component['components'], $form_id );
				if ( '' !== $child_css ) {
					$rules[] = $child_css;
				}
			}
		}

		return implode( "\n", $rules );
	}

	/**
	 * Build field part CSS rules for one controlled field component.
	 *
	 * @param array<string,mixed> $component Component data.
	 * @param string              $scope Form scope selector.
	 * @return array<int,string>
	 */
	private function grapesjs_field_inner_css_rules( array $component, $scope ) {
		$selector = $this->grapesjs_component_style_selector( $component );
		if ( '' === $selector ) {
			return array();
		}

		$attributes = is_array( $component['attributes'] ?? null ) ? $component['attributes'] : array();
		$base       = $scope . ' ' . $selector;
		$rules      = array();

		$label = $this->sanitize_grapesjs_style_declarations(
			array(
				'color'         => $this->sanitize_hex_color_value( $attributes['data-subin-label-color'] ?? '' ),
				'font-size'     => $this->pixel_value( $attributes['data-subin-label-font-size'] ?? '' ),
				'font-weight'   => $this->sanitize_choice( $attributes['data-subin-label-font-weight'] ?? '', array( '400', '500', '600', '700' ), '' ),
				'line-height'   => $this->decimal_value( $attributes['data-subin-label-line-height'] ?? '' ),
				'margin-bottom' => $this->pixel_value( $attributes['data-subin-label-margin-bottom'] ?? '' ),
			)
		);
		if ( '' !== $label ) {
			$rules[] = $base . ' .subin-field-label{' . $label . '}';
		}

		$input = $this->sanitize_grapesjs_style_declarations(
			array(
				'background-color' => $this->sanitize_hex_color_value( $attributes['data-subin-input-bg-color'] ?? '' ),
				'color'            => $this->sanitize_hex_color_value( $attributes['data-subin-input-text-color'] ?? '' ),
				'border-color'     => $this->sanitize_hex_color_value( $attributes['data-subin-input-border-color'] ?? '' ),
				'border-width'     => $this->pixel_value( $attributes['data-subin-input-border-width'] ?? '' ),
				'border-radius'    => $this->pixel_value( $attributes['data-subin-input-border-radius'] ?? '' ),
				'box-shadow'       => $this->sanitize_exact_css_choice(
					$attributes['data-subin-input-box-shadow'] ?? '',
					array( 'none', '0 4px 12px rgba(15, 23, 42, 0.12)', '0 10px 24px rgba(15, 23, 42, 0.16)', '0 18px 36px rgba(15, 23, 42, 0.22)' ),
					''
				),
				'padding'          => $this->box_value( $attributes['data-subin-input-padding'] ?? '' ),
				'min-height'       => $this->pixel_value( $attributes['data-subin-input-min-height'] ?? '' ),
			)
		);
		if ( '' !== $input ) {
			$rules[] = $base . ' .subin-field-input,' . $base . ' .subin-field-textarea,' . $base . ' .subin-choice input,' . $base . ' .subin-field-checkbox-box{' . $input . '}';
		}

		$placeholder = $this->sanitize_grapesjs_style_declarations(
			array(
				'color' => $this->sanitize_hex_color_value( $attributes['data-subin-placeholder-color'] ?? '' ),
			)
		);
		if ( '' !== $placeholder ) {
			$rules[] = $base . ' input::placeholder,' . $base . ' textarea::placeholder{' . $placeholder . '}';
		}

		$help = $this->sanitize_grapesjs_style_declarations(
			array(
				'color'      => $this->sanitize_hex_color_value( $attributes['data-subin-help-color'] ?? '' ),
				'font-size'  => $this->pixel_value( $attributes['data-subin-help-font-size'] ?? '' ),
				'line-height' => $this->decimal_value( $attributes['data-subin-help-line-height'] ?? '' ),
				'margin-top' => $this->pixel_value( $attributes['data-subin-help-margin-top'] ?? '' ),
			)
		);
		if ( '' !== $help ) {
			$rules[] = $base . ' .subin-field-help{' . $help . '}';
		}

		return $rules;
	}

	/**
	 * Return a safe unique selector for a GrapesJS component.
	 *
	 * @param array<string,mixed> $component Component data.
	 * @return string
	 */
	private function grapesjs_component_style_selector( array $component ) {
		foreach ( explode( ' ', $this->grapesjs_class_list( $component ) ) as $class ) {
			if ( preg_match( '/^subin-gjs-(?:component|el)-[A-Za-z0-9_-]+$/', $class ) ) {
				return '.' . sanitize_html_class( $class );
			}
		}

		$id = $this->grapesjs_component_id( $component );
		return '' !== $id ? '#' . $id : '';
	}

	/**
	 * Build scoped CSS from GrapesJS style manager rules.
	 *
	 * @param array<int,mixed> $styles GrapesJS styles.
	 * @param int              $form_id Form id.
	 * @return string
	 */
	private function grapesjs_css_from_styles( array $styles, $form_id ) {
		$scope = '.subin-form-' . absint( $form_id );
		$rules = array();

		foreach ( $styles as $rule ) {
			if ( ! is_array( $rule ) || ! is_array( $rule['style'] ?? null ) ) {
				continue;
			}

			$selectors = $this->grapesjs_style_rule_selectors( $rule );
			$css       = $this->sanitize_grapesjs_style_declarations( $rule['style'] );
			if ( empty( $selectors ) || '' === $css ) {
				continue;
			}

			$scoped_selectors = array();
			foreach ( $selectors as $selector ) {
				$scoped_selectors[] = $scope . ' ' . $selector;
			}

			$rules[] = implode( ', ', $scoped_selectors ) . '{' . $css . '}';
		}

		return implode( "\n", $rules );
	}

	/**
	 * Scope simple raw GrapesJS CSS rules to the current form.
	 *
	 * @param mixed $css Raw CSS.
	 * @param int   $form_id Form id.
	 * @return string
	 */
	private function scope_grapesjs_raw_css( $css, $form_id ) {
		$css     = trim( str_replace( "\r\n", "\n", (string) $css ) );
		$form_id = absint( $form_id );

		if ( '' === $css || $form_id <= 0 || substr_count( $css, '{' ) !== substr_count( $css, '}' ) ) {
			return '';
		}

		if ( preg_match( '/(@import|javascript\s*:|data\s*:|expression\s*\(|<\s*script|<\/?style|<\/?script)/i', $css ) ) {
			return '';
		}

		$scope  = '.subin-form-' . $form_id;
		$rules  = array();
		$covered = '';

		if ( ! preg_match_all( '/([^{}]+)\{([^{}]*)\}/', $css, $matches, PREG_SET_ORDER ) ) {
			return '';
		}

		foreach ( $matches as $match ) {
			$covered .= $match[0];
			$selectors = $this->sanitize_grapesjs_selector_list( $match[1] );
			$style     = $this->sanitize_grapesjs_declaration_string( $match[2] );
			if ( empty( $selectors ) || '' === $style ) {
				continue;
			}

			$scoped_selectors = array();
			foreach ( $selectors as $selector ) {
				$scoped_selectors[] = 0 === strpos( $selector, $scope ) ? $selector : $scope . ' ' . $selector;
			}
			$rules[] = implode( ', ', $scoped_selectors ) . '{' . $style . '}';
		}

		if ( preg_replace( '/\s+/', '', $covered ) !== preg_replace( '/\s+/', '', $css ) ) {
			return '';
		}

		return implode( "\n", $rules );
	}

	/**
	 * Sanitize a CSS declaration string with the same allowlist as GrapesJS style maps.
	 *
	 * @param string $declarations CSS declarations.
	 * @return string
	 */
	private function sanitize_grapesjs_declaration_string( $declarations ) {
		$style = array();
		foreach ( explode( ';', (string) $declarations ) as $declaration ) {
			if ( false === strpos( $declaration, ':' ) ) {
				continue;
			}
			list( $property, $value ) = explode( ':', $declaration, 2 );
			$style[ trim( $property ) ] = trim( $value );
		}

		return $this->sanitize_grapesjs_style_declarations( $style );
	}

	/**
	 * Build CSS selectors for a GrapesJS style rule.
	 *
	 * @param array<string,mixed> $rule Style rule.
	 * @return array<int,string>
	 */
	private function grapesjs_style_rule_selectors( array $rule ) {
		if ( is_string( $rule['selectorsAdd'] ?? null ) && '' !== trim( $rule['selectorsAdd'] ) ) {
			return $this->sanitize_grapesjs_selector_list( $rule['selectorsAdd'] );
		}

		$selectors = is_array( $rule['selectors'] ?? null ) ? $rule['selectors'] : array();
		$parts     = array();

		foreach ( $selectors as $selector ) {
			$name = '';
			if ( is_string( $selector ) ) {
				$name = $selector;
			} elseif ( is_array( $selector ) && is_string( $selector['name'] ?? null ) ) {
				$name = $selector['name'];
			} elseif ( is_array( $selector ) && is_string( $selector['label'] ?? null ) ) {
				$name = $selector['label'];
			}

			$name = trim( $name );
			if ( preg_match( '/^#[A-Za-z][A-Za-z0-9_-]{0,63}$/', $name ) ) {
				$parts[] = $name;
				continue;
			}

			$name = ltrim( $name, '.' );
			if ( $this->grapesjs_class_is_editor_only( $name ) ) {
				continue;
			}
			if ( '' !== $name && preg_match( '/^[A-Za-z0-9_-]+$/', $name ) ) {
				$parts[] = '.' . sanitize_html_class( $name );
			}
		}

		$selector = implode( '', $parts );
		if ( '' === $selector ) {
			return array();
		}

		$state = $this->sanitize_grapesjs_style_state( $rule['state'] ?? '' );
		if ( '' !== $state ) {
			$selector .= ':' . $state;
		}

		return array( $selector );
	}

	/**
	 * Sanitize supported GrapesJS selector states.
	 *
	 * @param mixed $state Rule state.
	 * @return string
	 */
	private function sanitize_grapesjs_style_state( $state ) {
		$state = sanitize_key( $state );
		return in_array( $state, array( 'hover', 'focus', 'focus-visible', 'active', 'disabled' ), true ) ? $state : '';
	}

	/**
	 * Sanitize a comma-separated selector list from GrapesJS generated CSS.
	 *
	 * @param string $selector_list Selector list.
	 * @return array<int,string>
	 */
	private function sanitize_grapesjs_selector_list( $selector_list ) {
		$clean = array();
		foreach ( explode( ',', $selector_list ) as $selector ) {
			$selector = trim( $selector );
			if ( '' === $selector || ! preg_match( '/^[.#A-Za-z0-9_\\-\\s:>*]+$/', $selector ) ) {
				continue;
			}

			$selector = $this->grapesjs_clean_frontend_selector( $selector );
			if ( '' === $selector || $this->grapesjs_selector_is_editor_only( $selector ) ) {
				continue;
			}
			if ( false === strpos( $selector, '.subin-' ) && ! preg_match( '/#[A-Za-z][A-Za-z0-9_-]{0,63}\\b/', $selector ) ) {
				continue;
			}
			if ( preg_match( '/:(?!:placeholder\\b|before\\b|after\\b|first-child\\b|last-child\\b|hover\\b|focus\\b|focus-visible\\b|active\\b|disabled\\b)/i', $selector ) ) {
				continue;
			}

			$clean[] = preg_replace( '/\\s+/', ' ', $selector );
		}

		return $clean;
	}

	/**
	 * Remove editor-only class tokens from otherwise valid component selectors.
	 *
	 * @param string $selector Selector.
	 * @return string
	 */
	private function grapesjs_clean_frontend_selector( $selector ) {
		if ( false === strpos( $selector, '.subin-gjs-component-' ) && preg_match( '/\\.subin-gjs-(form-preview|form-flow|row|spacer-preview|image-placeholder)\\b|(^|[\\s>.,])\\.gjs-/i', $selector ) ) {
			return '';
		}

		$selector = preg_replace_callback(
			'/\\.([A-Za-z0-9_-]+)/',
			function ( $matches ) {
				return $this->grapesjs_class_is_editor_only( $matches[1] ) ? '' : $matches[0];
			},
			$selector
		);

		$selector = preg_replace( '/\\s+/', ' ', (string) $selector );
		$selector = preg_replace( '/\\s*>\\s*/', ' > ', (string) $selector );
		$selector = trim( (string) $selector );

		return preg_match( '/\\.subin-|#[A-Za-z][A-Za-z0-9_-]{0,63}\\b/', $selector ) ? $selector : '';
	}

	/**
	 * Detect editor-only selectors that must not be emitted on the public frontend.
	 *
	 * @param string $selector CSS selector.
	 * @return bool
	 */
	private function grapesjs_selector_is_editor_only( $selector ) {
		if ( preg_match( '/(^|[\\s>.,])\\.gjs-/i', $selector ) ) {
			return true;
		}

		if ( preg_match( '/\\.subin-gjs-(form-preview|form-flow|row|spacer-preview|image-placeholder)\\b/i', $selector ) ) {
			return true;
		}

		if ( preg_match( '/(drop-zone|placeholder|toolbar|selected|hovered|resizer|resize-handle|canvas|editor)/i', $selector ) ) {
			return true;
		}

		if ( false !== stripos( $selector, ':empty' ) || false !== stripos( $selector, 'content:' ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Sanitize scoped GrapesJS CSS before frontend output.
	 *
	 * @param mixed $css CSS.
	 * @param int   $form_id Form id.
	 * @return string
	 */
	private function sanitize_grapesjs_css( $css, $form_id ) {
		$css     = trim( str_replace( "\r\n", "\n", (string) $css ) );
		$form_id = absint( $form_id );

		if ( '' === $css || $form_id <= 0 || substr_count( $css, '{' ) !== substr_count( $css, '}' ) ) {
			return '';
		}

		if ( preg_match( '/(@import|javascript\s*:|data\s*:|expression\s*\(|<\s*script|<\/?style|<\/?script)/i', $css ) ) {
			return '';
		}

		if ( preg_match_all( '/url\s*\(\s*[\'"]?([^\'")]+)[\'"]?\s*\)/i', $css, $urls ) ) {
			foreach ( $urls[1] as $url ) {
				if ( '' === $this->sanitize_grapesjs_image_url( $url ) ) {
					return '';
				}
			}
		}

		$scope = '.subin-form-' . $form_id;
		if ( false === strpos( $css, $scope ) || ! preg_match_all( '/([^{}]+)\{([^{}]*)\}/', $css, $matches, PREG_SET_ORDER ) ) {
			return '';
		}

		$covered = '';
		foreach ( $matches as $match ) {
			$selector     = trim( $match[1] );
			$declarations = trim( $match[2] );
			$covered     .= $match[0];
			if ( '' === $selector || '' === $declarations || false === strpos( $selector, $scope ) ) {
				return '';
			}
		}

		return preg_replace( '/\s+/', '', $covered ) === preg_replace( '/\s+/', '', $css ) ? $css : '';
	}

	/**
	 * Sanitize one GrapesJS CSS value.
	 *
	 * @param mixed $value CSS value.
	 * @return string
	 */
	private function sanitize_grapesjs_css_value( $value ) {
		$value = trim( (string) $value );
		if ( '' === $value ) {
			return '';
		}

		if ( preg_match( '/(@import|javascript\s*:|data\s*:|expression\s*\(|<\s*script|<\/?style|<\/?script)/i', $value ) ) {
			return '';
		}

		if ( preg_match_all( '/url\s*\(\s*[\'"]?([^\'")]+)[\'"]?\s*\)/i', $value, $urls ) ) {
			foreach ( $urls[1] as $url ) {
				if ( '' === $this->sanitize_grapesjs_image_url( $url ) ) {
					return '';
				}
			}
		}

		return sanitize_text_field( $value );
	}

	/**
	 * Sanitize a GrapesJS image URL.
	 *
	 * @param mixed $url URL.
	 * @return string
	 */
	private function sanitize_grapesjs_image_url( $url ) {
		$url = esc_url_raw( (string) $url, array( 'http', 'https' ) );
		if ( '' === $url || preg_match( '/^(javascript|data):/i', $url ) ) {
			return '';
		}

		$path = strtolower( (string) wp_parse_url( $url, PHP_URL_PATH ) );
		if ( preg_match( '/\.(svg|svgz)$/i', $path ) || ! preg_match( '/\.(jpe?g|png|gif|webp|avif)$/i', $path ) ) {
			return '';
		}

		return $url;
	}

	/**
	 * Get sanitized Design Studio layout.
	 *
	 * @param array<string,mixed> $form Form row.
	 * @return array<string,mixed>
	 */
	private function design_layout( array $form ) {
		$settings = $this->raw_settings( $form );
		$layout   = is_array( $settings['design_studio_layout'] ?? null ) ? $settings['design_studio_layout'] : array();
		if ( empty( $layout ) && is_array( $settings['design_studio']['layout'] ?? null ) ) {
			$layout = $settings['design_studio']['layout'];
		}

		return $this->sanitize_layout_node( $layout, 'container' );
	}

	/**
	 * Sanitize one layout node.
	 *
	 * @param array<string,mixed> $node Layout node.
	 * @param string              $fallback_type Fallback type.
	 * @return array<string,mixed>
	 */
	private function sanitize_layout_node( array $node, $fallback_type = 'container' ) {
		$type = $this->sanitize_choice( $node['type'] ?? '', array( 'container', 'row', 'column', 'field', 'block' ), $fallback_type );
		$id   = sanitize_key( $node['id'] ?? '' );
		if ( '' === $id ) {
			$id = $type . '_' . substr( md5( wp_json_encode( $node ) ), 0, 10 );
		}

		$clean = array(
			'id'       => $id,
			'type'     => $type,
			'width'    => $this->sanitize_width_preset( $node['width'] ?? 100 ),
			'settings' => array(),
			'children' => array(),
		);

		if ( in_array( $type, array( 'field', 'block' ), true ) ) {
			$clean['ref_id'] = 'field' === $type ? absint( $node['ref_id'] ?? 0 ) : sanitize_key( $node['ref_id'] ?? '' );
			return $clean;
		}

		$allowed_child = 'container' === $type ? 'row' : ( 'row' === $type ? 'column' : '' );
		if ( 'column' === $type ) {
			$allowed_child = '';
		}
		$children = is_array( $node['children'] ?? null ) ? $node['children'] : array();
		foreach ( $children as $child ) {
			if ( ! is_array( $child ) ) {
				continue;
			}
			$child_type = sanitize_key( $child['type'] ?? '' );
			if ( 'column' === $type ) {
				if ( ! in_array( $child_type, array( 'field', 'block' ), true ) ) {
					continue;
				}
			} elseif ( $child_type !== $allowed_child ) {
				continue;
			}
			$clean['children'][] = $this->sanitize_layout_node( $child, $child_type );
		}

		return $clean;
	}

	/**
	 * Sanitize safe percentage width presets.
	 *
	 * @param mixed $width Width.
	 * @return int
	 */
	private function sanitize_width_preset( $width ) {
		$width = absint( $width );
		return in_array( $width, array( 25, 33, 50, 66, 75, 100 ), true ) ? $width : 100;
	}

	/**
	 * Sanitize one design block.
	 *
	 * @param array<string,mixed> $block Block.
	 * @return array<string,mixed>
	 */
	private function sanitize_design_block( array $block ) {
		$type = $this->sanitize_choice( $block['type'] ?? '', $this->design_block_types(), 'text' );
		$style_allowed = 'divider' === $type ? array( 'line', 'space' ) : ( 'notice' === $type ? array( 'neutral', 'info', 'success', 'warning' ) : array( 'neutral' ) );

		return array(
			'id'           => sanitize_key( $block['id'] ?? '' ),
			'type'         => $type,
			'title'        => sanitize_text_field( $block['title'] ?? '' ),
			'content'      => 'html' === $type ? $this->sanitize_custom_html( $block['content'] ?? '' ) : sanitize_textarea_field( $block['content'] ?? '' ),
			'position'     => $this->sanitize_choice( $block['position'] ?? '', $this->design_block_positions(), 'before_form' ),
			'style'        => $this->sanitize_choice( $block['style'] ?? '', $style_allowed, 'divider' === $type ? 'line' : 'neutral' ),
			'custom_class' => 'html' === $type ? $this->sanitize_class_list( $block['custom_class'] ?? '' ) : '',
			'sort_order'   => absint( $block['sort_order'] ?? 0 ),
			'enabled'      => ! array_key_exists( 'enabled', $block ) || (bool) $block['enabled'],
		);
	}

	/**
	 * Render a safe layout tree.
	 *
	 * @param int                            $form_id Form id.
	 * @param array<string,mixed>            $layout Layout tree.
	 * @param array<int,array<string,mixed>> $fields Fields.
	 * @param array<int,array<string,mixed>> $blocks Blocks.
	 * @param array<string,mixed>            $state Render state.
	 * @param bool                           $is_design_studio Design Studio mode.
	 * @return void
	 */
	private function render_design_layout( $form_id, array $layout, array $fields, array $blocks, array $state, $is_design_studio ) {
		$field_map = array();
		foreach ( $fields as $field ) {
			$field_id = absint( $field['id'] ?? 0 );
			if ( $field_id > 0 ) {
				$field_map[ $field_id ] = $field;
			}
		}

		$block_map = array();
		foreach ( $blocks as $block ) {
			$block_id = sanitize_key( $block['id'] ?? '' );
			if ( '' !== $block_id ) {
				$block_map[ $block_id ] = $block;
			}
		}

		$this->render_layout_node( $form_id, $layout, $field_map, $block_map, $state, $is_design_studio );
		$placed_field_ids = $this->layout_field_ids( $layout );
		foreach ( $fields as $field ) {
			$field_id = absint( $field['id'] ?? 0 );
			if ( $field_id <= 0 || in_array( $field_id, $placed_field_ids, true ) ) {
				continue;
			}
			$is_design_studio ? $this->render_design_studio_field( $form_id, $field, $state ) : $this->render_field( $form_id, $field, $state );
		}
	}

	/**
	 * Collect field ids used in a layout tree.
	 *
	 * @param array<string,mixed> $node Layout node.
	 * @return array<int,int>
	 */
	private function layout_field_ids( array $node ) {
		if ( 'field' === ( $node['type'] ?? '' ) ) {
			$field_id = absint( $node['ref_id'] ?? 0 );
			return $field_id > 0 ? array( $field_id ) : array();
		}

		$ids = array();
		foreach ( (array) ( $node['children'] ?? array() ) as $child ) {
			if ( is_array( $child ) ) {
				$ids = array_merge( $ids, $this->layout_field_ids( $child ) );
			}
		}

		return array_values( array_unique( $ids ) );
	}

	/**
	 * Render one layout node.
	 *
	 * @param int                                      $form_id Form id.
	 * @param array<string,mixed>                      $node Layout node.
	 * @param array<int,array<string,mixed>>           $field_map Field map.
	 * @param array<string,array<string,mixed>>        $block_map Block map.
	 * @param array<string,mixed>                      $state Render state.
	 * @param bool                                     $is_design_studio Design Studio mode.
	 * @return void
	 */
	private function render_layout_node( $form_id, array $node, array $field_map, array $block_map, array $state, $is_design_studio ) {
		$type = $this->sanitize_choice( $node['type'] ?? '', array( 'container', 'row', 'column', 'field', 'block' ), 'container' );
		$id   = sanitize_key( $node['id'] ?? '' );

		if ( 'field' === $type ) {
			$field_id = absint( $node['ref_id'] ?? 0 );
			if ( isset( $field_map[ $field_id ] ) ) {
				echo '<div class="' . esc_attr( 'subin-layout-item subin-layout-width-' . $this->sanitize_width_preset( $node['width'] ?? 100 ) ) . '">';
				$is_design_studio ? $this->render_design_studio_field( $form_id, $field_map[ $field_id ], $state ) : $this->render_field( $form_id, $field_map[ $field_id ], $state );
				echo '</div>';
			}
			return;
		}

		if ( 'block' === $type ) {
			$block_id = sanitize_key( $node['ref_id'] ?? '' );
			if ( isset( $block_map[ $block_id ] ) ) {
				echo '<div class="' . esc_attr( 'subin-layout-item subin-layout-width-' . $this->sanitize_width_preset( $node['width'] ?? 100 ) ) . '">';
				$this->render_design_block( $block_map[ $block_id ] );
				echo '</div>';
			}
			return;
		}

		$classes = array( 'subin-layout-' . $type );
		if ( 'column' === $type ) {
			$classes[] = 'subin-layout-width-' . $this->sanitize_width_preset( $node['width'] ?? 100 );
		}

		echo '<div class="' . esc_attr( implode( ' ', $classes ) ) . '" data-subin-layout-id="' . esc_attr( $id ) . '" data-subin-part="' . esc_attr( 'layout_' . $type ) . '" data-subin-label="' . esc_attr( ucfirst( $type ) ) . '">';
		foreach ( (array) ( $node['children'] ?? array() ) as $child ) {
			if ( is_array( $child ) ) {
				$this->render_layout_node( $form_id, $child, $field_map, $block_map, $state, $is_design_studio );
			}
		}
		echo '</div>';
	}

	/**
	 * Sanitize Custom CSS before output.
	 *
	 * @param mixed $css CSS.
	 * @param int   $form_id Form ID.
	 * @return string
	 */
	private function sanitize_scoped_custom_css( $css, $form_id ) {
		$css     = trim( str_replace( "\r\n", "\n", (string) $css ) );
		$form_id = absint( $form_id );

		if ( '' === $css || $form_id <= 0 ) {
			return '';
		}

		if ( substr_count( $css, '{' ) !== substr_count( $css, '}' ) ) {
			return '';
		}

		if ( preg_match( '/(<|>|@|url\s*\(|expression\s*\(|javascript\s*:|data\s*:|behavior\s*:|-moz-binding|<\/?style|<\/?script)/i', $css ) ) {
			return '';
		}

		$scope = '.subin-form-' . $form_id;
		if ( false === strpos( $css, $scope ) ) {
			return '';
		}

		if ( ! preg_match_all( '/([^{}]+)\{([^{}]*)\}/', $css, $matches, PREG_SET_ORDER ) ) {
			return '';
		}

		$covered = '';
		foreach ( $matches as $match ) {
			$selector     = trim( $match[1] );
			$declarations = trim( $match[2] );
			$covered     .= $match[0];

			if ( '' === $selector || '' === $declarations || false === strpos( $selector, $scope ) ) {
				return '';
			}
		}

		if ( preg_replace( '/\s+/', '', $covered ) !== preg_replace( '/\s+/', '', $css ) ) {
			return '';
		}

		return $css;
	}

	/**
	 * Render a sanitized custom HTML block.
	 *
	 * @param string $html HTML.
	 * @param string $position Position key.
	 * @return void
	 */
	private function render_custom_html_block( $html, $position ) {
		if ( '' === trim( $html ) ) {
			return;
		}

		printf(
			'<div class="subin-custom-html subin-custom-html--%1$s">%2$s</div>',
			esc_attr( sanitize_html_class( $position ) ),
			wp_kses( $html, $this->custom_html_allowed_tags(), array( 'http', 'https', 'mailto', 'tel' ) )
		);
	}

	/**
	 * Render design blocks for a position.
	 *
	 * @param array<int,array<string,mixed>> $blocks Blocks.
	 * @param string                         $position Position.
	 * @return void
	 */
	private function render_design_blocks( array $blocks, $position ) {
		$position = $this->sanitize_choice( $position, $this->design_block_positions(), 'before_form' );
		foreach ( $blocks as $block ) {
			if ( empty( $block['enabled'] ) || $position !== ( $block['position'] ?? '' ) ) {
				continue;
			}
			$this->render_design_block( $block );
		}
	}

	/**
	 * Render one design block.
	 *
	 * @param array<string,mixed> $block Block.
	 * @return void
	 */
	private function render_design_block( array $block ) {
		$type    = $this->sanitize_choice( $block['type'] ?? '', $this->design_block_types(), 'text' );
		$style   = 'divider' === $type ? $this->sanitize_choice( $block['style'] ?? '', array( 'line', 'space' ), 'line' ) : $this->sanitize_choice( $block['style'] ?? '', $this->design_block_styles(), 'neutral' );
		$classes = array(
			'subin-design-block',
			'subin-design-block--' . sanitize_html_class( $type ),
			'subin-design-block--' . sanitize_html_class( $style ),
		);

		if ( ! empty( $block['custom_class'] ) ) {
			$classes = array_merge( $classes, explode( ' ', $this->sanitize_class_list( $block['custom_class'] ) ) );
		}

		if ( 'divider' === $type ) {
			echo '<div class="' . esc_attr( implode( ' ', array_filter( $classes ) ) ) . '"';
			$this->design_block_attrs( $block, $type );
			echo '>';
			if ( 'line' === $style ) {
				echo '<hr />';
			}
			echo '</div>';
			return;
		}

		echo '<div class="' . esc_attr( implode( ' ', array_filter( $classes ) ) ) . '"';
		$this->design_block_attrs( $block, $type );
		echo '>';
		if ( '' !== (string) ( $block['title'] ?? '' ) ) {
			echo '<div class="subin-design-block__title">' . esc_html( $block['title'] ) . '</div>';
		}
		if ( 'html' === $type ) {
			echo wp_kses( (string) ( $block['content'] ?? '' ), $this->custom_html_allowed_tags(), array( 'http', 'https', 'mailto', 'tel' ) );
		} else {
			echo wpautop( esc_html( (string) ( $block['content'] ?? '' ) ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Text is escaped before paragraph formatting.
		}
		echo '</div>';
	}

	/**
	 * Print Design Studio block attributes.
	 *
	 * @param array<string,mixed> $block Block.
	 * @param string              $type Block type.
	 * @return void
	 */
	private function design_block_attrs( array $block, $type ) {
		$type_labels = array(
			'text'    => __( 'Text Block', 'submission-inbox' ),
			'html'    => __( 'HTML Block', 'submission-inbox' ),
			'notice'  => __( 'Notice Block', 'submission-inbox' ),
			'divider' => __( 'Divider Block', 'submission-inbox' ),
			'privacy' => __( 'Privacy Note', 'submission-inbox' ),
		);
		$label = $type_labels[ $type ] ?? __( 'Design Block', 'submission-inbox' );
		if ( '' !== (string) ( $block['title'] ?? '' ) ) {
			$label .= ': ' . sanitize_text_field( $block['title'] );
		}

		printf(
			' data-subin-part="design_block" data-subin-label="%1$s" data-subin-block-id="%2$s" data-subin-block-type="%3$s" data-subin-block-position="%4$s"',
			esc_attr( $label ),
			esc_attr( sanitize_key( $block['id'] ?? '' ) ),
			esc_attr( sanitize_key( $type ) ),
			esc_attr( sanitize_key( $block['position'] ?? '' ) )
		);
	}

	/**
	 * Sanitize a custom HTML block.
	 *
	 * @param mixed $html HTML.
	 * @return string
	 */
	private function sanitize_custom_html( $html ) {
		return wp_kses( (string) $html, $this->custom_html_allowed_tags(), array( 'http', 'https', 'mailto', 'tel' ) );
	}

	/**
	 * Sanitize a space-separated class list.
	 *
	 * @param mixed $classes Classes.
	 * @return string
	 */
	private function sanitize_class_list( $classes ) {
		$clean = array();
		foreach ( preg_split( '/\s+/', (string) $classes ) as $class ) {
			$class = sanitize_html_class( $class );
			if ( '' !== $class ) {
				$clean[] = $class;
			}
		}

		return implode( ' ', array_unique( $clean ) );
	}

	/**
	 * Get allowed tags for custom HTML blocks.
	 *
	 * @return array<string,array<string,bool>>
	 */
	private function custom_html_allowed_tags() {
		$global = array(
			'class'      => true,
			'aria-label' => true,
		);

		return array(
			'p'      => $global,
			'br'     => array(),
			'strong' => $global,
			'em'     => $global,
			'b'      => $global,
			'i'      => $global,
			'small'  => $global,
			'ul'     => $global,
			'ol'     => $global,
			'li'     => $global,
			'span'   => $global,
			'div'    => $global,
			'a'      => array(
				'href'       => true,
				'title'      => true,
				'target'     => true,
				'rel'        => true,
				'class'      => true,
				'aria-label' => true,
			),
			'hr'     => $global,
		);
	}

	/**
	 * Allowed design block types.
	 *
	 * @return array<int,string>
	 */
	private function design_block_types() {
		return array( 'text', 'html', 'notice', 'divider', 'privacy' );
	}

	/**
	 * Allowed design block positions.
	 *
	 * @return array<int,string>
	 */
	private function design_block_positions() {
		return array( 'before_form', 'after_header', 'before_fields', 'after_fields', 'before_submit', 'after_submit', 'before_footer', 'after_form' );
	}

	/**
	 * Allowed design block styles.
	 *
	 * @return array<int,string>
	 */
	private function design_block_styles() {
		return array( 'info', 'success', 'warning', 'neutral', 'line', 'space' );
	}

	/**
	 * Sanitize an allowlisted choice.
	 *
	 * @param mixed             $value Value.
	 * @param array<int,string> $allowed Allowed.
	 * @param string            $fallback Fallback.
	 * @return string
	 */
	private function sanitize_choice( $value, array $allowed, $fallback ) {
		$value = sanitize_key( (string) $value );
		return in_array( $value, $allowed, true ) ? $value : $fallback;
	}

	/**
	 * Sanitize a hex color value.
	 *
	 * @param mixed $value Value.
	 * @return string
	 */
	private function sanitize_hex_color_value( $value ) {
		$value = trim( (string) $value );
		return preg_match( '/^#[0-9a-f]{6}$/i', $value ) ? $value : '';
	}

	/**
	 * Sanitize a numeric pixel value.
	 *
	 * @param mixed $value Value.
	 * @return string
	 */
	private function pixel_value( $value ) {
		$value = trim( (string) $value );
		if ( ! preg_match( '/^\d{1,3}$/', $value ) ) {
			return '';
		}

		return min( 999, absint( $value ) ) . 'px';
	}

	/**
	 * Sanitize a unitless decimal value.
	 *
	 * @param mixed $value Value.
	 * @return string
	 */
	private function decimal_value( $value ) {
		$value = trim( (string) $value );
		if ( ! preg_match( '/^\d+(?:\.\d{1,2})?$/', $value ) ) {
			return '';
		}

		return (string) min( 10, max( 0, (float) $value ) );
	}

	/**
	 * Sanitize a CSS box shorthand.
	 *
	 * @param mixed $value Value.
	 * @return string
	 */
	private function box_value( $value ) {
		$parts = preg_split( '/\s+/', trim( (string) $value ) );
		$parts = is_array( $parts ) ? array_slice( $parts, 0, 4 ) : array();
		if ( empty( $parts ) ) {
			return '';
		}

		foreach ( $parts as $index => $part ) {
			if ( ! preg_match( '/^\d+(?:\.\d+)?(?:px|rem|em|%)?$/', $part ) ) {
				return '';
			}
			if ( preg_match( '/^\d/', $part ) && ! preg_match( '/(?:px|rem|em|%)$/', $part ) ) {
				$parts[ $index ] = $part . 'px';
			}
		}

		return implode( ' ', $parts );
	}

	/**
	 * Sanitize a CSS value by exact string allowlist.
	 *
	 * @param mixed             $value Value.
	 * @param array<int,string> $allowed Allowed values.
	 * @param string            $fallback Fallback.
	 * @return string
	 */
	private function sanitize_exact_css_choice( $value, array $allowed, $fallback ) {
		$value = trim( (string) $value );
		return in_array( $value, $allowed, true ) ? $value : $fallback;
	}
}
