<?php
/**
 * Shortcode Renderer - Universal shortcode building and rendering utilities.
 *
 * This class centralizes shortcode generation and rendering for all page builder
 * integrations (Gutenberg, Divi, Elementor, Beaver Builder) to ensure consistency.
 *
 * @package GravityKit\GravityView\Shortcodes
 * @since TODO
 */

namespace GravityKit\GravityView\Shortcode;

use GravityKit\GravityView\Foundation\Helpers\Arr;
use GV\View;
use GV\View_Settings;

/** If this file is called directly, abort. */
if ( ! defined( 'GRAVITYVIEW_DIR' ) ) {
	die();
}

/**
 * Universal shortcode building and rendering utilities.
 *
 * Provides two approaches to shortcode building:
 * - Simple: `build_from_block_atts()` - For basic page builders with limited settings
 * - Complete: `build_from_view_settings()` - For full-featured integrations reading from View_Settings
 *
 * @since TODO
 */
class ShortcodeRenderer {

	/**
	 * Scripts and styles to ignore when filtering assets.
	 *
	 * These patterns identify assets from other plugins that may cause conflicts
	 * when rendering Views in page builder contexts.
	 *
	 * @since TODO
	 *
	 * @var array
	 */
	const IGNORE_SCRIPTS_AND_STYLES = [ 'jetpack', 'elementor', 'yoast' ];

	/**
	 * Default allowlist patterns for GravityView assets.
	 *
	 * These patterns identify GravityView core and extension stylesheets/scripts
	 * by their registered handle names.
	 *
	 * @since TODO
	 *
	 * @var array
	 */
	const ALLOWLIST_HANDLE_PATTERNS = [
		'gravityview',
		'gv-',
		'gv_',
		'gk-',
		'gk_',
	];

	/**
	 * Script handles to skip in builder previews.
	 *
	 * @since TODO
	 *
	 * @var array
	 */
	const BUILDER_IGNORED_SCRIPT_HANDLES = [
		'jquery',
		'jquery-core',
		'jquery-migrate',
	];

	/**
	 * Map of block attribute names (camelCase) to shortcode attribute names (snake_case).
	 *
	 * @since TODO
	 *
	 * @var array
	 */
	const BLOCK_TO_SHORTCODE_MAP = [
		'viewId'         => 'id',
		'postId'         => 'post_id',
		'secret'         => 'secret',
		'pageSize'       => 'page_size',
		'sortField'      => 'sort_field',
		'sortDirection'  => 'sort_direction',
		'searchField'    => 'search_field',
		'searchValue'    => 'search_value',
		'searchOperator' => 'search_operator',
		'startDate'      => 'start_date',
		'endDate'        => 'end_date',
		'classValue'     => 'class',
		'offset'         => 'offset',
		'singleTitle'    => 'single_title',
		'backLinkLabel'  => 'back_link_label',
	];

	/**
	 * Renders a placeholder message for page builder editor contexts.
	 *
	 * @since TODO
	 *
	 * @param string $message The message to display.
	 *
	 * @return string HTML placeholder div.
	 */
	public static function render_placeholder( $message ) {
		return sprintf(
			'<div style="text-align:center; padding:20px; border:1px dashed #ccc; background:#f9f9f9;">%s</div>',
			esc_html( $message )
		);
	}

	/**
	 * Shared inline style for preview-fallback notices.
	 *
	 * @since TODO
	 *
	 * @var string
	 */
	const PREVIEW_NOTICE_STYLE = 'text-align:center; padding:20px; color:#666; font-style:italic;';

	/**
	 * Replace layouts that can't render in builder previews with a notice.
	 *
	 * Each fallback handler receives the full rendered array (content + scripts
	 * + styles) so it can swap the markup AND strip the layout's init scripts.
	 * Leaving those scripts in the payload would let the asset loader run them
	 * against the stubbed markup — e.g. DataTables retrying init against an
	 * empty table, causing duplicate-initialization attempts.
	 *
	 * Mirrors `src/PageBuilder/shared/js/preview-fallbacks.js` for builders that
	 * don't run the shared JS module on rendered output (e.g. Beaver Builder,
	 * which injects module HTML directly into the live DOM rather than through
	 * a React preview layer).
	 *
	 * @since TODO
	 *
	 * @param array $rendered Render result: `content`, `scripts`, `styles`.
	 * @param array $options  Optional overrides:
	 *                        - `datatables_notice` string
	 *                        - `maps_notice`       string
	 *
	 * @return array Modified render result.
	 */
	public static function apply_preview_fallbacks( array $rendered, array $options = [] ) {
		$html = $rendered['content'] ?? '';

		if ( empty( $html ) ) {
			return $rendered;
		}

		if ( false !== strpos( $html, 'gv-datatables' ) ) {
			$rendered = self::apply_datatables_fallback( $rendered, $options['datatables_notice'] ?? '' );
		}

		if ( false !== strpos( $html, 'gv-map-container' ) ) {
			$rendered = self::apply_maps_fallback( $rendered, $options['maps_notice'] ?? '' );
		}

		return $rendered;
	}

	/**
	 * Replace `<table class="gv-datatables">` bodies with a preview notice row
	 * and strip the layout's init scripts from the rendered assets.
	 *
	 * @since TODO
	 *
	 * @param array  $rendered Render result.
	 * @param string $notice   Optional override notice text.
	 *
	 * @return array Modified render result.
	 */
	private static function apply_datatables_fallback( array $rendered, $notice = '' ) {
		if ( '' === $notice ) {
			$notice = __( 'Entries from the DataTables layout are not available in this preview.', 'gk-gravityview' );
		}

		$rendered['content'] = preg_replace_callback(
			'#(<table[^>]*\bclass="[^"]*\bgv-datatables\b[^"]*"[^>]*>)(.*?)(</table>)#is',
			function ( $matches ) use ( $notice ) {
				preg_match_all( '#<th[\s>]#i', $matches[2], $th_matches );
				$colspan = max( count( $th_matches[0] ), 1 );

				$thead = '';
				if ( preg_match( '#<thead\b.*?</thead>#is', $matches[2], $thead_match ) ) {
					$thead = $thead_match[0];
				}

				$tbody = sprintf(
					'<tbody><tr><td colspan="%d" style="%s">%s</td></tr></tbody>',
					$colspan,
					esc_attr( self::PREVIEW_NOTICE_STYLE ),
					esc_html( $notice )
				);

				return $matches[1] . $thead . $tbody . $matches[3];
			},
			$rendered['content']
		);

		$rendered['scripts'] = self::filter_script_assets(
			$rendered['scripts'] ?? [],
			[ 'gv-datatables', 'gv-dt-', 'datatables-views', 'dataTables' ],
			[ 'DataTable(', 'dataTables_' ]
		);

		// Builders like Beaver Builder render into the live DOM, so anything
		// enqueued during `do_shortcode()` still gets printed by `wp_footer`
		// — running DataTables init against our stubbed <table> regardless of
		// what we put in the preview asset payload. Dequeue the actual handles
		// so WP doesn't print the `<script>` tags in the first place.
		self::dequeue_assets_by_src( [ 'gv-datatables', 'gv-dt-', 'datatables-views', 'datatables.css' ] );

		return $rendered;
	}

	/**
	 * Replace `.gv-map-canvas` contents with a preview notice and strip the
	 * layout's init scripts from the rendered assets.
	 *
	 * @since TODO
	 *
	 * @param array  $rendered Render result.
	 * @param string $notice   Optional override notice text.
	 *
	 * @return array Modified render result.
	 */
	private static function apply_maps_fallback( array $rendered, $notice = '' ) {
		if ( '' === $notice ) {
			$notice = __( 'Map is not available in this preview.', 'gk-gravityview' );
		}

		$rendered['content'] = preg_replace_callback(
			'#(<div[^>]*\bclass="[^"]*\bgv-map-canvas\b[^"]*"[^>]*>)(.*?)(</div>)#is',
			function ( $matches ) use ( $notice ) {
				return $matches[1] . sprintf(
					'<p style="%s">%s</p>',
					esc_attr( self::PREVIEW_NOTICE_STYLE ),
					esc_html( $notice )
				) . $matches[3];
			},
			$rendered['content']
		);

		$rendered['scripts'] = self::filter_script_assets(
			$rendered['scripts'] ?? [],
			[ 'gv-maps', 'gravityview-maps', 'google-maps' ],
			[ 'new google.maps.', 'gvMapsInit' ]
		);

		self::dequeue_assets_by_src( [ 'gv-maps', 'gravityview-maps', 'google-maps', 'maps.googleapis.com' ] );

		return $rendered;
	}

	/**
	 * Dequeue any enqueued scripts/styles whose `src` matches one of the needles,
	 * so wp_footer/wp_head don't print `<script>`/`<link>` tags that would
	 * re-initialize layouts we've already replaced with a preview stub.
	 *
	 * @since TODO
	 *
	 * @param string[] $src_needles Substrings to match against registered script/style URLs.
	 *
	 * @return void
	 */
	private static function dequeue_assets_by_src( array $src_needles ) {
		$matches = static function ( $src, array $needles ) {
			if ( ! is_string( $src ) || '' === $src ) {
				return false;
			}
			foreach ( $needles as $needle ) {
				if ( '' !== $needle && false !== strpos( $src, $needle ) ) {
					return true;
				}
			}
			return false;
		};

		global $wp_scripts, $wp_styles;

		foreach ( [ $wp_scripts, $wp_styles ] as $source ) {
			if ( ! $source || empty( $source->registered ) ) {
				continue;
			}
			foreach ( $source->registered as $handle => $dep ) {
				if ( $matches( $dep->src ?? '', $src_needles ) ) {
					if ( $source === $wp_scripts ) {
						wp_dequeue_script( $handle );
					} else {
						wp_dequeue_style( $handle );
					}
				}
			}
		}
	}

	/**
	 * Drop entries from the collected scripts array whose `src` matches any
	 * of `$src_needles` or whose inline `data`/`before`/`after` content
	 * matches any of `$inline_needles`.
	 *
	 * @since TODO
	 *
	 * @param array    $scripts        Script entries as produced by render().
	 * @param string[] $src_needles    Substrings that identify the library by URL.
	 * @param string[] $inline_needles Substrings that identify the init snippet.
	 *
	 * @return array Filtered script entries.
	 */
	private static function filter_script_assets( array $scripts, array $src_needles, array $inline_needles ) {
		$matches = static function ( $haystack, array $needles ) {
			if ( ! is_string( $haystack ) || '' === $haystack ) {
				return false;
			}
			foreach ( $needles as $needle ) {
				if ( '' !== $needle && false !== strpos( $haystack, $needle ) ) {
					return true;
				}
			}
			return false;
		};

		return array_values( array_filter(
			$scripts,
			static function ( $entry ) use ( $matches, $src_needles, $inline_needles ) {
				if ( is_string( $entry ) ) {
					return ! $matches( $entry, $src_needles );
				}

				if ( is_array( $entry ) ) {
					if ( $matches( $entry['src'] ?? '', $src_needles ) ) {
						return false;
					}
					foreach ( [ 'data', 'before', 'after' ] as $key ) {
						if ( $matches( $entry[ $key ] ?? '', $inline_needles ) ) {
							return false;
						}
					}
				}

				return true;
			}
		) );
	}

	/**
	 * Render a JavaScript asset loader for builder/editor previews.
	 *
	 * @since TODO
	 *
	 * @param array $assets Rendered assets array with scripts/styles.
	 *
	 * @return string Script tag markup or empty string.
	 */
	public static function render_asset_loader( $assets ) {
		$styles  = isset( $assets['styles'] ) ? array_values( $assets['styles'] ) : [];
		$scripts = isset( $assets['scripts'] ) ? array_values( $assets['scripts'] ) : [];

		if ( empty( $styles ) && empty( $scripts ) ) {
			return '';
		}

		$payload = wp_json_encode( [
			'styles'  => $styles,
			'scripts' => $scripts,
		] );

		if ( false === $payload ) {
			return '';
		}

		$asset_path = GRAVITYVIEW_DIR . 'src/PageBuilder/assets/gv-builder-asset-loader.js';

		wp_enqueue_script(
			'gk-gravityview-builder-assets',
			plugins_url( 'src/PageBuilder/assets/gv-builder-asset-loader.js', GRAVITYVIEW_FILE ),
			[],
			is_readable( $asset_path ) ? filemtime( $asset_path ) : GV_PLUGIN_VERSION,
			true
		);

		return sprintf(
			'<div class="gk-gravityview-asset-payload" data-gv-assets="%s"></div>',
			esc_attr( $payload )
		);
	}

	/**
	 * Formats an attributes array into a shortcode attributes string.
	 *
	 * Handles both scalar and array values. Array values are formatted with
	 * numbered suffixes (e.g., sort_direction, sort_direction_2, sort_direction_3).
	 *
	 * @since TODO
	 *
	 * @param array $atts Attributes to format (key => value pairs).
	 *
	 * @return string Formatted attributes string (e.g., 'key1="value1" key2="value2"').
	 */
	public static function format_atts_string( $atts ) {
		$parts = [];

		foreach ( $atts as $key => $value ) {
			if ( is_array( $value ) ) {
				// Handle array values (e.g., multi-sort: sort_direction, sort_direction_2).
				foreach ( $value as $index => $item ) {
					$suffix  = 0 === $index ? '' : '_' . ( $index + 1 );
					$escaped = esc_attr( self::strip_shortcode_delimiters( $item ) );
					$parts[] = sprintf( '%s%s="%s"', $key, $suffix, $escaped );
				}
			} else {
				$escaped = esc_attr( self::strip_shortcode_delimiters( $value ) );
				$parts[] = sprintf( '%s="%s"', $key, $escaped );
			}
		}

		return implode( ' ', $parts );
	}

	/**
	 * Strips square-bracket shortcode delimiters from a value bound for a
	 * shortcode-attribute string.
	 *
	 * esc_attr(), wp_strip_all_tags(), and wp_kses_post() all leave `[` and `]`
	 * intact, so a builder attribute carrying them can prematurely close the
	 * shortcode and smuggle a second one into do_shortcode(). Brackets are not
	 * valid inside a shortcode attribute value, so removing them is lossless for
	 * legitimate input while closing the injection. Applied on top of (not in
	 * place of) the existing esc_attr()/sanitisation.
	 *
	 * @since TBD
	 *
	 * @param mixed $value Attribute value (cast to string).
	 *
	 * @return string The value with `[` and `]` removed.
	 */
	private static function strip_shortcode_delimiters( $value ) {
		return str_replace( [ '[', ']' ], '', (string) $value );
	}

	/**
	 * Converts block attributes array to shortcode attributes array.
	 *
	 * This is useful for page builders that use camelCase attribute names
	 * (like Gutenberg blocks) and need to convert to snake_case shortcode attributes.
	 *
	 * @since TODO
	 *
	 * @param array $block_attributes Block attributes array (camelCase keys).
	 *
	 * @return array Shortcode attributes array (snake_case keys).
	 */
	public static function map_block_atts_to_shortcode_atts( $block_attributes = [] ) {
		$shortcode_attributes = [];

		// Remove searchOperator if searchValue is empty.
		if ( isset( $block_attributes['searchOperator'] ) && ( ! isset( $block_attributes['searchValue'] ) || '' === trim( (string) $block_attributes['searchValue'] ) ) ) {
			unset( $block_attributes['searchOperator'] );
		}

		foreach ( $block_attributes as $attribute => $value ) {
			if ( ! isset( self::BLOCK_TO_SHORTCODE_MAP[ $attribute ] ) ) {
				continue;
			}

			if ( '' === $value ) {
				continue;
			}

			$shortcode_attributes[ self::BLOCK_TO_SHORTCODE_MAP[ $attribute ] ] = $value;
		}

		return $shortcode_attributes;
	}

	/**
	 * Build a [gravityview] shortcode string from block-style attributes.
	 *
	 * This is the SIMPLE approach for page builders with limited UI controls.
	 * For full-featured integrations, use `build_from_view_settings()` instead.
	 *
	 * @since TODO
	 *
	 * @param array       $block_atts Block-style attributes (camelCase: viewId, pageSize, etc.).
	 * @param string|null $secret     Optional. View validation secret. If provided, added to shortcode.
	 *
	 * @return string The formatted [gravityview ...] shortcode string.
	 */
	public static function build_from_block_atts( $block_atts, $secret = null ) {
		$shortcode_atts = self::map_block_atts_to_shortcode_atts( $block_atts );

		if ( $secret ) {
			$shortcode_atts['secret'] = $secret;
		}

		return sprintf( '[gravityview %s]', self::format_atts_string( $shortcode_atts ) );
	}

	/**
	 * Build a [gravityview] shortcode string from View settings.
	 *
	 * This is the COMPLETE approach that reads from View_Settings::defaults()
	 * and includes ALL settings with `show_in_shortcode=true`. This approach
	 * is used by Advanced Elementor Widget and other full-featured integrations.
	 *
	 * Supports array values for multi-sort (e.g., sort_direction[0], sort_direction_2).
	 *
	 * @since TODO
	 *
	 * @param array  $settings Widget/module settings array (snake_case keys matching View_Settings).
	 * @param View   $view     View object.
	 * @param string $secret   Optional. View validation secret. Auto-retrieved from View if not provided.
	 *
	 * @return string The formatted [gravityview ...] shortcode string.
	 */
	public static function build_from_view_settings( $settings, $view, $secret = null ) {
		$atts = self::convert_settings_to_shortcode_atts( $settings );

		// Add secret if provided or get from view.
		$secret = $secret ?? $view->get_validation_secret();
		if ( $secret ) {
			$atts['secret'] = $secret;
		}

		// Add view ID.
		$atts['id'] = $view->ID;

		// Reverse to put `id` first, `secret` second for readability.
		$atts = array_reverse( $atts, true );

		return sprintf( '[gravityview %s]', self::format_atts_string( $atts ) );
	}

	/**
	 * Convert widget/module settings to shortcode attributes.
	 *
	 * Reads from View_Settings::defaults() and only includes settings
	 * where `show_in_shortcode=true` and value differs from default.
	 *
	 * @since TODO
	 *
	 * @param array $settings Widget/module settings.
	 *
	 * @return array Shortcode attributes.
	 */
	public static function convert_settings_to_shortcode_atts( $settings ) {
		$defaults = View_Settings::defaults( true );
		$atts     = [];

		foreach ( $defaults as $key => $view_setting ) {
			// Only render settings that are shown in the shortcode.
			if ( empty( $view_setting['show_in_shortcode'] ) ) {
				continue;
			}

			// Get the passed value, falling back to default if empty.
			$passed_value = ( ! isset( $settings[ $key ] ) || null === $settings[ $key ] || '' === $settings[ $key ] )
				? $view_setting['value']
				: $settings[ $key ];

			// Convert based on type.
			switch ( $view_setting['type'] ) {
				case 'number':
					$converted_value = (int) $passed_value;
					break;

				case 'checkbox':
					// Handle various truthy representations (Elementor uses 'yes').
					$converted_value = in_array( $passed_value, [ 1, '1', true, 'yes' ], true ) ? 1 : 0;
					break;

				default:
					$converted_value = $passed_value;
					break;
			}

			// Only add if different from default value (type-flexible comparison).
			// For checkbox fields, normalize both sides to int before comparing.
			$default_value = $view_setting['value'];
			if ( 'checkbox' === $view_setting['type'] ) {
				$default_value = in_array( $default_value, [ 1, '1', true, 'yes' ], true ) ? 1 : 0;
			}

			if ( is_array( $converted_value ) || is_array( $default_value ) ) {
				if ( wp_json_encode( $converted_value ) === wp_json_encode( $default_value ) ) {
					continue;
				}
			} elseif ( (string) $converted_value === (string) $default_value ) {
				continue;
			}

			$atts[ $key ] = $converted_value;
		}

		return $atts;
	}

	/**
	 * Filters asset handles based on patterns.
	 *
	 * This method filters WordPress script/style handles using pattern matching.
	 * It can operate in two modes:
	 * - 'blocklist': Remove handles matching any pattern (default for backward compatibility)
	 * - 'allowlist': Keep only handles matching at least one pattern
	 *
	 * Filtering happens on handles BEFORE dependency resolution, which prevents
	 * unrelated dependencies from being included in the final output.
	 *
	 * @since TODO
	 *
	 * @param array  $handles  Array of asset handles (slugs) to filter.
	 * @param array  $patterns Array of patterns to match against handles.
	 * @param string $mode     Filter mode: 'allowlist' or 'blocklist'. Default 'blocklist'.
	 *
	 * @return array Filtered array of handles.
	 */
	public static function filter_asset_handles( $handles, $patterns = [], $mode = 'blocklist' ) {
		if ( empty( $handles ) ) {
			return $handles;
		}

		if ( empty( $patterns ) ) {
			return 'allowlist' === $mode ? [] : $handles;
		}

		// Pass the delimiter '/' to preg_quote to properly escape it within patterns.
		$pattern_regex = '/(' . implode( '|', array_map( function( $p ) { return preg_quote( $p, '/' ); }, $patterns ) ) . ')/i';

		if ( 'allowlist' === $mode ) {
			// Keep only handles that match at least one pattern.
			return array_values( preg_grep( $pattern_regex, $handles ) );
		}

		// Blocklist mode: remove handles that match any pattern.
		return array_values( array_diff( $handles, preg_grep( $pattern_regex, $handles ) ) );
	}

	/**
	 * Renders shortcode and returns rendered content along with newly enqueued scripts and styles.
	 *
	 * This is the universal rendering method used by all page builder integrations.
	 *
	 * @since TODO
	 *
	 * @param string $shortcode The shortcode to render.
	 * @param array  $options   {
	 *     Optional. Configuration options.
	 *
	 *     @type array $allowed_style_patterns  Patterns to allowlist for styles. If provided, only
	 *                                          styles with handles matching these patterns are returned.
	 *     @type array $allowed_script_patterns Patterns to allowlist for scripts. If provided, only
	 *                                          scripts with handles matching these patterns are returned.
	 * }
	 *
	 * @return array{content: string, scripts: array, styles: array}
	 */
	public static function render( $shortcode, $options = [] ) {
		global $wp_scripts, $wp_styles;

		// Ensure WordPress script/style systems are initialized.
		if ( ! $wp_scripts instanceof \WP_Scripts ) {
			$wp_scripts = wp_scripts();
		}
		if ( ! $wp_styles instanceof \WP_Styles ) {
			$wp_styles = wp_styles();
		}

		$scripts_before_shortcode = $wp_scripts->queue;
		$styles_before_shortcode  = $wp_styles->queue;

		// Use output buffering to capture any stray output from the shortcode or enqueue hooks.
		// In REST block-renderer requests, plugins that echo resource hints during wp_enqueue_scripts
		// would otherwise leak HTML into the response body before the JSON payload.
		ob_start();
		$rendered_shortcode = do_shortcode( $shortcode );

		if ( 0 === did_action( 'wp_enqueue_scripts' ) ) {
			do_action( 'wp_enqueue_scripts' );
		}
		ob_end_clean();

		$gravityview_frontend = \GravityView_frontend::getInstance();
		$view_data            = \GravityView_View_Data::getInstance();
		$view_ids             = $view_data->maybe_get_view_id( $shortcode );

		// Save singleton state so we can restore it after rendering.
		$previous_output_data   = $gravityview_frontend->getGvOutputData();
		$previous_post_id       = $gravityview_frontend->getPostId();
		$previous_has_shortcode = $gravityview_frontend->isPostHasShortcode();

		try {
			$gravityview_frontend->setGvOutputData( $view_data );

			if ( empty( $gravityview_frontend->getPostId() ) ) {
				// First, check for a post_id in the shortcode attributes.
				$shortcode_atts = shortcode_parse_atts( $shortcode );
				$post_id        = 0;

				if ( ! empty( $shortcode_atts['post_id'] ) ) {
					$post_id = (int) $shortcode_atts['post_id'];
				} elseif ( ! empty( $shortcode_atts['postId'] ) ) {
					$post_id = (int) $shortcode_atts['postId'];
				}

				// Fall back to POST data, global $post, or View IDs.
				if ( ! $post_id ) {
					$post_id = \GV\Utils::_POST( 'post_id', 0 );
				}

				if ( ! $post_id ) {
					$post_id = \GV\Utils::_POST( 'postId', 0 );
				}

				if ( ! $post_id && isset( $GLOBALS['post'] ) && $GLOBALS['post'] instanceof \WP_Post ) {
					$post_id = $GLOBALS['post']->ID;
				}

				if ( ! $post_id && $view_ids ) {
					$post_id = is_array( $view_ids ) ? reset( $view_ids ) : $view_ids;
				}

				if ( $post_id ) {
					$gravityview_frontend->setPostId( (int) $post_id );
					$gravityview_frontend->setPostHasShortcode( true );
				}
			}

			$gravityview_frontend->add_scripts_and_styles();

			if ( wp_script_is( 'gravityview-fe-view', 'registered' ) ) {
				$views_data = $gravityview_frontend->getGvOutputData();
				$views      = $views_data ? $views_data->get_views() : [];

				$js_localization = [
					'cookiepath' => COOKIEPATH,
					'clear'      => _x( 'Clear', 'Clear all data from the form', 'gk-gravityview' ),
					'reset'      => _x( 'Reset', 'Reset the search form to the state that existed on page load', 'gk-gravityview' ),
				];

				/**
				 * Modify the gvGlobals data used by front-end scripts.
				 *
				 * @param array $js_localization The data passed to the Javascript file.
				 * @param array $views Array of View data arrays with View settings.
				 */
				$js_localization = apply_filters( 'gravityview_js_localization', $js_localization, $views );

				wp_localize_script( 'gravityview-fe-view', 'gvGlobals', $js_localization );
			}

			$scripts_after_shortcode = $wp_scripts->queue;
			$styles_after_shortcode  = $wp_styles->queue;

			$newly_enqueued_scripts = array_diff( $scripts_after_shortcode, $scripts_before_shortcode );
			$newly_enqueued_styles  = array_diff( $styles_after_shortcode, $styles_before_shortcode );

			// First, apply blocklist to remove scripts/styles that may cause conflicts.
			$newly_enqueued_scripts = self::filter_asset_handles(
				$newly_enqueued_scripts,
				self::IGNORE_SCRIPTS_AND_STYLES,
				'blocklist'
			);
			$newly_enqueued_styles = self::filter_asset_handles(
				$newly_enqueued_styles,
				self::IGNORE_SCRIPTS_AND_STYLES,
				'blocklist'
			);

			// Then, apply allowlist if patterns are provided to further filter assets.
			if ( ! empty( $options['allowed_script_patterns'] ) ) {
				$newly_enqueued_scripts = self::filter_asset_handles(
					$newly_enqueued_scripts,
					$options['allowed_script_patterns'],
					'allowlist'
				);
			}

			if ( ! empty( $options['allowed_style_patterns'] ) ) {
				$newly_enqueued_styles = self::filter_asset_handles(
					$newly_enqueued_styles,
					$options['allowed_style_patterns'],
					'allowlist'
				);
			}

			$ignored_script_handles = isset( $options['ignored_script_handles'] ) && is_array( $options['ignored_script_handles'] )
				? $options['ignored_script_handles']
				: [];

			// This will return an array of all dependencies sorted in the order they should be loaded.
			$visited          = [];
			$get_dependencies = function ( $handle, $source, $dependencies = [] ) use ( &$get_dependencies, $ignored_script_handles, &$visited ) {
				if ( in_array( $handle, $ignored_script_handles, true ) ) {
					return $dependencies;
				}

				if ( in_array( $handle, $visited, true ) ) {
					return $dependencies;
				}
				$visited[] = $handle;

				if ( empty( $source->registered[ $handle ] ) ) {
					return $dependencies;
				}

				// Check for inline content (scripts use 'data', styles use 'before'/'after').
				$has_inline_data = $source->registered[ $handle ]->extra && (
					! empty( $source->registered[ $handle ]->extra['data'] ) ||
					! empty( $source->registered[ $handle ]->extra['before'] ) ||
					! empty( $source->registered[ $handle ]->extra['after'] )
				);

				if ( $has_inline_data ) {
					$inline_content = [];

					// For scripts: use 'data' (localized scripts, inline scripts).
					if ( ! empty( $source->registered[ $handle ]->extra['data'] ) ) {
						$inline_content['data'] = $source->registered[ $handle ]->extra['data'];
					}

					// For styles: use 'before' and 'after' (wp_add_inline_style).
					if ( ! empty( $source->registered[ $handle ]->extra['before'] ) ) {
						$inline_content['before'] = is_array( $source->registered[ $handle ]->extra['before'] )
							? implode( "\n", $source->registered[ $handle ]->extra['before'] )
							: $source->registered[ $handle ]->extra['before'];
					}

					if ( ! empty( $source->registered[ $handle ]->extra['after'] ) ) {
						$inline_content['after'] = is_array( $source->registered[ $handle ]->extra['after'] )
							? implode( "\n", $source->registered[ $handle ]->extra['after'] )
							: $source->registered[ $handle ]->extra['after'];
					}

					array_unshift(
						$dependencies,
						array_filter(
							array_merge(
								[ 'src' => $source->registered[ $handle ]->src ],
								$inline_content
							)
						)
					);
				} elseif ( $source->registered[ $handle ]->src ) {
					array_unshift( $dependencies, $source->registered[ $handle ]->src );
				}

				if ( ! $source->registered[ $handle ]->deps ) {
					return $dependencies;
				}

				foreach ( $source->registered[ $handle ]->deps as $dependency ) {
					array_unshift( $dependencies, $get_dependencies( $dependency, $source ) );
				}

				return Arr::flatten( $dependencies );
			};

			$script_dependencies = [];
			foreach ( $newly_enqueued_scripts as $script ) {
				$script_dependencies = array_merge( $script_dependencies, $get_dependencies( $script, $wp_scripts ) );
			}

			$visited            = [];
			$style_dependencies = [];
			foreach ( $newly_enqueued_styles as $style ) {
				$style_dependencies = array_merge( $style_dependencies, $get_dependencies( $style, $wp_styles ) );
			}

			return [
				'scripts' => array_unique( $script_dependencies, SORT_REGULAR ),
				'styles'  => array_unique( $style_dependencies, SORT_REGULAR ),
				'content' => $rendered_shortcode,
			];
		} finally {
			// Restore singleton state so subsequent renders are not affected.
			$gravityview_frontend->setGvOutputData( $previous_output_data );
			$gravityview_frontend->setPostId( $previous_post_id );
			$gravityview_frontend->setPostHasShortcode( $previous_has_shortcode );
		}
	}

	/**
	 * Build any shortcode from attributes using a custom attribute mapping.
	 *
	 * This is a generic method that can build any GravityView shortcode
	 * ([gravityview], [gventry], [gvfield], [gv_entry_link], etc.).
	 *
	 * @since TODO
	 *
	 * @param string $shortcode_name     Shortcode name (e.g., 'gravityview', 'gventry').
	 * @param array  $block_atts         Block attributes (camelCase).
	 * @param array  $attribute_map      Mapping (camelCase => snake_case).
	 * @param bool   $supports_content   Whether shortcode supports content (wrapping).
	 *
	 * @return string Formatted shortcode string.
	 */
	public static function build_shortcode_from_atts( $shortcode_name, $block_atts, $attribute_map, $supports_content = false ) {
		// Remove searchOperator when searchValue is empty (meaningless without a value).
		if ( isset( $block_atts['searchOperator'] ) && ( ! isset( $block_atts['searchValue'] ) || '' === trim( (string) $block_atts['searchValue'] ) ) ) {
			unset( $block_atts['searchOperator'] );
		}

		$shortcode_atts = [];
		$raw_extras     = [];

		foreach ( $attribute_map as $block_key => $shortcode_key ) {
			if ( isset( $block_atts[ $block_key ] ) && '' !== $block_atts[ $block_key ] && null !== $block_atts[ $block_key ] ) {
				// Special handling for fieldSettingOverrides (doesn't use key="value" format).
				if ( 'field_setting_overrides' === $shortcode_key ) {
					// Raw extras are concatenated into the shortcode string
					// unescaped (they carry their own key="value" syntax), so
					// strip bracket delimiters to stop a smuggled shortcode.
					$raw_extras[] = self::strip_shortcode_delimiters( $block_atts[ $block_key ] );
				} else {
					$shortcode_atts[ $shortcode_key ] = $block_atts[ $block_key ];
				}
			}
		}

		// Handle content for wrapping shortcodes (like [gv_entry_link]content[/gv_entry_link]).
		$content = '';
		if ( $supports_content && isset( $block_atts['content'] ) && '' !== $block_atts['content'] ) {
			$content = wp_kses_post( $block_atts['content'] );
			// Remove content from attributes so it's not duplicated.
			unset( $shortcode_atts['content'] );
		}

		// Build shortcode string.
		$atts_string = self::format_atts_string( $shortcode_atts );

		if ( ! empty( $raw_extras ) ) {
			$atts_string = trim( $atts_string . ' ' . implode( ' ', $raw_extras ) );
		}

		if ( $supports_content && '' !== $content ) {
			return sprintf( '[%s %s]%s[/%s]', $shortcode_name, $atts_string, $content, $shortcode_name );
		}

		return sprintf( '[%s %s]', $shortcode_name, $atts_string );
	}
}
