<?php

/**
 * Paid Memberships Pro.
 *
 * Integrates the Custom Post Type Membership Access add-on with our access control, so
 * that PMPro decides who may view a podcast or episode and its own restriction message is
 * shown instead of ours.
 *
 * The Member RSS add-on is handled separately, in `public/pmpro-member-rss.php`. The
 * two add-ons install independently of one another, and the split follows what each class is
 * for: this one decides who may see a page, that one authenticates a request for a feed.
 *
 * Nothing here may make the feed depend on the current user of its own accord. Feed output is
 * cached and the same copy is served to every subscriber, so an answer that varied by visitor
 * would be rendered once and then handed to everyone who asked for it afterwards.
 * `filter_is_content_restricted()` is what the public feed consults, and it is written the way
 * it is for exactly this reason: answered from the content alone, never from who is asking.
 *
 * A member feed does bring `filter_user_access()` into play, and is meant to. What makes that
 * safe is the pairing — a member has been authenticated and the cache key varies to match —
 * and both halves of it live in the Member RSS class, not in this one.
 *
 * @link    https://aiopodcasting.com
 * @since   1.0.3
 *
 * @package AiO_Podcast_Studio
 */

// Exit if accessed directly
if ( ! defined( 'WPINC' ) ) {
	die;
}

/**
 * AIOPOD_Public_PMPro_CPT class.
 *
 * @since 1.0.3
 */
class AIOPOD_Public_PMPro_CPT {

	/**
	 * Resolved PMPro access, keyed by user and post.
	 *
	 * @since  1.0.3
	 * @access private
	 * @var    array
	 */
	private $access_cache = array();

	/**
	 * Filter whether site owners keep their default access to their own content.
	 *
	 * Paid Memberships Pro does not grant access to administrators, so once it restricts a
	 * post its decision applies to everyone. Without this a site owner would be served the
	 * post with the restriction message sitting in place of its description, because Paid
	 * Memberships Pro filters the content before we build our own output from it.
	 *
	 * @since  1.0.3
	 * @param  bool $has_owner_access True if the current user is a site owner or the author.
	 * @param  int  $post_id          ID of the post being checked.
	 * @return bool                   True to keep the owner access, false to give it up.
	 */
	public function filter_owner_access( $has_owner_access, $post_id ) {
		if ( ! $has_owner_access ) {
			return $has_owner_access;
		}

		$post_id = (int) $post_id;

		if ( $post_id <= 0 || ! $this->is_active() || ! $this->is_supported_post_type( $post_id ) ) {
			return $has_owner_access;
		}

		$resolved = $this->resolve( $post_id );

		return $resolved['allowed'];
	}

	/**
	 * Filter whether the current user has access to a specific podcast or episode.
	 *
	 * @since  1.0.3
	 * @param  bool   $has_access True if the user has access, false otherwise.
	 * @param  string $capability Capability being checked.
	 * @param  int    $post_id    Optional. ID of the post to check access against.
	 * @return bool               True if the current user has access, false otherwise.
	 */
	public function filter_user_access( $has_access, $capability, $post_id = 0 ) {
		// Never grant back access another access-control mechanism has already denied
		if ( 'view_aiopod_podcast' !== $capability || ! $has_access ) {
			return $has_access;
		}

		$post_id = (int) $post_id;

		if ( $post_id <= 0 || ! $this->is_active() || ! $this->is_supported_post_type( $post_id ) ) {
			return $has_access;
		}

		$resolved = $this->resolve( $post_id );

		return $resolved['allowed'];
	}

	/**
	 * Filter whether a podcast or episode is restricted to a limited audience.
	 *
	 * Answered from the membership levels stored against the post, never from the current
	 * user, so the result is the same for every visitor. Paid Memberships Pro returns those
	 * levels alongside its access decision, so this costs no extra query.
	 *
	 * @since  1.0.3
	 * @param  bool $is_restricted True if the content is already known to be restricted.
	 * @param  int  $post_id       Podcast or episode ID.
	 * @return bool                True if the content is restricted, false if not.
	 */
	public function filter_is_content_restricted( $is_restricted, $post_id ) {
		if ( $is_restricted ) {
			return $is_restricted;
		}

		$post_id = (int) $post_id;

		if ( $post_id <= 0 || ! $this->is_active() || ! $this->is_supported_post_type( $post_id ) ) {
			return $is_restricted;
		}

		$access = $this->get_access( $post_id );

		// Membership levels stored against the post itself
		if ( ! empty( $access['ids'] ) ) {
			return true;
		}

		// Otherwise the parent podcast, when an episode inherits from one
		$parent_id = aiopod_get_inherited_podcast_id( $post_id );

		if ( $parent_id > 0 ) {
			$parent_access = $this->get_access( $parent_id );

			if ( ! empty( $parent_access['ids'] ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Filter the access-denied output so Paid Memberships Pro shows its own message.
	 *
	 * Its message is returned as-is. It is built and escaped by Paid Memberships Pro and
	 * carries markup, such as the lock icon, that `wp_kses_post()` would strip.
	 *
	 * @since  1.0.3
	 * @param  string $html    Rendered access-denied HTML.
	 * @param  int    $post_id Post ID of the restricted podcast or episode.
	 * @param  string $context Context of the check, either 'podcast' or 'episode'.
	 * @param  string $content Content already produced upstream, or an empty string.
	 * @return string          Filtered access-denied HTML.
	 */
	public function filter_access_denied_html( $html, $post_id, $context, $content = '' ) {
		$post_id = (int) $post_id;

		if ( $post_id <= 0 || ! $this->is_active() || ! $this->is_supported_post_type( $post_id ) ) {
			return $html;
		}

		$resolved = $this->resolve( $post_id );

		// Access granted here, so something else denied it and its own message stands
		if ( $resolved['allowed'] ) {
			return $html;
		}

		// Paid Memberships Pro filters the content of a restricted post before we do, so
		// its output is already in place and is returned untouched. An inherited
		// restriction never qualifies, the content is still the full episode at that point
		if ( ! $resolved['inherited'] && '' !== $content && doing_filter( 'the_content' ) ) {
			return $content;
		}

		return pmpro_get_no_access_message( '', $resolved['ids'], $resolved['names'] );
	}

	/**
	 * Add our post types to the ones Paid Memberships Pro filters out of archives and searches.
	 *
	 * Once the WordPress taxonomies setting puts podcasts and episodes into the core category
	 * and tag archives, those archives are rendered by the theme rather than by us, so our own
	 * access checks never run for the items listed there. Paid Memberships Pro already solves
	 * this for posts and pages with `pmpro_search_filter()`, behind its own "Filter Searches and
	 * Archives" option — off by default, which matches what the site owner asked for.
	 *
	 * So we hand the decision to that switch instead of building a second one. An owner who has
	 * it on gets restricted podcasts and episodes withheld from those archives on the same terms
	 * as their posts; an owner who has it off gets them listed with title, excerpt and image, the
	 * content still protected on the single view. One control, and it follows whatever they set.
	 *
	 * Gated on the setting because that is what puts our post types in those archives in the
	 * first place. With it off, nothing about the previous behaviour changes.
	 *
	 * @since  1.0.4
	 * @param  array $post_types Post types Paid Memberships Pro filters. Defaults to page and post.
	 * @return array             The same list, with ours added when the setting calls for it.
	 */
	public function filter_search_filter_post_types( $post_types ) {
		$taxonomies_settings = aiopod_get_option( 'aiopod_taxonomies_settings' );

		if ( empty( $taxonomies_settings['wp_taxonomies'] ) || empty( $taxonomies_settings['include_in_core_archives'] ) ) {
			return $post_types;
		}

		if ( ! is_array( $post_types ) ) {
			$post_types = array( $post_types );
		}

		return array_values( array_unique( array_merge( $post_types, array( 'aiopod_podcasts', 'aiopod_episodes' ) ) ) );
	}

	/**
	 * Whether the Paid Memberships Pro functions we rely on are available.
	 *
	 * @since  1.0.3
	 * @access private
	 * @return bool True if Paid Memberships Pro is active, false if not.
	 */
	private function is_active() {
		return function_exists( 'pmpro_has_membership_access' ) && function_exists( 'pmpro_get_no_access_message' );
	}

	/**
	 * Whether a post is one of ours, and so one we hand the access decision over for.
	 *
	 * Deliberately not gated on the `pmpro_restrictable_post_types` filter the Custom Post
	 * Type Membership Access add-on supplies. That filter only decides which post types get
	 * the "Require Membership" box in the editor, it does not limit what Paid Memberships
	 * Pro enforces: membership levels already stored against a post keep being enforced even
	 * once the post type is deselected. Gating on it would leave those posts hidden by Paid
	 * Memberships Pro while we kept rendering the player around its restriction message.
	 *
	 * Posts with no membership levels are no concern here, `pmpro_has_membership_access()`
	 * grants access to those, so unrestricted content is never affected.
	 *
	 * @since  1.0.3
	 * @access private
	 * @param  int  $post_id Post ID to check.
	 * @return bool          True for a podcast or an episode, false for anything else.
	 */
	private function is_supported_post_type( $post_id ) {
		$post_type = get_post_type( $post_id );

		return ( 'aiopod_podcasts' === $post_type || 'aiopod_episodes' === $post_type );
	}

	/**
	 * Get the Paid Memberships Pro access decision for a single post.
	 *
	 * Results are cached for the request because the membership lookup is one uncached
	 * query per post, and a single episodes grid checks access many times over.
	 *
	 * Cached against the user as well as the post. The obvious key is the post alone, since
	 * one visitor makes one request — but that stopped being true when the Member RSS
	 * integration began authenticating a member part-way through a request with
	 * `wp_set_current_user()`. Nothing currently asks this question before that happens, so
	 * a post-only key would work today and fail silently the day something did, by answering
	 * for the visitor who arrived rather than the member who was let in.
	 *
	 * @since  1.0.3
	 * @access private
	 * @param  int   $post_id Post ID to check.
	 * @return array          Access decision with `allowed`, `ids` and `names` keys.
	 */
	private function get_access( $post_id ) {
		$post_id = (int) $post_id;

		$cache_key = get_current_user_id() . ':' . $post_id;

		if ( ! isset( $this->access_cache[ $cache_key ] ) ) {
			$access = pmpro_has_membership_access( $post_id, null, true );

			// Only returned as an array when membership levels were requested
			if ( ! is_array( $access ) ) {
				$access = array( ! empty( $access ), array(), array() );
			}

			$this->access_cache[ $cache_key ] = array(
				'allowed' => ! empty( $access[0] ),
				'ids'     => ! empty( $access[1] ) ? (array) $access[1] : array(),
				'names'   => ! empty( $access[2] ) ? (array) $access[2] : array()
			);
		}

		return $this->access_cache[ $cache_key ];
	}

	/**
	 * Resolve the effective Paid Memberships Pro decision for a post.
	 *
	 * The membership levels returned are the ones to build a restriction message from,
	 * always the most specific available: the post's own levels when it is restricted
	 * directly, the parent podcast's levels when the restriction is inherited.
	 *
	 * @since  1.0.3
	 * @access private
	 * @param  int   $post_id Post ID to resolve.
	 * @return array          Decision with `allowed`, `inherited`, `ids` and `names` keys.
	 */
	private function resolve( $post_id ) {
		$post_id = (int) $post_id;

		$access = $this->get_access( $post_id );

		// Restricted in its own right, so report against its own membership levels
		if ( ! $access['allowed'] ) {
			return array(
				'allowed'   => false,
				'inherited' => false,
				'ids'       => $access['ids'],
				'names'     => $access['names']
			);
		}

		// Otherwise fall back to the parent podcast, when an episode inherits from one
		$parent_id = aiopod_get_inherited_podcast_id( $post_id );

		if ( $parent_id > 0 ) {
			$parent_access = $this->get_access( $parent_id );

			if ( ! $parent_access['allowed'] ) {
				return array(
					'allowed'   => false,
					'inherited' => true,
					'ids'       => $parent_access['ids'],
					'names'     => $parent_access['names']
				);
			}
		}

		return array(
			'allowed'   => true,
			'inherited' => false,
			'ids'       => array(),
			'names'     => array()
		);
	}

}
