<?php
/**
 * Server-side translation: queryParams → WP_Query args.
 *
 * Mirrors the editor's `query/normalize.js` so server render and editor
 * preview produce equivalent post sets. Canonical input shape is an
 * object map keyed by canonical param id — `[ 'postType' => 'post',
 * 'perPage' => 6, 'taxQuery' => [ {...}, ... ] ]` — see catalog.js for
 * the full id list. Repeatable params (`taxQuery`, `taxQueryExclude`)
 * are stored as arrays under the same key, not as separate rows like
 * the legacy array-of-bags shape.
 *
 * Why a class with static methods (vs free functions): keeps the helper
 * surface namespaced + grouped, and the unit-test boundary obvious. No
 * instance state, no DI.
 *
 * @package Blocksify\Blocks\ContentLoop
 */

defined( 'ABSPATH' ) || exit;

if ( ! class_exists( 'Blocksify_CL_Query' ) ) :

class Blocksify_CL_Query {

	/**
	 * Free baseline handler registry for `queryParams` → `WP_Query` args
	 * translation. Each entry is a callable `function ( $val, array $args,
	 * array $flat ): array` that mutates and returns the args bag. Pro /
	 * 3rd-party extends via the `blocksify.cl.query.build_handlers` filter
	 * — registering a new id handler is the canonical way to ship support
	 * for advanced query params (meta_query, orderby meta_value, custom
	 * date_query rows, …) without touching Free core.
	 *
	 * Conventions mirror the JS twin `query/normalize.js`:
	 *   - `postType` + `perPage` are seeded directly on the args bag
	 *     before dispatch so the defaults (`post`, `6`) survive when the
	 *     user removes those rows from the Repeater.
	 *   - `taxQuery` + `taxQueryExclude` are processed outside the
	 *     per-id dispatch because the include + exclude pairs build a
	 *     single combined `tax_query` clause.
	 *   - `order` / `featuredOnly` / `stickyPosts` have small post-
	 *     processing steps that read from `$flat` directly — handled
	 *     after the dispatch loop to keep per-handler signatures simple.
	 *
	 * Twin parity with `query/normalize.js` is checked manually — when
	 * extending one, extend the other.
	 *
	 * @return array<string,callable>
	 */
	private static function get_build_handlers() {
		$pass_through = function ( $arg_key ) {
			return function ( $val, $args ) use ( $arg_key ) {
				$args[ $arg_key ] = $val;
				return $args;
			};
		};
		$handlers = array(
			// Seeded before dispatch — handlers are no-ops to avoid
			// double-writing the default.
			'postType'        => function ( $val, $args ) {
				$args['post_type'] = sanitize_key( (string) $val );
				return $args;
			},
			'perPage'         => function ( $val, $args ) {
				$args['posts_per_page'] = max( 1, min( 50, (int) $val ) );
				return $args;
			},
			// Repeatable taxonomy clauses — handled in `apply_tax_query`
			// after dispatch so include + exclude rows combine into one
			// `tax_query` bag.
			'taxQuery'        => function ( $val, $args ) {
				return $args;
			},
			'taxQueryExclude' => function ( $val, $args ) {
				return $args;
			},
			// Sticky behavior + featured-only meta_query applied as
			// post-processing steps (see `build_args` below). Handlers
			// no-op so the dispatch doesn't write invalid keys.
			'stickyPosts'     => function ( $val, $args ) {
				return $args;
			},
			'featuredOnly'    => function ( $val, $args ) {
				return $args;
			},
			// Boolean filter — no-op handler so dispatch recognizes the id
			// without writing an invalid WP_Query arg. Actual enforcement
			// (inject `post__not_in: [host_id]`) happens after dispatch in
			// the singular-context post-processing branch.
			'excludeCurrent'  => function ( $val, $args ) {
				return $args;
			},
			'page'            => $pass_through( 'paged' ),
			'search'          => function ( $val, $args ) {
				$args['s'] = (string) $val;
				return $args;
			},
			'orderBy'         => $pass_through( 'orderby' ),
			'author'          => $pass_through( 'author__in' ),
			'authorExclude'   => $pass_through( 'author__not_in' ),
			'include'         => $pass_through( 'post__in' ),
			'exclude'         => $pass_through( 'post__not_in' ),
			'parent'          => $pass_through( 'post_parent__in' ),
			'parentExclude'   => $pass_through( 'post_parent__not_in' ),
			'status'          => $pass_through( 'post_status' ),
			'order'           => function ( $val, $args ) {
				$args['order'] = strtoupper( (string) $val ) === 'ASC' ? 'ASC' : 'DESC';
				return $args;
			},
			'offset'          => function ( $val, $args ) {
				$args['offset'] = max( 0, (int) $val );
				return $args;
			},
		);

		/**
		 * Filter the handler registry. Pro / 3rd-party can register
		 * handlers for new queryParam ids (e.g. `metaQuery`, advanced
		 * date_query rows, orderby meta_value) without touching Free
		 * core. See class header. Phase 4.2: second `$entity` arg lets
		 * Pro target a single entity's handler bag per SPEC §5.5.
		 *
		 * @param array  $handlers Map of `id => callable( $val, $args, $flat ): array`.
		 * @param string $entity   `'posts'` for this call site.
		 */
		return (array) apply_filters(
			'blocksify.cl.query.build_handlers',
			$handlers,
			'posts'
		);
	}

	/**
	 * Build WP_Query args from queryParams + current page number.
	 *
	 * @param array $query_params Raw queryParams attribute (object map).
	 * @param int   $current_page Current pagination page (≥ 1).
	 * @param array $context      Optional render-time context:
	 *                            - `current_post_id` (int): host post ID
	 *                              from `get_the_ID()` (SSR) or REST
	 *                              request body. Used together with the
	 *                              `excludeCurrent` queryParam (catalog
	 *                              boolean filter) to inject
	 *                              `post__not_in: [host_id]` and hide the
	 *                              host post from "related posts" / "more
	 *                              from this section" layouts.
	 * @return array
	 */
	public static function build_args( $query_params, $current_page = 1, $context = array() ) {
		$flat = is_array( $query_params ) ? $query_params : array();

		// Seed defaults so removing the row from the Repeater doesn't drop
		// the baseline (`post`, `6`). Handlers for postType / perPage
		// overwrite when the user authored the row.
		$args = array(
			'post_type'           => 'post',
			'posts_per_page'      => 6,
			'ignore_sticky_posts' => true,
			'no_found_rows'       => true,
		);

		// Per-id handler dispatch — Phase 3.X Pro Query foundation. Pro /
		// 3rd-party registers handlers via the
		// `blocksify.cl.query.build_handlers` filter (see
		// `get_build_handlers`).
		$handlers = self::get_build_handlers();
		foreach ( $flat as $id => $val ) {
			if ( '' === $val || null === $val ) {
				continue;
			}
			if ( ! isset( $handlers[ $id ] ) || ! is_callable( $handlers[ $id ] ) ) {
				continue;
			}
			$next = call_user_func( $handlers[ $id ], $val, $args, $flat );
			if ( is_array( $next ) ) {
				$args = $next;
			}
		}

		// Random orderby — drop direction so WP doesn't double-sort.
		if ( isset( $args['orderby'] ) && 'rand' === $args['orderby'] ) {
			unset( $args['order'] );
		}

		// Tax query (cross-row aggregation — handled outside dispatch).
		$tax_query = self::build_tax_query(
			isset( $flat['taxQuery'] ) ? (array) $flat['taxQuery'] : array(),
			isset( $flat['taxQueryExclude'] ) ? (array) $flat['taxQueryExclude'] : array()
		);
		if ( ! empty( $tax_query ) ) {
			$args['tax_query'] = $tax_query;
		}

		// Date query.
		$date_query = self::build_date_query(
			isset( $flat['after'] ) ? $flat['after'] : null,
			isset( $flat['before'] ) ? $flat['before'] : null
		);
		if ( ! empty( $date_query ) ) {
			$args['date_query'] = $date_query;
		}

		// Featured Posts (Decision #34) — restrict to posts with a featured
		// image. WP stores featured-image attachment IDs in the
		// `_thumbnail_id` post-meta key; an EXISTS clause filters to posts
		// that have one set. Editor preview filters client-side on
		// `_embedded['wp:featuredmedia']` (see `edit.js`) since the default
		// `wp/v2/posts` REST endpoint doesn't expose `meta_query`.
		if ( ! empty( $flat['featuredOnly'] ) ) {
			$args['meta_query']   = isset( $args['meta_query'] ) ? (array) $args['meta_query'] : array();
			$args['meta_query'][] = array(
				'key'     => '_thumbnail_id',
				'compare' => 'EXISTS',
			);
		}

		// Sticky posts handling.
		$args = self::apply_sticky_handling( $args, isset( $flat['stickyPosts'] ) ? (string) $flat['stickyPosts'] : 'include' );

		// Compound offset: page advances by per_page, user offset adds on top.
		$page    = max( 1, (int) $current_page );
		$offset  = isset( $flat['offset'] ) ? max( 0, (int) $flat['offset'] ) : 0;
		$per     = (int) $args['posts_per_page'];
		if ( $page > 1 || $offset > 0 ) {
			$args['offset'] = ( ( $page - 1 ) * $per ) + $offset;
			// `paged` and `offset` together confuse WP_Query — prefer offset.
			unset( $args['paged'] );
		}

		// Exclude current host post — `excludeCurrent` is a boolean
		// queryParam in the catalog (catalog id `excludeCurrent`, default
		// ON via `DEFAULT_QUERY_PARAMS.posts`). Common "related posts" /
		// "more from this section" use case: CL block on a single post
		// shouldn't duplicate the host post in the loop. Applied here
		// (not as a per-id handler) because it needs the runtime context
		// `current_post_id` which lives outside the queryParams shape.
		$exclude_current = ! empty( $flat['excludeCurrent'] );
		$current_post_id = isset( $context['current_post_id'] ) ? (int) $context['current_post_id'] : 0;
		if ( $exclude_current && $current_post_id > 0 ) {
			$existing             = isset( $args['post__not_in'] ) ? (array) $args['post__not_in'] : array();
			$args['post__not_in'] = array_merge( $existing, array( $current_post_id ) );
		}

		// Sanitize array-of-IDs args.
		foreach ( array( 'author__in', 'author__not_in', 'post__in', 'post__not_in', 'post_parent__in', 'post_parent__not_in' ) as $k ) {
			if ( isset( $args[ $k ] ) ) {
				$args[ $k ] = array_values( array_filter( array_map( 'absint', (array) $args[ $k ] ) ) );
				if ( empty( $args[ $k ] ) ) {
					unset( $args[ $k ] );
				}
			}
		}

		// Status — keep array shape if multi, scalar if single.
		if ( isset( $args['post_status'] ) ) {
			$status = array_values( array_filter( array_map( 'sanitize_key', (array) $args['post_status'] ) ) );
			if ( empty( $status ) ) {
				unset( $args['post_status'] );
			} else {
				$args['post_status'] = count( $status ) === 1 ? $status[0] : $status;
			}
		}

		// Search — trim / drop empty.
		if ( isset( $args['s'] ) ) {
			$args['s'] = trim( (string) $args['s'] );
			if ( '' === $args['s'] ) unset( $args['s'] );
		}

		/**
		 * Filter the final WP_Query args before instantiation. Last-step
		 * override hook — Pro / user code uses this for runtime context-
		 * aware substitutions (URL params → query args, current-user
		 * resolution, archive context inheritance, …). See SPEC §4 Pro
		 * extension hooks for the runtime context-aware design space.
		 *
		 * @param array $args         WP_Query args.
		 * @param array $query_params Raw queryParams source.
		 * @param int   $current_page Current pagination page.
		 */
		return apply_filters( 'blocksify.cl.query_args', $args, $query_params, $current_page );
	}

	/**
	 * Build WP_Query `tax_query` from include + exclude rows. Multi-row
	 * relation defaults to AND so users can combine filters (Category=A
	 * AND Tag=B). Empty rows skipped.
	 *
	 * @param array $include_rows
	 * @param array $exclude_rows
	 * @return array
	 */
	private static function build_tax_query( $include_rows, $exclude_rows ) {
		$clauses = array();
		foreach ( $include_rows as $row ) {
			$clause = self::tax_clause( $row, 'IN' );
			if ( $clause ) $clauses[] = $clause;
		}
		foreach ( $exclude_rows as $row ) {
			$clause = self::tax_clause( $row, 'NOT IN' );
			if ( $clause ) $clauses[] = $clause;
		}
		if ( count( $clauses ) > 1 ) {
			$clauses['relation'] = 'AND';
		}
		return $clauses;
	}

	/**
	 * Single tax_query clause — null if row is empty/invalid.
	 *
	 * @param mixed  $row
	 * @param string $operator
	 * @return array|null
	 */
	private static function tax_clause( $row, $operator ) {
		if ( ! is_array( $row ) || empty( $row['taxonomy'] ) || empty( $row['terms'] ) ) {
			return null;
		}
		return array(
			'taxonomy'         => sanitize_key( $row['taxonomy'] ),
			'field'            => 'term_id',
			'terms'            => array_values( array_filter( array_map( 'absint', (array) $row['terms'] ) ) ),
			'operator'         => $operator,
			'include_children' => isset( $row['includeChildren'] ) ? (bool) $row['includeChildren'] : true,
		);
	}

	/**
	 * Build `date_query` from after/before strings.
	 *
	 * @param string|null $after
	 * @param string|null $before
	 * @return array
	 */
	private static function build_date_query( $after, $before ) {
		$dq = array();
		if ( $after && strtotime( $after ) ) {
			$dq['after'] = (string) $after;
		}
		if ( $before && strtotime( $before ) ) {
			$dq['before'] = (string) $before;
		}
		if ( empty( $dq ) ) return array();
		$dq['inclusive'] = true;
		return array( $dq );
	}

	/**
	 * Apply sticky-post behavior. Modes:
	 *   - include: WP default (sticky float to top)
	 *   - exclude: hide sticky from results
	 *   - ignore:  ignore sticky-ness (treat as normal posts)
	 *   - only:    show only stickies
	 *
	 * @param array  $args
	 * @param string $mode
	 * @return array
	 */
	private static function apply_sticky_handling( $args, $mode ) {
		$stickies = (array) get_option( 'sticky_posts', array() );
		switch ( $mode ) {
			case 'exclude':
				if ( ! empty( $stickies ) ) {
					$args['post__not_in'] = array_values(
						array_unique(
							array_merge( isset( $args['post__not_in'] ) ? (array) $args['post__not_in'] : array(), $stickies )
						)
					);
				}
				$args['ignore_sticky_posts'] = true;
				break;
			case 'only':
				$args['post__in']            = ! empty( $stickies ) ? array_map( 'absint', $stickies ) : array( 0 );
				$args['ignore_sticky_posts'] = true;
				break;
			case 'ignore':
				$args['ignore_sticky_posts'] = true;
				break;
			case 'include':
			default:
				// WP default — show stickies floated to top. We had set
				// ignore_sticky_posts=true at the top for general queries,
				// flip it back here so the user-facing "include" works.
				$args['ignore_sticky_posts'] = false;
				break;
		}
		return $args;
	}
}

endif;
