<?php

namespace WPGraphQL\Data\Connection;

use GraphQL\Error\InvariantViolation;
use GraphQL\Type\Definition\ResolveInfo;
use WPGraphQL\AppContext;
use WPGraphQL\Model\Post;
use WPGraphQL\Utils\Utils;

/**
 * Class PostObjectConnectionResolver
 *
 * @package WPGraphQL\Data\Connection
 * @extends \WPGraphQL\Data\Connection\AbstractConnectionResolver<\WP_Query>
 */
class PostObjectConnectionResolver extends AbstractConnectionResolver {

	/**
	 * The name of the post type, or array of post types the connection resolver is resolving for
	 *
	 * @var mixed|string|string[]
	 */
	protected $post_type;

	/**
	 * {@inheritDoc}
	 *
	 * @param mixed|string|string[] $post_type The post type to resolve for
	 */
	public function __construct( $source, array $args, AppContext $context, ResolveInfo $info, $post_type = 'any' ) {

		/**
		 * The $post_type can either be a single value or an array of post_types to
		 * pass to WP_Query.
		 *
		 * If the value is revision or attachment, we will leave the value
		 * as a string, as we validate against this later.
		 *
		 * If the value is anything else, we cast as an array. For example
		 *
		 * $post_type = 'post' would become [ 'post ' ], as we check later
		 * for `in_array()` if the $post_type is not "attachment" or "revision"
		 */
		if ( 'revision' === $post_type || 'attachment' === $post_type ) {
			$this->post_type = $post_type;
		} elseif ( 'any' === $post_type ) {
			$post_types      = \WPGraphQL::get_allowed_post_types();
			$this->post_type = ! empty( $post_types ) ? array_values( $post_types ) : [];
		} else {
			$post_type = is_array( $post_type ) ? $post_type : [ $post_type ];
			unset( $post_type['attachment'] );
			unset( $post_type['revision'] );
			$this->post_type = $post_type;
		}

		/**
		 * Call the parent construct to setup class data
		 */
		parent::__construct( $source, $args, $context, $info );
	}

	/**
	 * {@inheritDoc}
	 */
	protected function loader_name(): string {
		return 'post';
	}

	/**
	 * {@inheritDoc}
	 */
	protected function query_class(): string {
		return \WP_Query::class;
	}

	/**
	 * {@inheritDoc}
	 *
	 * @throws \GraphQL\Error\InvariantViolation If the query has been modified to suppress_filters.
	 */
	protected function query( array $query_args ) {
		$query = parent::query( $query_args );

		if ( isset( $query->query_vars['suppress_filters'] ) && true === $query->query_vars['suppress_filters'] ) {
			throw new InvariantViolation( esc_html__( 'WP_Query has been modified by a plugin or theme to suppress_filters, which will cause issues with WPGraphQL Execution. If you need to suppress filters for a specific reason within GraphQL, consider registering a custom field to the WPGraphQL Schema with a custom resolver.', 'wp-graphql' ) );
		}

		return $query;
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_ids_from_query() {
		/**
		 * @todo This is for b/c. We can just use $this->get_query().
		 */
		$query = isset( $this->query ) ? $this->query : $this->get_query();

		/** @var int[] */
		$ids = ! empty( $query->posts ) ? $query->posts : [];

		// If we're going backwards, we need to reverse the array.
		$args = $this->get_args();

		if ( ! empty( $args['last'] ) ) {
			$ids = array_reverse( $ids );
		}

		return $ids;
	}

	/**
	 * {@inheritDoc}
	 */
	public function should_execute() {
		/**
		 * If the post_type is not revision we can just return the parent::should_execute().
		 *
		 * @todo This works because AbstractConnectionResolver::pre_should_execute does a permission check on the `Post` model )
		 */
		if ( ! isset( $this->post_type ) || 'revision' !== $this->post_type ) {
			return parent::should_execute();
		}

		// If the connection is from the RootQuery (i.e. it doesn't have a `Post` source), check if the user has the 'edit_posts' capability.
		if ( ! $this->source instanceof Post && current_user_can( 'edit_posts' ) ) {
			return true;
		}

		// For revisions, we only want to execute the connection query if the user has access to edit the parent post.
		if ( $this->source instanceof Post && isset( $this->source->post_type ) ) {
			$parent_post_type_obj = get_post_type_object( $this->source->post_type );

			if ( isset( $parent_post_type_obj->cap->edit_post ) && current_user_can( $parent_post_type_obj->cap->edit_post, $this->source->databaseId ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * {@inheritDoc}
	 */
	protected function prepare_query_args( array $args ): array {
		/**
		 * Prepare for later use
		 */
		$last = ! empty( $args['last'] ) ? $args['last'] : null;

		$query_args = [];
		/**
		 * Ignore sticky posts by default
		 */
		$query_args['ignore_sticky_posts'] = true;

		/**
		 * Set the post_type for the query based on the type of post being queried
		 */
		$query_args['post_type'] = ! empty( $this->post_type ) ? $this->post_type : 'post';

		/**
		 * Don't calculate the total rows, it's not needed and can be expensive
		 */
		$query_args['no_found_rows'] = true;

		/**
		 * Set the post_status to "publish" by default
		 */
		$query_args['post_status'] = 'publish';

		/**
		 * Set posts_per_page the highest value of $first and $last, with a (filterable) max of 100
		 */
		$query_args['posts_per_page'] = $this->one_to_one ? 1 : $this->get_query_amount() + 1;

		// set the graphql cursor args
		$query_args['graphql_cursor_compare'] = ! empty( $last ) ? '>' : '<';
		$query_args['graphql_after_cursor']   = $this->get_after_offset();
		$query_args['graphql_before_cursor']  = $this->get_before_offset();

		/**
		 * If the cursor offsets not empty,
		 * ignore sticky posts on the query
		 */
		if ( ! empty( $this->get_after_offset() ) || ! empty( $this->get_before_offset() ) ) {
			$query_args['ignore_sticky_posts'] = true;
		}

		/**
		 * Pass the graphql $args to the WP_Query
		 */
		$query_args['graphql_args'] = $args;

		/**
		 * Collect the input_fields and sanitize them to prepare them for sending to the WP_Query
		 */
		$input_fields = [];
		if ( ! empty( $args['where'] ) ) {
			$input_fields = $this->sanitize_input_fields( $args['where'] );
		}

		/**
		 * If the post_type is "attachment", use 'any' status to catch all attachment statuses.
		 *
		 * Most attachments have 'inherit' status (default), but plugins may change
		 * attachment status to 'publish' or other statuses. Using 'any' ensures we catch
		 * attachments regardless of their status. The Model's is_private() method will handle
		 * privacy checks based on the attachment's parent and status.
		 *
		 * For revisions, keep the default 'inherit' status.
		 */
		if ( 'attachment' === $this->post_type ) {
			$query_args['post_status'] = 'any';
		} elseif ( 'revision' === $this->post_type ) {
			$query_args['post_status'] = 'inherit';
		}

		/**
		 * Unset the "post_parent" for attachments, as we don't really care if they
		 * have a post_parent set by default
		 */
		if ( 'attachment' === $this->post_type && isset( $input_fields['parent'] ) ) {
			unset( $input_fields['parent'] );
		}

		/**
		 * Merge the input_fields with the default query_args
		 */
		if ( ! empty( $input_fields ) ) {
			$query_args = array_merge( $query_args, $input_fields );
		}

		/**
		 * If the query is a search, the source is not another Post, and the parent input $arg is not
		 * explicitly set in the query, unset the $query_args['post_parent'] so the search
		 * can search all posts, not just top level posts.
		 *
		 * The search input arg is mapped to `s` before this point (see sanitize_input_fields).
		 */
		if ( ! $this->source instanceof \WP_Post && isset( $query_args['s'] ) && ! isset( $input_fields['parent'] ) ) {
			unset( $query_args['post_parent'] );
		}

		if ( empty( $args['where']['orderby'] ) && ! empty( $query_args['post__in'] ) ) {
			$post_in = $query_args['post__in'];
			// Make sure the IDs are integers
			$post_in = array_map(
				static function ( $id ) {
					return absint( $id );
				},
				$post_in
			);

			// If we're coming backwards, let's reverse the IDs
			if ( ! empty( $args['last'] ) || ! empty( $args['before'] ) ) {
				$post_in = array_reverse( $post_in );
			}

			$cursor_offset = $this->get_offset_for_cursor( $args['after'] ?? ( $args['before'] ?? 0 ) );

			if ( ! empty( $cursor_offset ) ) {
				// Determine if the offset is in the array
				$key = array_search( $cursor_offset, $post_in, true );

				// If the offset is in the array
				if ( false !== $key ) {
					$key     = absint( $key );
					$post_in = array_slice( $post_in, $key + 1, null, true );
				}
			}

			$query_args['post__in'] = $post_in;
			$query_args['orderby']  = 'post__in';
			$query_args['order']    = isset( $last ) ? 'ASC' : 'DESC';
		}

		/**
		 * Map the orderby inputArgs to the WP_Query
		 */
		if ( isset( $args['where']['orderby'] ) && is_array( $args['where']['orderby'] ) ) {
			$query_args['orderby'] = [];

			foreach ( $args['where']['orderby'] as $orderby_input ) {
				// Create a type hint for orderby_input. This is an array with a field and order key.
				/** @var array<string,string> $orderby_input */
				if ( empty( $orderby_input['field'] ) ) {
					continue;
				}

				/**
				 * These orderby options should not include the order parameter.
				 */
				if ( in_array(
					$orderby_input['field'],
					[
						'post__in',
						'post_name__in',
						'post_parent__in',
					],
					true
				) ) {
					$query_args['orderby'] = esc_sql( $orderby_input['field'] );

					// If we're ordering explicitly, there's no reason to check other orderby inputs.
					break;
				}

				$order = $orderby_input['order'];

				if ( isset( $query_args['graphql_args']['last'] ) && ! empty( $query_args['graphql_args']['last'] ) ) {
					if ( 'ASC' === $order ) {
						$order = 'DESC';
					} else {
						$order = 'ASC';
					}
				}

				$query_args['orderby'][ esc_sql( $orderby_input['field'] ) ] = esc_sql( $order );
			}
		}

		/**
		 * Convert meta_value_num to separate meta_value value field which our
		 * graphql_wp_term_query_cursor_pagination_support knowns how to handle
		 */
		if ( isset( $query_args['orderby'] ) && 'meta_value_num' === $query_args['orderby'] ) {
			$query_args['orderby'] = [
				'meta_value' => empty( $query_args['order'] ) ? 'DESC' : $query_args['order'], // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
			];
			unset( $query_args['order'] );
			$query_args['meta_type'] = 'NUMERIC';
		}

		/**
		 * If there's no orderby params in the inputArgs, set order based on the first/last argument
		 */
		if ( empty( $query_args['orderby'] ) ) {
			$query_args['order'] = ! empty( $last ) ? 'ASC' : 'DESC';
		}

		/**
		 * NOTE: Only IDs should be queried here as the Deferred resolution will handle
		 * fetching the full objects, either from cache of from a follow-up query to the DB
		 */
		$query_args['fields'] = 'ids';

		/**
		 * Filter the $query args to allow folks to customize queries programmatically
		 *
		 * @param array<string,mixed>                  $query_args The args that will be passed to the WP_Query
		 * @param mixed                                $source     The source that's passed down the GraphQL queries
		 * @param array<string,mixed>                  $args       The inputArgs on the field
		 * @param \WPGraphQL\AppContext                $context    The AppContext passed down the GraphQL tree
		 * @param \GraphQL\Type\Definition\ResolveInfo $info       The ResolveInfo passed down the GraphQL tree
		 *
		 * @hookGroup connections
		 * @since 0.0.6
		 */
		return apply_filters( 'graphql_post_object_connection_query_args', $query_args, $this->source, $args, $this->context, $this->info );
	}

	/**
	 * This sets up the "allowed" args, and translates the GraphQL-friendly keys to WP_Query
	 * friendly keys. There's probably a cleaner/more dynamic way to approach this, but
	 * this was quick. I'd be down to explore more dynamic ways to map this, but for
	 * now this gets the job done.
	 *
	 * @param array<string,mixed> $where_args The args passed to the connection
	 *
	 * @return array<string,mixed>
	 * @since  0.0.5
	 */
	public function sanitize_input_fields( array $where_args ) {
		$arg_mapping = [
			'authorIn'      => 'author__in',
			'authorName'    => 'author_name',
			'authorNotIn'   => 'author__not_in',
			'categoryId'    => 'cat',
			'categoryIn'    => 'category__in',
			'categoryName'  => 'category_name',
			'categoryNotIn' => 'category__not_in',
			'contentTypes'  => 'post_type',
			'dateQuery'     => 'date_query',
			'hasPassword'   => 'has_password',
			'id'            => 'p',
			'in'            => 'post__in',
			'mimeType'      => 'post_mime_type',
			'nameIn'        => 'post_name__in',
			'notIn'         => 'post__not_in',
			'parent'        => 'post_parent',
			'parentIn'      => 'post_parent__in',
			'parentNotIn'   => 'post_parent__not_in',
			'password'      => 'post_password',
			'search'        => 's',
			'stati'         => 'post_status',
			'status'        => 'post_status',
			'tagId'         => 'tag_id',
			'tagIds'        => 'tag__and',
			'tagIn'         => 'tag__in',
			'tagNotIn'      => 'tag__not_in',
			'tagSlugAnd'    => 'tag_slug__and',
			'tagSlugIn'     => 'tag_slug__in',
		];

		/**
		 * Map and sanitize the input args to the WP_Query compatible args
		 */
		$query_args = Utils::map_input( $where_args, $arg_mapping );

		if ( ! empty( $query_args['post_status'] ) ) {
			$allowed_stati             = $this->sanitize_post_stati( $query_args['post_status'] );
			$query_args['post_status'] = ! empty( $allowed_stati ) ? $allowed_stati : [ 'publish' ];
		}

		/**
		 * Filter the result set by sticky posts.
		 *
		 * WPGraphQL never floats sticky posts to the top of the results (ignore_sticky_posts
		 * is always true, which is also cursor-pagination safe). Instead `isSticky` filters
		 * the result set, modeling the WP REST API `sticky` parameter: true limits the query
		 * to sticky posts, false excludes them.
		 */
		if ( isset( $where_args['isSticky'] ) ) {
			$sticky_posts = get_option( 'sticky_posts', [] );
			$sticky_posts = is_array( $sticky_posts ) ? array_map( 'absint', $sticky_posts ) : [];

			if ( true === $where_args['isSticky'] ) {
				// Limit to sticky posts, intersecting with any explicit `in` filter.
				$query_args['post__in'] = ! empty( $query_args['post__in'] )
					? array_intersect( $sticky_posts, $query_args['post__in'] )
					: $sticky_posts;

				// WP_Query ignores an empty post__in, so force an impossible ID to return no results.
				if ( empty( $query_args['post__in'] ) ) {
					$query_args['post__in'] = [ 0 ];
				}
			} elseif ( ! empty( $sticky_posts ) ) {
				// Exclude sticky posts, merging with any explicit `notIn` filter.
				$query_args['post__not_in'] = array_merge( // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in
					! empty( $query_args['post__not_in'] ) ? $query_args['post__not_in'] : [],
					$sticky_posts
				);
			}
		}

		/**
		 * Filter the connection by the per-post template assignment (`_wp_page_template`),
		 * which covers both classic page templates and block-theme custom templates.
		 *
		 * This is the one place a core `where` arg introduces a meta_query. It is bounded to a
		 * single indexed meta key, but a meta_query is still more expensive than the indexed
		 * post columns the other args use.
		 *
		 * TODO (Query Cost): when the query complexity / cost analysis system lands
		 * (see plans/001-query-complexity-validation-rule.md and the filter/sort RFC #1385),
		 * this arg MUST be assigned a cost weighting so meta_query-backed filters can be priced
		 * and guarded. It is intentionally shipped without one now because that system does not
		 * yet exist.
		 */
		if ( isset( $where_args['template'] ) && is_string( $where_args['template'] ) && '' !== $where_args['template'] ) {
			if ( 'default' === $where_args['template'] ) {
				// Content on the default template has no specific template assigned: the meta is
				// absent, empty, or the literal "default".
				$query_args['meta_query'][] = [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query, SlevomatCodingStandard.Arrays.DisallowPartiallyKeyed.DisallowedPartiallyKeyed
					'relation' => 'OR',
					[
						'key'     => '_wp_page_template',
						'compare' => 'NOT EXISTS',
					],
					[
						'key'     => '_wp_page_template',
						'value'   => [ '', 'default' ],
						'compare' => 'IN',
					],
				];
			} else {
				$query_args['meta_query'][] = [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
					'key'   => '_wp_page_template',
					'value' => $where_args['template'],
				];
			}
		}

		/**
		 * Filter the input fields
		 * This allows plugins/themes to hook in and alter what $args should be allowed to be passed
		 * from a GraphQL Query to the WP_Query
		 *
		 * @param array<string,mixed>                  $query_args The mapped query arguments
		 * @param array<string,mixed>                  $args       Query "where" args
		 * @param mixed                                $source     The query results for a query calling this
		 * @param array<string,mixed>                  $all_args   All of the arguments for the query (not just the "where" args)
		 * @param \WPGraphQL\AppContext                $context    The AppContext object
		 * @param \GraphQL\Type\Definition\ResolveInfo $info       The ResolveInfo object
		 * @param mixed|string|string[]                $post_type  The post type for the query
		 *
		 * @hookGroup connections
		 * @since 0.0.5
		 */
		$query_args = apply_filters( 'graphql_map_input_fields_to_wp_query', $query_args, $where_args, $this->source, $this->get_args(), $this->context, $this->info, $this->post_type );

		/**
		 * Return the Query Args
		 */
		return ! empty( $query_args ) && is_array( $query_args ) ? $query_args : [];
	}

	/**
	 * Limit the status of posts a user can query.
	 *
	 * By default, published posts are public, and other statuses require permission to access.
	 *
	 * This strips the status from the query_args if the user doesn't have permission to query for
	 * posts of that status.
	 *
	 * @param string[]|string $stati The status(es) to sanitize.
	 *
	 * @return string[]|null
	 */
	public function sanitize_post_stati( $stati ) {
		/**
		 * If no stati is explicitly set by the input, default to publish. This will be the
		 * most common scenario.
		 */
		if ( empty( $stati ) ) {
			$stati = [ 'publish' ];
		}

		/**
		 * Parse the list of stati
		 */
		$statuses = wp_parse_slug_list( $stati );

		/**
		 * Get the Post Type object
		 */
		$post_type_objects = [];
		if ( is_array( $this->post_type ) ) {
			foreach ( $this->post_type as $post_type ) {
				$post_type_objects[] = get_post_type_object( $post_type );
			}
		} else {
			$post_type_objects[] = get_post_type_object( $this->post_type );
		}

		/**
		 * Make sure the statuses are allowed to be queried by the current user. If so, allow it,
		 * otherwise remove it from the $allowed_statuses that will be passed to WP_Query.
		 *
		 * For connections spanning multiple post types, a status is only allowed if the current
		 * user can query it for every post type in the connection (the most restrictive choice).
		 */
		$allowed_statuses = array_values(
			array_filter(
				$statuses,
				function ( $status ) use ( $post_type_objects ) {
					foreach ( $post_type_objects as $post_type_object ) {
						if ( ! $this->can_query_post_status( (string) $status, $post_type_object ) ) {
							return false;
						}
					}

					return true;
				}
			)
		);

		/**
		 * Filters the post statuses the current user is allowed to query in this connection.
		 *
		 * Allows extensions to adjust which statuses are queryable (for example to expose an
		 * additional custom status to specific users, or to further restrict access).
		 *
		 * @param string[]                                                $allowed_statuses   The statuses determined to be queryable by the current user.
		 * @param string[]                                                $requested_statuses The statuses requested via the connection `stati` where arg.
		 * @param array<\WP_Post_Type|null>                               $post_type_objects  The post type objects the connection resolves.
		 * @param \WPGraphQL\Data\Connection\PostObjectConnectionResolver $resolver           The connection resolver instance.
		 *
		 * @hookGroup connections
		 * @since 2.17.0
		 */
		$allowed_statuses = apply_filters( 'graphql_allowed_post_stati', $allowed_statuses, $statuses, $post_type_objects, $this );

		/**
		 * If there are no allowed statuses to pass to WP_Query, prevent the connection
		 * from executing
		 *
		 * For example, if a subscriber tries to query:
		 *
		 * {
		 *   posts( where: { stati: [ DRAFT ] } ) {
		 *     ...fields
		 *   }
		 * }
		 *
		 * We can safely prevent the execution of the query because they are asking for content
		 * in a status that we know they can't ask for.
		 */
		if ( empty( $allowed_statuses ) ) {
			$this->should_execute = false;
		}

		/**
		 * Return the $allowed_statuses to the query args
		 */
		return $allowed_statuses;
	}

	/**
	 * Determines whether the current user can query posts of the given status for the given post type.
	 *
	 * Published content, and any status whose `public` flag is true, are queryable by everyone,
	 * the same way they are exposed on the WordPress front-end and the REST API (e.g. custom
	 * statuses registered with `'public' => true`). The `private` status requires the post type's
	 * `read_private_posts` capability. All other statuses (draft, pending, future, trash, and
	 * custom non-public statuses) require the post type's `edit_posts` capability.
	 *
	 * @param string             $status           The post status to check.
	 * @param \WP_Post_Type|null $post_type_object The post type object the status is being queried for.
	 */
	protected function can_query_post_status( string $status, $post_type_object ): bool {
		// An unregistered/invalid post type can't be queried.
		if ( ! $post_type_object instanceof \WP_Post_Type ) {
			return false;
		}

		if ( 'publish' === $status ) {
			return true;
		}

		// A status flagged public is queryable by anyone, mirroring the WP front-end and REST API.
		$status_object = get_post_status_object( $status );
		if ( $status_object instanceof \stdClass && true === $status_object->public ) {
			return true;
		}

		// The private status is queryable by users who can read private posts (CPT-aware via the cap object).
		if ( 'private' === $status ) {
			return isset( $post_type_object->cap->read_private_posts ) && current_user_can( $post_type_object->cap->read_private_posts );
		}

		// All other statuses require edit capabilities.
		return isset( $post_type_object->cap->edit_posts ) && current_user_can( $post_type_object->cap->edit_posts );
	}

	/**
	 * {@inheritDoc}
	 */
	protected function prepare_args( array $args ): array {
		if ( ! empty( $args['where'] ) ) {
			// Ensure all IDs are converted to database IDs.
			foreach ( $args['where'] as $input_key => $input_value ) {
				if ( empty( $input_value ) ) {
					continue;
				}

				switch ( $input_key ) {
					case 'in':
					case 'notIn':
					case 'parent':
					case 'parentIn':
					case 'parentNotIn':
					case 'authorIn':
					case 'authorNotIn':
					case 'categoryIn':
					case 'categoryNotIn':
					case 'tagId':
					case 'tagIn':
					case 'tagNotIn':
						if ( is_array( $input_value ) ) {
							$args['where'][ $input_key ] = array_map(
								static function ( $id ) {
									return Utils::get_database_id_from_id( $id );
								},
								$input_value
							);
							break;
						}

						$args['where'][ $input_key ] = Utils::get_database_id_from_id( $input_value );
						break;
				}
			}
		}

		/**
		 * Filters the GraphQL args before they are used in get_query_args().
		 *
		 * @param array<string,mixed> $args            The GraphQL args passed to the resolver.
		 * @param self                $resolver        Instance of the ConnectionResolver.
		 * @param array<string,mixed> $unfiltered_args Array of arguments input in the field as part of the GraphQL query.
		 *
		 * @hookGroup connections
		 * @since 1.11.0
		 */
		return apply_filters( 'graphql_post_object_connection_args', $args, $this, $this->get_unfiltered_args() );
	}

	/**
	 * {@inheritDoc}
	 *
	 * @param int $offset The ID of the node used in the cursor offset.
	 */
	public function is_valid_offset( $offset ) {
		return (bool) get_post( absint( $offset ) );
	}
}
