<?php
/**
 * Server-side render for blocksify/content-loop.
 *
 * Phase 4.1 — entity-aware dispatch. Top-level `queryEntity` attribute
 * (default `'posts'`) selects the data source. Free ships `'posts'`
 * (via WP_Query); `'terms'` ships in Phase 4.2 (via `get_terms()`).
 * Pro registers additional entities via the `blocksify.cl.entities`
 * filter and provides the entity-specific render through
 * `blocksify.cl.render_entity_loop`.
 *
 * Per-instance CSS comes from `Blocksify_CSS_Pipeline` + the trait pipeline
 * via `Blocksify_CSS_Generator::generate_block_css()`. Layout properties
 * (columns + col-gap + row-gap) emit directly on
 * `.bsy-cl-{uid}.cl-is-grid` via
 * `Blocksify_CSS_Generator::emit_content_loop_local_rules()` — no
 * custom-property indirection (Decision #35).
 *
 * @package Blocksify
 *
 * @var array    $attributes Block attributes.
 * @var string   $content    Saved block markup (always empty for dynamic).
 * @var WP_Block $block      Parsed block instance.
 */

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

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

// Recursion guard — prevent infinite loop when a Content-Loop card's
// excerpt extraction re-enters a nested Content-Loop block.
//
// Scenario: blog archive shows post A → render `the_content` of A → A's
// content includes a Content-Loop block → that block queries 6 latest
// posts → each card calls `get_the_excerpt( $post_id )` for the
// description field → on themes whose excerpt pipeline applies the
// `the_content` filter without `excerpt_remove_blocks()` first
// (Customify's `Customify_Post_Entry::trim_excerpt()` is the canonical
// case in the field), `do_blocks` re-runs on the nested post's content
// → if that nested post ALSO has a Content-Loop block, this template
// fires again, queries the same set, recurses. The query result
// includes posts that include this same block → exponential explosion.
// On Customify's blog with 5 published posts each carrying a Content-
// Loop block, the recursion bursts past 3000+ `the_post` fires and
// exhausts PHP's 256 MB memory limit (fatal lands at
// `class-post-entry.php:406` — memory was already drained well before
// that join() ever ran).
//
// Guard via the global `$GLOBALS['_blocksify_cl_depth']` counter. When
// we see we're already inside a Content-Loop render, bail with no
// output — DOM stays clean (no placeholder element to dirty the cards
// the user actually sees on the outer block). The counter is
// auto-decremented by `$bsy_cl_release`'s destructor when this
// template's scope ends, so sibling (NOT-nested) Content-Loop blocks
// elsewhere on the same page render normally.
if ( ! isset( $GLOBALS['_blocksify_cl_depth'] ) ) {
	$GLOBALS['_blocksify_cl_depth'] = 0;
}
if ( $GLOBALS['_blocksify_cl_depth'] > 0 ) {
	return;
}
$GLOBALS['_blocksify_cl_depth']++;
// Auto-decrement on scope exit. Covers every path below — switch
// returns, exceptions, normal completion. The closure holds a
// reference to the global counter and fires on destruct.
$bsy_cl_release = new class() {
	public function __destruct() {
		if ( isset( $GLOBALS['_blocksify_cl_depth'] ) && $GLOBALS['_blocksify_cl_depth'] > 0 ) {
			$GLOBALS['_blocksify_cl_depth']--;
		}
	}
};

// Resolve entity (Phase 4.1 — SPEC §5.7).
//
// Defensive validation: the filter returns the FULL registry of known
// entities (Free baseline + Pro additions). If the saved `queryEntity` value
// is unknown (manual block-HTML edit, stale Pro plugin deactivated) fall
// back to `'posts'` rather than crash or render empty. Falling back avoids
// the alternative — silently producing wrong output by running posts-shape
// queryParams through a Pro entity's renderer (or vice versa).
$query_entity = isset( $attributes['queryEntity'] )
	? (string) $attributes['queryEntity']
	: 'posts';

/**
 * Registered entity kinds for the Content Loop block.
 *
 * Free ships `posts` + `terms`. Pro registers additional kinds (`users`,
 * custom CPT entities, …) by returning an array of `{ value, label }`
 * descriptors merged into the input. The `value` is the slug that gets
 * stored in the `queryEntity` block attribute; the `label` surfaces in the
 * Inspector entity picker once that UI lands in Phase 4.3.
 *
 * Signature: `( array $entities ) => array`
 * Entry shape: `[ 'value' => 'posts', 'label' => 'Posts' ]`
 *
 * @since 1.0.0 (Phase 4.1) — SPEC §5.5
 */
$registered_entities = (array) apply_filters(
	'blocksify.cl.entities',
	array(
		array( 'value' => 'posts', 'label' => __( 'Posts', 'blocksify' ) ),
		array( 'value' => 'terms', 'label' => __( 'Terms', 'blocksify' ) ),
	)
);
$known_values = wp_list_pluck( $registered_entities, 'value' );
if ( ! in_array( $query_entity, $known_values, true ) ) {
	$query_entity = 'posts';
}

// Pagination URL var branches on `querySource` (Phase 6.1 follow-up fix
// for decoupling across all 3 source modes):
//   - 'inherit' → WP main `paged` var (the archive / search / taxonomy
//                 template owns the URL; the block re-uses the main
//                 query, so per-block scoping is meaningless — we share
//                 the page's pagination).
//   - 'inline' / Pro saved → per-block `cl_{uid}_page` so multi-block
//                            isolation works (Decision #39 + SPEC §4.1
//                            URL convention).
$query_source_for_page = isset( $attributes['querySource'] ) ? (string) $attributes['querySource'] : 'inline';
// Decision #68 — stateless mode (`paginationUrlState: 'none'`) ignores
// the URL pagination key entirely. The address bar never reflects widget
// state in this mode, so a stale `?cl_{uid}_page=N` (typed, bookmarked,
// or leaked from a previous `'push'` session) must not pin the SSR to
// a specific page. Server + client both start at page 1; `'push'` keeps
// the existing per-block URL-key convention. Inherit mode is unaffected
// (theme owns the URL — `'none'` is meaningful only for self-paged
// inline / Pro saved blocks).
$paging_url_state = isset( $attributes['paginationUrlState'] ) ? (string) $attributes['paginationUrlState'] : 'none';
if ( 'inherit' === $query_source_for_page ) {
	$current_page = max( 1, (int) get_query_var( 'paged' ) );
} elseif ( 'none' === $paging_url_state ) {
	$current_page = 1;
} else {
	$page_key     = 'cl_' . $uid . '_page';
	$current_page = isset( $_GET[ $page_key ] ) ? max( 1, (int) $_GET[ $page_key ] ) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only pagination param.
}

/**
 * Phase 4.4 — conditional `view.js` enqueue.
 *
 * Free `view.js` is a ~25 LOC dispatcher that scans `[class*="bsy-cl-"]`
 * elements on page load and invokes per-`layoutType` handlers registered
 * via the `blocksify.cl.view.handlers` JS filter. Free ships zero
 * handlers — the dispatcher is a no-op until Pro registers something
 * (carousel / masonry / infinite-scroll / etc.) — so we only enqueue
 * the script when the block actually needs runtime JS.
 *
 * Default needs-js rule: `layoutType` is non-grid (list / carousel /
 * masonry). Pro flips `true` via the `blocksify.cl.view.needs_js` PHP
 * filter when a Free-grid layout uses Pro features that need runtime
 * JS (conditional visibility per card with `display: none` toggles,
 * scroll-triggered animations, lazy hydrate, etc.).
 *
 * Per-instance enqueue auto-dedupes via WordPress when multiple Content
 * Loop blocks on the same page each call `wp_enqueue_script` with the
 * same handle. Script is registered at plugin init by
 * `blocksify_register_content_loop_view()` in blocksify.php (mirrors
 * the Section `blocksify-section-scroll-effect` precedent).
 *
 * Signature: `( bool $needs_js, array $attrs ) => bool`
 *
 * @since 1.0.0 (Phase 4.4)
 */
$layout_type = isset( $attributes['layoutType'] ) ? (string) $attributes['layoutType'] : 'grid';
// Phase 6.2 — merge defaults so a fresh block insert (saved markup has
// only `{"uid":"..."}`) still sees the default `paginationItems` and
// triggers the JS enqueue. The full renderer also merges defaults
// (idempotent: merging twice produces the same array) so the cost is a
// single WP_Block_Type_Registry lookup.
$attributes_for_js     = Blocksify_CSS_Pipeline::merge_block_defaults( 'blocksify/content-loop', $attributes );
$has_pagination_items  = isset( $attributes_for_js['paginationItems'] )
	&& is_array( $attributes_for_js['paginationItems'] )
	&& ! empty( $attributes_for_js['paginationItems'] );
// Force view.js enqueue when `paginationItems` is authored so the REST
// in-place swap controller (Decision #65) wires up automatically on any
// layoutType. Non-grid layoutTypes still trigger the enqueue for their
// Pro JS handlers (carousel / masonry / list infinite-scroll) per
// Phase 4.4.
$default_needs_js = $has_pagination_items
	|| in_array( $layout_type, array( 'list', 'carousel', 'masonry' ), true );
$needs_js         = (bool) apply_filters( 'blocksify.cl.view.needs_js', $default_needs_js, $attributes );
if ( $needs_js ) {
	wp_enqueue_script( 'blocksify-content-loop-view' );
}

// Per-request output cache. Themes that render the post content multiple
// times per blog-archive entry — header part, body part, footer part,
// related-posts widget, sidebar excerpt, related-by-tag, etc. — trigger
// `apply_filters( 'the_content', … )` on the same source content 30+
// times per page. Each pass re-runs `do_blocks` which re-invokes THIS
// render with IDENTICAL `$uid` + `$current_page` + `$attributes` and
// produces IDENTICAL output. On Customify's blog with 5 published posts
// each containing a Content-Loop, this snowballed to 338 identical
// renders per request, ate ~0.5 MB per render, and exhausted PHP's
// 256 MB memory limit. Caching the rendered markup keyed on the inputs
// is the perf fix — second through Nth call return the cached buffer
// without re-running the query or the card markup builder.
//
// Cache key combines:
//   - `$uid`                — per block instance.
//   - `$current_page`       — pagination resolved before this point
//                              (covers inherit mode where the URL var
//                              lives on the main query, not under
//                              `cl_{uid}_page`).
//   - `md5( $attributes )`  — saved block attrs; busts when the parsed
//                              block markup is rewritten mid-request
//                              (rare, but theoretically possible).
//   - `md5( $cl_url_state )`— full snapshot of every `cl_{uid}_*` GET
//                              param. Reserves namespace for upcoming
//                              Loop Filter / Loop Search blocks that
//                              will URL-encode filter / search state
//                              under that prefix and trigger a full
//                              page reload (the REST in-place swap
//                              controller in `class-blocksify-content-loop-rest.php`
//                              uses a different code path entirely
//                              and is not affected by this cache).
//                              Empty for vanilla SSR — no extra key
//                              noise. Sorted before hashing so the
//                              key is order-stable across PHP $_GET
//                              ordering quirks.
// Lives in `$GLOBALS` for the request only — no persistence layer
// needed; the request lifecycle handles invalidation.
$cl_url_state = array();
$cl_prefix    = 'cl_' . $uid . '_';
foreach ( $_GET as $k => $v ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only param snapshot for cache key.
	if ( is_string( $k ) && 0 === strpos( $k, $cl_prefix ) ) {
		$cl_url_state[ $k ] = is_scalar( $v ) ? (string) $v : wp_json_encode( $v );
	}
}
ksort( $cl_url_state );
$cache_key = $uid . '|' . $current_page . '|' . md5(
	wp_json_encode( $attributes ) . '|' . wp_json_encode( $cl_url_state )
);
if ( ! isset( $GLOBALS['_blocksify_cl_cache'] ) ) {
	$GLOBALS['_blocksify_cl_cache'] = array();
}
if ( isset( $GLOBALS['_blocksify_cl_cache'][ $cache_key ] ) ) {
	echo $GLOBALS['_blocksify_cl_cache'][ $cache_key ]; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- cached self-rendered markup.
	return;
}
ob_start();
switch ( $query_entity ) {
	case 'posts':
		Blocksify_Content_Loop_Renderer::render_posts_loop( $attributes, $current_page, $block );
		break;

	case 'terms':
		Blocksify_Content_Loop_Renderer::render_terms_loop( $attributes, $current_page, $block );
		break;

	default:
		/**
		 * Render dispatch for Pro-registered entities (`users`, custom kinds).
		 *
		 * Pro returns the fully-rendered HTML string for the entity's loop.
		 * Returning `null` signals "no Pro handler available" → Free renders
		 * the unavailable placeholder (NOT a posts fallback — running
		 * posts-shape `queryParams` through a Pro entity's stored params
		 * produces undefined `WP_Query` behavior). The placeholder is a
		 * clear "Pro feature unavailable" affordance the user can recover
		 * from (re-activate Pro, switch entity back to a Free kind).
		 *
		 * Signature: `( ?string $rendered, string $entity, array $attrs, int $current_page ) => ?string`
		 *
		 * @since 1.0.0 (Phase 4.1) — SPEC §5.5
		 */
		$rendered = apply_filters(
			'blocksify.cl.render_entity_loop',
			null,
			$query_entity,
			$attributes,
			$current_page
		);
		if ( null === $rendered ) {
			Blocksify_Content_Loop_Renderer::render_entity_unavailable_placeholder(
				$query_entity,
				$attributes
			);
		} else {
			echo $rendered; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pro callback owns escaping per filter contract.
		}
		break;
}
$rendered_output = (string) ob_get_clean();
$GLOBALS['_blocksify_cl_cache'][ $cache_key ] = $rendered_output;
echo $rendered_output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- cached self-rendered markup.
