<?php

/**
 * Read policy for the public post grid.
 *
 * The REST route in blocks/bw-post-grid.php is unauthenticated, so this list is
 * what stands between a caller and the database: a post type that is not a key
 * here cannot be queried at all, and a meta key not named in its 'meta' list is
 * never sent to the browser - neither by the endpoint nor by the server-rendered
 * payload the block localizes into the page.
 *
 * Keys are drawn from the choices offered by the block's ACF "post_type" field.
 * When a layout is added to blocks/template/bw-post-grid.php, add the fields it
 * renders here in the same change, or the grid will come back empty.
 *
 * @param string|null $post_type Null returns the whole policy.
 * @return array|false           False when the post type is not served.
 */
function bw_post_grid_policy ($post_type = null) {
	$policy = apply_filters('bw_post_grid_policy', array(
		'employee' => array(
			'meta' => array('first_name', 'last_name', 'title', 'work_phone', 'email'),
			'tax'  => array('site', 'department'),
		),
	));

	if ($post_type === null) return $policy;
	if (!isset($policy[$post_type])) return false;

	return array_merge(array('meta' => array(), 'tax' => array()), $policy[$post_type]);
}

/**
 * Ceiling on how many posts one request may pull. The block's ACF fields cap
 * rows at 40 and columns at 5, so the grid itself never asks for more.
 */
function bw_post_grid_max_per_page () {
	return (int) apply_filters('bw_post_grid_max_per_page', 200);
}

/**
 * The meta a post may expose to the browser.
 *
 * With an allow-list only those keys survive. Without one - a post type the
 * block is pointed at but that has no policy entry - protected keys are still
 * stripped, so plugin internals and ACF field references never ship.
 */
function bw_post_grid_public_meta ($post_id, $allowed = false) {
	$meta = get_fields($post_id);
	if (!is_array($meta)) $meta = array();

	foreach (get_post_meta($post_id) as $key => $values) {
		if (!is_array($values) || isset($meta[$key])) continue;
		// first stored value; array_pop(array_reverse(...)) did the same but
		// passed a temporary by reference, which PHP 8 notices on
		$meta[$key] = reset($values);
	}

	if (is_array($allowed)) {
		return array_intersect_key($meta, array_flip($allowed));
	}

	foreach (array_keys($meta) as $key) {
		if (is_protected_meta($key, 'post')) unset($meta[$key]);
	}
	return $meta;
}

/**
 * Sorting is a query surface too: an unchecked key lets a caller build an
 * EXISTS join against any meta key and read the row count back off found_posts.
 */
function bw_post_grid_clean_order_by ($order_by, $allowed_meta) {
	if (empty($order_by) || !is_array($order_by)) return false;

	$clean = array();
	foreach ($order_by as $order) {
		if (!is_array($order) || empty($order['key'])) continue;

		// $.param serializes booleans as the strings "true"/"false", and a
		// non-empty "false" would otherwise read as true here
		$is_meta = !empty($order['is_meta']) && $order['is_meta'] !== 'false';
		$key = (string) $order['key'];

		if ($is_meta) {
			if (!in_array($key, $allowed_meta, true)) continue;
		} else if (!in_array($key, array('title', 'date', 'name', 'menu_order'), true)) {
			continue;
		}

		$order_dir = isset($order['order']) ? strtoupper((string) $order['order']) : '';

		$clean[] = array(
			'is_meta' => $is_meta,
			'key'     => $key,
			'order'   => $order_dir === 'DESC' ? 'DESC' : 'ASC',
		);
	}
	return $clean ? $clean : false;
}

function bw_post_grid_request ($request) {

	$post_type = (string) $request->get_param('post_type');
	if ($post_type === '') $post_type = 'post';

	$policy = bw_post_grid_policy($post_type);
	if (!$policy) {
		return new WP_Error(
			'bw_post_grid_post_type_not_allowed',
			'This endpoint does not serve that post type.',
			array('status' => 400)
		);
	}

	// filters may only reference fields this post type actually publishes
	$meta = $request->get_param('meta');
	if (empty($meta) || !is_array($meta)) {
		$meta = false;
	} else {
		$meta = array_intersect_key($meta, array_flip($policy['meta']));
		if (empty($meta)) $meta = false;
	}

	$tax = $request->get_param('tax');
	if (empty($tax) || !is_array($tax)) {
		$tax = false;
	} else {
		$tax = array_intersect_key($tax, array_flip($policy['tax']));
		if (empty($tax)) $tax = false;
	}

	$posts_per_page = (int) $request->get_param('posts_per_page');
	if ($posts_per_page < 1) $posts_per_page = 12;

	$paged = (int) $request->get_param('paged');
	if ($paged < 1) $paged = 1;

	$order_by = bw_post_grid_clean_order_by($request->get_param('order_by'), $policy['meta']);

	return post_grid($post_type, $meta, $tax, $posts_per_page, $paged, $order_by);
}

// this query is run for the templates and the request more endpoint
function post_grid ($post_type, $meta = false, $tax = false, $posts_per_page = 20, $paged = 1, $order_by = false) {

	// applied on both paths: the localized payload is as public as the endpoint
	$policy       = bw_post_grid_policy($post_type);
	$allowed_meta = $policy ? $policy['meta'] : false;
	$allowed_tax  = $policy ? $policy['tax']  : false;

	$posts_per_page = min((int) $posts_per_page, bw_post_grid_max_per_page());
	if ($posts_per_page < 1) $posts_per_page = 1;

	$args = array(
		'post_type'      => $post_type,
		'post_status'    => 'publish',
		'posts_per_page' => $posts_per_page,
		'paged'          => max(1, (int) $paged),
	);
	if ($meta) {
		$args['meta_query'] = array();
		if (count($meta) > 1) $args['meta_query']['relation'] = 'AND';
		foreach ($meta as $key => $value) {
			if (is_array($value)) {
				$args['meta_query']["{$key}_clause"] = $value;
			} else {
				$args['meta_query']["{$key}_clause"] = array(
					'key'   => $key,
					'value' => $value
				);
			}
		}
	}
	if ($tax) {
		$args['tax_query'] = array();
		if (count($tax) > 1) $args['tax_query']['relation'] = 'AND';
		foreach ($tax as $taxonomy => $terms) {
			$args['tax_query'][] = array(
				'taxonomy' => $taxonomy,
				'field'    => 'slug',
				'terms'    => $terms
			);
		}
	}
	if ($order_by) {
		$args['orderby'] = array();
		foreach ($order_by as $index => $order) {
			if (!is_array($order)) return;
			if (empty($order['key'])) continue;
			if (!empty($order['is_meta'])) {
				$clause = "{$order['key']}_clause";
				if (empty($args['meta_query'][$clause])) {
					$args['meta_query'][$clause] = array(
						'key'     => $order['key'],
						'compare' => 'EXISTS'
					);
				}
				// order by the named clause, never the raw meta key
				$order['key'] = $clause;
			}
			$args['orderby'][$order['key']] = $order['order'];
		}
	}

	$data = new stdClass();
	$data->posts = array();
	$results = new WP_Query($args);
	$data->found_posts = $results->found_posts;

	while ($results->have_posts()) {
		$results->the_post();

		$data->posts[] = $post = new stdClass();

		$post->title = get_the_title();
		$post->link = get_permalink();
		$post->excerpt = get_the_excerpt();

		if ($image = wp_get_attachment_image_src(get_post_thumbnail_id(), "thumbnail")) {
			$post->image = $image[0];
		}

		$post->meta = bw_post_grid_public_meta(get_the_ID(), $allowed_meta);

		$post->tax = array();
		foreach (get_object_taxonomies($post_type, 'objects') as $taxonomy => $object) {
			if (is_array($allowed_tax)) {
				if (!in_array($taxonomy, $allowed_tax, true)) continue;
			} else if (empty($object->public)) {
				continue;
			}
			$post->tax[$taxonomy] = wp_get_object_terms(get_the_ID(), $taxonomy);
		}
	}
	wp_reset_postdata();
	return $data;
}
