<?php
/**
 * Server-side renderer for blocksify/content-loop.
 *
 * Walks the 4-zone layout (media + header + body + footer) inside The Loop
 * and emits per-card HTML. Must mirror the JS twin
 * `src/blocks/content-loop/edit/renderLayout.js` byte-for-byte (FOUNDATION §9.1).
 *
 * Layout shape (Phase 3.4 + 3.5):
 *   media:  { image: { value } | null,
 *             overlays: { [pos]: { children: ElementRef[], styling? } },
 *             styling? }
 *   header: ElementRef[] with side 'L'|'R'
 *   body:   ElementRef[]
 *   footer: ElementRef[] with side 'L'|'R'
 *   {header,headerLeft,headerRight,body,footer,footerLeft,footerRight}Styling?
 *     — sparse per-zone wrapper styling (added in Phase 3.5)
 *
 * DOM class contract:
 *   .bsy-card                    — article wrapper
 *   .bsy-card__media             — figure (only when image OR overlays present)
 *   .bsy-card__media-image       — <a> wrapping <img>
 *   .bsy-card__overlay-{tl,tr,center,bl,br}
 *   .bsy-card__content           — div wrapping header + body + footer (Decision #33);
 *                                  emitted only when at least one of those zones renders
 *   .bsy-card__header / footer   — flex zones (header/footer tag)
 *   .bsy-card__{header,footer}-{left,right} — sub-wrappers (only when BOTH sides filled)
 *   .bsy-card__header / footer + .cl-is-only-{left,right} — when only one side filled
 *   .bsy-card__body              — flex column
 *   .bsy-card__{title,excerpt,date,modified,author,category,tags,comments,reading-time}
 *   .bsy-card__term              — single term link inside category/tags wrapper
 *
 * Element classes are a single token; no ordinal suffix (Phase 2 enforces one
 * element per type for the default catalog). Custom user-defined element slugs
 * use the reserved `.bsy-card__c-{slug}` namespace (no Phase 2 UI emits this yet).
 *
 * Image element is restricted to the media zone — if encountered in
 * header / body / footer the renderer silently skips it.
 *
 * @package Blocksify\Blocks\ContentLoop
 */

defined( 'ABSPATH' ) || exit;

if ( ! class_exists( 'Blocksify_Content_Loop_Renderer' ) ) :

class Blocksify_Content_Loop_Renderer {

	const OVERLAY_POSITIONS = array( 'tl', 'tr', 'center', 'bl', 'br' );

	/**
	 * Mirrors `IMAGE_DEFAULT_VALUE` in `src/blocks/content-loop/constants.js`.
	 * Keep both sides in sync — also matches `block.json` `attributes.layout
	 * .default.media.image.value` (the canonical seed for fresh blocks).
	 */
	const IMAGE_DEFAULT_ASPECT_RATIO    = '16/9';
	const IMAGE_DEFAULT_OBJECT_FIT      = 'cover';
	const IMAGE_DEFAULT_OBJECT_POSITION = 'center';

	/**
	 * Image hover-effect (zoom) constants — mirror of
	 * `IMAGE_HOVER_ZOOM_SCALE` / `IMAGE_HOVER_TRANSITION` in
	 * `src/blocks/content-loop/constants.js`. Consumed by the CSS generator
	 * twin (`content_loop_image_hover_css`); keep both sides in sync.
	 */
	const IMAGE_HOVER_ZOOM_SCALE        = '1.08';
	const IMAGE_HOVER_TRANSITION        = 'transform 450ms ease';

	/**
	 * Media-figure overlay hover transition — mirror of
	 * `MEDIA_OVERLAY_HOVER_TRANSITION` in
	 * `src/blocks/content-loop/constants.js`. Consumed by the CSS generator
	 * twin (`content_loop_media_bg_overlay_css`); keep both sides in sync.
	 */
	const MEDIA_OVERLAY_HOVER_TRANSITION = 'background-color 250ms ease, opacity 250ms ease';

	/**
	 * Description / excerpt length-unit default — twin of
	 * `DESCRIPTION_DEFAULT_LENGTH_UNIT` in JS constants. `html_description`
	 * branches on this value (words / characters / sentences).
	 */
	const DESCRIPTION_DEFAULT_LENGTH_UNIT = 'words';

	/**
	 * Author avatar default size in pixels — twin of
	 * `AUTHOR_DEFAULT_AVATAR_SIZE` in JS constants.
	 */
	const AUTHOR_DEFAULT_AVATAR_SIZE = 32;

	/**
	 * Twin of `ZONE_STYLING_MAP` in `inspector/content/layout-ops.js`. Each
	 * entry is `[ path_segment_csv, part_selector ]` — the JS map uses an
	 * array path; PHP encodes path as a CSV string so the constant remains
	 * a flat array literal usable from class scope.
	 *
	 * Adding a new zone styling surface? Update BOTH this map and the JS
	 * twin. The renderer's container walker iterates this map in declaration
	 * order to keep editor + frontend emit byte-identical.
	 */
	const ZONE_STYLING_MAP = array(
		'media'       => array( 'media.styling',       ' .bsy-card__media' ),
		'card'        => array( 'cardStyling',         ' .bsy-card' ),
		'cardContent' => array( 'cardContentStyling',  ' .bsy-card__content' ),
		'header'      => array( 'headerStyling',       ' .bsy-card__header' ),
		'headerLeft'  => array( 'headerLeftStyling',   ' .bsy-card__header-left' ),
		'headerRight' => array( 'headerRightStyling',  ' .bsy-card__header-right' ),
		'body'        => array( 'bodyStyling',         ' .bsy-card__body' ),
		'footer'      => array( 'footerStyling',       ' .bsy-card__footer' ),
		'footerLeft'  => array( 'footerLeftStyling',   ' .bsy-card__footer-left' ),
		'footerRight' => array( 'footerRightStyling',  ' .bsy-card__footer-right' ),
	);

	/**
	 * Build the pre-escaped wrapper `class` + `id` attribute strings for
	 * the per-instance `<div>` wrapping a Content Loop block (Phase 4.2
	 * factor — used by `render_posts_loop`, `render_terms_loop`, and
	 * `render_entity_unavailable_placeholder`).
	 *
	 * Returns `[ class_attr, id_attr ]` where:
	 *   - `class_attr` is the escaped class list (no surrounding quotes).
	 *   - `id_attr`    is a complete ` id="…"` chunk (empty string when no
	 *                  anchor authored).
	 *
	 * Composes the wrapper from three sources:
	 *   - `bsy-cl-{uid}` per-instance scoper (always emitted).
	 *   - `cl-is-{layoutType}` listing-dimension modifier (default `grid`).
	 *   - `cl-is-card-{cardLayout}` per-card-dimension modifier (default
	 *     `stacked`).
	 *   - User-authored `className` attribute (appended last).
	 *
	 * @param array  $attributes Block attributes.
	 * @param string $uid        Sanitised per-instance uid.
	 * @return array{0:string,1:string} `[ $class_attr, $id_attr ]`.
	 */
	private static function build_wrapper_classes( $attributes, $uid ) {
		$layout_type = isset( $attributes['layoutType'] ) ? sanitize_html_class( (string) $attributes['layoutType'] ) : '';
		if ( ! in_array( $layout_type, array( 'grid', 'list', 'carousel', 'masonry' ), true ) ) {
			$layout_type = 'grid';
		}

		$card_layout = isset( $attributes['cardLayout'] ) ? sanitize_html_class( (string) $attributes['cardLayout'] ) : '';
		if ( ! in_array( $card_layout, array( 'stacked', 'row' ), true ) ) {
			$card_layout = 'stacked';
		}

		$wrapper_classes = array(
			'bsy-cl-' . $uid,
			'cl-is-' . $layout_type,
			'cl-is-card-' . $card_layout,
		);
		$user_class = isset( $attributes['className'] ) ? trim( (string) $attributes['className'] ) : '';
		if ( '' !== $user_class ) {
			$wrapper_classes[] = $user_class;
		}
		$class_attr = esc_attr( implode( ' ', array_filter( $wrapper_classes ) ) );

		$id_attr = '';
		$anchor  = isset( $attributes['anchor'] ) ? trim( (string) $attributes['anchor'] ) : '';
		if ( '' !== $anchor ) {
			$id_attr = ' id="' . esc_attr( $anchor ) . '"';
		}

		// `data-uid` lets view.js look up the per-instance uid without
		// parsing the `bsy-cl-{uid}` class (the `[class*="bsy-cl-"]`
		// substring selector also matches the inner pagination wrapper
		// `.bsy-cl-paging`, which would mis-extract "paging" as the uid).
		$id_attr .= ' data-uid="' . esc_attr( $uid ) . '"';

		// `data-query-source` on the OUTER wrapper (not on `.bsy-cl-paging`)
		// — view.js reads it at init regardless of whether pagination is
		// authored. Filter / Search future blocks dispatch refresh without
		// needing the paging bar to exist. URL key picker reads from here.
		$query_source = isset( $attributes['querySource'] ) ? (string) $attributes['querySource'] : 'inline';
		$id_attr     .= ' data-query-source="' . esc_attr( $query_source ) . '"';

		// `data-host-id` + `data-host-type` — REST swap looks up the saved
		// (sparse) attrs from the host post / template by `(host_id, uid)`
		// instead of round-tripping a `data-query` JSON snapshot through
		// the DOM. Saved attrs are the canonical source — `parse_blocks()`
		// at REST time reads the latest authored state directly. The
		// wrapper carries only the lookup coordinates.
		//
		// Resolution rules in `resolve_block_host`:
		//   - Singular post / page         → post id + 'post'
		//   - FSE template archive         → template id ('theme//slug') + 'template'
		//   - Pro context (saved-query, …) → Pro filter returns its own pair
		//   - Otherwise                    → [0, ''] (no emit, view.js bails)
		//
		// view.js falls back to anchor-href full-page reload when the attrs
		// are absent — the documented degraded path for reusable blocks /
		// widget areas / Pro contexts without a host resolver.
		$has_paging_items = isset( $attributes['paginationItems'] )
			&& is_array( $attributes['paginationItems'] )
			&& ! empty( $attributes['paginationItems'] );
		if ( $has_paging_items && 'inherit' !== $query_source ) {
			list( $host_id, $host_type ) = self::resolve_block_host( $attributes );
			if ( '' !== $host_type && ( ( is_int( $host_id ) && $host_id > 0 ) || ( is_string( $host_id ) && '' !== $host_id ) ) ) {
				$id_attr .= ' data-host-id="' . esc_attr( (string) $host_id ) . '"';
				$id_attr .= ' data-host-type="' . esc_attr( $host_type ) . '"';
			}
		}

		// `data-url-state` — only meaningful when pagination is authored AND
		// user explicitly opted into `'push'` (default flipped to `'none'`
		// per Decision #71). Suppress when default to keep the wrapper lean.
		if ( $has_paging_items ) {
			$url_state = isset( $attributes['paginationUrlState'] ) ? (string) $attributes['paginationUrlState'] : 'none';
			if ( 'push' === $url_state ) {
				$id_attr .= ' data-url-state="push"';
			}
		}

		// `data-current-post-id` — emit only when the `excludeCurrent`
		// queryParam (catalog boolean filter) is ON AND we're on a
		// singular page (the only context the filter is meaningful in).
		// view.js reads this and forwards to REST so the `post__not_in`
		// injection survives in-place page swap. Archive / terms /
		// non-singular contexts emit nothing — keeps the wrapper minimal
		// for the 99% case where exclusion doesn't apply.
		$query_params_for_exclude = isset( $attributes['queryParams'] ) && is_array( $attributes['queryParams'] )
			? $attributes['queryParams']
			: array();
		if ( ! empty( $query_params_for_exclude['excludeCurrent'] ) && is_singular() ) {
			$host_id = (int) get_the_ID();
			if ( $host_id > 0 ) {
				$id_attr .= ' data-current-post-id="' . (int) $host_id . '"';
			}
		}

		return array( $class_attr, $id_attr );
	}

	/**
	 * Resolve the host post / template that owns this CL block so the REST
	 * swap controller can read saved (sparse) attrs straight from
	 * `post_content` instead of round-tripping a DOM JSON snapshot.
	 *
	 * Returns `[ host_id, host_type ]`:
	 *   - `'post'`     → host_id is the integer post ID (singular page /
	 *                    post / Pro CPT viewed singular).
	 *   - `'template'` → host_id is the FSE template's canonical string
	 *                    ID (`'{theme}//{slug}'`, e.g. `'twentytwentyfive//archive'`),
	 *                    set on the global `$_wp_current_template_id` by
	 *                    `locate_block_template()` during render.
	 *   - Pro slug     → Pro plugin owns interpretation. Resolver returns
	 *                    a `[host_id, host_type]` pair the matching
	 *                    `blocksify.cl.rest.host_content` filter knows
	 *                    how to expand back into post_content at REST time.
	 *
	 * Fallback `[0, '']` suppresses the data-host-* emit on the wrapper —
	 * view.js's init bails when the lookup coordinates are missing and the
	 * SSR anchor `href` carries the user through a full-page reload to
	 * `?cl_{uid}_page=N`. V1 documented degraded path covers reusable
	 * blocks (`core/block`), widget areas, and any Pro context without a
	 * host resolver.
	 *
	 * @param array $attributes Full block attrs (post-default-merge).
	 * @return array{0:int|string,1:string} `[ host_id, host_type ]`.
	 */
	protected static function resolve_block_host( $attributes ) {
		/**
		 * Pro host resolver — short-circuit for non-standard contexts
		 * (saved-query records, custom CPTs, multi-tenant routing).
		 *
		 * Returning a `[host_id, host_type]` pair pins the lookup;
		 * returning `null` falls through to the Free defaults below.
		 * The companion `blocksify.cl.rest.host_content` filter expands
		 * the same `(host_id, host_type)` pair back into a post_content
		 * string at REST time.
		 *
		 * Signature: `( ?array $resolved, array $attrs ) => ?array`
		 *
		 * @since 1.0.0
		 */
		$pro = apply_filters( 'blocksify.cl.rest.host_resolver', null, $attributes );
		// Indexed-pair shape `[host_id, host_type]` only — `isset` instead
		// of `count` so an associative array from a misbehaving Pro filter
		// falls through quietly instead of triggering an "undefined index"
		// notice on PHP 8.
		if ( is_array( $pro ) && isset( $pro[0], $pro[1] ) ) {
			$pro_id   = $pro[0];
			$pro_type = is_string( $pro[1] ) ? $pro[1] : '';
			if ( '' !== $pro_type && ( ( is_int( $pro_id ) && $pro_id > 0 ) || ( is_string( $pro_id ) && '' !== $pro_id ) ) ) {
				return array( $pro_id, $pro_type );
			}
		}

		if ( is_singular() ) {
			$post_id = (int) get_queried_object_id();
			if ( $post_id > 0 ) {
				return array( $post_id, 'post' );
			}
		}

		// FSE template render (archive, search, 404, …). `locate_block_template()`
		// sets the global to the canonical `'theme//slug'` ID during the
		// `template_include` chain (WP 6.3+). Block themes only; classic
		// themes leave the global unset and fall through to the empty
		// pair below.
		if ( ! empty( $GLOBALS['_wp_current_template_id'] ) && is_string( $GLOBALS['_wp_current_template_id'] ) ) {
			return array( (string) $GLOBALS['_wp_current_template_id'], 'template' );
		}

		return array( 0, '' );
	}

	/**
	 * Phase 4.1 entity dispatch — Posts entity loop.
	 *
	 * Owns the WP_Query setup (`querySource` branch: inline → build args
	 * from `queryParams`; inherit → reuse main `$wp_query`; Pro → dispatch
	 * via `blocksify.cl.query.resolve`), wrapper element + class emit
	 * (per-instance `.bsy-cl-{uid}` + listing modifier + card-layout
	 * modifier), and the empty / populated branches. Per-post card render
	 * is delegated to `render_card_posts()`.
	 *
	 * SPEC §5.7 — sibling methods:
	 *   `render_terms_loop`  → Phase 4.2 (Terms entity Free, below)
	 *   `render_users_loop`  → Pro
	 *
	 * @param array         $attributes   Full block attribute set (merged with defaults).
	 * @param int           $current_page Scoped pagination value (`?cl_{uid}_page=N`).
	 * @param WP_Block|null $block        Parsed block instance forwarded to the
	 *                                    `blocksify.cl.query.resolve` filter so
	 *                                    Pro callbacks can read parsed inner-block
	 *                                    state when resolving non-inline sources.
	 */
	/**
	 * Per-request render-depth counter. Prevents infinite recursion when
	 * a Content Loop block ends up nested inside its own `do_blocks()`
	 * chain — most commonly when `querySource = 'inherit'` is used on a
	 * singular template (single post / page) AND a theme or plugin
	 * filter wraps `get_the_excerpt()` in `do_blocks()`: the card's
	 * `description` element resolves an excerpt → `do_blocks` parses
	 * the same post's content → finds the same Content Loop block →
	 * re-renders → recurses forever → 256MB OOM.
	 *
	 * Counter (not a UID stack) so any nested re-entry — across posts,
	 * terms, or Pro entity loops — bails. Sibling blocks render
	 * sequentially without issue (depth: 0 → 1 → 0 → 1 → 0).
	 *
	 * `try { … } finally { depth-- }` so an exception inside the loop
	 * doesn't strand the counter at a non-zero value for subsequent
	 * blocks in the same request. Fatal errors kill the request anyway
	 * so the counter resets at PHP shutdown.
	 */
	private static $render_depth = 0;

	public static function render_posts_loop( $attributes, $current_page, $block = null ) {
		$uid = isset( $attributes['uid'] )
			? preg_replace( '/[^a-z0-9_-]/i', '', (string) $attributes['uid'] )
			: '';
		if ( '' === $uid ) {
			return;
		}

		$query_source_early = isset( $attributes['querySource'] ) ? (string) $attributes['querySource'] : 'inline';

		// Recursion guard #1 — `inherit` on a singular template is a
		// config error. `inherit` mode (Decision #39) is for archive
		// contexts (category / tag / archive / search) where WP main
		// query returns a LIST of posts. On a singular template, main
		// query carries 1 post (the one being viewed), so Content Loop
		// would render a card for that same post. If the card's
		// `description` element calls `get_the_excerpt()` and any
		// excerpt filter runs `do_blocks()` (Kadence, some SEO plugins,
		// custom theme code), the embedded Content Loop block parses
		// again → re-renders → infinite recursion → 256MB OOM
		// (reported 2026-05-18 on post `phase-3-x-pro-query-smoke`).
		// Bail silently so the page still renders the rest of its
		// content. Inspector preview catches this via the editor's
		// own logic (renderLayout doesn't honor inherit mode).
		if ( 'inherit' === $query_source_early && function_exists( 'is_singular' ) && is_singular() ) {
			return;
		}

		// Recursion guard #2 — defense-in-depth. Any re-entry (Pro
		// saved-query self-reference, custom theme template re-running
		// the_content on a card's excerpt, etc.) bails.
		if ( self::$render_depth > 0 ) {
			return;
		}
		self::$render_depth++;

		try {
		// Build WP_Query from `querySource` (Phase 3.X Pro Query foundation):
		//   - 'inline'  (default): build args from the inline `queryParams` Repeater.
		//   - 'inherit': read posts from the current page's main `$wp_query`
		//                (archive, search, taxonomy template, …). Falls back to
		//                inline behavior when there's no main query in scope
		//                (e.g. front page, custom REST endpoint).
		//   - other:    Pro / 3rd-party intercepts via the
		//                `blocksify.cl.query.resolve` filter and returns either
		//                a `WP_Query` instance or a `WP_Query` args array.
		//                Returning `null` falls through to inline.
		// Merge defaults FIRST so the query construction below can read
		// `paginationItems` to decide whether `WP_Query` needs to compute
		// `found_posts` / `max_num_pages` (the per-instance args
		// builder defaults to `no_found_rows: true` for the no-pagination
		// case). `merge_block_defaults` is side-effect-free + cheap (one
		// `WP_Block_Type_Registry` lookup) so calling it early is safe.
		$attrs_merged          = Blocksify_CSS_Pipeline::merge_block_defaults( 'blocksify/content-loop', $attributes );
		$has_pagination_items  = isset( $attrs_merged['paginationItems'] )
			&& is_array( $attrs_merged['paginationItems'] )
			&& ! empty( $attrs_merged['paginationItems'] );

		$query_source = isset( $attributes['querySource'] ) ? (string) $attributes['querySource'] : 'inline';
		$query_params = isset( $attributes['queryParams'] ) ? (array) $attributes['queryParams'] : array();
		$query        = null;
		// Track the args array used to build the query so the out-of-range
		// clamp can re-run with `paged` adjusted. Stays null for the
		// `inherit` path (theme owns the query) and the Pro WP_Query
		// escape hatch (Pro built it — Free can't post-mutate).
		$active_args = null;
		$reset_postdata_required = false;

		if ( 'inherit' === $query_source ) {
			global $wp_query;
			if ( $wp_query instanceof WP_Query && $wp_query->have_posts() ) {
				// Re-use the page's main query — no new WP_Query, no
				// `wp_reset_postdata` (the loop runs against the global query
				// state which the WP template owns).
				$query = $wp_query;
			}
		} elseif ( 'inline' !== $query_source ) {
			/**
			 * Pro / 3rd-party intercepts `querySource` other than `inline` /
			 * `inherit` (e.g. saved queries from a custom DB table).
			 * Implementations return either a `WP_Query` instance OR an args
			 * array. Returning `null` falls through to the inline path so the
			 * block degrades gracefully when the Pro extension is missing.
			 *
			 * Pagination contract (Phase 6.1 follow-up):
			 *   - Returning ARGS → Free auto-injects `paged` into the args
			 *     when `paginationItems` is authored and the key isn't
			 *     already set. Pro can override by pinning `paged` itself
			 *     (e.g. a saved query that opts out of paging by pinning
			 *     `paged => 1`); Free won't overwrite.
			 *   - Returning WP_Query INSTANCE → Free cannot post-mutate;
			 *     Pro is responsible for building the query with the
			 *     correct `paged` arg using the `$current_page` param
			 *     passed to this filter as the 4th argument.
			 *
			 * @param mixed         $resolved     `null` default — return WP_Query | args.
			 * @param array         $attributes   Block attributes.
			 * @param WP_Block|null $block        Parsed block instance.
			 * @param int           $current_page Current pagination page (≥ 1).
			 */
			$resolved = apply_filters( 'blocksify.cl.query.resolve', null, $attributes, $block, $current_page );
			if ( $resolved instanceof WP_Query ) {
				$query = $resolved;
				$reset_postdata_required = true;
			} elseif ( is_array( $resolved ) ) {
				if ( $has_pagination_items ) {
					$resolved['no_found_rows'] = false;
					// Auto-inject `paged` so the Pro callback doesn't have
					// to peek `$_GET` itself. Pro opts out by setting
					// `paged` explicitly before returning the args.
					if ( ! isset( $resolved['paged'] ) ) {
						$resolved['paged'] = $current_page;
					}
				}
				$active_args             = $resolved;
				$query                   = new WP_Query( $resolved );
				$reset_postdata_required = true;
			}
		}

		if ( ! ( $query instanceof WP_Query ) ) {
			// Pass render-time context so `excludeCurrent` (default true)
			// can inject `post__not_in: [host_post_id]` when the CL block
			// is rendered on a singular page — the most common cause of
			// the "current post appears in the loop" duplicate seen on
			// "related posts" / "more from this section" layouts.
			// `current_post_id` only — `excludeCurrent` lives in
			// queryParams (catalog boolean filter), build_args reads it
			// directly from `$flat['excludeCurrent']`.
			$build_context = array(
				'current_post_id' => is_singular() ? (int) get_the_ID() : 0,
			);
			$query_args = Blocksify_CL_Query::build_args( $query_params, $current_page, $build_context );
			// Phase 6.1 — pagination context needs `found_posts` +
			// `max_num_pages`, both of which require the SQL `SELECT
			// FOUND_ROWS()` call WP_Query skips when `no_found_rows: true`.
			// Override only when paginationItems is present — preserves the
			// micro-optimisation for blocks without pagination authored.
			if ( $has_pagination_items ) {
				$query_args['no_found_rows'] = false;
			}
			$active_args             = $query_args;
			$query                   = new WP_Query( $query_args );
			$reset_postdata_required = true;
		}

		// Out-of-range URL hack clamp (Phase 6.1 follow-up). When the user
		// visits `?cl_{uid}_page=999` past the last actual page, WP_Query
		// returns 0 rows. SQL_CALC_FOUND_ROWS reports the correct total
		// on MySQL but Studio's SQLite integration zeroes out
		// `found_posts` when OFFSET overshoots, so we can't trust the
		// `max_num_pages` reading from the failed query. Probe at page 1
		// to get accurate totals, then clamp + rebuild so the user sees
		// the last page instead of an empty state. Only triggers when we
		// built the query ourselves (inline + Pro saved-args paths) AND
		// the failed query is past page 1.
		if ( $has_pagination_items
			&& null !== $active_args
			&& $current_page > 1
			&& ! $query->have_posts()
		) {
			$probe_args                   = $active_args;
			$probe_args['posts_per_page'] = 1;
			unset( $probe_args['offset'], $probe_args['paged'] );
			$probe_args['no_found_rows'] = false;
			$probe_args['fields']        = 'ids';
			$probe                       = new WP_Query( $probe_args );
			if ( $probe->found_posts > 0 ) {
				$per       = isset( $active_args['posts_per_page'] ) ? (int) $active_args['posts_per_page'] : (int) get_option( 'posts_per_page', 6 );
				$max_pages = max( 1, (int) ceil( $probe->found_posts / max( 1, $per ) ) );
				if ( $current_page > $max_pages ) {
					$current_page = $max_pages;
					// Rebuild from scratch — re-call the canonical args
					// builder with the clamped page so `offset` arithmetic
					// stays in one place (`Blocksify_CL_Query::build_args`
					// applies user `offset` from queryParams on top of the
					// page-derived offset). For Pro saved-args path, we
					// can't re-call build_args (the args came from a Pro
					// callback) so we re-trigger the filter with the
					// clamped `$current_page` so Pro's own auto-inject (if
					// any) recomputes — Free's auto-inject in the filter
					// branch above re-fires correctly with the new value.
					if ( 'inline' === $query_source ) {
						$active_args                  = Blocksify_CL_Query::build_args(
							$query_params,
							$current_page,
							array(
								'current_post_id' => is_singular() ? (int) get_the_ID() : 0,
							)
						);
						$active_args['no_found_rows'] = false;
					} else {
						$resolved_again = apply_filters( 'blocksify.cl.query.resolve', null, $attributes, $block, $current_page );
						if ( is_array( $resolved_again ) ) {
							$resolved_again['no_found_rows'] = false;
							if ( ! isset( $resolved_again['paged'] ) ) {
								$resolved_again['paged'] = $current_page;
							}
							$active_args = $resolved_again;
						}
					}
					$query = new WP_Query( $active_args );
				}
			}
			wp_reset_postdata();
		}

		// Phase 6.3 bug fix — load-more accumulated SSR. Direct visit to
		// `?cl_{uid}_page=N` on a load-more variant block should render
		// posts 1..N×perPage (the accumulated state the user would see
		// after N-1 in-page "Load more" clicks), not just the single
		// page-N slice. Without this, a user who shared / bookmarked the
		// URL during a load-more session would land on an isolated last
		// page disconnected from the progressive-disclosure UX. Capture
		// the per-page totals BEFORE the accumulated rebuild so paging_ctx
		// still reflects per-page semantics (totalPages for load-more
		// button disable-on-last-page gate). REST endpoint stays per-page
		// (each "Load more" click appends ONE page worth, not the
		// accumulated set).
		$ctx_per_page    = isset( $query->query_vars['posts_per_page'] )
			? max( 1, (int) $query->query_vars['posts_per_page'] )
			: (int) get_option( 'posts_per_page', 6 );
		$ctx_total_items = max( 0, (int) $query->found_posts );
		$ctx_total_pages = max( 0, (int) $query->max_num_pages );
		if (
			$has_pagination_items
			&& null !== $active_args
			&& $current_page > 1
			&& self::has_load_more_nav( (array) ( $attrs_merged['paginationItems'] ?? array() ) )
		) {
			$accum_args                   = $active_args;
			$accum_args['posts_per_page'] = $ctx_per_page * $current_page;
			unset( $accum_args['offset'], $accum_args['paged'] );
			$accum_args['no_found_rows'] = false;
			$query                       = new WP_Query( $accum_args );
		}

		list( $class_attr, $id_attr ) = self::build_wrapper_classes( $attrs_merged, $uid );

		if ( ! $query->have_posts() ) {
			if ( $reset_postdata_required ) {
				wp_reset_postdata();
			}
			printf(
				'<div class="%1$s"%2$s>%3$s</div>',
				$class_attr,                                       // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already esc_attr'd above.
				$id_attr,                                          // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already esc_attr'd above.
				self::render_empty_state( $attrs_merged )          // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- default pre-escaped; Pro filter contract documents callbacks own their escaping.
			);
			return;
		}

		echo '<div class="' . $class_attr . '"' . $id_attr . '>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pre-escaped above.

		// Phase 6.2 — extract the card loop into a reusable helper so the
		// REST endpoint (in-place swap) can return just the grid children
		// without re-implementing the visibility + setup_postdata + layout
		// resolution dance.
		echo self::render_grid_inner_posts( $query, $attrs_merged ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- internal renderer pre-escapes per element.

		// Phase 6.1 — pagination bar (SPEC §5.1). Replaces the legacy
		// `blocksify.cl.pagination` filter with a structured item-dispatch
		// pipeline (`render_pagination_items` → `html_paging_{type}` →
		// `paging_item_render` Pro fallback). Pagination context drives the
		// numbered nav window + count formatting + per-page selected option.
		if ( $has_pagination_items ) {
			// Use per-page totals captured BEFORE the load-more accumulated
			// rebuild so the paging_ctx reflects "this is page N of M
			// {per_page}-card pages" semantics. After accumulation,
			// `$query->max_num_pages` would compute against the inflated
			// `posts_per_page = per_page * current_page` and report 1 page
			// — wrong for the load-more button's disable-on-last-page
			// gate. Pre-capture stays correct for both per-page and
			// accumulated paths.
			$paging_ctx = array(
				'currentPage' => max( 1, min( $current_page, max( 1, $ctx_total_pages ) ) ),
				'totalPages'  => $ctx_total_pages,
				'totalItems'  => $ctx_total_items,
				'perPage'     => $ctx_per_page,
				'uid'         => $uid,
				// Phase 6.1 follow-up — paging_url() reads this to pick the
				// URL key (`paged` for inherit, `cl_{uid}_page` otherwise).
				// Also surfaces on the wrapper as `data-query-source` for
				// the Phase 6.2 view.js controller to mirror server-side.
				'querySource' => $query_source,
			);
			echo self::render_pagination_items( $attrs_merged['paginationItems'], $paging_ctx, $attrs_merged ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- internal builder pre-escapes; Pro filter contract documents callbacks own their escaping.
		}

		echo '</div>';
		if ( $reset_postdata_required ) {
			wp_reset_postdata();
		}
		} finally {
			// Recursion guard cleanup — runs even on exceptions inside
			// the loop body so subsequent blocks in the same request
			// (sibling Content Loop blocks on the same page) start at
			// depth 0.
			self::$render_depth--;
			if ( self::$render_depth < 0 ) {
				self::$render_depth = 0;
			}
		}
	}

	/**
	 * Phase 4.2 entity dispatch — Terms entity loop (SPEC §5.7).
	 *
	 * Parallel to `render_posts_loop` but drives a `WP_Term[]` set via
	 * `get_terms()` instead of `WP_Post[]` via `WP_Query`. Wrapper-class
	 * emit + querySource branch logic match the posts loop; the differences
	 * live entirely in the data source + per-card render delegate.
	 *
	 * `querySource = 'inherit'` is invalid for terms (no main-query
	 * equivalent — `category.php` archives load posts, not terms — per
	 * SPEC §5.1) and falls through to inline. Pro `querySource = 'saved'`
	 * dispatches via `blocksify.cl.query.resolve` exactly like posts;
	 * callbacks return a `get_terms()` args array (no WP_Query instance —
	 * terms has no Query-class equivalent in core).
	 *
	 * @param array         $attributes   Full block attribute set (merged with defaults).
	 * @param int           $current_page Scoped pagination value (`?cl_{uid}_page=N`).
	 * @param WP_Block|null $block        Parsed block instance forwarded to the
	 *                                    `blocksify.cl.query.resolve` filter.
	 */
	public static function render_terms_loop( $attributes, $current_page, $block = null ) {
		$uid = isset( $attributes['uid'] )
			? preg_replace( '/[^a-z0-9_-]/i', '', (string) $attributes['uid'] )
			: '';
		if ( '' === $uid ) {
			return;
		}

		$query_source = isset( $attributes['querySource'] ) ? (string) $attributes['querySource'] : 'inline';

		// Recursion guards — see `render_posts_loop` header docblock for
		// rationale. Terms loop is less likely to trigger the inherit-on-
		// singular path (Decision #49 documents `inherit` as posts-only —
		// terms have no main-query equivalent), but the depth counter
		// catches edge cases: Pro saved-query for terms that recurses, or
		// a theme template re-running do_blocks on term description text
		// that contains the same Content Loop block.
		if ( 'inherit' === $query_source && function_exists( 'is_singular' ) && is_singular() ) {
			return;
		}
		if ( self::$render_depth > 0 ) {
			return;
		}
		self::$render_depth++;

		try {
		$query_params = isset( $attributes['queryParams'] ) ? (array) $attributes['queryParams'] : array();
		$args         = null;

		if ( 'inline' !== $query_source && 'inherit' !== $query_source ) {
			// 4-arg signature per Phase 6.1 follow-up — `$current_page`
			// surfaces to Pro callbacks so they can pin pagination state
			// themselves (e.g. saved query that opts out of paging) without
			// peeking `$_GET`. Pre-release schema break, no shim per
			// CLAUDE.md. PHP allows old 3-arg callbacks to keep loading
			// (drops the extra arg silently).
			$resolved = apply_filters( 'blocksify.cl.query.resolve', null, $attributes, $block, $current_page );
			if ( is_array( $resolved ) ) {
				$args = $resolved;
				// Auto-inject `offset` when paginationItems authored. Terms
				// uses `offset` + `number` (no `paged` arg in get_terms()).
				// Pro can opt out by setting `offset` itself before
				// returning the args.
				$has_pagination_items_terms = isset( $attributes['paginationItems'] )
					&& is_array( $attributes['paginationItems'] )
					&& ! empty( $attributes['paginationItems'] );
				if ( $has_pagination_items_terms && ! isset( $args['offset'] ) ) {
					$per_page_for_offset = max( 1, (int) ( $args['number'] ?? 6 ) );
					$args['offset']      = ( max( 1, $current_page ) - 1 ) * $per_page_for_offset;
				}
			}
			// Terms has no WP_Query analogue, so a WP_Query return value is
			// ignored (Pro callbacks should return args arrays for terms).
		}

		if ( null === $args ) {
			$args = Blocksify_Terms_Query::build_args( $query_params, $current_page );
		}

		$has_pagination_items_terms = isset( $attributes['paginationItems'] )
			&& is_array( $attributes['paginationItems'] )
			&& ! empty( $attributes['paginationItems'] );

		$terms = get_terms( $args );
		if ( is_wp_error( $terms ) ) {
			$terms = array();
		}

		// Out-of-range URL hack clamp (Phase 6.1 follow-up). When the user
		// visits `?cl_{uid}_page=999` past the last actual page, `get_terms`
		// returns an empty array even though there are matching terms.
		// Probe `wp_count_terms` to know the real total + clamp + re-fetch
		// so the user sees the last page instead of an empty state. Only
		// triggers when `paginationItems` is authored (otherwise we don't
		// care about pagination context); inherit mode for terms is invalid
		// per Decision #49 so this covers inline + Pro saved-args.
		if ( empty( $terms ) && $has_pagination_items_terms && $current_page > 1 ) {
			$per_page    = max( 1, (int) ( $args['number'] ?? 6 ) );
			$count_args  = $args;
			unset( $count_args['number'], $count_args['offset'], $count_args['fields'] );
			$count_args['fields'] = 'count';
			$total_items = (int) wp_count_terms( $count_args );
			$total_pages = $per_page > 0 ? (int) ceil( $total_items / $per_page ) : 0;
			if ( $total_items > 0 && $current_page > $total_pages ) {
				$current_page    = $total_pages;
				$args['offset']  = ( $current_page - 1 ) * $per_page;
				$terms           = get_terms( $args );
				if ( is_wp_error( $terms ) ) {
					$terms = array();
				}
			}
		}

		$attrs_merged = Blocksify_CSS_Pipeline::merge_block_defaults( 'blocksify/content-loop', $attributes );

		// Phase 6.3 bug fix — load-more accumulated SSR (terms twin). See
		// the posts loop equivalent for full rationale. Terms uses
		// `offset` + `number` (no `paged` arg) so the accumulated query
		// sets `number = per_page * current_page, offset = 0`.
		if (
			$has_pagination_items_terms
			&& $current_page > 1
			&& self::has_load_more_nav( (array) ( $attrs_merged['paginationItems'] ?? array() ) )
		) {
			$base_per_page    = max( 1, (int) ( $args['number'] ?? 6 ) );
			$accum_args       = $args;
			$accum_args['number'] = $base_per_page * $current_page;
			$accum_args['offset'] = 0;
			$terms_accum      = get_terms( $accum_args );
			if ( ! is_wp_error( $terms_accum ) && ! empty( $terms_accum ) ) {
				$terms = $terms_accum;
			}
		}

		list( $class_attr, $id_attr ) = self::build_wrapper_classes( $attrs_merged, $uid );

		if ( empty( $terms ) ) {
			printf(
				'<div class="%1$s"%2$s>%3$s</div>',
				$class_attr,                                       // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_attr'd above.
				$id_attr,                                          // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_attr'd above.
				self::render_empty_state( $attrs_merged )          // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- default pre-escaped; Pro filter contract documents callbacks own their escaping.
			);
			return;
		}

		echo '<div class="' . $class_attr . '"' . $id_attr . '>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pre-escaped above.

		// Phase 6.2 — extract the term loop into a reusable helper so the
		// REST endpoint can return just the grid children when paginating
		// terms in-place.
		echo self::render_grid_inner_terms( $terms, $attrs_merged ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- internal renderer pre-escapes per element.

		// Phase 6.1 — pagination bar. Terms has no WP_Query analogue, so
		// the totals come from `wp_count_terms()` (filtered for the same
		// taxonomy + matching `parent` / `hide_empty` / `search` /
		// `include` / `exclude` args the loop used) + the resolved
		// `number` arg drives perPage.
		$paging_items = isset( $attrs_merged['paginationItems'] ) && is_array( $attrs_merged['paginationItems'] )
			? $attrs_merged['paginationItems']
			: array();
		if ( ! empty( $paging_items ) ) {
			$per_page    = isset( $args['number'] ) ? max( 1, (int) $args['number'] ) : 6;
			$count_args  = $args;
			unset( $count_args['number'], $count_args['offset'], $count_args['fields'] );
			$count_args['fields'] = 'count';
			$total_items = (int) wp_count_terms( $count_args );
			$total_pages = $per_page > 0 ? (int) ceil( $total_items / $per_page ) : 0;
			$paging_ctx  = array(
				'currentPage' => max( 1, min( $current_page, max( 1, $total_pages ) ) ),
				'totalPages'  => max( 0, $total_pages ),
				'totalItems'  => max( 0, $total_items ),
				'perPage'     => $per_page,
				'uid'         => $uid,
				// Phase 6.1 follow-up — paging_url() reads this to pick the
				// URL key. Terms entity never has inherit mode (Decision
				// #49 — `category.php` archives load posts, not terms), so
				// this always resolves to `cl_{uid}_page`.
				'querySource' => $query_source,
			);
			echo self::render_pagination_items( $paging_items, $paging_ctx, $attrs_merged ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- internal builder pre-escapes; Pro filter contract documents callbacks own their escaping.
		}

		echo '</div>';
		} finally {
			// Recursion guard cleanup — mirror of render_posts_loop's
			// try/finally so terms loop respects the same depth counter.
			self::$render_depth--;
			if ( self::$render_depth < 0 ) {
				self::$render_depth = 0;
			}
		}
	}

	/**
	 * Render a placeholder when the requested entity has no Free renderer
	 * AND no Pro handler returned content. Used by any Pro-registered
	 * entity whose `blocksify.cl.render_entity_loop` filter returned `null`
	 * (e.g. Pro plugin deactivated while a saved `queryEntity = 'users'`
	 * block is on the page).
	 *
	 * Renders a `.bsy-cl__empty` element inside the per-instance wrapper so
	 * editor preview + frontend share the same DOM shape; the placeholder
	 * is visually distinct from "no posts found" / "no terms found"
	 * (different copy) so users can recover by re-activating Pro or
	 * switching entity.
	 *
	 * @param string $entity     Entity slug whose renderer is unavailable.
	 * @param array  $attributes Block attributes (used for wrapper class +
	 *                           anchor id).
	 */
	public static function render_entity_unavailable_placeholder( $entity, $attributes ) {
		$uid = isset( $attributes['uid'] )
			? preg_replace( '/[^a-z0-9_-]/i', '', (string) $attributes['uid'] )
			: '';
		if ( '' === $uid ) {
			return;
		}

		$attrs_merged                 = Blocksify_CSS_Pipeline::merge_block_defaults( 'blocksify/content-loop', $attributes );
		list( $class_attr, $id_attr ) = self::build_wrapper_classes( $attrs_merged, $uid );

		// Entity-unavailable copy is intentionally distinct from the
		// generic empty-state (`blocksify.cl.empty_state` filter, SPEC
		// §5.1 simplified signature) — "Pro extension missing" is a
		// different recovery affordance than "no posts match the
		// filters". Pro extensions that want to ship their own
		// fallback HTML should hook `blocksify.cl.render_entity_loop`
		// (returning the full loop) instead of overriding this
		// placeholder.
		$placeholder = sprintf(
			'<p class="bsy-cl__empty">%s</p>',
			esc_html(
				sprintf(
					/* translators: %s: entity slug (e.g. "users", "products") */
					__( 'Content type "%s" is unavailable. Activate the matching Blocksify extension or pick a different content type.', 'blocksify' ),
					$entity
				)
			)
		);
		printf(
			'<div class="%1$s"%2$s>%3$s</div>',
			$class_attr,    // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_attr'd above.
			$id_attr,       // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_attr'd above.
			$placeholder    // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- pre-escaped via esc_html above.
		);
	}

	/**
	 * Phase 4.4 — forward-compat hook for the Pro Users entity. Returns
	 * the role slugs available for filtering a Users-entity query (Pro
	 * registers the Users entity catalog via `blocksify.cl.entities`; its
	 * query catalog wires a Role multi-select that reads from this helper).
	 *
	 * Free has no consumer — Users entity ships in Pro — but the helper
	 * lands here so Pro plugins can extend the default role list via the
	 * `blocksify.cl.user_roles` filter and Free + Pro stay in sync on
	 * "which roles are considered selectable" (e.g. site owners that hide
	 * the `subscriber` role from picker UIs).
	 *
	 * Default returns every registered role slug from `wp_roles()`. Pro
	 * filters in to drop / reorder / rename entries.
	 *
	 * Signature: `( string[] $roles ) => string[]`
	 *
	 * @since 1.0.0 (Phase 4.4)
	 *
	 * @return string[] Role slugs.
	 */
	public static function get_available_user_roles() {
		$default = function_exists( 'wp_roles' ) ? array_keys( wp_roles()->roles ) : array();
		return (array) apply_filters( 'blocksify.cl.user_roles', $default );
	}

	/**
	 * Render one card for the Posts entity. Emits
	 * `<article class="bsy-card">…</article>` for the current post inside
	 * The Loop.
	 *
	 * Inner zones go through two buffers (per Decision #33): one for the media
	 * zone, one for the non-media zones (header + body + footer). The
	 * non-media buffer is wrapped in `<div class="bsy-card__content">` only
	 * when at least one of header / body / footer produced output — empty
	 * content wrappers never appear in DOM. This lets row-mode CSS use a
	 * simple 2-column grid (figure + content) instead of the earlier
	 * `grid-row` span hack which left empty space below content when fewer
	 * than 3 non-media zones were authored.
	 *
	 * When both buffers come back empty, the article ends up pure-empty
	 * (no whitespace between opening + closing tags) so the
	 * `.bsy-card:empty { display: none }` rule still hides it.
	 *
	 * SPEC §5.7 — sibling methods land in later phases:
	 *   `render_card_terms( $layout, WP_Term $term, $attrs )` → Phase 4.3
	 *   `render_card_users( $layout, WP_User $user, $attrs )` → Pro
	 *
	 * @param array $layout  Block's `layout` attribute.
	 * @param int   $post_id Current post ID.
	 * @param array $attrs   Block attributes (forwarded to filters).
	 */
	public static function render_card_posts( $layout, $post_id, $attrs ) {
		$layout = self::ensure_shape( $layout );

		// Typed context bag forwarded to every zone renderer + element
		// render handler + the `blocksify.cl.element_render.{type}` filter.
		// The `entity` key tells handlers which data source to read from;
		// `post_id` is set only for the posts entity (sibling `term` field
		// on `render_card_terms`).
		$context = array(
			'entity'  => 'posts',
			'post_id' => $post_id,
			'attrs'   => $attrs,
		);

		/**
		 * Per-card wrapper classes.
		 *
		 * The `$object` argument is the entity's loop object — `WP_Post` for
		 * the posts entity, `WP_Term` for terms (Phase 4.2), Pro-defined for
		 * Pro entities. Pre-existing posts-only callbacks reading `$post->ID`
		 * etc. continue to work for the posts case unchanged.
		 *
		 * Signature: `( array $classes, array $attrs, mixed $object ) => array`
		 *
		 * @since 1.0.0
		 */
		$card_classes = (array) apply_filters(
			'blocksify.cl.card_classes',
			array( 'bsy-card' ),
			$attrs,
			get_post( $post_id )
		);

		$post_object = get_post( $post_id );

		/**
		 * Phase 4.4 — fires before the per-card markup is echoed. Pro hooks
		 * here for animation prep / tracking pixels / scroll-effect wrappers
		 * that should bracket the entire visual unit (including any
		 * `card_wrapper` `<a>`).
		 *
		 * Signature: `do_action( 'blocksify.cl.before_card', mixed $object, array $attrs )`
		 *
		 * @since 1.0.0 (Phase 4.4)
		 */
		do_action( 'blocksify.cl.before_card', $post_object, $attrs );

		// Buffer 1: media zone.
		ob_start();
		self::render_media( $layout['media'], $context );
		$media_html = ob_get_clean();

		// Buffer 2: non-media zones, only wrapped when at least one produces
		// output. The zone renderer helpers return without emitting anything
		// when their inputs are empty (verified in render_header_or_footer +
		// render_body), so checking for '' is sufficient — no risk of stray
		// whitespace or comments leaking in.
		ob_start();
		self::render_header_or_footer(
			$layout['header'],
			'header',
			'bsy-card__header',
			$context
		);
		self::render_body( $layout['body'], $context );
		self::render_header_or_footer(
			$layout['footer'],
			'footer',
			'bsy-card__footer',
			$context
		);
		$content_inner = ob_get_clean();
		$content_html  = '' !== $content_inner
			? '<div class="bsy-card__content">' . $content_inner . '</div>'
			: '';

		/**
		 * Card-link overlay — `blocksify.cl.card_wrapper` filter
		 * (Decision #55 refactor of Phase 4.4).
		 *
		 * Free `cardLink` attribute toggle (`'none'` | `'card-wrap'`) seeds
		 * the filter with the per-entity archive URL. Pro can hook to:
		 *   - Override the URL (affiliate / tracking / modal trigger).
		 *   - Opt in regardless of the Free toggle.
		 *   - Opt out by returning null.
		 *
		 * Renderer shape: when the filter returns a non-empty string, an
		 * `<a class="bsy-card__link-overlay">` SIBLING is emitted INSIDE
		 * `<article>` (not as an outer wrap — Phase 4.4 schema break, no
		 * migration shim per pre-release). Inner element links (Title,
		 * Read More, taxonomy terms, term link CTA) coexist via CSS
		 * z-index layering — overlay z-index: 1, content z-index: 2, inner
		 * anchors z-index: 3. Clicking outside an inner link activates
		 * the overlay's URL; clicking an inner link wins (higher z-index).
		 * HTML5 valid — no nested `<a>` because overlay is a sibling.
		 *
		 * Signature: `( ?string $href, mixed $object, array $attrs ) => ?string`
		 *
		 * @since 1.0.0 (Phase 4.4 — Decision #55 refactor)
		 */
		$default_href = ( 'card-wrap' === ( $attrs['cardLink'] ?? 'default' ) )
			? self::resolve_url( $context )
			: null;
		$overlay_href = apply_filters( 'blocksify.cl.card_wrapper', $default_href, $post_object, $attrs );

		$overlay_html = '';
		if ( is_string( $overlay_href ) && '' !== $overlay_href ) {
			$overlay_html = sprintf(
				'<a class="bsy-card__link-overlay" href="%1$s" aria-label="%2$s"></a>',
				esc_url( $overlay_href ),
				esc_attr( self::resolve_card_title( $context ) )
			);
			// Modifier class lets static CSS apply the z-index layering
			// rules only when the overlay is actually rendered — clean
			// state cascade when toggling Card link off.
			$card_classes[] = 'cl-is-card-clickable';
		}

		echo '<article class="' . esc_attr( implode( ' ', $card_classes ) ) . '">'
			. $overlay_html  // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- composed from escaped pieces.
			. $media_html    // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- composed from escaped pieces.
			. $content_html  // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- composed from escaped pieces.
			. '</article>';

		/**
		 * Phase 4.4 — fires after the per-card markup is echoed. Companion
		 * to `blocksify.cl.before_card`; brackets the entire visual unit
		 * (article + overlay sibling inside).
		 *
		 * Signature: `do_action( 'blocksify.cl.after_card', mixed $object, array $attrs )`
		 *
		 * @since 1.0.0 (Phase 4.4)
		 */
		do_action( 'blocksify.cl.after_card', $post_object, $attrs );
	}

	/**
	 * Render one card for the Terms entity (Phase 4.3 — replaces the 4.2
	 * placeholder body). Walks the full layout via the shared zone
	 * helpers, branched by `$context['entity'] = 'terms'`. Element render
	 * handlers (`html_title`, `html_description`, `html_count`,
	 * `html_link`) read the term-specific binding (`$context['term']`);
	 * posts-only elements (date / author / category / tags / comments /
	 * reading-time) guard on `$context['entity']` and silently return ''
	 * so a posts layout dragged into a terms-entity card renders the
	 * cross-entity slots only without warnings.
	 *
	 * DOM contract identical to the posts card: `<article class="bsy-card">`
	 * wraps the media buffer + the content buffer; pure-empty articles get
	 * hidden via `.bsy-card:empty { display: none }`. SPEC §5.7.
	 *
	 * @param array   $layout Block's `layout` attribute.
	 * @param WP_Term $term   Term object from the `get_terms()` loop.
	 * @param array   $attrs  Block attributes (forwarded to filters).
	 */
	public static function render_card_terms( $layout, $term, $attrs ) {
		if ( ! ( $term instanceof WP_Term ) ) {
			return;
		}

		$layout = self::ensure_shape( $layout );

		// Typed context bag — sibling shape to the posts card's `post_id`
		// keyed entry. `term` carries the full `WP_Term`; handlers read
		// `$context['term']->name`, `$context['term']->description`,
		// `$context['term']->count`, etc.
		$context = array(
			'entity' => 'terms',
			'term'   => $term,
			'attrs'  => $attrs,
		);

		$card_classes = (array) apply_filters(
			'blocksify.cl.card_classes',
			array( 'bsy-card' ),
			$attrs,
			$term
		);

		// Phase 4.4 — `before_card` action (mirror of the posts branch).
		// Same signature; `$object` is the `WP_Term` for terms entity.
		do_action( 'blocksify.cl.before_card', $term, $attrs );

		// Buffer 1: media zone. Free terms have no `media.image` in their
		// default layout (and the catalog has no `image` element); the
		// buffer ends up empty unless Pro registered a term-thumbnail
		// element or the user authored an overlay slot.
		ob_start();
		self::render_media( $layout['media'], $context );
		$media_html = ob_get_clean();

		// Buffer 2: non-media zones — same wrap rule as posts (only when
		// at least one zone produces output).
		ob_start();
		self::render_header_or_footer(
			$layout['header'],
			'header',
			'bsy-card__header',
			$context
		);
		self::render_body( $layout['body'], $context );
		self::render_header_or_footer(
			$layout['footer'],
			'footer',
			'bsy-card__footer',
			$context
		);
		$content_inner = ob_get_clean();
		$content_html  = '' !== $content_inner
			? '<div class="bsy-card__content">' . $content_inner . '</div>'
			: '';

		// Card-link overlay (mirror of the posts branch — Decision #55).
		// See `render_card_posts` for the full contract docblock.
		$default_href = ( 'card-wrap' === ( $attrs['cardLink'] ?? 'default' ) )
			? self::resolve_url( $context )
			: null;
		$overlay_href = apply_filters( 'blocksify.cl.card_wrapper', $default_href, $term, $attrs );

		$overlay_html = '';
		if ( is_string( $overlay_href ) && '' !== $overlay_href ) {
			$overlay_html = sprintf(
				'<a class="bsy-card__link-overlay" href="%1$s" aria-label="%2$s"></a>',
				esc_url( $overlay_href ),
				esc_attr( self::resolve_card_title( $context ) )
			);
			$card_classes[] = 'cl-is-card-clickable';
		}

		echo '<article class="' . esc_attr( implode( ' ', $card_classes ) ) . '">'
			. $overlay_html  // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- composed from escaped pieces.
			. $media_html    // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- composed from escaped pieces.
			. $content_html  // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- composed from escaped pieces.
			. '</article>';

		// Phase 4.4 — `after_card` action (mirror of the posts branch).
		do_action( 'blocksify.cl.after_card', $term, $attrs );
	}

	protected static function ensure_shape( $layout ) {
		if ( ! is_array( $layout ) ) {
			$layout = array();
		}
		if ( ! isset( $layout['media'] ) || ! is_array( $layout['media'] ) ) {
			$layout['media'] = array();
		}
		if ( ! isset( $layout['media']['image'] ) ) {
			$layout['media']['image'] = null;
		}
		if ( ! isset( $layout['media']['overlays'] ) || ! is_array( $layout['media']['overlays'] ) ) {
			$layout['media']['overlays'] = array();
		}
		// Decision #36 — overlays are sparse: empty slots are omitted from
		// the array instead of stored as `null` sentinels. The render path
		// iterates `self::OVERLAY_POSITIONS` and reads via the null
		// coalescing operator, so absent keys equate to "no element here".
		foreach ( array( 'header', 'body', 'footer' ) as $zone ) {
			if ( ! isset( $layout[ $zone ] ) || ! is_array( $layout[ $zone ] ) ) {
				$layout[ $zone ] = array();
			}
		}
		return $layout;
	}

	/**
	 * Walk every element in `$layout` that carries a non-empty `styling`
	 * sub-object. PHP twin of the JS `walkLayout` filter (Content Loop
	 * `edit.js`) — Phase 2 ONLY Title opts in (`defaultStyleGroups` is set
	 * on the Title catalog entry); Phase 4 expands element-type coverage.
	 *
	 * Walks in render order: media.image → overlays.{tl,tr,center,bl,br} →
	 * header[] → body[] → footer[]. Matches the JS-side walker so the same
	 * element ordering produces deterministic CSS output.
	 *
	 * @param array $layout Block's `layout` attribute.
	 * @return array<int, array> Styled nodes (each carries at least `type` + `styling`).
	 */
	public static function walk_layout_with_styling( $layout ) {
		$layout = self::ensure_shape( $layout );
		$out    = array();

		if ( self::has_styling( $layout['media']['image'] ) ) {
			$out[] = $layout['media']['image'];
		}
		foreach ( self::OVERLAY_POSITIONS as $pos ) {
			$slot = $layout['media']['overlays'][ $pos ] ?? null;
			if ( ! is_array( $slot ) || ! isset( $slot['children'] ) || ! is_array( $slot['children'] ) ) {
				continue;
			}
			foreach ( $slot['children'] as $child ) {
				if ( self::has_styling( $child ) ) {
					$out[] = $child;
				}
			}
		}
		foreach ( array( 'header', 'body', 'footer' ) as $zone ) {
			foreach ( $layout[ $zone ] as $node ) {
				if ( self::has_styling( $node ) ) {
					$out[] = $node;
				}
			}
		}
		return $out;
	}

	/**
	 * Build sparse-storage preview CSS for every element in `$layout` with
	 * styling. Frontend equivalent of edit.js's per-element walker +
	 * `buildSparsePreviewCss` loop.
	 *
	 * Per FOUNDATION §6.4 + §9.1: declarations match the JS aggregator
	 * byte-for-byte for the same input. Selectors intentionally differ —
	 * frontend uses single-class (0,1,0) `.bsy-cl-{uid} .bsy-card__{type}`
	 * and wins via source-order; editor uses bumped specificity.
	 *
	 * Frontend always emits the desktop base + tablet/mobile `@media` rules
	 * + `:hover` rules from `emit_hover_decls`. Editor's hover-mode preview
	 * lock is JS-only (it freezes the canvas for editing); frontend has no
	 * such mode — real mouse `:hover` triggers the override naturally.
	 *
	 * @param array  $layout Block's `layout` attribute.
	 * @param string $uid    8-char hex uid.
	 * @param array  $attrs  Block attributes (forwarded for future filter use).
	 * @return string CSS string (no `<style>` wrapper). Empty when no styled elements.
	 */
	public static function emit_layout_sparse_css( $layout, $uid, $attrs ) {
		if ( empty( $uid ) ) {
			return '';
		}

		$css = '';

		// Element-level styling — `media.image` + each overlay slot child +
		// every header / body / footer item with a non-empty `styling`.
		$styled = self::walk_layout_with_styling( $layout );
		foreach ( $styled as $node ) {
			$type = isset( $node['type'] ) && is_string( $node['type'] ) ? $node['type'] : '';
			if ( '' === $type ) {
				continue;
			}
			$css .= Blocksify_Styling_Repeater::build_sparse_preview_css(
				$node['styling'],
				array(
					'block_name'         => 'blocksify/content-loop',
					'uid'                => $uid,
					'part_selector'      => self::element_part_selector( $type ),
					'consumer_name'      => 'content-loop',
					'selector_overrides' => self::element_selector_overrides( $type ),
				)
			);
		}

		// Container-level styling (Phase 3.4 + 3.5) — zone wrappers + overlay
		// slot wrappers. Mirror of the JS aggregator's `walkContainerStyling`
		// loop in `edit.js`.
		$css .= self::emit_container_sparse_css( $layout, $uid );

		// Card spacing extras — the Card Style Layout V-Gap (sparse
		// `cardStyling.rowGap`) drives BOTH inner-card gap surfaces in
		// both card-layout modes:
		//   1. `.bsy-card { row-gap }`         — standard sparse walk
		//      above (Layout group default cssBuilder + partSelector
		//      ` .bsy-card`). Covers `cardLayout='stacked'` vertical
		//      figure ↔ content separator.
		//   2. `.bsy-card { column-gap }`      — added here so
		//      `cardLayout='row'` (grid 2-col) gets a figure ↔ content
		//      separator on the horizontal axis. Same rowGap value
		//      reused as column-gap.
		//   3. `.bsy-card__content { row-gap }` — added here too,
		//      mirrors onto the inner column-flex wrapper so header ↔
		//      body ↔ footer separator tracks the same control.
		// JS twin: `cardSpacingExtrasCss` in `edit.js`.
		$css .= self::emit_card_spacing_extras( $layout, $uid );

		// Footer "Stick to bottom" (layout.footerStick) — push the footer to
		// the bottom of the content column. Off by default.
		$css .= self::emit_footer_stick_css( $layout, $uid );

		return $css;
	}

	/**
	 * Emit the two "extras" rules that complement the standard sparse
	 * walker for Card Style's Layout V-Gap — `.bsy-card { column-gap }`
	 * for row-mode horizontal separator coverage and
	 * `.bsy-card__content { row-gap }` for the inner zone separator
	 * mirror. JS twin: `cardSpacingExtrasCss` in `edit.js`.
	 *
	 * @param array  $layout Block layout attribute.
	 * @param string $uid    Block uid.
	 * @return string CSS string ('' when no rowGap authored on cardStyling).
	 */
	protected static function emit_card_spacing_extras( $layout, $uid ) {
		if ( ! is_array( $layout ) ) {
			return '';
		}
		$card_styling = isset( $layout['cardStyling'] ) && is_array( $layout['cardStyling'] )
			? $layout['cardStyling']
			: null;
		if ( ! $card_styling
			|| ! isset( $card_styling['rowGap'] )
			|| ! is_array( $card_styling['rowGap'] )
		) {
			return '';
		}
		$row_gap_entry = $card_styling['rowGap'];
		$ctx_block     = 'blocksify/content-loop';
		$consumer      = 'content-loop';

		// Bumped specificity `.bsy-card.bsy-card` (0,3,0) so per-instance
		// row + column gap wins over the static `.cl-is-card-row .bsy-card
		// { gap: xs }` rule (0,3,0). The standard sparse walk in
		// `Blocksify_CSS_Generator::emit_content_loop_local_rules`
		// already emits `.bsy-card { row-gap }` (0,2,0) but loses to the
		// static rule on row-mode cards; this extras pair authoritatively
		// wins via the bumped selector.
		$css = Blocksify_Styling_Repeater::build_sparse_preview_css(
			array(
				'rowGap'    => $row_gap_entry,
				'columnGap' => $row_gap_entry,
			),
			array(
				'block_name'    => $ctx_block,
				'uid'           => $uid,
				'part_selector' => ' .bsy-card.bsy-card',
				'consumer_name' => $consumer,
			)
		);
		$css .= Blocksify_Styling_Repeater::build_sparse_preview_css(
			array( 'rowGap' => $row_gap_entry ),
			array(
				'block_name'    => $ctx_block,
				'uid'           => $uid,
				'part_selector' => ' .bsy-card__content',
				'consumer_name' => $consumer,
			)
		);
		return $css;
	}

	/**
	 * Footer "Stick to bottom" — when `layout.footerStick` is truthy, push
	 * the footer to the bottom of the content column so it aligns with the
	 * bottom of a taller media column (row layout) or with sibling cards'
	 * footers in an equal-height grid. `.bsy-card__content { flex }` lets the
	 * content fill the card height in stacked mode (a no-op on the grid item
	 * in row mode, where the column already stretches); `.bsy-card__footer {
	 * margin-top: auto }` absorbs the slack above the footer to do the push.
	 * Off by default — emits nothing when the toggle is absent / false. JS
	 * twin: the `footerStick` branch in `buildContentLoopPreviewCss`.
	 *
	 * @param array  $layout Block layout attribute.
	 * @param string $uid    Block uid.
	 * @return string CSS string ('' when off / no uid).
	 */
	protected static function emit_footer_stick_css( $layout, $uid ) {
		if ( ! is_array( $layout ) || empty( $layout['footerStick'] ) || empty( $uid ) ) {
			return '';
		}
		$base = '.bsy-cl-' . $uid;
		return $base . ' .bsy-card__content{flex:1 1 auto}'
			. $base . ' .bsy-card__footer{margin-top:auto}';
	}

	/**
	 * Emit per-instance CSS for zone + overlay slot wrapper styling. Pure
	 * twin of the JS `walkContainerStyling` + `buildSparsePreviewCss` loop
	 * in `edit.js`. Selector for each zone surface comes from
	 * `self::ZONE_STYLING_MAP` (zone wrappers) + the literal
	 * `.bsy-card__overlay-{pos}` (overlay slot wrappers).
	 *
	 * @param array  $layout Block layout attribute.
	 * @param string $uid    Block uid.
	 * @return string CSS string (no `<style>` wrapper).
	 */
	protected static function emit_container_sparse_css( $layout, $uid ) {
		if ( ! is_array( $layout ) ) {
			return '';
		}
		$css = '';

		foreach ( self::ZONE_STYLING_MAP as $zone_key => $entry ) {
			list( $path_csv, $selector ) = $entry;
			$styling = self::read_path( $layout, $path_csv );
			if ( ! is_array( $styling ) || empty( $styling ) ) {
				continue;
			}
			$css .= Blocksify_Styling_Repeater::build_sparse_preview_css(
				$styling,
				array(
					'block_name'    => 'blocksify/content-loop',
					'uid'           => $uid,
					'part_selector' => $selector,
					'consumer_name' => 'content-loop',
				)
			);
		}

		$overlays = isset( $layout['media']['overlays'] ) && is_array( $layout['media']['overlays'] )
			? $layout['media']['overlays']
			: array();
		foreach ( self::OVERLAY_POSITIONS as $pos ) {
			$slot = $overlays[ $pos ] ?? null;
			if ( ! is_array( $slot )
				|| ! isset( $slot['styling'] )
				|| ! is_array( $slot['styling'] )
				|| empty( $slot['styling'] )
			) {
				continue;
			}
			$css .= Blocksify_Styling_Repeater::build_sparse_preview_css(
				$slot['styling'],
				array(
					'block_name'    => 'blocksify/content-loop',
					'uid'           => $uid,
					'part_selector' => ' .bsy-card__overlay-' . $pos,
					'consumer_name' => 'content-loop',
				)
			);
		}

		return $css;
	}

	/**
	 * Read a dotted path from a nested array. Used by the container walker
	 * to resolve `ZONE_STYLING_MAP` paths against the layout.
	 *
	 * @param array  $source Source array.
	 * @param string $path   Dotted path ('a.b.c').
	 * @return mixed Value at path or null when any segment missing.
	 */
	protected static function read_path( $source, $path ) {
		$segs = explode( '.', $path );
		$cur  = $source;
		foreach ( $segs as $seg ) {
			if ( ! is_array( $cur ) || ! array_key_exists( $seg, $cur ) ) {
				return null;
			}
			$cur = $cur[ $seg ];
		}
		return $cur;
	}

	/**
	 * PHP twin of the JS `elementPartSelector` helper. Maps an element
	 * `type` to its DOM-selector relative to `.bsy-cl-{uid}`.
	 *
	 * - Image renders into `.bsy-card__media-image` (a/figure wrapper
	 *   around the post thumbnail).
	 * - Category / tags render `<div class="bsy-card__{type}">` wrapping
	 *   N `<a class="bsy-card__term">` chip children — per-instance
	 *   styling targets the chips so padding / background / border /
	 *   typography apply to each chip individually (Decision #58). The
	 *   companion `element_selector_overrides` routes container-level
	 *   groups (layout / margin / position / overflow / zIndex) back to
	 *   the wrapper.
	 * - Every other element type follows the default `.bsy-card__{type}`
	 *   convention.
	 *
	 * Pro custom-meta elements (Decision #29) will hit the `c-{slug}`
	 * namespace when their UI lands; extend here when that happens.
	 *
	 * @param string $type Catalog element id.
	 * @return string Leading-space selector.
	 */
	protected static function element_part_selector( $type ) {
		if ( 'image' === $type ) {
			return ' .bsy-card__media-image';
		}
		if ( 'category' === $type || 'tags' === $type ) {
			return ' .bsy-card__' . $type . ' .bsy-card__term';
		}
		return ' .bsy-card__' . $type;
	}

	/**
	 * PHP twin of the JS `elementSelectorOverrides` helper. Returns the
	 * per-group selector overrides for elements whose primary surface
	 * (`element_part_selector`) is a child but a few groups belong on
	 * the wrapper instead. Decision #58.
	 *
	 * @param string $type Catalog element id.
	 * @return array Map `{ groupKey: selectorSuffix }`, empty when no
	 *               overrides apply.
	 */
	protected static function element_selector_overrides( $type ) {
		if ( 'category' !== $type && 'tags' !== $type ) {
			return array();
		}
		$wrapper = ' .bsy-card__' . $type;
		return array(
			'layout'   => $wrapper,
			'margin'   => $wrapper,
			'position' => $wrapper,
			'overflow' => $wrapper,
			'zIndex'   => $wrapper,
		);
	}

	/**
	 * `true` when `$node` carries a non-empty `styling` sub-object.
	 *
	 * @param mixed $node Layout entry (element node or null).
	 * @return bool
	 */
	protected static function has_styling( $node ) {
		return is_array( $node )
			&& isset( $node['styling'] )
			&& is_array( $node['styling'] )
			&& ! empty( $node['styling'] );
	}

	/**
	 * Render the media zone. Image resolution priority (Decision #34):
	 *   1. `has_post_thumbnail()` → real featured image (`html_image`)
	 *   2. `blocksify.cl.media_image_fallback` filter → theme/Pro override
	 *   3. SVG placeholder (`html_image_placeholder`) — only when overlays
	 *      would otherwise float in an empty figure
	 *   4. Skip the entire media zone (status-quo)
	 */
	protected static function render_media( $media, $context ) {
		$has_image_element = ! empty( $media['image'] );
		$overlays          = isset( $media['overlays'] ) && is_array( $media['overlays'] ) ? $media['overlays'] : array();
		$filled_overlays   = array();
		foreach ( self::OVERLAY_POSITIONS as $pos ) {
			$slot = $overlays[ $pos ] ?? null;
			if ( is_array( $slot )
				&& isset( $slot['children'] )
				&& is_array( $slot['children'] )
				&& ! empty( $slot['children'] )
			) {
				$filled_overlays[ $pos ] = $slot['children'];
			}
		}

		// Resolve image HTML: real featured → theme filter → empty. The
		// URL needed for the `<a>` wrapper depends on the entity (post
		// permalink vs term archive), so resolve only inside the image
		// branch — keeps `$post_id` reads out of the path when entity
		// isn't posts.
		$image_html = '';
		$value      = ( $has_image_element && isset( $media['image']['value'] ) && is_array( $media['image']['value'] ) )
			? $media['image']['value']
			: array();
		if ( $has_image_element ) {
			$image_html = self::html_image( $value, $context );
			// SVG placeholder — fall back when the Image element is
			// configured AND no real featured image resolves. Previously
			// gated on `! empty( $filled_overlays )` (placeholder only
			// when overlays would otherwise float), but that left
			// row-mode posts inconsistent: posts with thumbnails rendered
			// 2-col, posts without collapsed to 1-col via
			// `:not(:has(.bsy-card__media))`. Showing the placeholder
			// when the Image element is in the card config keeps the
			// grid uniform. Users who want clean text-only cards remove
			// the Image element — `$has_image_element` is then false and
			// the final skip-guard below drops the figure.
			//
			// Phase 4.5 — twin drift fix: gate the placeholder fallback
			// on `entity === 'posts'` to match the JS twin (which gates
			// at `renderLayout.js::renderMediaZone`). Pro plugins that
			// register an `image` element for non-posts entities (e.g.
			// term thumbnail via custom field) are responsible for
			// supplying their own placeholder via the
			// `blocksify.cl.media_image_fallback` filter — Free's SVG
			// placeholder is a posts-grid-consistency mechanism, not a
			// generic "image-element is configured but missing" sentinel.
			$entity = isset( $context['entity'] ) ? (string) $context['entity'] : 'posts';
			if ( '' === $image_html && 'posts' === $entity ) {
				$placeholder_url = self::resolve_url( $context );
				$image_html      = self::html_image_placeholder( $value, $placeholder_url );
			}
		}

		// Nothing to show → skip the figure entirely.
		if ( '' === $image_html && empty( $filled_overlays ) ) {
			return;
		}

		echo '<figure class="bsy-card__media">';
		echo $image_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already-escaped by html_image / placeholder helper / theme filter (filter contract documented inline).
		foreach ( $filled_overlays as $pos => $children ) {
			echo '<div class="bsy-card__overlay-' . esc_attr( $pos ) . '">';
			foreach ( $children as $child ) {
				self::render_element( $child, $context, false );
			}
			echo '</div>';
		}
		echo '</figure>';
	}

	/**
	 * Render header/footer zone. Logic:
	 *   - both L + R filled  → wrapper-of-wrapper (`<tag>L-div + R-div</tag>`)
	 *   - only one side      → flat (elements direct children of zone) +
	 *                          `cl-is-only-{left,right}` modifier
	 *   - neither            → skip
	 */
	protected static function render_header_or_footer(
		$zone_arr,
		$tag,
		$wrapper_class,
		$context
	) {
		$left  = array();
		$right = array();
		foreach ( $zone_arr as $el ) {
			if ( isset( $el['side'] ) && 'R' === $el['side'] ) {
				$right[] = $el;
			} else {
				$left[] = $el;
			}
		}
		$has_left  = ! empty( $left );
		$has_right = ! empty( $right );
		if ( ! $has_left && ! $has_right ) {
			return;
		}
		$tag = sanitize_html_class( $tag );

		if ( $has_left && $has_right ) {
			echo '<' . $tag . ' class="' . esc_attr( $wrapper_class ) . '">';
			echo '<div class="' . esc_attr( $wrapper_class . '-left' ) . '">';
			foreach ( $left as $el ) {
				self::render_element( $el, $context, false );
			}
			echo '</div>';
			echo '<div class="' . esc_attr( $wrapper_class . '-right' ) . '">';
			foreach ( $right as $el ) {
				self::render_element( $el, $context, false );
			}
			echo '</div>';
			echo '</' . $tag . '>';
			return;
		}

		// Only one side filled — flatten + modifier.
		$modifier = $has_right ? 'cl-is-only-right' : 'cl-is-only-left';
		$elements = $has_right ? $right : $left;
		echo '<' . $tag . ' class="' . esc_attr( $wrapper_class . ' ' . $modifier ) . '">';
		foreach ( $elements as $el ) {
			self::render_element( $el, $context, false );
		}
		echo '</' . $tag . '>';
	}

	protected static function render_body( $body_arr, $context ) {
		if ( empty( $body_arr ) ) {
			return;
		}
		echo '<div class="bsy-card__body">';
		foreach ( $body_arr as $el ) {
			self::render_element( $el, $context, false );
		}
		echo '</div>';
	}

	protected static function render_element( $node, $context, $in_media ) {
		if ( ! is_array( $node ) || ! isset( $node['type'] ) ) {
			return;
		}
		$type = (string) $node['type'];

		// Image element is media-zone-exclusive. If a programmatic edge case
		// puts it in header/body/footer, silently skip rather than emit a
		// stray <a> with no media wrapper context.
		if ( 'image' === $type && ! $in_media ) {
			return;
		}

		$value = isset( $node['value'] ) && is_array( $node['value'] ) ? $node['value'] : array();

		$html = self::render_element_html( $type, $value, $context, $in_media );

		/**
		 * Per-element render override (one filter per element `type`).
		 *
		 * The `$context` typed array carries the entity binding so callbacks
		 * don't have to call `get_post()` / `get_term()` to know which loop
		 * produced the node. Entity-specific shapes:
		 *
		 *   posts: [ 'entity'=>'posts', 'post_id'=>int,        'attrs'=>array ]
		 *   terms: [ 'entity'=>'terms', 'term'=>WP_Term,        'attrs'=>array ]
		 *   users: [ 'entity'=>'users', 'user'=>WP_User,        'attrs'=>array ]  (Pro)
		 *
		 * Always check `$context['entity']` before reading entity-specific
		 * keys. Phase 4.3: filter shape stable; the context bag is now
		 * propagated from the entity-aware card renderer (no synthesis at
		 * the call site).
		 *
		 * Signature: `( string $html, array $node, array $context ) => string`
		 *
		 * @since 1.0.0
		 */
		$html = (string) apply_filters(
			'blocksify.cl.element_render.' . $type,
			$html,
			$node,
			$context
		);

		echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- composed from escaped pieces.
	}

	protected static function render_element_html( $type, $value, $context, $in_media ) {
		$entity = isset( $context['entity'] ) ? (string) $context['entity'] : 'posts';
		switch ( $type ) {
			case 'image':
				return self::html_image( $value, $context );
			case 'title':
				return self::html_title( $value, $context );
			case 'description':
				return self::html_description( $value, $context );
			case 'count':
				return 'terms' === $entity ? self::html_count( $value, $context ) : '';
			case 'link':
				return self::html_link( $value, $context );
			case 'date':
				return 'posts' === $entity ? self::html_date( $value, $context ) : '';
			case 'modified':
				return 'posts' === $entity ? self::html_modified( $value, $context ) : '';
			case 'author':
				return 'posts' === $entity ? self::html_author( $value, $context ) : '';
			case 'category':
				return 'posts' === $entity
					? self::html_taxonomy( $value, $context, 'category', 'bsy-card__category' )
					: '';
			case 'tags':
				return 'posts' === $entity
					? self::html_taxonomy( $value, $context, 'post_tag', 'bsy-card__tags' )
					: '';
			case 'comments':
				return 'posts' === $entity ? self::html_comments( $value, $context ) : '';
			case 'reading-time':
				return 'posts' === $entity ? self::html_reading_time( $value, $context ) : '';
		}
		return '';
	}

	/**
	 * Resolve the per-entity canonical URL — post permalink for posts,
	 * term archive link for terms. Returns '#' as a last-resort fallback
	 * when the lookup fails (e.g. missing post id, WP_Error from
	 * `get_term_link`). Centralised so `html_image` + `html_title` +
	 * `html_link` agree on the URL source.
	 *
	 * @param array $context Typed context bag (see render_element doc).
	 * @return string Escaped-safe URL string (caller still escapes via esc_url).
	 */
	protected static function resolve_url( $context ) {
		$entity = isset( $context['entity'] ) ? (string) $context['entity'] : 'posts';
		if ( 'terms' === $entity && isset( $context['term'] ) && $context['term'] instanceof WP_Term ) {
			$url = get_term_link( $context['term'] );
			return is_wp_error( $url ) ? '#' : $url;
		}
		if ( isset( $context['post_id'] ) && (int) $context['post_id'] > 0 ) {
			$url = get_permalink( (int) $context['post_id'] );
			return $url ? $url : '#';
		}
		return '#';
	}

	/**
	 * Resolve the per-entity title text — post title for posts, term name
	 * for terms. Used by the card-link overlay's `aria-label` so screen
	 * readers announce the destination meaningfully ("Read more about
	 * <post title>" rather than a bare "link"). Centralised parallel to
	 * `resolve_url` so future Pro entities can share the same shape.
	 *
	 * Phase 4.X follow-up (Decision #55).
	 *
	 * @param array $context Typed context bag (see render_element doc).
	 * @return string Plain text title (caller escapes via esc_attr).
	 */
	protected static function resolve_card_title( $context ) {
		$entity = isset( $context['entity'] ) ? (string) $context['entity'] : 'posts';
		if ( 'terms' === $entity && isset( $context['term'] ) && $context['term'] instanceof WP_Term ) {
			return (string) $context['term']->name;
		}
		if ( isset( $context['post_id'] ) && (int) $context['post_id'] > 0 ) {
			return (string) get_the_title( (int) $context['post_id'] );
		}
		return '';
	}

	/**
	 * Resolve the image HTML for one card. Two-step lookup:
	 *
	 *   1. `has_post_thumbnail()` → real featured image wrapped in `<a>`
	 *   2. `apply_filters('blocksify.cl.media_image_fallback', ...)` →
	 *      theme / Pro override. Phase 4.1 — filter signature upgraded to
	 *      a typed `$context` array for entity-aware callbacks:
	 *
	 *        ( string $html, array $context ) => string
	 *
	 *      Posts-entity context shape:
	 *
	 *        [
	 *          'entity'  => 'posts',
	 *          'post_id' => int,
	 *          'value'   => array,  // Image element `value` (size, etc.)
	 *          'url'     => string, // permalink for `<a>` wrapper
	 *        ]
	 *
	 *      Return `''` to fall through (caller emits SVG placeholder when
	 *      the Image element is configured, otherwise skips the media
	 *      zone). Return a complete `<a class="bsy-card__media-image"
	 *      href="…">…</a>` (or `<div>` wrapper when `value.link === false`)
	 *      — filter callbacks own their own escaping per WP filter
	 *      conventions.
	 *
	 * SVG placeholder + zone-skip live in `render_media()` (caller); this
	 * helper stops at the filter step so the priority order is explicit.
	 */
	protected static function html_image( $value, $context ) {
		$entity = isset( $context['entity'] ) ? (string) $context['entity'] : 'posts';

		// Non-posts entities have no Free image source. Bail to the
		// filter step so Pro can supply (e.g. term thumbnail meta).
		if ( 'posts' === $entity ) {
			$post_id = isset( $context['post_id'] ) ? (int) $context['post_id'] : 0;
			$url     = self::resolve_url( $context );
			if ( $post_id > 0 && has_post_thumbnail( $post_id ) ) {
				$size  = isset( $value['size'] ) ? sanitize_key( (string) $value['size'] ) : 'medium_large';
				$thumb = get_the_post_thumbnail(
					$post_id,
					$size,
					array(
						'loading'  => 'lazy',
						'decoding' => 'async',
						'alt'      => get_the_title( $post_id ),
					)
				);
				if ( $thumb ) {
					$link = ! isset( $value['link'] ) || false !== $value['link'];
					if ( $link ) {
						return sprintf(
							'<a class="bsy-card__media-image" href="%1$s">%2$s</a>',
							esc_url( $url ),
							$thumb // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- WP-escaped.
						);
					}
					return sprintf(
						'<div class="bsy-card__media-image">%1$s</div>',
						$thumb // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- WP-escaped.
					);
				}
			}
		}

		// Filter receives the full context bag; callbacks can read
		// `$context['entity']` to branch on entity. URL is included for
		// posts back-compat — callbacks targeting older Free versions
		// kept reading it from the array.
		$filter_context        = $context;
		$filter_context['value'] = $value;
		$filter_context['url']  = self::resolve_url( $context );
		$fallback                = apply_filters(
			'blocksify.cl.media_image_fallback',
			'',
			$filter_context
		);
		return is_string( $fallback ) ? $fallback : '';
	}

	/**
	 * Placeholder rendered when the post has no featured image. Dispatch
	 * per the Image element's `value.placeholderMode`:
	 *
	 *   - `'none'` (default) → returns '' so the media slot stays empty.
	 *                  Card collapses to its content rows without a media
	 *                  fill — the frontend default, mirroring WP core's
	 *                  Query Loop (a missing featured image emits nothing).
	 *                  The editor still draws a placeholder as an authoring
	 *                  aid (JS twin `renderImagePlaceholder`); this PHP path
	 *                  is frontend-only, so the two intentionally diverge
	 *                  (§9.1).
	 *   - `'custom'` → renders `<img>` of `value.placeholderImage` so the
	 *                  user's uploaded fallback shows when posts lack a
	 *                  featured image.
	 *   - `'svg'`    → core-style box + diagonal line (the same illustration
	 *                  WP core's media placeholder draws). `stroke:
	 *                  currentcolor` (see `.bsy-card__media-image.is-placeholder`
	 *                  in style.css) recolors it via the card's text/link
	 *                  color on any theme — no hardcoded grey.
	 *
	 * Aspect ratio mirrors the Image element's `value.aspectRatio` so
	 * cards stay the same height as cards that DO have an image.
	 */
	protected static function html_image_placeholder( $value, $url ) {
		$mode = isset( $value['placeholderMode'] ) ? (string) $value['placeholderMode'] : 'none';
		if ( 'none' === $mode ) {
			return '';
		}

		$aspect = isset( $value['aspectRatio'] )
			? (string) $value['aspectRatio']
			: self::IMAGE_DEFAULT_ASPECT_RATIO;
		if ( ! preg_match( '#^[0-9]+/[0-9]+$#', $aspect ) ) {
			$aspect = self::IMAGE_DEFAULT_ASPECT_RATIO;
		}

		if ( 'custom' === $mode && ! empty( $value['placeholderImage']['url'] ) ) {
			$ph = $value['placeholderImage'];
			$alt = isset( $ph['alt'] ) ? (string) $ph['alt'] : '';
			return sprintf(
				'<a class="bsy-card__media-image is-placeholder is-custom" href="%1$s" style="aspect-ratio:%2$s" aria-hidden="false"><img src="%3$s" alt="%4$s" loading="lazy" /></a>',
				esc_url( $url ),
				esc_attr( $aspect ),
				esc_url( (string) $ph['url'] ),
				esc_attr( $alt )
			);
		}

		// Core-style box + diagonal-line placeholder.
		return sprintf(
			'<a class="bsy-card__media-image is-placeholder" href="%1$s" style="aspect-ratio:%2$s" aria-hidden="false">%3$s</a>',
			esc_url( $url ),
			esc_attr( $aspect ),
			'<svg viewBox="0 0 60 60" preserveAspectRatio="none" fill="none" aria-hidden="true" focusable="false"><path vector-effect="non-scaling-stroke" d="M60 60 0 0"/></svg>'
		);
	}

	protected static function html_title( $value, $context ) {
		$tag = isset( $value['tag'] ) ? sanitize_html_class( (string) $value['tag'] ) : 'h3';
		if ( ! in_array( $tag, array( 'h2', 'h3', 'h4', 'h5', 'h6' ), true ) ) {
			$tag = 'h3';
		}
		$entity = isset( $context['entity'] ) ? (string) $context['entity'] : 'posts';
		if ( 'terms' === $entity && isset( $context['term'] ) && $context['term'] instanceof WP_Term ) {
			$text = (string) $context['term']->name;
		} else {
			$post_id = isset( $context['post_id'] ) ? (int) $context['post_id'] : 0;
			$text    = $post_id > 0 ? get_the_title( $post_id ) : '';
		}
		// `maxChars` truncation (Phase 5) — null / 0 / unset = full title;
		// anything >0 truncates with `…` ellipsis. Cap matches editor max.
		if (
			isset( $value['maxChars'] )
			&& is_numeric( $value['maxChars'] )
			&& (int) $value['maxChars'] > 0
		) {
			$max = min( 200, (int) $value['maxChars'] );
			$len = function_exists( 'mb_strlen' ) ? mb_strlen( $text ) : strlen( $text );
			if ( $len > $max ) {
				$text = ( function_exists( 'mb_substr' ) ? mb_substr( $text, 0, $max ) : substr( $text, 0, $max ) ) . '…';
			}
		}
		$title_html = esc_html( $text );
		$link       = ! isset( $value['link'] ) || false !== $value['link'];
		if ( $link ) {
			$url        = self::resolve_url( $context );
			$title_html = sprintf( '<a href="%1$s">%2$s</a>', esc_url( $url ), $title_html );
		}
		return sprintf(
			'<%1$s class="bsy-card__title">%2$s</%1$s>',
			esc_attr( $tag ),
			$title_html // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- composed.
		);
	}

	/**
	 * Cross-entity description renderer (Phase 4.3 — Decision #50). Both
	 * entities share the same length × lengthUnit trim logic (Phase 5
	 * iter#3 promoted Terms description to use `DescriptionEditor` —
	 * SPEC §5.3 "no length param" relaxed because term descriptions can
	 * be just as long as post excerpts). Shared DOM class
	 * `.bsy-card__description` styles every entity with one CSS rule.
	 * No prefix/suffix/icon wrap — body-copy slot.
	 */
	protected static function html_description( $value, $context ) {
		$entity = isset( $context['entity'] ) ? (string) $context['entity'] : 'posts';
		if ( 'terms' === $entity && isset( $context['term'] ) && $context['term'] instanceof WP_Term ) {
			$source = wp_strip_all_tags( (string) $context['term']->description );
		} else {
			$post_id = isset( $context['post_id'] ) ? (int) $context['post_id'] : 0;
			if ( $post_id <= 0 ) {
				return '';
			}
			$source = get_the_excerpt( $post_id );
			if ( '' === $source ) {
				$source = wp_strip_all_tags( get_the_content( null, false, $post_id ) );
			}
		}
		if ( '' === $source ) {
			return '';
		}
		$length = isset( $value['length'] ) ? max( 1, (int) $value['length'] ) : 20;
		$unit   = isset( $value['lengthUnit'] ) && is_string( $value['lengthUnit'] )
			? $value['lengthUnit']
			: self::DESCRIPTION_DEFAULT_LENGTH_UNIT;
		switch ( $unit ) {
			case 'characters':
				$text = self::trim_characters( $source, $length );
				break;
			case 'sentences':
				$text = self::trim_sentences( $source, $length );
				break;
			case 'words':
			default:
				$text = wp_trim_words( $source, $length, '…' );
				break;
		}
		return '<p class="bsy-card__description">' . esc_html( $text ) . '</p>';
	}

	protected static function html_date( $value, $context ) {
		$post_id = isset( $context['post_id'] ) ? (int) $context['post_id'] : 0;
		if ( $post_id <= 0 ) {
			return '';
		}
		if ( ! empty( $value['relative'] ) ) {
			$timestamp = get_post_time( 'U', false, $post_id, true );
			$txt       = $timestamp
				? self::compose_relative_text( $value, $timestamp )
				: '';
		} else {
			$txt = get_the_date( '', $post_id );
		}
		$inner = esc_html( (string) $txt );
		// `link` toggle (Phase 5 iter#2) — wrap the date text in an anchor
		// to the post permalink. Common pattern across themes; parity with
		// author / category / tags / title link toggles.
		if ( ! empty( $value['link'] ) ) {
			$permalink = get_permalink( $post_id );
			if ( $permalink ) {
				$inner = sprintf(
					'<a class="bsy-card__date-link" href="%1$s">%2$s</a>',
					esc_url( $permalink ),
					$inner
				);
			}
		}
		return self::wrap_element(
			'date',
			$value,
			'<span class="bsy-card__date">%s</span>',
			$inner
		);
	}

	protected static function html_modified( $value, $context ) {
		$post_id = isset( $context['post_id'] ) ? (int) $context['post_id'] : 0;
		if ( $post_id <= 0 ) {
			return '';
		}
		if ( ! empty( $value['relative'] ) ) {
			$timestamp = get_post_modified_time( 'U', false, $post_id, true );
			$txt       = $timestamp
				? self::compose_relative_text( $value, $timestamp )
				: '';
		} else {
			$txt = get_the_modified_date( '', $post_id );
		}
		$inner = esc_html( (string) $txt );
		if ( ! empty( $value['link'] ) ) {
			$permalink = get_permalink( $post_id );
			if ( $permalink ) {
				$inner = sprintf(
					'<a class="bsy-card__modified-link" href="%1$s">%2$s</a>',
					esc_url( $permalink ),
					$inner
				);
			}
		}
		return self::wrap_element(
			'modified',
			$value,
			'<span class="bsy-card__modified">%s</span>',
			$inner
		);
	}

	/**
	 * Compose the relative-time string for `date` / `modified` elements
	 * when `relative` is on. Format: `"{human_time_diff} {relativeSuffix}"`.
	 *
	 * `relativeSuffix` defaults to the i18n "ago" string so legacy stored
	 * values (no key authored) keep rendering "3 days ago". An explicit
	 * empty string drops the suffix entirely — render becomes "3 days".
	 *
	 * @param array $value     Element value bag.
	 * @param int   $timestamp UTC timestamp.
	 * @return string Composed text (escaping handled by caller).
	 */
	protected static function compose_relative_text( $value, $timestamp ) {
		$diff = human_time_diff(
			$timestamp,
			current_time( 'timestamp' )
		);
		// Default i18n word — used when key absent. An explicit '' from
		// the user wins (drops the suffix); only an unset key triggers
		// the default.
		$suffix = isset( $value['relativeSuffix'] ) && is_string( $value['relativeSuffix'] )
			? $value['relativeSuffix']
			: __( 'ago', 'blocksify' );
		return '' !== $suffix ? $diff . ' ' . $suffix : $diff;
	}

	protected static function html_author( $value, $context ) {
		$post_id = isset( $context['post_id'] ) ? (int) $context['post_id'] : 0;
		if ( $post_id <= 0 ) {
			return '';
		}
		$author_id = (int) get_post_field( 'post_author', $post_id );
		$name      = get_the_author_meta( 'display_name', $author_id );
		// Orphan posts (post_author=0, or author user deleted without
		// reassigning posts) → skip the element entirely instead of
		// emitting an icon-only span. The author element semantically
		// shows "the author of this post"; when there is no author, the
		// truthful render is no element. Caller's empty-card collapse
		// (`.bsy-card:empty { display: none }`) handles the cascade when
		// the author was the only element configured.
		if ( '' === $name || $author_id <= 0 ) {
			return '';
		}
		$link_url  = ! empty( $value['link'] ) ? get_author_posts_url( $author_id ) : '';

		// Avatar (Phase 5) — emitted before the icon slot so visual order
		// is [avatar][icon][prefix][name][suffix]. Avatar lives outside the
		// shared icon slot so users can ship both an avatar and a separate
		// icon (e.g. badge after avatar) without conflict.
		$avatar = '';
		if ( ! empty( $value['avatar'] ) ) {
			$size = isset( $value['avatarSize'] ) && (int) $value['avatarSize'] > 0
				? (int) $value['avatarSize']
				: self::AUTHOR_DEFAULT_AVATAR_SIZE;
			$rendered = get_avatar(
				$author_id,
				$size,
				'',
				'',
				array( 'class' => 'bsy-card__author-avatar' )
			);
			if ( is_string( $rendered ) ) {
				$avatar = $rendered;
			}
		}

		$name_html = esc_html( $name );
		if ( $link_url ) {
			$inner = sprintf(
				'<a class="bsy-card__author-link" href="%1$s">%2$s</a>',
				esc_url( $link_url ),
				$name_html
			);
		} else {
			$inner = $name_html;
		}

		return self::wrap_element(
			'author',
			$value,
			'<span class="bsy-card__author">%s</span>',
			$inner,
			$avatar
		);
	}

	protected static function html_taxonomy( $value, $context, $taxonomy, $wrapper_cls ) {
		$post_id = isset( $context['post_id'] ) ? (int) $context['post_id'] : 0;
		if ( $post_id <= 0 ) {
			return '';
		}
		$terms = get_the_terms( $post_id, $taxonomy );
		if ( ! $terms || is_wp_error( $terms ) ) {
			return '';
		}
		// `maxTerms` cap — Phase 5 element settings.
		if (
			isset( $value['maxTerms'] )
			&& is_numeric( $value['maxTerms'] )
			&& (int) $value['maxTerms'] > 0
		) {
			$terms = array_slice( $terms, 0, max( 1, min( 20, (int) $value['maxTerms'] ) ) );
		}

		$link_on   = ! isset( $value['link'] ) || false !== $value['link'];
		$separator = isset( $value['separator'] ) && is_string( $value['separator'] )
			? $value['separator']
			: '';

		$parts = array();
		foreach ( $terms as $term ) {
			$name = esc_html( $term->name );
			if ( $link_on ) {
				$tlink = get_term_link( $term );
				if ( ! is_wp_error( $tlink ) ) {
					$parts[] = '<a class="bsy-card__term" href="' . esc_url( $tlink ) . '">' . $name . '</a>';
					continue;
				}
			}
			$parts[] = '<span class="bsy-card__term">' . $name . '</span>';
		}

		// Separator is a literal text token between rendered anchors. Empty
		// default preserves the pre-Phase-5 behavior where CSS gap on the
		// flex wrapper provided visual spacing.
		$joined = implode( esc_html( $separator ), $parts );

		// Slug matches wrapper's element class so power users can target
		// per-taxonomy decorations via `.bsy-card__category-icon` etc.
		$slug = ( 'bsy-card__category' === $wrapper_cls ) ? 'category' : 'tags';

		return self::wrap_element(
			$slug,
			$value,
			'<div class="' . esc_attr( $wrapper_cls ) . '">%s</div>',
			$joined // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- parts pre-escaped.
		);
	}

	protected static function html_comments( $value, $context ) {
		$post_id = isset( $context['post_id'] ) ? (int) $context['post_id'] : 0;
		if ( $post_id <= 0 ) {
			return '';
		}
		$count = (int) get_comments_number( $post_id );
		if ( $count <= 0 && ! empty( $value['hideEmpty'] ) ) {
			return '';
		}
		// `unitLabel` overrides the baked-in i18n plural ("1 comment" /
		// "5 comments") with a single user-authored word ("replies",
		// "responses", "thảo luận"). When the user authors a label, the
		// plural form is lost by design — keeping it would require two
		// fields. Empty / unset key falls back to the plural-aware _n.
		$unit = isset( $value['unitLabel'] ) && is_string( $value['unitLabel'] )
			? $value['unitLabel']
			: '';
		if ( '' !== $unit ) {
			$txt = $count . ' ' . $unit;
		} else {
			$txt = sprintf(
				/* translators: %d: comments count */
				_n( '%d comment', '%d comments', $count, 'blocksify' ),
				$count
			);
		}
		$inner = esc_html( $txt );
		if ( ! empty( $value['link'] ) ) {
			$permalink = get_permalink( $post_id );
			if ( $permalink ) {
				$inner = sprintf(
					'<a class="bsy-card__comments-link" href="%1$s">%2$s</a>',
					esc_url( $permalink . '#comments' ),
					$inner
				);
			}
		}
		return self::wrap_element(
			'comments',
			$value,
			'<span class="bsy-card__comments">%s</span>',
			$inner
		);
	}

	protected static function html_reading_time( $value, $context ) {
		$post_id = isset( $context['post_id'] ) ? (int) $context['post_id'] : 0;
		if ( $post_id <= 0 ) {
			return '';
		}
		$wpm = isset( $value['wpm'] ) && (int) $value['wpm'] > 0 ? (int) $value['wpm'] : 200;
		$content = get_post_field( 'post_content', $post_id );
		$count   = self::word_count( $content );
		$minutes = max( 1, (int) round( $count / $wpm ) );
		// `unitLabel` overrides the baked-in i18n "min read" with a single
		// user-authored phrase. Mirrors comments.unitLabel pattern — empty
		// / unset falls back to plural-aware `_n` i18n. When the user
		// authors a label, the singular/plural distinction is lost by
		// design (keeping it would require two fields).
		$unit = isset( $value['unitLabel'] ) && is_string( $value['unitLabel'] )
			? $value['unitLabel']
			: '';
		if ( '' !== $unit ) {
			$txt = $minutes . ' ' . $unit;
		} else {
			$txt = sprintf(
				/* translators: %d: minutes to read */
				_n( '%d min read', '%d min read', $minutes, 'blocksify' ),
				$minutes
			);
		}
		return self::wrap_element(
			'reading-time',
			$value,
			'<span class="bsy-card__reading-time">%s</span>',
			esc_html( $txt )
		);
	}

	/**
	 * Terms-only — render the term's post count. Phase 5 iter#3 adds
	 * `unitLabel` override (mirror comments.unitLabel) + prefix / suffix
	 * / icon wrap. Empty / unset `unitLabel` falls back to plural-aware
	 * `_n('%d post', '%d posts')` baseline. Terms-only DOM class
	 * `.bsy-card__count` per Decision #50 (entity-specific concepts use
	 * entity-bound IDs).
	 */
	protected static function html_count( $value, $context ) {
		if ( ! isset( $context['term'] ) || ! ( $context['term'] instanceof WP_Term ) ) {
			return '';
		}
		$count = (int) $context['term']->count;
		$unit  = isset( $value['unitLabel'] ) && is_string( $value['unitLabel'] )
			? $value['unitLabel']
			: '';
		if ( '' !== $unit ) {
			$txt = $count . ' ' . $unit;
		} else {
			$txt = sprintf(
				/* translators: %d: post count for a term */
				_n( '%d post', '%d posts', $count, 'blocksify' ),
				$count
			);
		}
		return self::wrap_element(
			'count',
			$value,
			'<span class="bsy-card__count">%s</span>',
			esc_html( $txt )
		);
	}

	/**
	 * Cross-entity "Read more" / "Learn more" CTA button (Phase 5 iter#3
	 * promoted from Terms-only to shared). Label is user-authored via
	 * `LinkEditor`; href via `resolve_url` (post permalink for Posts,
	 * term archive for Terms). Icon optional with `iconPosition` toggle
	 * (`before` default / `after` for trailing-arrow CTA). Shared DOM
	 * class `.bsy-card__link` styled by Decision #56 outlined-button
	 * baseline. No prefix/suffix — the label IS the visible text.
	 */
	protected static function html_link( $value, $context ) {
		$url = self::resolve_url( $context );
		if ( '#' === $url ) {
			return '';
		}
		$entity   = isset( $context['entity'] ) ? (string) $context['entity'] : 'posts';
		$default  = 'terms' === $entity
			? __( 'Learn more', 'blocksify' )
			: __( 'Read more', 'blocksify' );
		// `''` (explicitly cleared via the editor) renders icon-only; only an
		// absent / non-string label falls back to the entity default. Mirrors
		// JS `renderLink`.
		$label    = isset( $value['label'] ) && is_string( $value['label'] )
			? $value['label']
			: $default;
		$position = isset( $value['iconPosition'] ) && 'after' === $value['iconPosition']
			? 'after'
			: 'before';
		// Optional explicit icon size (px) — sparse; unset keeps the 1em glyph.
		$icon_size = isset( $value['iconSize'] ) && is_numeric( $value['iconSize'] ) && (int) $value['iconSize'] > 0
			? (int) $value['iconSize']
			: null;
		$icon_html = self::render_icon(
			isset( $value['icon'] ) ? $value['icon'] : null,
			'link',
			$position,
			$icon_size
		);
		$inner = 'after' === $position
			? esc_html( $label ) . $icon_html
			: $icon_html . esc_html( $label );
		// Icon-only CTA — no visible text but a real icon remains. Add
		// `is-icon-only` (centers the glyph + squares the box so a large
		// border-radius renders a clean circle) + an `aria-label` for the
		// accessible name. Mirrors JS `renderLink`.
		$text_empty = '' === trim( $label );
		$icon_only  = $text_empty && '' !== $icon_html;
		$class      = 'bsy-card__link' . ( $icon_only ? ' is-icon-only' : '' );
		// Show-on-hover reveal — hidden until card hover/focus (CSS-only,
		// `.bsy-card:hover .bsy-card__link.is-hover-reveal`). `is-anim-*`
		// picks the reveal animation. JS twin: `renderLink`.
		if ( ! empty( $value['showOnHover'] ) ) {
			$allowed_anim = array( 'none', 'fade', 'slide-up', 'slide-down', 'zoom' );
			$anim         = isset( $value['hoverAnimation'] ) && in_array( $value['hoverAnimation'], $allowed_anim, true )
				? $value['hoverAnimation']
				: 'fade';
			$class       .= ' is-hover-reveal is-anim-' . $anim;
		}
		$aria       = $text_empty
			? ' aria-label="' . esc_attr( $default ) . '"'
			: '';
		return sprintf(
			'<a class="%1$s" href="%2$s"%3$s>%4$s</a>',
			esc_attr( $class ),
			esc_url( $url ),
			$aria, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_attr-built attribute.
			$inner // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- composed from escaped parts.
		);
	}

	protected static function word_count( $html ) {
		if ( '' === $html ) {
			return 0;
		}
		$stripped = wp_strip_all_tags( (string) $html );
		$tokens   = preg_split( '/\s+/', trim( $stripped ) );
		return is_array( $tokens ) ? count( array_filter( $tokens ) ) : 0;
	}

	/**
	 * Compose the final `[leading?][icon][prefix][content][suffix]` shell
	 * inside the wrapper template. Phase 5 helper used by all text-bearing
	 * `html_*` methods so prefix / suffix / icon land consistently.
	 *
	 * Twin: `wrapElement()` in `src/blocks/content-loop/edit/renderLayout.js`.
	 *
	 * @param string $slug         Element slug used for child class names
	 *                              (`bsy-card__{slug}-icon`, …).
	 * @param array  $value        Element value bag (reads `icon`, `prefix`,
	 *                              `suffix`).
	 * @param string $wrapper_tpl  sprintf template with one `%s` slot for
	 *                              the composed inner HTML.
	 * @param string $content_html Inner HTML (already escaped / composed).
	 * @param string $leading_html Optional raw leading HTML (e.g. author
	 *                              avatar) inserted before the icon slot.
	 * @return string Final wrapper string.
	 */
	protected static function wrap_element( $slug, $value, $wrapper_tpl, $content_html, $leading_html = '' ) {
		$icon_html   = self::render_icon( isset( $value['icon'] ) ? $value['icon'] : null, $slug );
		$prefix_text = isset( $value['prefix'] ) && is_string( $value['prefix'] ) ? $value['prefix'] : '';
		$suffix_text = isset( $value['suffix'] ) && is_string( $value['suffix'] ) ? $value['suffix'] : '';

		$prefix_html = '' !== $prefix_text
			? '<span class="bsy-card__' . esc_attr( $slug ) . '-prefix">' . esc_html( $prefix_text ) . '</span>'
			: '';
		$suffix_html = '' !== $suffix_text
			? '<span class="bsy-card__' . esc_attr( $slug ) . '-suffix">' . esc_html( $suffix_text ) . '</span>'
			: '';

		$inner = $leading_html . $icon_html . $prefix_html . $content_html . $suffix_html;
		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- composed from pre-escaped parts.
		return sprintf( $wrapper_tpl, $inner );
	}

	/**
	 * Render an icon bag (see `src/shared/icon.js` for shape) as inline
	 * markup. SVG is echoed verbatim — sanitization happens at JS commit
	 * time via `IconLibrary`'s `sanitizeSvg()`. URL falls back to a plain
	 * `<img>` for upload-based icons.
	 *
	 * Wrapper class pair = `bsy-card__el-icon bsy-card__{slug}-icon` so one
	 * baseline CSS rule (`.bsy-card__el-icon`) handles sizing while power
	 * users target the per-element companion class for finer control.
	 * `$position = 'after'` adds `is-after` modifier so the shared margin
	 * rule flips inline-start vs inline-end (Phase 5 iter#3 Link element).
	 *
	 * Twin: `renderElementIcon()` in JS.
	 *
	 * @param mixed  $icon     Icon bag or null.
	 * @param string $slug     Element slug (date, modified, author, …).
	 * @param string $position 'before' (default) or 'after' — adds the
	 *                         `is-after` modifier for trailing-icon CTAs.
	 * @return string Markup or '' when no icon.
	 */
	protected static function render_icon( $icon, $slug, $position = 'before', $size = null ) {
		if ( ! is_array( $icon ) ) {
			return '';
		}
		$svg = Blocksify_Icons::render( $icon );
		$url = isset( $icon['url'] ) && is_string( $icon['url'] ) ? $icon['url'] : '';
		if ( '' === $svg && '' === $url ) {
			return '';
		}
		$class_attr = 'bsy-card__el-icon bsy-card__' . esc_attr( $slug ) . '-icon';
		if ( 'after' === $position ) {
			$class_attr .= ' is-after';
		}
		// Optional explicit icon size — the glyph svg/img is sized in `em`
		// (1em via the static `.bsy-card__el-icon svg` rule), so a `font-size`
		// on the span scales it. Only emitted when the caller passes a
		// positive size (e.g. the Link element's `iconSize`). JS twin:
		// `ElementIconView` in renderLayout.js.
		$style_attr = ( is_numeric( $size ) && (float) $size > 0 )
			? ' style="font-size:' . (int) $size . 'px"'
			: '';
		if ( '' !== $svg ) {
			// Lucide-resolved + custom-svg bags emit sanitized markup here
			// (sanitizeSvg runs at JS commit time for custom-svg paste; the
			// Lucide builder emits known-safe upstream markup).
			return '<span class="' . $class_attr . '"' . $style_attr . '>' . $svg . '</span>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		}
		return '<span class="' . $class_attr . '"' . $style_attr . '><img src="' . esc_url( $url ) . '" alt="" /></span>';
	}

	/**
	 * Trim text to the first N characters at a word boundary, appending an
	 * ellipsis when the cut shortens the input. Twin: `trimCharacters()` JS.
	 *
	 * @param string $text Source text (plain — caller strips tags).
	 * @param int    $n    Character cap.
	 * @return string Truncated text.
	 */
	protected static function trim_characters( $text, $n ) {
		$len = function_exists( 'mb_strlen' ) ? mb_strlen( $text ) : strlen( $text );
		if ( $len <= $n ) {
			return $text;
		}
		$cut = function_exists( 'mb_substr' ) ? mb_substr( $text, 0, $n ) : substr( $text, 0, $n );
		// Back off to the last whitespace so we don't cut a word in half.
		$last_space = function_exists( 'mb_strrpos' ) ? mb_strrpos( $cut, ' ' ) : strrpos( $cut, ' ' );
		if ( false !== $last_space && $last_space > 0 ) {
			$cut = function_exists( 'mb_substr' ) ? mb_substr( $cut, 0, $last_space ) : substr( $cut, 0, $last_space );
		}
		return rtrim( $cut ) . '…';
	}

	/**
	 * Keep the first N sentence-shaped chunks (split on `[.!?]+` followed
	 * by whitespace or end-of-string). Twin: `trimSentences()` JS.
	 *
	 * @param string $text Source text.
	 * @param int    $n    Sentence count cap.
	 * @return string Truncated text.
	 */
	protected static function trim_sentences( $text, $n ) {
		$matches = array();
		if ( preg_match_all( '/[^.!?]+[.!?]+(\s|$)?/u', $text, $matches ) && ! empty( $matches[0] ) ) {
			$first = array_slice( $matches[0], 0, $n );
			return trim( implode( '', $first ) );
		}
		return $text;
	}

	// ============================================================
	// Phase 6.2 — Grid-inner extraction (REST + SSR share one loop)
	// ============================================================

	/**
	 * Render the concatenated `<article>` markup for the Posts entity grid
	 * — the "grid inner" used by both SSR (`render_posts_loop` echoes the
	 * result inside its wrapper) and the REST in-place swap endpoint
	 * (returns the raw string as `response.html` for the JS controller to
	 * `insertAdjacentHTML('beforebegin', ...)` against the existing
	 * `.bsy-cl-paging` element).
	 *
	 * Captures `setup_postdata()` side-effects via `ob_start()` — the
	 * `$query->the_post()` call inside the loop both advances the iterator
	 * AND populates the global post context that `get_the_title()` /
	 * `get_the_excerpt()` / etc. read in `render_card_posts`.
	 *
	 * @param WP_Query $query Query instance (already `have_posts()`-ed by caller).
	 * @param array    $attrs Block attributes (merged with defaults).
	 * @return string Concatenated `<article class="bsy-card">…</article>` markup.
	 */
	public static function render_grid_inner_posts( $query, $attrs ) {
		if ( ! ( $query instanceof WP_Query ) ) {
			return '';
		}
		$layout = isset( $attrs['layout'] ) && is_array( $attrs['layout'] )
			? $attrs['layout']
			: array();
		ob_start();
		while ( $query->have_posts() ) {
			$query->the_post();
			// Phase 4.4 — per-card visibility gate. Free returns true; Pro
			// Conditional Visibility plugin returns false to skip the card
			// (e.g. role-gated content, scheduled visibility windows).
			$visible = (bool) apply_filters(
				'blocksify.cl.card_visibility',
				true,
				get_post(),
				$attrs
			);
			if ( ! $visible ) {
				continue;
			}
			self::render_card_posts( $layout, get_the_ID(), $attrs );
		}
		return (string) ob_get_clean();
	}

	/**
	 * Render the concatenated `<article>` markup for the Terms entity grid.
	 * Twin of `render_grid_inner_posts` for terms — iterates a `WP_Term[]`
	 * set from `get_terms()` and calls `render_card_terms` per term.
	 *
	 * @param array $terms `WP_Term[]` from the resolved `get_terms()` args.
	 * @param array $attrs Block attributes (merged with defaults).
	 * @return string Concatenated `<article class="bsy-card">…</article>` markup.
	 */
	public static function render_grid_inner_terms( $terms, $attrs ) {
		if ( ! is_array( $terms ) || empty( $terms ) ) {
			return '';
		}
		$layout = isset( $attrs['layout'] ) && is_array( $attrs['layout'] )
			? $attrs['layout']
			: array();
		ob_start();
		foreach ( $terms as $term ) {
			if ( ! ( $term instanceof WP_Term ) ) {
				continue;
			}
			$visible = (bool) apply_filters(
				'blocksify.cl.card_visibility',
				true,
				$term,
				$attrs
			);
			if ( ! $visible ) {
				continue;
			}
			self::render_card_terms( $layout, $term, $attrs );
		}
		return (string) ob_get_clean();
	}

	// ============================================================
	// Phase 6.1 — Pagination + Empty State (SPEC-content-loop-paging)
	// ============================================================

	/**
	 * Render the empty-state block (Phase 6.1 — SPEC §5.1).
	 *
	 * Centralised so `render_posts_loop` + `render_terms_loop` share one
	 * code path. Entity is inferred from `$attrs['queryEntity']` so the
	 * filter signature stays minimal — Pro callbacks read entity from the
	 * attrs bag when needed.
	 *
	 * @param array $attrs Block attributes (full, merged with defaults).
	 * @return string HTML for the empty-state region.
	 */
	public static function render_empty_state( $attrs ) {
		$text = isset( $attrs['emptyStateText'] )
			? (string) $attrs['emptyStateText']
			: '';
		if ( '' === $text ) {
			$text = __( 'No posts found.', 'blocksify' );
		}
		$html = '<p class="bsy-cl__empty">' . esc_html( $text ) . '</p>';
		/**
		 * Pro custom empty-state HTML.
		 *
		 * Returning a non-empty string replaces the Free default. Pro can
		 * return arbitrary HTML (block-tree output, illustration + CTA,
		 * etc.). Pro callback owns escaping per the contract.
		 *
		 * Signature: `( string $html, array $attrs ) => string`
		 *
		 * @since 1.0.0 (Phase 6.1) — SPEC §5.1
		 */
		return (string) apply_filters( 'blocksify.cl.empty_state', $html, $attrs );
	}

	/**
	 * Render the pagination bar below the grid (Phase 6.1 — SPEC §5.1).
	 *
	 * Dispatches per item type to the matching `html_paging_{type}` static
	 * method. Pro custom item types (sort / view-toggle / filter-chip
	 * registered via `blocksify.cl.pagination_items` JS filter) fall
	 * through to the `blocksify.cl.paging_item_render` filter so Pro PHP
	 * callbacks can render their own item-type markup.
	 *
	 * Empty `$items` array → returns ''. Single-shot override via the
	 * `blocksify.cl.pagination_render` filter returning non-null lets
	 * Pro replace the entire bar (e.g. infinite-scroll variants that
	 * emit a sentinel `<div data-paging-load-more>` instead of a nav).
	 *
	 * @param array $items   Pagination items array.
	 * @param array $context `{currentPage, totalPages, totalItems, perPage, uid}`.
	 * @param array $attrs   Block attributes.
	 * @return string HTML for the pagination bar (or '' when items empty).
	 */
	public static function render_pagination_items( $items, $context, $attrs ) {
		if ( empty( $items ) || ! is_array( $items ) ) {
			return '';
		}
		/**
		 * Pro override for the whole pagination rendering.
		 *
		 * Return a non-null string to short-circuit Free's item dispatch.
		 * Useful for infinite-scroll / Pro pagination variants that
		 * emit non-standard DOM (sentinel element instead of nav).
		 *
		 * Signature: `( ?string $html, array $items, array $context, array $attrs ) => ?string`
		 *
		 * @since 1.0.0 (Phase 6.1) — SPEC §5.1
		 */
		$override = apply_filters( 'blocksify.cl.pagination_render', null, $items, $context, $attrs );
		if ( null !== $override ) {
			return (string) $override;
		}

		$inner = '';
		foreach ( $items as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			$type = isset( $item['id'] ) ? (string) $item['id'] : '';
			switch ( $type ) {
				case 'nav':
					$inner .= self::html_paging_nav( $item, $context, $attrs );
					break;
				case 'count':
					$inner .= self::html_paging_count( $item, $context, $attrs );
					break;
				case 'per-page':
					$inner .= self::html_paging_per_page( $item, $context, $attrs );
					break;
				default:
					/**
					 * Pro item-type render dispatch. Return non-empty HTML
					 * to ship a custom item; empty string skips the item.
					 *
					 * Signature: `( string $html, string $type, array $item, array $context, array $attrs ) => string`
					 *
					 * @since 1.0.0 (Phase 6.1) — SPEC §5.1
					 */
					$inner .= (string) apply_filters(
						'blocksify.cl.paging_item_render',
						'',
						$type,
						$item,
						$context,
						$attrs
					);
			}
		}
		if ( '' === $inner ) {
			return '';
		}
		// Outer wrapper is a neutral `<div>` — `<nav>` children (`__nav`)
		// carry their own landmark role, so we don't stack
		// `role="navigation"` ancestors. Data attrs that view.js needs at
		// init (`data-query-source`, `data-query`, `data-url-state`) live
		// on the outer block wrapper (`build_wrapper_classes`), not here —
		// Filter / Search future dispatches refresh without requiring the
		// paging bar to exist, so its data lives one level up.
		return '<div class="bsy-cl-paging">' . $inner . '</div>';
	}

	/**
	 * URL builder for one paging anchor — preserves any other query
	 * params on the current page via `add_query_arg()`.
	 *
	 * Source-aware URL key (Phase 6.1 follow-up):
	 *   - `'inherit'`            → WP main `paged` var (block re-uses the
	 *                              page's main query; theme owns URL).
	 *   - `'inline'` / Pro saved → per-block `cl_{uid}_page` so multi-block
	 *                              pagination stays isolated.
	 *
	 * @param string $uid          Block uid.
	 * @param int    $page         Target page number (1-based).
	 * @param string $query_source `'inline' | 'inherit' | 'saved' | Pro slug` — picks URL key.
	 * @return string Escaped URL (`esc_url`'d for `href` attribute use).
	 */
	/**
	 * Phase 6.3 — detect whether any nav item in `$items` is a load-more
	 * style. Used by `render_posts_loop` + `render_terms_loop` to decide
	 * whether direct-visit SSR at `?page=N` should accumulate posts
	 * 1..N×perPage (load-more's progressive-disclosure UX) instead of
	 * rendering only the page-N slice (numbered nav's per-page UX).
	 *
	 * Mixed configs (load-more + numbered in the same block) resolve in
	 * favour of accumulated semantics — load-more's UX is dominant when
	 * present. Edge case: documented; users opting in to numbered nav
	 * should remove the load-more item.
	 *
	 * @param array $items Pagination items array.
	 * @return bool True if at least one nav item has `style: 'load-more'`.
	 */
	protected static function has_load_more_nav( $items ) {
		foreach ( $items as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			$id    = $item['id'] ?? '';
			$style = $item['value']['style'] ?? '';
			if ( 'nav' === $id && 'load-more' === $style ) {
				return true;
			}
		}
		return false;
	}

	protected static function paging_url( $uid, $page, $query_source = 'inline' ) {
		// Inherit mode → defer to WP's native `get_pagenum_link()` so the
		// emitted URL matches the host page's permalink scheme (pretty
		// `/category/{slug}/page/N/` when rewrite rules are set, falls
		// back to `?paged=N` otherwise). Matches what `paginate_links()` /
		// `the_posts_pagination()` emit on the same archive context, so
		// CL inherit-mode pagination feels native to the page it lives on.
		if ( 'inherit' === $query_source ) {
			if ( $page <= 1 ) {
				$base = function_exists( 'get_pagenum_link' ) ? get_pagenum_link( 1 ) : home_url( '/' );
				return esc_url( remove_query_arg( 'paged', $base ) );
			}
			$url = function_exists( 'get_pagenum_link' )
				? get_pagenum_link( (int) $page )
				: add_query_arg( 'paged', (int) $page );
			return esc_url( $url );
		}
		// Inline / Pro saved — per-block scoped query key so multi-block
		// pagination on the same page stays isolated.
		$key = 'cl_' . $uid . '_page';
		if ( $page <= 1 ) {
			$url = remove_query_arg( $key );
		} else {
			$url = add_query_arg( $key, (int) $page );
		}
		return esc_url( $url );
	}

	/**
	 * Render the `nav` paging item — numbers / prev-next / load-more.
	 *
	 * Numbered mode emits `« 1 2 … 5 6 7 … 10 »` with ellipsis bands
	 * controlled by `siblingCount` (0-3). Prev-Next emits `‹ … Page X of
	 * Y … ›` with optional indicator text. Load-more emits a single
	 * `<a data-paging-load-more href="?cl_{uid}_page=N+1">` (no-JS
	 * fallback navigates; Phase 6.2 view.js handler intercepts the click
	 * for in-place append).
	 *
	 * @param array $item    Item record `{id, value, styling?}`.
	 * @param array $context Pagination context.
	 * @param array $attrs   Block attributes.
	 * @return string Pagination nav HTML.
	 */
	protected static function html_paging_nav( $item, $context, $attrs ) {
		$value = isset( $item['value'] ) && is_array( $item['value'] )
			? $item['value']
			: array();
		$style        = isset( $value['style'] ) ? (string) $value['style'] : 'numbers';
		$uid          = isset( $context['uid'] ) ? (string) $context['uid'] : '';
		$cur          = max( 1, (int) ( $context['currentPage'] ?? 1 ) );
		$total        = max( 0, (int) ( $context['totalPages'] ?? 0 ) );
		// Source-aware URL key (Phase 6.1 follow-up — Decision #39 +
		// SPEC §4.1). Threaded through `paging_url()` so anchor `href`
		// values use `?paged=N` for inherit mode (theme owns URL) vs
		// `?cl_{uid}_page=N` for inline + Pro saved (per-block scoped).
		$query_source = isset( $context['querySource'] ) ? (string) $context['querySource'] : 'inline';

		if ( $total <= 1 ) {
			// Single page → nothing to navigate. Hide the nav. Count + per-page
			// items still render via their own html_paging_* methods.
			return '';
		}

		// Decision #70 — pagination icons replace the legacy entity arrows
		// (`«` / `»`) for numbers + prev-next variants and append/prepend
		// to the load-more button. Defaults arrow-left / arrow-right /
		// arrow-down are inlined SVG strings shared with the JS twin
		// (`pagination-icons.js`) so SSR + REST swap + editor preview
		// render identical markup.
		$prev_icon_svg = self::resolve_paging_icon_svg( $value, 'previousIcon', 'arrow-left' );
		$next_icon_svg = self::resolve_paging_icon_svg( $value, 'nextIcon', 'arrow-right' );

		if ( 'prev-next' === $style ) {
			$show_ind = ( $value['showIndicator'] ?? true ) !== false;

			// Decision #71 — optional text labels alongside the icon.
			// `previousLabel` / `nextLabel` default empty (icon-only); when
			// authored, render as a `<span class="__btn-label">` next to
			// the icon. SVG always renders. aria-label derived from the
			// authored text when present, else falls back to the
			// localized default for SR consistency.
			$prev_text = isset( $value['previousLabel'] ) && '' !== (string) $value['previousLabel']
				? (string) $value['previousLabel']
				: '';
			$next_text = isset( $value['nextLabel'] ) && '' !== (string) $value['nextLabel']
				? (string) $value['nextLabel']
				: '';
			$prev_aria = '' !== $prev_text
				? esc_attr( $prev_text )
				: esc_attr__( 'Previous page', 'blocksify' );
			$next_aria = '' !== $next_text
				? esc_attr( $next_text )
				: esc_attr__( 'Next page', 'blocksify' );

			$prev_icon_span = '<span class="bsy-cl-paging__btn-icon" aria-hidden="true">' . $prev_icon_svg . '</span>';
			$next_icon_span = '<span class="bsy-cl-paging__btn-icon" aria-hidden="true">' . $next_icon_svg . '</span>';
			$prev_text_span = '' !== $prev_text
				? '<span class="bsy-cl-paging__btn-label">' . esc_html( $prev_text ) . '</span>'
				: '';
			$next_text_span = '' !== $next_text
				? '<span class="bsy-cl-paging__btn-label">' . esc_html( $next_text ) . '</span>'
				: '';
			// Prev: icon then optional text (leading-icon).
			// Next: optional text then icon (trailing-icon).
			$prev_inner = $prev_icon_span . $prev_text_span;
			$next_inner = $next_text_span . $next_icon_span;

			$prev_html = $cur > 1
				? '<a href="' . self::paging_url( $uid, $cur - 1, $query_source ) . '" class="bsy-cl-paging__btn bsy-cl-paging__btn--prev" data-page="' . ( $cur - 1 ) . '" rel="prev" aria-label="' . $prev_aria . '">' . $prev_inner . '</a>'
				: '<span class="bsy-cl-paging__btn bsy-cl-paging__btn--prev is-disabled" aria-disabled="true" aria-label="' . $prev_aria . '">' . $prev_inner . '</span>';
			$next_html = $cur < $total
				? '<a href="' . self::paging_url( $uid, $cur + 1, $query_source ) . '" class="bsy-cl-paging__btn bsy-cl-paging__btn--next" data-page="' . ( $cur + 1 ) . '" rel="next" aria-label="' . $next_aria . '">' . $next_inner . '</a>'
				: '<span class="bsy-cl-paging__btn bsy-cl-paging__btn--next is-disabled" aria-disabled="true" aria-label="' . $next_aria . '">' . $next_inner . '</span>';

			$indicator = '';
			if ( $show_ind ) {
				/* translators: 1: current page, 2: total pages */
				$indicator_template = __( 'Page %1$d of %2$d', 'blocksify' );
				$indicator          = ' <span class="bsy-cl-paging__indicator" data-format="' . esc_attr( $indicator_template ) . '">' . esc_html( sprintf(
					$indicator_template,
					$cur,
					$total
				) ) . '</span> ';
			}

			return '<nav class="bsy-cl-paging__nav" data-paging-nav="prev-next" aria-label="' . esc_attr__( 'Pagination', 'blocksify' ) . '">' . $prev_html . $indicator . $next_html . '</nav>';
		}

		if ( 'load-more' === $style ) {
			// Decision #72 — distinguish missing key (use default text)
			// from empty-string value (user explicitly cleared → icon-
			// only). `isset` returns true for both null and missing in
			// older PHP; use `array_key_exists` for exact semantics.
			if ( array_key_exists( 'buttonLabel', $value ) && is_string( $value['buttonLabel'] ) ) {
				$btn_label = (string) $value['buttonLabel'];
			} else {
				$btn_label = __( 'Load more', 'blocksify' );
			}
			// Decision #70 — load-more icon + position. Default arrow-down
			// on the right of the label; `iconPosition: 'left'` flips it
			// to the leading edge. Icon wraps in a `<span>` with an
			// inert class so user styling on the button's text element
			// stays scoped to the label.
			$lm_icon_svg = self::resolve_paging_icon_svg( $value, 'icon', 'arrow-down' );
			$lm_position = isset( $value['iconPosition'] ) && 'left' === $value['iconPosition']
				? 'left'
				: 'right';
			$icon_span    = '<span class="bsy-cl-paging__btn-icon" aria-hidden="true">' . $lm_icon_svg . '</span>';
			// Decision #72 — render the label span only when non-empty
			// so cleared `buttonLabel: ''` produces an icon-only button.
			$label_span   = '' !== $btn_label
				? '<span class="bsy-cl-paging__btn-label">' . esc_html( $btn_label ) . '</span>'
				: '';
			$btn_inner    = 'left' === $lm_position
				? $icon_span . $label_span
				: $label_span . $icon_span;
			$position_cls = 'bsy-cl-paging__btn--icon-' . $lm_position;
			// Load-more uses `<a>` (matches numbers + prev-next nav variants
			// in the same row) so theme `button {}` rules don't bleed in and
			// the no-JS fallback navigates to `?cl_{uid}_page=N+1` (load-more
			// accumulated SSR special case in `render()` then renders pages
			// 1..N+1 progressively). JS controller intercepts the click via
			// `e.preventDefault()` and switches to append-mode swap. Last
			// page emits `<span class="...is-disabled">` mirroring the
			// prev/next disabled pattern — click suppression rides on the
			// static CSS `.is-disabled { pointer-events: none }` rule.
			if ( $cur >= $total ) {
				return '<nav class="bsy-cl-paging__nav" data-paging-nav="load-more" aria-label="' . esc_attr__( 'Pagination', 'blocksify' ) . '"><span class="bsy-cl-paging__btn ' . $position_cls . ' is-disabled" aria-disabled="true">' . $btn_inner . '</span></nav>';
			}
			return '<nav class="bsy-cl-paging__nav" data-paging-nav="load-more" aria-label="' . esc_attr__( 'Pagination', 'blocksify' ) . '"><a href="' . self::paging_url( $uid, $cur + 1, $query_source ) . '" class="bsy-cl-paging__btn ' . $position_cls . '" data-paging-load-more data-page="' . ( $cur + 1 ) . '" rel="next">' . $btn_inner . '</a></nav>';
		}

		// Numbered style (default).
		$sibling = isset( $value['siblingCount'] ) ? max( 0, min( 3, (int) $value['siblingCount'] ) ) : 1;

		$pages_to_render = self::compute_paging_page_list( $cur, $total, $sibling );
		$out             = '';

		// Prev arrow icon (Decision #70 replaces `«`). Disabled boundary
		// keeps the icon for visual consistency but drops the anchor +
		// adds `aria-disabled="true"`.
		$prev_aria_num = esc_attr__( 'Previous page', 'blocksify' );
		$next_aria_num = esc_attr__( 'Next page', 'blocksify' );
		$out          .= $cur > 1
			? '<a href="' . self::paging_url( $uid, $cur - 1, $query_source ) . '" class="bsy-cl-paging__btn bsy-cl-paging__btn--prev" data-page="' . ( $cur - 1 ) . '" rel="prev" aria-label="' . $prev_aria_num . '">' . $prev_icon_svg . '</a>'
			: '<span class="bsy-cl-paging__btn bsy-cl-paging__btn--prev is-disabled" aria-disabled="true" aria-label="' . $prev_aria_num . '">' . $prev_icon_svg . '</span>';

		foreach ( $pages_to_render as $p ) {
			if ( '...' === $p ) {
				$out .= '<span class="bsy-cl-paging__ellipsis" aria-hidden="true">&hellip;</span>';
				continue;
			}
			if ( (int) $p === $cur ) {
				$out .= '<span class="bsy-cl-paging__btn" data-page="' . (int) $p . '" aria-current="page">' . (int) $p . '</span>';
			} else {
				$out .= '<a href="' . self::paging_url( $uid, (int) $p, $query_source ) . '" class="bsy-cl-paging__btn" data-page="' . (int) $p . '" aria-label="' . esc_attr( sprintf(
					/* translators: %d: page number */
					__( 'Page %d', 'blocksify' ),
					(int) $p
				) ) . '">' . (int) $p . '</a>';
			}
		}

		// Next arrow icon (Decision #70 replaces `»`).
		$out .= $cur < $total
			? '<a href="' . self::paging_url( $uid, $cur + 1, $query_source ) . '" class="bsy-cl-paging__btn bsy-cl-paging__btn--next" data-page="' . ( $cur + 1 ) . '" rel="next" aria-label="' . $next_aria_num . '">' . $next_icon_svg . '</a>'
			: '<span class="bsy-cl-paging__btn bsy-cl-paging__btn--next is-disabled" aria-disabled="true" aria-label="' . $next_aria_num . '">' . $next_icon_svg . '</span>';

		// `data-paging-sibling-count` + `data-paging-page-label` carry the
		// minimum metadata view.js needs to re-render the numbered band
		// client-side after a REST swap (compute new ellipsis pattern +
		// localize new button aria-labels). Replaces the heavier
		// `data-paging-config` JSON dump that lived on the outer paging
		// `<div>` — these two tiny attrs stay on the nav element where the
		// information is conceptually scoped. The page-label template carries
		// the `%d` placeholder unsubstituted; view.js owns the substitution
		// per new button.
		$page_label_template = __( 'Page %d', 'blocksify' );

		return '<nav class="bsy-cl-paging__nav" data-paging-nav="numbers" data-paging-sibling-count="' . (int) $sibling . '" data-paging-page-label="' . esc_attr( $page_label_template ) . '" aria-label="' . esc_attr__( 'Pagination', 'blocksify' ) . '">' . $out . '</nav>';
	}

	/**
	 * Default pagination icon SVG — thin delegate to the shared Lucide
	 * resolver. Kept as a named entry point because Pro extensions may
	 * call it directly to compose their own paging UI.
	 *
	 * @param string $name Icon name (typically `arrow-left` / `arrow-right` / `arrow-down`).
	 * @return string SVG markup string.
	 */
	public static function default_nav_icon_svg( $name ) {
		return Blocksify_Icons::resolve( 'lucide', (string) $name );
	}

	/**
	 * Resolve the active icon SVG markup for one pagination icon slot.
	 * Stored bag wins via `Blocksify_Icons::render()` — handles legacy
	 * `svg` field, custom-svg paste, custom-upload, and sparse Lucide
	 * picks (the new default after P4 of the icon-resolver refactor).
	 * Falls back to the named Lucide default when the slot is unset.
	 *
	 * @param array  $value        Editor value bag (`$item['value']`).
	 * @param string $field_key    Slot key (`previousIcon` | `nextIcon` | `icon`).
	 * @param string $default_name Lucide name for the fallback default.
	 * @return string SVG markup string ready for raw emit.
	 */
	public static function resolve_paging_icon_svg( $value, $field_key, $default_name ) {
		if ( isset( $value[ $field_key ] ) && is_array( $value[ $field_key ] ) ) {
			$svg = Blocksify_Icons::render( $value[ $field_key ] );
			if ( '' !== $svg ) {
				return $svg;
			}
		}
		return Blocksify_Icons::resolve( 'lucide', (string) $default_name );
	}

	/**
	 * Compute the list of page numbers + ellipsis markers to render for
	 * numbered pagination, given the current page + total + siblingCount.
	 *
	 * Always shows: first page, last page, current page, and `sibling`
	 * pages on either side of current. Ellipsis `...` markers fill the
	 * gaps. Examples (current=5, total=10):
	 *   sibling=0 → 1 ... 5 ... 10
	 *   sibling=1 → 1 ... 4 5 6 ... 10
	 *   sibling=2 → 1 ... 3 4 5 6 7 ... 10
	 *
	 * @param int $current Current page (1-based).
	 * @param int $total   Total pages.
	 * @param int $sibling Sibling band size (0-3).
	 * @return array Sequence of integers and `'...'` markers.
	 */
	protected static function compute_paging_page_list( $current, $total, $sibling ) {
		if ( $total <= 1 ) {
			return array( 1 );
		}
		// Threshold below which we just emit all pages — no ellipsis needed.
		$max_visible = 5 + ( $sibling * 2 ); // 1 + ellipsis + window + ellipsis + last (roughly).
		if ( $total <= $max_visible ) {
			$all = array();
			for ( $i = 1; $i <= $total; $i++ ) {
				$all[] = $i;
			}
			return $all;
		}

		$range_start = max( 2, $current - $sibling );
		$range_end   = min( $total - 1, $current + $sibling );

		$out = array( 1 );
		if ( $range_start > 2 ) {
			$out[] = '...';
		}
		for ( $i = $range_start; $i <= $range_end; $i++ ) {
			$out[] = $i;
		}
		if ( $range_end < $total - 1 ) {
			$out[] = '...';
		}
		$out[] = $total;
		return $out;
	}

	/**
	 * Render the `count` paging item — printf-formatted result count text
	 * (`Showing 1–12 of 47`). `%1$d = start`, `%2$d = end`, `%3$d = total`.
	 *
	 * @param array $item    Item record.
	 * @param array $context Pagination context.
	 * @param array $attrs   Block attributes (unused here, kept for hook parity).
	 * @return string `<span>` count HTML.
	 */
	protected static function html_paging_count( $item, $context, $attrs ) {
		unset( $attrs );
		$value  = isset( $item['value'] ) && is_array( $item['value'] )
			? $item['value']
			: array();
		$format = isset( $value['format'] ) && '' !== $value['format']
			? (string) $value['format']
			: __( 'Showing %1$d–%2$d of %3$d', 'blocksify' );

		$total   = max( 0, (int) ( $context['totalItems'] ?? 0 ) );
		$per     = max( 1, (int) ( $context['perPage'] ?? 1 ) );
		$current = max( 1, (int) ( $context['currentPage'] ?? 1 ) );
		$start   = $total > 0 ? ( ( $current - 1 ) * $per ) + 1 : 0;
		$end     = min( $current * $per, $total );

		// PHP `sprintf` accepts the numbered positional specifiers ($1$d etc.).
		// User-supplied formats with malformed specifiers fall back to the
		// original string to avoid PHP warnings.
		$text = @sprintf( $format, $start, $end, $total ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- format string user-author, sprintf warns on mismatch; fallback to raw string.
		if ( false === $text || null === $text ) {
			$text = $format;
		}
		// Phase 6.3 (Gap #1) — emit the user-authored format string on
		// `data-format` so view.js's `updateCount` can re-render with the
		// same sprintf grammar after an in-place swap. Without this the
		// JS side fell back to an English hardcode ("Showing X–Y of Z")
		// and clobbered any user-authored localized format on every
		// pagination click. `aria-live="polite"` so screen readers
		// announce the new range text on swap (Phase 6.3 a11y audit).
		return '<span class="bsy-cl-paging__count" data-format="' . esc_attr( $format ) . '" aria-live="polite">' . esc_html( $text ) . '</span>';
	}

	/**
	 * Render the `per-page` paging item — `<label><span>Label:</span>
	 * <select>` for picking the perPage value. Phase 6.2 view.js binds
	 * `change` to re-fetch with the new perPage + reset to page 1.
	 *
	 * @param array $item    Item record.
	 * @param array $context Pagination context.
	 * @param array $attrs   Block attributes.
	 * @return string `<label>` per-page HTML.
	 */
	protected static function html_paging_per_page( $item, $context, $attrs ) {
		unset( $attrs );
		$value   = isset( $item['value'] ) && is_array( $item['value'] )
			? $item['value']
			: array();
		$label   = isset( $value['label'] ) ? (string) $value['label'] : '';
		$options = isset( $value['options'] ) && is_array( $value['options'] )
			? array_values( array_unique( array_filter( array_map( 'intval', $value['options'] ), function ( $n ) { return $n > 0; } ) ) )
			: array( 6, 12, 24, 48 );
		if ( empty( $options ) ) {
			return '';
		}
		$current_per = max( 1, (int) ( $context['perPage'] ?? 6 ) );

		$opts = '';
		foreach ( $options as $n ) {
			$opts .= '<option value="' . (int) $n . '"' . selected( $current_per, (int) $n, false ) . '>' . (int) $n . '</option>';
		}
		$label_html = '' !== $label
			? '<span class="bsy-cl-paging__per-page-label">' . esc_html( $label ) . '</span>'
			: '';
		return '<label class="bsy-cl-paging__per-page">' . $label_html . '<select data-per-page>' . $opts . '</select></label>';
	}

	/**
	 * Per-item selector resolver — PHP twin of JS
	 * `pagingItemPartSelector()` (`inspector/content/pagination-helpers.js`).
	 * Decision #67 chip-selector pattern.
	 *
	 * @param string $type Item id.
	 * @return string Leading-space CSS selector relative to `.bsy-cl-{uid}`.
	 */
	public static function paging_item_part_selector( $type ) {
		if ( 'nav' === $type ) {
			return ' .bsy-cl-paging__nav .bsy-cl-paging__btn';
		}
		if ( 'per-page' === $type ) {
			// Decision #71 — wrapper-scope so the "Per page:" label text +
			// the `<select>` both pick up user-authored typography /
			// padding / etc. Static CSS `font: inherit; color: inherit`
			// on the select keeps the typography cascading into the
			// native control.
			return ' .bsy-cl-paging__per-page';
		}
		return ' .bsy-cl-paging__' . $type;
	}

	/**
	 * Per-item selector overrides — PHP twin of JS
	 * `pagingItemSelectorOverrides()`.
	 *
	 * @param string $type Item id.
	 * @return array|null Map of `{groupKey: selectorSuffix}` or null.
	 */
	public static function paging_item_selector_overrides( $type ) {
		if ( 'nav' === $type ) {
			$wrapper = ' .bsy-cl-paging__nav';
			return array(
				'layout'   => $wrapper,
				'margin'   => $wrapper,
				'position' => $wrapper,
				'overflow' => $wrapper,
				'zIndex'   => $wrapper,
			);
		}
		if ( 'per-page' === $type ) {
			// Decision #71 — `per-page` partSelector now points directly
			// at the wrapper, so layout / margin / positioning groups
			// also land there with no override needed.
			return null;
		}
		return null;
	}

	/**
	 * Emit per-instance CSS for pagination — twin of edit.js's pagination
	 * sparse-walk loop. Wired into the block-local CSS pipeline through
	 * `Blocksify_CSS_Generator::emit_content_loop_local_rules`.
	 *
	 * @param array  $attrs Block attributes.
	 * @param string $uid   Block uid.
	 * @return string CSS string ('' when no pagination styling authored).
	 */
	public static function emit_pagination_sparse_css( $attrs, $uid ) {
		if ( empty( $uid ) ) {
			return '';
		}
		$css = '';

		// Per-item styling — PR 3 splits storage out of `paginationItems`
		// into separate top-level maps keyed by item id. Default-strips
		// independently (item config can match default while styling
		// authored differently, or vice versa).
		$items = isset( $attrs['paginationItems'] ) && is_array( $attrs['paginationItems'] )
			? $attrs['paginationItems']
			: array();
		$item_styling_map = isset( $attrs['paginationItemStyling'] ) && is_array( $attrs['paginationItemStyling'] )
			? $attrs['paginationItemStyling']
			: array();
		$item_indicator_map = isset( $attrs['paginationItemIndicatorStyling'] ) && is_array( $attrs['paginationItemIndicatorStyling'] )
			? $attrs['paginationItemIndicatorStyling']
			: array();

		foreach ( $items as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			$type = isset( $item['id'] ) ? (string) $item['id'] : '';
			if ( '' === $type ) {
				continue;
			}
			$styling = isset( $item_styling_map[ $type ] ) && is_array( $item_styling_map[ $type ] )
				? $item_styling_map[ $type ]
				: array();
			if ( ! empty( $styling ) ) {
				$css .= Blocksify_Styling_Repeater::build_sparse_preview_css(
					$styling,
					array(
						'block_name'         => 'blocksify/content-loop',
						'uid'                => $uid,
						'part_selector'      => self::paging_item_part_selector( $type ),
						'consumer_name'      => 'content-loop-paging',
						'selector_overrides' => self::paging_item_selector_overrides( $type ),
					)
				);
			}

			// Indicator styling (Decision #70) — `.bsy-cl-paging__indicator`
			// span inside the prev-next nav. Only emitted when the item is
			// `nav` + `style === 'prev-next'` + `showIndicator !== false`
			// + sparse storage has at least one authored prop. Static CSS
			// baselines font-size 90%; this layer overrides per user
			// authoring (typography surface only — see
			// `PAGING_INDICATOR_*` constants in `inspector/Panels.js`).
			if ( 'nav' === $type ) {
				$value             = isset( $item['value'] ) && is_array( $item['value'] ) ? $item['value'] : array();
				$is_prev_next      = isset( $value['style'] ) && 'prev-next' === $value['style'];
				$show_indicator    = ! isset( $value['showIndicator'] ) || false !== $value['showIndicator'];
				$indicator_styling = isset( $item_indicator_map[ $type ] ) && is_array( $item_indicator_map[ $type ] )
					? $item_indicator_map[ $type ]
					: array();
				if ( $is_prev_next && $show_indicator && ! empty( $indicator_styling ) ) {
					$css .= Blocksify_Styling_Repeater::build_sparse_preview_css(
						$indicator_styling,
						array(
							'block_name'    => 'blocksify/content-loop',
							'uid'           => $uid,
							'part_selector' => ' .bsy-cl-paging__indicator',
							'consumer_name' => 'content-loop-paging',
						)
					);
				}
			}
		}

		// Outer wrapper styling. `component_defaults` (Decision #69) threads
		// the consumer's seed direction (`row` for the pagination bar) to
		// the layout group emit so a user authoring `alignItems: center`
		// alone doesn't fall back to the `column` default and stack items
		// vertically. Mirrors `PAGINATION_OUTER_COMPONENT_DEFAULTS` in
		// `inspector/Panels.js` byte-for-byte (FOUNDATION §9.1 twin).
		$outer = isset( $attrs['paginationStyling'] ) && is_array( $attrs['paginationStyling'] )
			? $attrs['paginationStyling']
			: array();
		if ( ! empty( $outer ) ) {
			$css .= Blocksify_Styling_Repeater::build_sparse_preview_css(
				$outer,
				array(
					'block_name'         => 'blocksify/content-loop',
					'uid'                => $uid,
					'part_selector'      => ' .bsy-cl-paging',
					'consumer_name'      => 'content-loop-paging',
					'component_defaults' => array(
						'layout' => array( 'direction' => 'row' ),
					),
				)
			);
		}

		return $css;
	}
}

endif;
