<?php
/**
 * Episodes REST Controller class.
 *
 * Custom endpoint for querying multiple post-types for episodes.
 * Mimics `WP_REST_Posts_Controller` as closely as possible.
 *
 * @package SeriouslySimplePodcasting
 * @since 1.19.12
 */

namespace SeriouslySimplePodcasting\Rest;

use SeriouslySimplePodcasting\Controllers\Passthrough_Controller;
use SeriouslySimplePodcasting\Entities\Sync_Status;
use SeriouslySimplePodcasting\Repositories\Episode_Repository;
use WP_REST_Controller;
use WP_REST_Posts_Controller;
use WP_REST_Server;
use WP_Query;

/**
 * Custom endpoint for querying for multiple post-types for episodes.
 * Mimics `WP_REST_Posts_Controller` as closely as possible.
 *
 * New filters:
 *  - `rest_episode_query` Filters the query arguments as generated
 *    from the request parameters.
 *
 * @author Jonathan Bossenger
 * @package Seriously Simple Podcasting
 * @since 1.19.12
 */

/**
 * Extended from the code shared by Ruben Vreeken, (https://github.com/Rayraz)
 * on stackoverflow (http://stackoverflow.com/questions/38059805/query-multiple-post-types-using-wp-rest-api-v2-wordpress)
 */

/**
 * Class Episodes_Controller
 */
class Episodes_Rest_Controller extends WP_REST_Controller {

	/**
	 * REST API namespace.
	 *
	 * @var string
	 */
	public $namespace;

	/**
	 * REST API base.
	 *
	 * @var string
	 */
	public $rest_base;

	/**
	 * Post types array.
	 *
	 * @var array
	 */
	public $post_types;

	/**
	 * Episode repository instance.
	 *
	 * @var Episode_Repository
	 */
	protected $episode_repository;

	/**
	 * Constructor.
	 *
	 * @param Episode_Repository $episode_repository Episode repository instance.
	 */
	public function __construct( $episode_repository ) {
		$this->episode_repository = $episode_repository;

		$this->namespace  = 'ssp/v1';
		$this->rest_base  = '/episodes';
		$this->post_types = ssp_post_types();
	}

	/**
	 * Register the routes for the objects of the controller.
	 *
	 * Sets up REST API endpoints for episodes.
	 */
	public function register_routes() {
		register_rest_route(
			$this->namespace,
			$this->rest_base,
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_items' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => $this->get_collection_params(),
				),
			)
		);

		register_rest_route(
			$this->namespace,
			'/podcasts/(?P<series>[\d]+)' . $this->rest_base,
			array(
				array(
					'methods'             => WP_REST_Server::READABLE,
					'callback'            => array( $this, 'get_items' ),
					'permission_callback' => array( $this, 'get_items_permissions_check' ),
					'args'                => $this->get_collection_params(),
				),
			)
		);

		/**
		 * Custom route to update the episode's podmotor_episode_id and audio_file.
		 * Might be used in future to update other episode properties.
		 * In future should replace @see update_rest_podcast()
		 */
		register_rest_route(
			'ssp/v1',
			'/episodes/(?P<episode_id>[\d]+)',
			array(
				'methods'             => 'PUT',
				'callback'            => array( $this, 'update_episode' ),
				'permission_callback' => array( $this, 'update_item_permissions_check' ),
			)
		);

		/**
		 * Update podcast passthrough settings (ads, campaigns).
		 */
		register_rest_route(
			$this->namespace,
			'/podcasts/(?P<series_id>[\d]+)',
			array(
				'methods'             => 'PUT',
				'callback'            => array( $this, 'update_podcast' ),
				'permission_callback' => array( $this, 'update_item_permissions_check' ),
			)
		);
	}

	/**
	 * Validates Castos authentication headers.
	 *
	 * Requires the action-bound headers (X-Castos-Signature, X-Castos-Timestamp, X-Castos-Nonce),
	 * verifies the signature against the stored API token over the method/path/body/timestamp/nonce
	 * canonical message, and rejects reused nonces.
	 *
	 * Static method for use in filters and other contexts.
	 *
	 * @param \WP_REST_Request $request     Full data about the request.
	 * @param array            $request_data Request data to use for signature calculation (typically JSON body or empty array for GET).
	 *
	 * @return bool|\WP_Error True if authentication is valid, WP_Error if invalid (with appropriate error code).
	 */
	public static function validate_castos_authentication( $request, $request_data = array() ) {
		$signature  = $request->get_header( 'X-Castos-Signature' );
		$timestamp  = $request->get_header( 'X-Castos-Timestamp' );
		$status_401 = array( 'status' => 401 );

		if ( empty( $signature ) || empty( $timestamp ) ) {
			return new \WP_Error( 'missing_signature', 'No signature or timestamp provided.', $status_401 );
		}

		if ( ! ctype_digit( (string) $timestamp ) || abs( time() - (int) $timestamp ) > self::signature_freshness() ) {
			return new \WP_Error( 'invalid_timestamp', 'Invalid timestamp provided.', $status_401 );
		}

		$stored_key = ssp_get_option( 'podmotor_account_api_token' );

		if ( empty( $stored_key ) ) {
			return new \WP_Error( 'no_api_key', 'Request signature invalid.', $status_401 );
		}

		$nonce = $request->get_header( 'X-Castos-Nonce' );

		// Require the action-bound nonce. Castos sends it for every authenticated request to
		// SSP >= 3.16.2, so a missing nonce is never a legitimate request to this version.
		if ( empty( $nonce ) ) {
			return new \WP_Error( 'missing_nonce', 'Request signature invalid.', $status_401 );
		}

		$expected_signature = self::action_bound_signature( $request, $request_data, $timestamp, $nonce, $stored_key );

		if ( ! hash_equals( $expected_signature, $signature ) ) {
			return new \WP_Error( 'invalid_signature', 'Request signature invalid.', $status_401 );
		}

		// Reject nonce reuse within the freshness window. Checked after signature verification so
		// the nonce store can only ever hold values an attacker could not have forged.
		if ( self::is_nonce_used( $nonce ) ) {
			return new \WP_Error( 'replayed_nonce', 'Request signature invalid.', $status_401 );
		}

		return true;
	}

	/**
	 * Records a verified nonce and reports whether it was already seen.
	 *
	 * Only valid (signature-verified) nonces reach this point, so the store
	 * cannot be flooded with forged values. The TTL matches the signature
	 * freshness window, after which the timestamp check rejects the request anyway.
	 *
	 * The first claim is atomic via wp_cache_add(), which fails if the key already
	 * exists — closing the read-then-write race between concurrent replays on sites
	 * with a persistent object cache. A transient provides the durable record (and
	 * the store itself on sites without a persistent object cache, where wp_cache_add
	 * is per-request only; there the residual replay is of the *same* request, which
	 * the action binding already renders idempotent).
	 *
	 * @param string $nonce Per-request nonce header value.
	 *
	 * @return bool True if the nonce was already used (replay), false on first use.
	 */
	protected static function is_nonce_used( $nonce ) {
		$key = 'ssp_castos_nonce_' . md5( $nonce );

		if ( ! wp_cache_add( $key, 1, 'ssp_castos_nonce', self::signature_freshness() ) ) {
			return true;
		}

		if ( false !== get_transient( $key ) ) {
			return true;
		}

		set_transient( $key, 1, self::signature_freshness() );

		return false;
	}

	/**
	 * Returns the freshness window (in seconds) for Castos request timestamps and nonces.
	 *
	 * 5 minutes, per the Castos signer team's recommendation (no shorter than ~5 min) to
	 * tolerate WordPress host clock skew. Replay is handled by the one-time nonce, so this
	 * is a secondary guard rather than the primary defense.
	 *
	 * @return int
	 */
	protected static function signature_freshness() {
		return 5 * MINUTE_IN_SECONDS;
	}

	/**
	 * Builds the expected HMAC for the action-bound signature scheme.
	 *
	 * Binds the signature to the HTTP method, full request path (including the
	 * REST prefix and any sub-directory), URL query, body, timestamp and per-request
	 * nonce, matching the Castos client signer. The canonical message is
	 * METHOD\nPATH\ncanonical_query\njson_body\ntimestamp\nnonce.
	 *
	 * PATH is reconstructed as the canonical `{home path}/{rest-prefix}{route}`
	 * the Castos signer builds (it always signs the literal `/wp-json/` path),
	 * rather than the raw request URI or `rest_url()`. This matches regardless of
	 * the site's permalink structure, sub-directory install, or a reverse-proxy
	 * path prefix.
	 *
	 * @param \WP_REST_Request $request      Full data about the request.
	 * @param array            $request_data Request body used for signing.
	 * @param string           $timestamp    Request timestamp header value.
	 * @param string           $nonce        Per-request nonce header value.
	 * @param string           $stored_key   Shared API token used as the HMAC secret.
	 *
	 * @return string Hex-encoded HMAC-SHA256 signature.
	 */
	protected static function action_bound_signature( $request, $request_data, $timestamp, $nonce, $stored_key ) {
		$home_path = rtrim( (string) wp_parse_url( home_url(), PHP_URL_PATH ), '/' );
		$path      = $home_path . '/' . trim( rest_get_url_prefix(), '/' ) . $request->get_route();

		$message = implode(
			"\n",
			array(
				strtoupper( $request->get_method() ),
				$path,
				self::canonical_query( $request->get_query_params() ),
				json_encode( $request_data ),
				$timestamp,
				$nonce,
			)
		);

		return hash_hmac( 'sha256', $message, $stored_key );
	}

	/**
	 * Canonicalizes the URL query so signer and verifier hash byte-for-byte identical bytes:
	 * keys sorted, RFC3986 encoding (space as %20, not '+'), empty query collapses to ''.
	 * Mirrors App\Services\SSP\SspRequestSigner::canonicalQuery() in the Castos backend.
	 *
	 * Every received query param is bound — including Castos's `castos-nonce` cache-buster.
	 * Any param an attacker adds or alters on a signed GET therefore changes the signed
	 * message and fails verification: the query is authenticated, never trusted from the
	 * URL alone. Only flat params are signed (Castos never sends bracket/array notation).
	 *
	 * @param array $query Query parameters as received (WP_REST_Request::get_query_params()).
	 *
	 * @return string
	 */
	protected static function canonical_query( $query ) {
		if ( empty( $query ) ) {
			return '';
		}

		ksort( $query );

		return http_build_query( $query, '', '&', PHP_QUERY_RFC3986 );
	}

	/**
	 * Determines the appropriate post_status for WP_Query based on authentication.
	 *
	 * For Castos-authenticated requests, ensures 'private' is included in the status array
	 * to allow fetching private episodes. For WordPress-authenticated users (via cookies)
	 * with edit_posts capability, respects the $request['status'] parameter. For completely
	 * unauthenticated requests, hardcodes to 'publish' for security.
	 *
	 * @param \WP_REST_Request $request            Full data about the request.
	 * @param bool             $castos_authenticated Whether the request has valid Castos authentication.
	 *
	 * @return string|array Post status(es) to use in WP_Query.
	 */
	protected function get_post_status_for_query( $request, $castos_authenticated ) {
		// Check if user can edit posts (WordPress authenticated OR Castos authenticated)
		$can_edit_posts = $castos_authenticated || current_user_can( 'edit_posts' );

		if ( ! $can_edit_posts ) {
			return 'publish';
		}

		// Return early if request status is empty with private and publish
		return $request['status'] ?? array( 'publish', 'private' );
	}


	/**
	 * @param $request
	 *
	 * @return bool|\WP_Error
	 */
	public function update_item_permissions_check( $request ) {
		$request_data = $request->get_json_params();
		return static::validate_castos_authentication( $request, $request_data );
	}

	/**
	 * Get a collection of items
	 *
	 * @param \WP_REST_Request $request Full data about the request.
	 *
	 * @return \WP_Error|\WP_REST_Response
	 */
	public function update_episode( $request ) {
		try {
			$this->validate_update_episode_request( $request );
			$url_params = $request->get_url_params();
			$episode_id = $url_params['episode_id'];
			$new_data   = $request->get_json_params();

			// In case of the error update sync information to show it to user.
			if ( ! empty( $new_data['error'] ) ) {
				$this->episode_repository->update_episode_sync_error( $episode_id, $new_data['error'] );
			} else {
				// Successful sync, remove the possible previous error.
				$this->episode_repository->delete_episode_sync_error( $episode_id );
			}

			// Update file URL (meta key defaults to 'audio_file').
			$audio_file_meta_key = apply_filters( 'ssp_audio_file_meta_key', 'audio_file' );

			// Update the file data.
			if ( ! empty( $new_data['file']['id'] ) && ! empty( $new_data['file']['url'] ) ) {

				// Update Castos file ID.
				update_post_meta( $episode_id, 'podmotor_file_id', $new_data['file']['id'] );

				update_post_meta( $episode_id, $audio_file_meta_key, $new_data['file']['url'] );

				// Also, update the 'enclosure' field (WP-standard "url\nsize\nmime\n" format) for
				// easy moving from/to other plugins. Size/MIME come from the Castos payload when
				// present, falling back to the stored filesize_raw meta, then 0 + extension MIME.
				$file_size = ! empty( $new_data['file']['size'] )
					? $new_data['file']['size']
					: get_post_meta( $episode_id, 'filesize_raw', true );
				$mime_type = empty( $new_data['file']['mime_type'] ) ? '' : $new_data['file']['mime_type'];
				$enclosure = $this->episode_repository->format_enclosure( $new_data['file']['url'], $file_size, $mime_type );
				update_post_meta( $episode_id, 'enclosure', $enclosure );
			}

			// Update Castos episode ID
			if ( ! empty( $new_data['episode']['id'] ) ) {
				update_post_meta( $episode_id, 'podmotor_episode_id', $new_data['episode']['id'] );
			}

			$full_success = ! empty( $new_data['file']['id'] ) && ! empty( $new_data['file']['url'] ) && ! empty( $new_data['episode']['id'] );

			$sync_status = $full_success ? Sync_Status::SYNC_STATUS_SYNCED : Sync_Status::SYNC_STATUS_FAILED;

			$this->episode_repository->update_episode_sync_status( $episode_id, $sync_status );

			return rest_ensure_response(
				array(
					'id'      => intval( $episode_id ),
					'file'    => array(
						'id'  => intval( get_post_meta( $episode_id, 'podmotor_file_id', true ) ),
						'url' => get_post_meta( $episode_id, $audio_file_meta_key, true ),
					),
					'episode' => array(
						'id' => intval( get_post_meta( $episode_id, 'podmotor_episode_id', true ) ),
					),
				)
			);
		} catch ( \Exception $e ) {
			return new \WP_Error( $e->getCode(), $e->getMessage() );
		}
	}

	/**
	 * Updates podcast passthrough settings (ads, campaigns).
	 *
	 * @param \WP_REST_Request $request Full data about the request.
	 *
	 * @return \WP_REST_Response Response indicating success or failure.
	 */
	public function update_podcast( $request ) {
		$series_id         = (int) $request->get_param( 'series_id' );
		$ads_enabled       = $request->get_param( 'ads_enabled' );
		$campaigns_enabled = $request->get_param( 'campaigns_enabled' );

		if ( null !== $ads_enabled ) {
			ssp_update_option( Passthrough_Controller::ENABLE_ADS_OPTION, rest_sanitize_boolean( $ads_enabled ) ? 'on' : '', $series_id );
		}

		if ( null !== $campaigns_enabled ) {
			ssp_update_option( Passthrough_Controller::ENABLE_CAMPAIGNS_OPTION, rest_sanitize_boolean( $campaigns_enabled ) ? 'on' : '', $series_id );
		}

		$this->flush_episode_file_data_transients( $series_id );

		return rest_ensure_response(
			array(
				'updated'   => true,
				'series_id' => $series_id,
			)
		);
	}

	/**
	 * Flushes cached episode file data transients for all episodes in a series.
	 *
	 * @param int $series_id Series ID.
	 */
	protected function flush_episode_file_data_transients( $series_id ) {
		$args = array(
			'post_type'      => ssp_post_types(),
			'post_status'    => 'any',
			'posts_per_page' => -1,
			'fields'         => 'ids',
		);

		if ( $series_id ) {
			$args['tax_query'] = array(
				array(
					'taxonomy' => ssp_series_taxonomy(),
					'field'    => 'term_id',
					'terms'    => $series_id,
				),
			);
		}

		$episode_ids = get_posts( $args );

		foreach ( $episode_ids as $episode_id ) {
			delete_transient( 'ssp_episode_file_data_' . $episode_id );
		}
	}

	/**
	 * Validates update request
	 *
	 * @param \WP_REST_Request $request Full data about the request.
	 *
	 * @throws \Exception
	 */
	private function validate_update_episode_request( $request ) {
		$url_params = $request->get_url_params();

		if ( ! is_array( $url_params ) || empty( $url_params['episode_id'] ) ) {
			throw new \Exception( 'Empty episode ID', 400 );
		}

		$episode = get_post( intval( $url_params['episode_id'] ) );

		if ( ! $episode ) {
			throw new \Exception( 'Episode not found', 404 );
		}

		$new_data = $request->get_json_params();

		if ( ! is_array( $new_data ) || empty( $new_data ) ) {
			throw new \Exception( 'Empty JSON', 400 );
		}

		// In case there is an error message don't validate it further.
		if ( ! empty( $new_data['error'] ) ) {
			return;
		}

		if ( empty( $new_data['file']['id'] ) ) {
			throw new \Exception( 'Empty file ID', 400 );
		}

		if ( empty( $new_data['file']['url'] ) ) {
			throw new \Exception( 'Empty file URL', 400 );
		}

		if ( empty( $new_data['episode']['id'] ) ) {
			throw new \Exception( 'Empty Castos episode ID', 400 );
		}
	}

	/**
	 * Check if a given request has access to get items
	 *
	 * @param \WP_REST_Request $request Full data about the request.
	 *
	 * @return bool Always returns true to allow public access.
	 */
	public function get_items_permissions_check( $request ) {
		return true;
	}

	/**
	 * Reports whether the request carries a valid WordPress REST nonce.
	 *
	 * Cookie-derived privilege (private statuses, unfiltered filter args) is granted only when
	 * this passes, so a cross-site request that rides the login cookie without the nonce cannot
	 * elevate. @wordpress/api-fetch sends the nonce (X-WP-Nonce) for block-editor callers.
	 *
	 * @param \WP_REST_Request $request Full data about the request.
	 *
	 * @return bool
	 */
	protected static function has_valid_rest_nonce( $request ) {
		$nonce = $request->get_header( 'X-WP-Nonce' );

		if ( empty( $nonce ) ) {
			$nonce = $request->get_param( '_wpnonce' );
		}

		// A malformed request can supply a non-scalar _wpnonce (e.g. `?_wpnonce[]=x`);
		// wp_verify_nonce() would choke on it, so treat anything non-scalar as no nonce.
		if ( empty( $nonce ) || ! is_scalar( $nonce ) ) {
			return false;
		}

		return (bool) wp_verify_nonce( $nonce, 'wp_rest' );
	}

	/**
	 * Get a collection of items
	 *
	 * @param \WP_REST_Request $request Full data about the request.
	 *
	 * @return \WP_Error|\WP_REST_Response
	 */
	public function get_items( $request ) {
		// WordPress REST API resets the current user to 0 when a cookie request arrives without a
		// valid REST nonce (CSRF protection). Only restore that identity when a valid nonce is
		// present — otherwise the request stays public. Block-editor callers use
		// @wordpress/api-fetch, which sends X-WP-Nonce, so legitimate editor use is unaffected.
		if ( self::has_valid_rest_nonce( $request ) ) {
			Rest_Api_Controller::authenticate_user_from_cookie();
		}

		// Check if request has valid Castos authentication (optional for GET requests)
		$castos_authenticated = true === static::validate_castos_authentication( $request, array() );

		$args                        = array();
		$args['author__in']          = $request['author'];
		$args['author__not_in']      = $request['author_exclude'];
		$args['menu_order']          = $request['menu_order'];
		$args['offset']              = $request['offset'];
		$args['order']               = $request['order'];
		$args['orderby']             = $request['orderby'];
		$args['paged']               = $request['page'];
		$args['post__in']            = $request['include'];
		$args['post__not_in']        = $request['exclude'];
		$args['posts_per_page']      = $request['per_page'];
		$args['name']                = $request['slug'];
		$args['post_type']           = $this->post_types;
		$args['post_parent__in']     = $request['parent'];
		$args['post_parent__not_in'] = $request['parent_exclude'];

		// Handle post_status: if Castos authenticated, include private episodes in query
			$args['post_status'] = $this->get_post_status_for_query( $request, $castos_authenticated );

		$args['s'] = $request['search'];

		$args['date_query'] = array();
		// Set before into date query. Date query must be specified as an array
		// of an array.
		if ( isset( $request['before'] ) ) {
			$args['date_query'][0]['before'] = $request['before'];
		}

		// Set after into date query. Date query must be specified as an array
		// of an array.
		if ( isset( $request['after'] ) ) {
			$args['date_query'][0]['after'] = $request['after'];
		}

		if ( is_array( $request['filter'] ) ) {
			$can_edit = $castos_authenticated || current_user_can( 'edit_posts' );
			$filter   = $this->sanitize_filter_args( $request['filter'], $can_edit );
			$args     = array_merge( $args, $filter );
			unset( $args['filter'] );
		}

		// Ensure array of post_types
		if ( ! is_array( $args['post_type'] ) ) {
			$args['post_type'] = array( $args['post_type'] );
		}

		/**
		 * Filter the query arguments for a request.
		 *
		 * Enables adding extra arguments or setting defaults for a post
		 * collection request.
		 *
		 * @see https://developer.wordpress.org/reference/classes/wp_user_query/
		 *
		 * @param array $args Key value array of query var to query value.
		 * @param \WP_REST_Request $request The request used.
		 */
		$args       = apply_filters( 'rest_episode_query', $args, $request );
		$query_args = $this->prepare_items_query( $args, $request );

		// Get taxonomies for each of the requested post_types
		$taxonomies = wp_list_filter( get_object_taxonomies( $query_args['post_type'], 'objects' ), array( 'show_in_rest' => true ) );

		// Construct taxonomy query
		foreach ( $taxonomies as $taxonomy ) {
			$base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name;

			if ( ! empty( $request[ $base ] ) ) {
				$query_args['tax_query'][] = array(
					'taxonomy'         => $taxonomy->name,
					'field'            => 'term_id',
					'terms'            => $request[ $base ],
					'include_children' => false,
				);
			}
		}

		// Execute the query
		$posts_query  = new WP_Query();
		$query_result = $posts_query->query( $query_args );

		// Handle query results
		$posts = array();

		// Prepare Divi content
		add_filter(
			'the_content',
			function ( $content ) {
				if ( false !== strpos( $content, '[et_pb_' ) ) {
					// Remove all [et_pb...] and [/et_pb...] shortcodes
					$content = preg_replace( '/\\[\\/et_pb[^\\]]*\\]|\\[et_pb[^\\]]*\\]/', '', $content );
				}

				return $content;
			}
		);

		foreach ( $query_result as $post ) {

			// Get PostController for Post Type
			$posts_controller = new WP_REST_Posts_Controller( $post->post_type );

			// If Castos-authenticated, skip WordPress permission checks entirely
			// (Castos authentication already validated the request)
			// For unauthenticated requests, check WordPress permissions
			if ( ! $castos_authenticated && ! $posts_controller->check_read_permission( $post ) ) {
				continue;
			}

			$data    = $posts_controller->prepare_item_for_response( $post, $request );
			$posts[] = $posts_controller->prepare_response_for_collection( $data );
		}

		// Calc total post count.
		// Safe to use found_posts for all requests because sanitize_filter_args()
		// prevents unauthenticated users from overriding post_status via the allowlist.
		$page        = (int) $query_args['paged'];
		$total_posts = $posts_query->found_posts;

		// Out-of-bounds, run the query again without LIMIT for total count.
		if ( $total_posts < 1 ) {
			unset( $query_args['paged'] );
			$count_query = new WP_Query();
			$count_query->query( $query_args );
			$total_posts = $count_query->found_posts;
		}

		// Calc total page count
		$max_pages = ceil( $total_posts / (int) $query_args['posts_per_page'] );

		$request_params = $request->get_query_params();

		// Add additional current and recent options to the response
		if ( ! empty( $request_params['get_additional_options'] ) ) {
			array_unshift(
				$posts,
				array(
					'id'    => 0,
					'title' => array(
						'rendered' => __( 'Current Episode', 'seriously-simple-podcasting' ),
					),
				)
			);

			array_unshift(
				$posts,
				array(
					'id'    => - 1,
					'title' => array(
						'rendered' => __( 'Latest Episode', 'seriously-simple-podcasting' ),
					),
				)
			);
		}

		// Construct response
		$response = rest_ensure_response( $posts );
		$response->header( 'X-WP-Total', (int) $total_posts );
		$response->header( 'X-WP-TotalPages', (int) $max_pages );

		// Construct base url for pagination links
		if ( ! empty( $request_params['filter'] ) ) {
			// Normalize the pagination params.
			unset( $request_params['filter']['posts_per_page'] );
			unset( $request_params['filter']['paged'] );
		}
		$base = add_query_arg( $request_params, rest_url( sprintf( '/%s/%s', $this->namespace, $this->rest_base ) ) );

		// Create link for previous page, if needed
		if ( $page > 1 ) {
			$prev_page = $page - 1;
			if ( $prev_page > $max_pages ) {
				$prev_page = $max_pages;
			}
			$prev_link = add_query_arg( 'page', $prev_page, $base );
			$response->link_header( 'prev', $prev_link );
		}

		// Create link for next page, if needed
		if ( $max_pages > $page ) {
			$next_page = $page + 1;
			$next_link = add_query_arg( 'page', $next_page, $base );
			$response->link_header( 'next', $next_link );
		}

		return $response;
	}

	/**
	 * Sanitize filter args based on authentication status.
	 *
	 * Uses an allowlist for unauthenticated requests to prevent query injection.
	 * Authenticated users and Castos requests pass through unfiltered.
	 *
	 * @param array $filter   Raw filter arguments from the request.
	 * @param bool  $can_edit Whether the user has edit_posts capability or Castos auth.
	 *
	 * @return array Sanitized filter arguments.
	 */
	protected function sanitize_filter_args( array $filter, bool $can_edit ): array {
		if ( $can_edit ) {
			return $filter;
		}

		$allowed_keys = array(
			's',
			'orderby',
			'order',
			'posts_per_page',
			'paged',
			'offset',
			'date_query',
		);

		$sanitized = array_intersect_key( $filter, array_flip( $allowed_keys ) );

		if ( isset( $sanitized['s'] ) ) {
			$sanitized['s'] = sanitize_text_field( $sanitized['s'] );
		}

		if ( isset( $sanitized['order'] ) ) {
			$sanitized['order'] = in_array( strtolower( (string) $sanitized['order'] ), array( 'asc', 'desc' ), true )
				? strtolower( $sanitized['order'] )
				: 'desc';
		}

		if ( isset( $sanitized['orderby'] ) ) {
			$allowed_orderby = array( 'date', 'id', 'include', 'title', 'slug', 'menu_order' );
			if ( ! in_array( (string) $sanitized['orderby'], $allowed_orderby, true ) ) {
				$sanitized['orderby'] = 'date';
			}
		}

		if ( isset( $sanitized['posts_per_page'] ) ) {
			$sanitized['posts_per_page'] = min( 500, max( 1, absint( $sanitized['posts_per_page'] ) ) );
		}

		if ( isset( $sanitized['paged'] ) ) {
			$sanitized['paged'] = max( 1, absint( $sanitized['paged'] ) );
		}

		if ( isset( $sanitized['offset'] ) ) {
			$sanitized['offset'] = max( 0, absint( $sanitized['offset'] ) );
		}

		if ( isset( $sanitized['date_query'] ) && ! is_array( $sanitized['date_query'] ) ) {
			unset( $sanitized['date_query'] );
		}

		return $sanitized;
	}

	/**
	 * Determine the allowed query_vars for a get_items() response and prepare
	 * for WP_Query.
	 *
	 * @param array            $prepared_args
	 * @param \WP_REST_Request $request
	 *
	 * @return array            $query_args
	 */
	protected function prepare_items_query( $prepared_args = array(), $request = null ) {
		$query_args = array();

		foreach ( $prepared_args as $key => $value ) {
			/**
			 * Filters the query_vars used in get_items() for the constructed query.
			 *
			 * The dynamic portion of the hook name, `$key`, refers to the query_var key.
			 *
			 * @param string $value The query_var value.
			 *
			 * @since 4.7.0
			 */
			$query_args[ $key ] = apply_filters( "rest_query_var-{$key}", $value ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
		}

		if ( ! in_array( 'post', $this->post_types ) || ! isset( $query_args['ignore_sticky_posts'] ) ) {
			$query_args['ignore_sticky_posts'] = true;
		}

		// Map to proper WP_Query orderby param.
		if ( isset( $query_args['orderby'] ) && isset( $request['orderby'] ) ) {
			$orderby_mappings = array(
				'id'            => 'ID',
				'include'       => 'post__in',
				'slug'          => 'post_name',
				'include_slugs' => 'post_name__in',
			);

			if ( isset( $orderby_mappings[ $request['orderby'] ] ) ) {
				$query_args['orderby'] = $orderby_mappings[ $request['orderby'] ];
			}
		}

		return $query_args;
	}

	/**
	 * Get all the WP Query vars that are allowed for the API request.
	 *
	 * @return array
	 */
	protected function get_allowed_query_vars( $post_types ) {
		global $wp;
		$edit_posts = true;

		/**
		 * Filter the publicly allowed query vars.
		 *
		 * Allows adjusting of the default query vars that are made public.
		 *
		 * @param array  Array  of allowed WP_Query query vars.
		 */
		$valid_vars = apply_filters( 'query_vars', $wp->public_query_vars );

		/**
		 * We allow 'private' query vars for authorized users only.
		 *
		 * It the user has `edit_posts` capabilty for *every* requested post
		 * type, we also allow use of private query parameters, which are only
		 * undesirable on the frontend, but are safe for use in query strings.
		 *
		 * To disable anyway, use `add_filter( 'rest_private_query_vars',
		 * '__return_empty_array' );`
		 *
		 * @param array $private_query_vars Array of allowed query vars for
		 *                                    authorized users.
		 *
		 * @var boolean
		 */
		foreach ( $post_types as $post_type ) {
			$post_type_obj = get_post_type_object( $post_type );
			if ( ! current_user_can( $post_type_obj->cap->edit_posts ) ) {
				$edit_posts = false;
				break;
			}
		}
		if ( $edit_posts ) {
			$private    = apply_filters( 'rest_private_query_vars', $wp->private_query_vars );
			$valid_vars = array_merge( $valid_vars, $private );
		}

		// Define our own in addition to WP's normal vars.
		$rest_valid = array(
			'author__in',
			'author__not_in',
			'ignore_sticky_posts',
			'menu_order',
			'offset',
			'post__in',
			'post__not_in',
			'post_parent',
			'post_parent__in',
			'post_parent__not_in',
			'posts_per_page',
			'date_query',
		);
		$valid_vars = array_merge( $valid_vars, $rest_valid );

		/**
		 * Filter allowed query vars for the REST API.
		 *
		 * This filter allows you to add or remove query vars from the final
		 * allowed list for all requests, including unauthenticated ones. To
		 * alter the vars for editors only, {@see rest_private_query_vars}.
		 *
		 * @param array {
		 *    Array of allowed WP_Query query vars.
		 *
		 * @param string $allowed_query_var The query var to allow.
		 * }
		 */
		$valid_vars = apply_filters( 'rest_query_vars', $valid_vars );

		return $valid_vars;
	}

	/**
	 * Get the query params for collections of attachments.
	 *
	 * @return array
	 */
	public function get_collection_params() {
		$params = parent::get_collection_params();

		$params['context']['default'] = 'view';

		$params['per_page'] = array(
			'description'       => 'Maximum number of items to be returned in result set.',
			'type'              => 'integer',
			'default'           => 10,
			'minimum'           => 1,
			'maximum'           => 500,
			'sanitize_callback' => 'absint',
			'validate_callback' => 'rest_validate_request_arg',
		);

		$params['after'] = array(
			'description'       => __( 'Limit response to resources published after a given ISO8601 compliant date.' ),
			'type'              => 'string',
			'format'            => 'date-time',
			'validate_callback' => 'rest_validate_request_arg',
		);

		$params['author']         = array(
			'description'       => __( 'Limit result set to posts assigned to specific authors.' ),
			'type'              => 'array',
			'default'           => array(),
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['author_exclude'] = array(
			'description'       => __( 'Ensure result set excludes posts assigned to specific authors.' ),
			'type'              => 'array',
			'default'           => array(),
			'sanitize_callback' => 'wp_parse_id_list',
			'validate_callback' => 'rest_validate_request_arg',
		);

		$params['before']  = array(
			'description'       => __( 'Limit response to resources published before a given ISO8601 compliant date.' ),
			'type'              => 'string',
			'format'            => 'date-time',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['exclude'] = array(
			'description'       => __( 'Ensure result set excludes specific ids.' ),
			'type'              => 'array',
			'default'           => array(),
			'sanitize_callback' => 'wp_parse_id_list',
		);
		$params['include'] = array(
			'description'       => __( 'Limit result set to specific ids.' ),
			'type'              => 'array',
			'default'           => array(),
			'sanitize_callback' => 'wp_parse_id_list',
		);

		$params['menu_order'] = array(
			'description'       => __( 'Limit result set to resources with a specific menu_order value.' ),
			'type'              => 'integer',
			'sanitize_callback' => 'absint',
			'validate_callback' => 'rest_validate_request_arg',
		);

		$params['offset']  = array(
			'description'       => __( 'Offset the result set by a specific number of items.' ),
			'type'              => 'integer',
			'sanitize_callback' => 'absint',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['order']   = array(
			'description'       => __( 'Order sort attribute ascending or descending.' ),
			'type'              => 'string',
			'default'           => 'desc',
			'enum'              => array( 'asc', 'desc' ),
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['orderby'] = array(
			'description'       => __( 'Sort collection by object attribute.' ),
			'type'              => 'string',
			'default'           => 'date',
			'enum'              => array(
				'date',
				'id',
				'include',
				'title',
				'slug',
			),
			'validate_callback' => 'rest_validate_request_arg',
		);

		$params['orderby']['enum'][] = 'menu_order';

		$params['parent']         = array(
			'description'       => __( 'Limit result set to those of particular parent ids.' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_list',
			'default'           => array(),
		);
		$params['parent_exclude'] = array(
			'description'       => __( 'Limit result set to all items except those of a particular parent id.' ),
			'type'              => 'array',
			'sanitize_callback' => 'wp_parse_id_list',
			'default'           => array(),
		);

		$params['slug']   = array(
			'description'       => __( 'Limit result set to posts with a specific slug.' ),
			'type'              => 'string',
			'validate_callback' => 'rest_validate_request_arg',
		);
		$params['status'] = array(
			'default'           => 'publish',
			'description'       => __( 'Limit result set to posts assigned a specific status.' ),
			'sanitize_callback' => 'sanitize_key',
			'type'              => 'string',
			'validate_callback' => array( $this, 'validate_user_can_query_private_statuses' ),
		);
		$params['filter'] = array(
			'description' => __( 'Use WP Query arguments to modify the response; private query vars require appropriate authorization.' ),
		);

		$taxonomies = wp_list_filter( get_object_taxonomies( get_post_types( array(), 'names' ), 'objects' ), array( 'show_in_rest' => true ) );
		foreach ( $taxonomies as $taxonomy ) {
			$base = ! empty( $taxonomy->rest_base ) ? $taxonomy->rest_base : $taxonomy->name;

			$params[ $base ] = array(
				'description'       => sprintf( __( 'Limit result set to all items that have the specified term assigned in the %s taxonomy.' ), $base ),
				'type'              => 'array',
				'sanitize_callback' => 'wp_parse_id_list',
				'default'           => array(),
			);
		}

		return $params;
	}

	/**
	 * Validate whether the user can query private statuses
	 *
	 * @param mixed $value
	 *
	 * @return \WP_Error|boolean
	 */
	public function validate_user_can_query_private_statuses( $value ) {
		if ( 'publish' === $value ) {
			return true;
		}

		foreach ( $this->post_types as $post_type ) {
			$post_type_obj = get_post_type_object( $post_type );
			if ( ! current_user_can( $post_type_obj->cap->edit_posts ) ) {
				return new \WP_Error(
					'rest_forbidden_status',
					__( 'Status is forbidden' ),
					array(
						'status'    => rest_authorization_required_code(),
						'post_type' => $post_type_obj->name,
					)
				);
			}
		}

		return true;
	}
}
