<?php
/**
 * Content Loop REST controller — in-place page swap (paging) +
 * future-ready surface for Loop Filter / Loop Search refresh.
 *
 * Single endpoint: `POST /wp-json/blocksify/v1/cl/{uid}/render` returns
 * the grid children HTML + paging state metadata so the frontend view.js
 * controller can swap cards in place + patch the nav state client-side
 * (no full page reload).
 *
 * Request body shape:
 *   {
 *     "page": 2,
 *     "hostId":   1457,             // int post ID OR string 'theme//slug'
 *     "hostType": "post",           // 'post' | 'template' | Pro slug
 *     "querySource": "inline",       // Mirrors wrapper `data-query-source`
 *     "overrides": {                // Runtime-only mutations on top of saved attrs
 *       "perPage": 12,              //   per-page select change
 *       "filters": { ... },         //   Pro Loop Filter payload
 *       "currentPostId": 1457       //   excludeCurrent host post id (PR #169)
 *     }
 *   }
 *
 * Server looks up the saved (sparse) attrs from the host post / template
 * via `parse_blocks( $host->post_content )` + uid match, inflates defaults
 * via `Blocksify_CSS_Pipeline::merge_block_defaults`, applies runtime
 * overrides, then dispatches to the entity-specific renderer. Replaces
 * the legacy `attrs` request param — saved post_content is the canonical
 * source, so the DOM no longer needs to carry a `data-query` round-trip
 * payload (~700B per CL block instance saved).
 *
 * Response body shape:
 *   {
 *     "html":        "<article class=\"bsy-card\">…</article>…",
 *     "currentPage": 2,
 *     "totalPages":  8,
 *     "totalItems":  47,
 *     "perPage":     6
 *   }
 *
 * `html` carries grid CHILDREN only — the wrapper + pagination bar belong
 * to the SSR shell. view.js inserts `html` before the `.bsy-cl-paging`
 * sibling and patches nav state from the metadata (numbered ellipsis
 * band recompute, prev-next anchor toggle, count text via locale-aware
 * sprintf on `data-format`). No paging HTML round-trips through REST —
 * keeps the response payload lean + the saved DOM clean of round-trip
 * carriers.
 *
 * Public endpoint (`__return_true`) — frontend visitors paginate freely.
 * Pro can layer auth via the standard `rest_authentication_errors` filter
 * or override the controller via `rest_endpoints`.
 *
 * @package Blocksify
 */

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

if ( ! class_exists( 'Blocksify_Content_Loop_REST' ) ) :

/**
 * REST controller for Content Loop in-place pagination swap.
 */
class Blocksify_Content_Loop_REST {

	/**
	 * REST namespace + base — matches the URL `/wp-json/blocksify/v1/cl/{uid}/render`.
	 */
	const REST_NAMESPACE = 'blocksify/v1';
	const REST_BASE      = 'cl';

	/**
	 * Wire the route registration to `rest_api_init`. Idempotent — safe
	 * to call once at plugin boot.
	 */
	public static function boot() {
		add_action( 'rest_api_init', array( __CLASS__, 'register_routes' ) );
	}

	/**
	 * Register the `POST /cl/(?P<uid>...)/render` route.
	 */
	public static function register_routes() {
		register_rest_route(
			self::REST_NAMESPACE,
			'/' . self::REST_BASE . '/(?P<uid>[a-zA-Z0-9_-]+)/render',
			array(
				array(
					'methods'             => WP_REST_Server::CREATABLE,
					'callback'            => array( __CLASS__, 'handle_render' ),
					'permission_callback' => '__return_true',
					'args'                => array(
						'uid'         => array(
							'type'              => 'string',
							'required'          => true,
							'sanitize_callback' => array( __CLASS__, 'sanitize_uid' ),
						),
						'page'        => array(
							'type'    => 'integer',
							'default' => 1,
						),
						// `hostId` is the lookup coordinate for the host
						// post / template — int for `'post'` (numeric post
						// ID), string for `'template'` (canonical
						// `'theme//slug'`). Pro `host_type`s may use either
						// shape; the schema accepts both.
						'hostId'      => array(
							'type'     => array( 'integer', 'string' ),
							'required' => true,
						),
						// `hostType` enum is intentionally open so Pro can
						// register custom slugs via the
						// `blocksify.cl.rest.host_resolver` filter without
						// a core schema update.
						'hostType'    => array(
							'type'     => 'string',
							'required' => true,
						),
						// Runtime-only mutations layered on top of saved
						// attrs at REST time: `perPage` from the per-page
						// selector, `filters` from Pro Loop Filter,
						// `currentPostId` from PR #169's `excludeCurrent`.
						'overrides'   => array(
							'type'    => 'object',
							'default' => array(),
						),
						'querySource' => array(
							'type'    => 'string',
							'default' => 'inline',
						),
					),
				),
			)
		);
	}

	/**
	 * UID sanitizer — same regex the renderer uses on the block attribute
	 * (`[^a-z0-9_-]/i` strip). Returns empty string for unrecognised input
	 * so the handler can return a 400 early.
	 *
	 * @param mixed $value Raw uid from the route param.
	 * @return string Sanitised uid (may be empty).
	 */
	public static function sanitize_uid( $value ) {
		return preg_replace( '/[^a-z0-9_-]/i', '', (string) $value );
	}

	/**
	 * REST callback — accepts the JSON payload, rebuilds the query at the
	 * requested page, and returns the grid children HTML + pagination
	 * metadata.
	 *
	 * Three entity branches mirror the renderer's `render_posts_loop` /
	 * `render_terms_loop` / Pro `render_entity_loop` dispatch. Pro
	 * entities that don't expose a REST-compatible loop should override
	 * this controller (via `rest_endpoints` filter) — Free's default
	 * dispatches via the same `query.resolve` filter the SSR path uses,
	 * so a Pro entity that returns args from `query.resolve` works
	 * without any extra REST plumbing.
	 *
	 * @param WP_REST_Request $request Request instance.
	 * @return WP_REST_Response|WP_Error
	 */
	public static function handle_render( $request ) {
		$uid = (string) $request->get_param( 'uid' );
		if ( '' === $uid ) {
			return new WP_Error( 'blocksify_cl_invalid_uid', __( 'Invalid block uid.', 'blocksify' ), array( 'status' => 400 ) );
		}

		$page      = max( 1, (int) $request->get_param( 'page' ) );
		$host_id   = $request->get_param( 'hostId' );
		$host_type = (string) $request->get_param( 'hostType' );
		$overrides = (array) $request->get_param( 'overrides' );
		// hostId arrives as int OR string per the schema union — sanitise
		// to a canonical shape so downstream comparisons stay predictable.
		if ( is_string( $host_id ) ) {
			$host_id = trim( $host_id );
			// Numeric strings collapse to int (post IDs round-trip as
			// strings via JSON parse in JS); preserve `'theme//slug'`
			// strings verbatim.
			if ( '' !== $host_id && ctype_digit( $host_id ) ) {
				$host_id = (int) $host_id;
			}
		} elseif ( is_int( $host_id ) ) {
			$host_id = max( 0, $host_id );
		} else {
			$host_id = 0;
		}
		$host_type_empty = '' === $host_type;
		$host_id_empty   = ( is_int( $host_id ) && $host_id <= 0 ) || ( is_string( $host_id ) && '' === $host_id );
		if ( $host_type_empty || $host_id_empty ) {
			return new WP_Error( 'blocksify_cl_invalid_host', __( 'Missing host coordinates (hostId / hostType).', 'blocksify' ), array( 'status' => 400 ) );
		}

		$query_source = (string) $request->get_param( 'querySource' );
		if ( '' === $query_source ) {
			$query_source = 'inline';
		}

		// Inherit-mode REST swap is intentionally not in scope — the loop
		// re-uses `global $wp_query` from the page's template, which the
		// REST request doesn't carry. The frontend controller skips
		// inherit blocks (anchor `href="?paged=N"` does a page reload
		// instead). Defensive: if a client sends `querySource: inherit`
		// anyway, return 400 so the bug surfaces instead of silently
		// returning wrong content.
		if ( 'inherit' === $query_source ) {
			return new WP_Error( 'blocksify_cl_inherit_unsupported', __( 'Inherit-mode pagination is handled via full-page reload, not REST.', 'blocksify' ), array( 'status' => 400 ) );
		}

		// Read the saved (sparse) attrs straight from the host's
		// post_content. Saved content is the canonical source — the
		// renderer no longer ships a `data-query` JSON snapshot through
		// the DOM and the client doesn't forward attrs in the request
		// body. If the host or the block can't be located we return 404
		// so view.js's bail path triggers an anchor-href reload.
		$raw_attrs = self::lookup_block_attrs( $host_id, $host_type, $uid );
		if ( null === $raw_attrs ) {
			return new WP_Error(
				'blocksify_cl_block_not_found',
				__( 'Content Loop block not found in host post / template.', 'blocksify' ),
				array( 'status' => 404 )
			);
		}

		// Pin route-param uid + request querySource over the saved values
		// (route is canonical for the uid; querySource may have been
		// authored via a different querySource than the wrapper reports —
		// keep request consistent with the visible state on screen).
		$raw_attrs['uid']         = $uid;
		$raw_attrs['querySource'] = $query_source;

		$attrs = Blocksify_CSS_Pipeline::merge_block_defaults( 'blocksify/content-loop', $raw_attrs );

		// Apply runtime overrides. `perPage` patches `queryParams.perPage`
		// (PR 2 storage shape is an object map keyed by canonical param
		// id). `currentPostId` mirrors the PR #169 wrapper data-attr —
		// view.js forwards it for `excludeCurrent` handling on singular
		// pages. Filters (Pro Loop Filter) feed `blocksify.cl.query.filters`
		// downstream in `handle_posts` / `handle_terms`.
		if ( isset( $overrides['perPage'] ) ) {
			$per_page = max( 1, (int) $overrides['perPage'] );
			if ( ! isset( $attrs['queryParams'] ) || ! is_array( $attrs['queryParams'] ) ) {
				$attrs['queryParams'] = array();
			}
			$attrs['queryParams']['perPage'] = $per_page;
		}
		if ( isset( $overrides['currentPostId'] ) ) {
			$attrs['currentPostId'] = max( 0, (int) $overrides['currentPostId'] );
		}
		$filters = isset( $overrides['filters'] ) && is_array( $overrides['filters'] )
			? $overrides['filters']
			: array();

		$entity = isset( $attrs['queryEntity'] ) ? (string) $attrs['queryEntity'] : 'posts';

		switch ( $entity ) {
			case 'posts':
				return self::handle_posts( $attrs, $page, $filters, $query_source );
			case 'terms':
				return self::handle_terms( $attrs, $page, $filters, $query_source );
			default:
				// Pro-registered entities dispatch via the same
				// `render_entity_loop` filter SSR uses — but the filter
				// returns full markup (wrapper + cards + paging). Pro
				// REST integrations should override this controller via
				// the `rest_endpoints` filter to ship their own
				// inner-only renderer. Surface a clear 501 so Pro
				// implementers know they need to wire their own handler.
				return new WP_Error(
					'blocksify_cl_entity_unsupported',
					sprintf(
						/* translators: %s: entity slug (e.g. "users") */
						__( 'Entity "%s" has no REST handler. Pro plugins ship their own controller for non-default entities.', 'blocksify' ),
						$entity
					),
					array( 'status' => 501 )
				);
		}
	}

	/**
	 * Look up the saved (sparse) attrs of a CL block inside its host
	 * post / template by uid. Walks `innerBlocks` recursively — CL can be
	 * nested under Section / Container / Accordion / etc.
	 *
	 * Returns `null` when the host can't be loaded or the uid isn't
	 * present in the parsed block tree (REST handler maps this to a 404).
	 *
	 * @param int|string $host_id   Numeric post ID or canonical template id (`'theme//slug'`).
	 * @param string     $host_type `'post'` | `'template'` | Pro slug.
	 * @param string     $uid       Sanitised block uid (route param).
	 * @return array|null Raw (sparse) attrs, or null if the host / block can't be found.
	 */
	protected static function lookup_block_attrs( $host_id, $host_type, $uid ) {
		$content = self::get_host_content( $host_id, $host_type );
		if ( '' === $content ) {
			return null;
		}
		$blocks = parse_blocks( $content );
		return self::find_cl_block_by_uid_recursive( $blocks, $uid );
	}

	/**
	 * Resolve the host's `post_content` string for a given `(host_id,
	 * host_type)` pair. Defaults cover `'post'` (any post type — singular
	 * post / page / Pro CPT) and `'template'` (FSE `wp_template` lookup
	 * by canonical `'theme//slug'` ID). Pro registers custom host slugs
	 * via the `blocksify.cl.rest.host_content` filter.
	 *
	 * Returns `''` when the host is missing so callers can short-circuit
	 * to a 404 response.
	 *
	 * @param int|string $host_id   Numeric post ID or canonical template id.
	 * @param string     $host_type `'post'` | `'template'` | Pro slug.
	 * @return string Host post_content (may be empty).
	 */
	protected static function get_host_content( $host_id, $host_type ) {
		if ( 'post' === $host_type ) {
			$post = get_post( (int) $host_id );
			if ( ! $post instanceof WP_Post ) {
				return '';
			}
			// Defense-in-depth — only expose post_content of publicly
			// viewable hosts. Without this guard a request that pairs a
			// known uid with a draft / private / trash post id would leak
			// that post's content through the REST swap response (the
			// renderer's `render_grid_inner_*` doesn't itself gate
			// visibility, and `permission_callback` is `__return_true` to
			// keep the public-paginate UX). `is_post_publicly_viewable`
			// handles all relevant statuses (publish + post type viewable)
			// in one call.
			if ( ! function_exists( 'is_post_publicly_viewable' ) || ! is_post_publicly_viewable( $post ) ) {
				return '';
			}
			return (string) $post->post_content;
		}
		if ( 'template' === $host_type && function_exists( 'get_block_template' ) ) {
			$template = get_block_template( (string) $host_id, 'wp_template' );
			return $template && isset( $template->content ) ? (string) $template->content : '';
		}
		/**
		 * Pro host content resolver — expand a Pro `(host_id, host_type)`
		 * pair into a `post_content`-shaped string the REST endpoint can
		 * run `parse_blocks()` over. Paired with the renderer-side
		 * `blocksify.cl.rest.host_resolver` filter.
		 *
		 * Signature: `( string $content, int|string $host_id, string $host_type ) => string`
		 *
		 * @since 1.0.0
		 */
		return (string) apply_filters( 'blocksify.cl.rest.host_content', '', $host_id, $host_type );
	}

	/**
	 * Depth-first search through a parsed block tree for the first CL
	 * block whose attrs carry the requested uid. Returns the matched
	 * block's raw attrs (sparse — same shape as `parse_blocks()` returned
	 * directly).
	 *
	 * @param array  $blocks Result of `parse_blocks()` or an `innerBlocks` slice.
	 * @param string $uid    Target block uid.
	 * @return array|null Matched block attrs, or null if not found.
	 */
	protected static function find_cl_block_by_uid_recursive( $blocks, $uid ) {
		foreach ( $blocks as $block ) {
			if (
				is_array( $block )
				&& ( $block['blockName'] ?? '' ) === 'blocksify/content-loop'
				&& isset( $block['attrs']['uid'] )
				&& (string) $block['attrs']['uid'] === $uid
			) {
				return is_array( $block['attrs'] ) ? $block['attrs'] : array();
			}
			if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
				$found = self::find_cl_block_by_uid_recursive( $block['innerBlocks'], $uid );
				if ( null !== $found ) {
					return $found;
				}
			}
		}
		return null;
	}

	/**
	 * Build the WP_Query for the Posts entity at the requested page, then
	 * render the grid children. Mirrors `render_posts_loop`'s query-build
	 * dispatch (inline → `Blocksify_CL_Query::build_args`; Pro saved
	 * → `blocksify.cl.query.resolve` filter) so the REST path produces
	 * the same posts as the SSR path for equal input.
	 *
	 * @param array  $attrs        Merged block attributes.
	 * @param int    $page         Target page (1-based).
	 * @param array  $filters      Loop Filter payload (Pro).
	 * @param string $query_source `inline | saved | Pro slug`.
	 * @return WP_REST_Response
	 */
	protected static function handle_posts( $attrs, $page, $filters, $query_source ) {
		$uid          = (string) $attrs['uid'];
		$query_params = isset( $attrs['queryParams'] ) ? (array) $attrs['queryParams'] : array();
		$query        = null;
		$active_args  = null;

		if ( 'inline' !== $query_source ) {
			$resolved = apply_filters( 'blocksify.cl.query.resolve', null, $attrs, null, $page );
			if ( $resolved instanceof WP_Query ) {
				$query = $resolved;
			} elseif ( is_array( $resolved ) ) {
				$resolved['no_found_rows'] = false;
				if ( ! isset( $resolved['paged'] ) ) {
					$resolved['paged'] = $page;
				}
				$active_args = $resolved;
				$query       = new WP_Query( $resolved );
			}
		}

		if ( ! ( $query instanceof WP_Query ) ) {
			// `current_post_id` context — REST request body forwards the host
			// post ID via `attrs.currentPostId` (view.js reads it from
			// `data-current-post-id` on the wrapper). REST can't reach
			// `get_the_ID()` (no main query during REST). The `excludeCurrent`
			// flag itself lives inside queryParams (catalog boolean filter),
			// build_args reads it directly from there.
			$build_context = array(
				'current_post_id' => isset( $attrs['currentPostId'] ) ? max( 0, (int) $attrs['currentPostId'] ) : 0,
			);
			$args                  = Blocksify_CL_Query::build_args( $query_params, $page, $build_context );
			$args['no_found_rows'] = false;
			// Pro Loop Filter widget — refine the args from the filter
			// payload the client collected via `blocksify.cl.filter_state`.
			$args        = (array) apply_filters( 'blocksify.cl.query.filters', $args, $filters, $attrs );
			$active_args = $args;
			$query       = new WP_Query( $args );
		}

		// Reuse the SSR out-of-range clamp logic — see render_posts_loop
		// for the rationale. Skip when the query was built externally
		// (Pro WP_Query escape hatch) since we can't safely rebuild it.
		if ( $page > 1 && ! $query->have_posts() && null !== $active_args ) {
			$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 ( $page > $max_pages ) {
					$page = $max_pages;
					if ( 'inline' === $query_source ) {
						$active_args                  = Blocksify_CL_Query::build_args(
							$query_params,
							$page,
							array(
								'current_post_id' => isset( $attrs['currentPostId'] ) ? max( 0, (int) $attrs['currentPostId'] ) : 0,
							)
						);
						$active_args['no_found_rows'] = false;
						$active_args                  = (array) apply_filters( 'blocksify.cl.query.filters', $active_args, $filters, $attrs );
					} else {
						$resolved_again = apply_filters( 'blocksify.cl.query.resolve', null, $attrs, null, $page );
						if ( is_array( $resolved_again ) ) {
							$resolved_again['no_found_rows'] = false;
							if ( ! isset( $resolved_again['paged'] ) ) {
								$resolved_again['paged'] = $page;
							}
							$active_args = $resolved_again;
						}
					}
					$query = new WP_Query( $active_args );
				}
			}
			wp_reset_postdata();
		}

		$total_items = max( 0, (int) $query->found_posts );
		$total_pages = max( 0, (int) $query->max_num_pages );
		$per_page    = isset( $query->query_vars['posts_per_page'] ) ? (int) $query->query_vars['posts_per_page'] : (int) get_option( 'posts_per_page', 6 );

		if ( 0 === $total_items ) {
			wp_reset_postdata();
			return rest_ensure_response(
				array(
					'html'        => '',
					'currentPage' => 1,
					'totalPages'  => 0,
					'totalItems'  => 0,
					'perPage'     => max( 1, $per_page ),
				)
			);
		}

		$html = Blocksify_Content_Loop_Renderer::render_grid_inner_posts( $query, $attrs );
		wp_reset_postdata();

		$resolved_page = max( 1, min( $page, $total_pages ) );

		return rest_ensure_response(
			array(
				'html'        => $html,
				'currentPage' => $resolved_page,
				'totalPages'  => $total_pages,
				'totalItems'  => $total_items,
				'perPage'     => max( 1, $per_page ),
			)
		);
	}

	/**
	 * Terms entity REST handler — twin of `handle_posts` but iterates a
	 * `WP_Term[]` set from `get_terms()` instead of a `WP_Query`. Totals
	 * come from `wp_count_terms()` because `get_terms()` has no
	 * `found_posts` analogue.
	 *
	 * @param array  $attrs        Merged block attributes.
	 * @param int    $page         Target page (1-based).
	 * @param array  $filters      Loop Filter payload (Pro).
	 * @param string $query_source `inline | saved | Pro slug`.
	 * @return WP_REST_Response
	 */
	protected static function handle_terms( $attrs, $page, $filters, $query_source ) {
		$query_params = isset( $attrs['queryParams'] ) ? (array) $attrs['queryParams'] : array();
		$args         = null;

		if ( 'inline' !== $query_source ) {
			$resolved = apply_filters( 'blocksify.cl.query.resolve', null, $attrs, null, $page );
			if ( is_array( $resolved ) ) {
				$args = $resolved;
				if ( ! isset( $args['offset'] ) ) {
					$per_for_offset = max( 1, (int) ( $args['number'] ?? 6 ) );
					$args['offset'] = ( max( 1, $page ) - 1 ) * $per_for_offset;
				}
			}
		}

		if ( null === $args ) {
			$args = Blocksify_Terms_Query::build_args( $query_params, $page );
			$args = (array) apply_filters( 'blocksify.cl.query.filters', $args, $filters, $attrs );
		}

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

		$per_page = max( 1, (int) ( $args['number'] ?? 6 ) );

		// Out-of-range clamp — mirrors `render_terms_loop`. Probe via
		// `wp_count_terms` to get the real total + rebuild at the
		// clamped page.
		if ( empty( $terms ) && $page > 1 ) {
			$count_args = $args;
			unset( $count_args['number'], $count_args['offset'], $count_args['fields'] );
			$count_args['fields'] = 'count';
			$total_items_probe    = (int) wp_count_terms( $count_args );
			$total_pages_probe    = $per_page > 0 ? (int) ceil( $total_items_probe / $per_page ) : 0;
			if ( $total_items_probe > 0 && $page > $total_pages_probe ) {
				$page           = $total_pages_probe;
				$args['offset'] = ( $page - 1 ) * $per_page;
				$terms          = get_terms( $args );
				if ( is_wp_error( $terms ) ) {
					$terms = array();
				}
			}
		}

		$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 ( empty( $terms ) ) {
			return rest_ensure_response(
				array(
					'html'        => '',
					'currentPage' => 1,
					'totalPages'  => max( 0, $total_pages ),
					'totalItems'  => max( 0, $total_items ),
					'perPage'     => $per_page,
				)
			);
		}

		$html          = Blocksify_Content_Loop_Renderer::render_grid_inner_terms( $terms, $attrs );
		$resolved_page = max( 1, min( $page, max( 1, $total_pages ) ) );

		return rest_ensure_response(
			array(
				'html'        => $html,
				'currentPage' => $resolved_page,
				'totalPages'  => max( 0, $total_pages ),
				'totalItems'  => max( 0, $total_items ),
				'perPage'     => $per_page,
			)
		);
	}
}

endif;
