<?php
/**
 * A paywall that exchanges JWT tokens from WordPress.com to allow
 * a current visitor to view content that has been deemed "Premium content".
 *
 * @package Automattic\Jetpack\Extensions\Premium_Content
 */

namespace Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service;

use Automattic\Jetpack\Extensions\Premium_Content\JWT;
use WP_Error;
use WP_Post;
use const Automattic\Jetpack\Extensions\Subscriptions\META_NAME_FOR_POST_TIER_ID_SETTINGS;

if ( ! defined( 'ABSPATH' ) ) {
	exit( 0 );
}

/**
 * Class Abstract_Token_Subscription_Service
 *
 * @package Automattic\Jetpack\Extensions\Premium_Content\Subscription_Service
 */
abstract class Abstract_Token_Subscription_Service implements Subscription_Service {

	const JWT_AUTH_TOKEN_COOKIE_NAME                   = 'wp-jp-premium-content-session'; // wp prefix helps with skipping batcache
	const DECODE_EXCEPTION_FEATURE                     = 'memberships';
	const DECODE_EXCEPTION_MESSAGE                     = 'Problem decoding provided token';
	const REST_URL_ORIGIN                              = 'https://subscribe.wordpress.com/';
	const BLOG_SUB_ACTIVE                              = 'active';
	const BLOG_SUB_PENDING                             = 'pending';
	const POST_ACCESS_LEVEL_EVERYBODY                  = 'everybody';
	const POST_ACCESS_LEVEL_SUBSCRIBERS                = 'subscribers';
	const POST_ACCESS_LEVEL_PAID_SUBSCRIBERS           = 'paid_subscribers';
	const POST_ACCESS_LEVEL_PAID_SUBSCRIBERS_ALL_TIERS = 'paid_subscribers_all_tiers';

	/**
	 * An optional user_id to query against (omitting this will use either the token or current user id)
	 *
	 * @var int|null
	 */
	protected $user_id = null;

	/**
	 * Constructor
	 *
	 * @param int|null $user_id An optional user_id to query subscriptions against. Uses token from request/cookie or logged-in user information if omitted.
	 */
	public function __construct( $user_id = null ) {
		$this->user_id = $user_id;
	}

	/**
	 * Initialize the token subscription service.
	 *
	 * @inheritDoc
	 */
	public function initialize() {
		$this->get_and_set_token_from_request();
	}

	/**
	 * Set the token from the Request to the cookie and retrieve the token.
	 *
	 * @return string|null
	 */
	public function get_and_set_token_from_request() {
		// URL token always has a precedence, so it can overwrite the cookie when new data available.
		$token = $this->token_from_request();
		if ( null !== $token ) {
			$this->set_token_cookie( $token );
			return $token;
		}

		return $this->token_from_cookie();
	}

	/**
	 * Attempt to refresh the current token against the WordPress.com refresh endpoint
	 * and, on success, persist the fresh token in the cookie and return the decoded
	 * payload.
	 *
	 * This is called when a subscriber has a JWT token whose subscription data is
	 * stale (e.g. the cookie contains an old end_date from before a Stripe renewal).
	 * The refresh endpoint accepts the existing token, re-queries billing, and
	 * returns a fresh token reflecting current subscription state.
	 *
	 * @return array|null Decoded fresh payload on success, null on any failure.
	 */
	public function refresh_token_payload() {
		$current_token = $this->get_and_set_token_from_request();
		if ( empty( $current_token ) ) {
			return null;
		}

		$fresh_token = $this->fetch_refreshed_token( $current_token );
		if ( empty( $fresh_token ) ) {
			return null;
		}

		$fresh_payload = $this->decode_token( $fresh_token );
		if ( empty( $fresh_payload ) ) {
			return null;
		}

		$this->set_token_cookie( $fresh_token );
		return $fresh_payload;
	}

	/**
	 * POST the current token to the WordPress.com memberships token-refresh endpoint
	 * and return a fresh JWT string, or null on any failure.
	 *
	 * Endpoint contract (POST /sites/<site_id>/memberships/token/refresh):
	 *  - 200 + { success: true,  jwt_token: "<jwt>" } → fresh token; return it.
	 *  - 200 + { success: false, ... }                → wpcom refused the refresh
	 *    (token no longer eligible, or signature/site/user check failed).
	 *    Deterministic; clear the cookie so the visitor is routed through the normal
	 *    auth flow on the next page load.
	 *  - Anything else (non-200, WP_Error, network timeout, malformed body) → transient;
	 *    leave the cookie alone so a temporary outage does not mass-log-out subscribers.
	 *
	 * @param string $current_token The token to present for refresh.
	 * @return string|null Fresh token string, or null on failure.
	 */
	protected function fetch_refreshed_token( $current_token ) {
		$site_id = (int) $this->get_site_id();
		if ( $site_id <= 0 ) {
			return null;
		}

		$response = wp_remote_post(
			sprintf(
				'https://public-api.wordpress.com/rest/v1.1/sites/%d/memberships/token/refresh',
				$site_id
			),
			array(
				'timeout' => 5,
				'headers' => array( 'Content-Type' => 'application/json' ),
				'body'    => wp_json_encode(
					array( 'jwt_token' => $current_token ),
					JSON_UNESCAPED_SLASHES
				),
			)
		);

		if ( is_wp_error( $response ) ) {
			return null;
		}

		if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
			return null;
		}

		$body = json_decode( wp_remote_retrieve_body( $response ), true );
		if ( ! is_array( $body ) || ! isset( $body['success'] ) ) {
			return null;
		}

		if ( true === $body['success'] ) {
			if ( ! empty( $body['jwt_token'] ) && is_string( $body['jwt_token'] ) ) {
				return $body['jwt_token'];
			}
			// Malformed 200: success: true but no usable jwt_token. Treat as transient —
			// leave the cookie alone rather than logging the visitor out over a response
			// shape problem.
			return null;
		}

		// success === false → deterministic auth failure. Clear cookie.
		self::clear_token_cookie();
		return null;
	}

	/**
	 * Whether the token already carries a subscription whose product_id matches one of
	 * the required plans. Used to gate the refresh path: combined with `validate_subscriptions`
	 * having returned false, a match here implies the matching subscription's end_date is
	 * in the past — the only case the refresh endpoint can help with.
	 *
	 * @param int[] $valid_plan_ids      Plan IDs required by the post.
	 * @param array $token_subscriptions Subscriptions from the current token (keyed by product_id).
	 * @return bool
	 */
	public function token_has_matching_product( array $valid_plan_ids, array $token_subscriptions ) {
		if ( empty( $token_subscriptions ) ) {
			return false;
		}
		foreach ( $valid_plan_ids as $plan_id ) {
			$product_id = (int) get_post_meta( $plan_id, 'jetpack_memberships_product_id', true );
			if ( $product_id > 0 && isset( $token_subscriptions[ $product_id ] ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Get the site ID for the current site.
	 *
	 * @return int
	 */
	abstract public function get_site_id();

	/**
	 * Get the token payload .
	 *
	 * @return array
	 */
	public function get_token_payload() {
		$token = $this->get_and_set_token_from_request();
		if ( empty( $token ) ) {
			return array();
		}
		$token_payload = $this->decode_token( $token );
		if ( ! is_array( $token_payload ) ) {
			return array();
		}
		return $token_payload;
	}

	/**
	 * Get a token property, otherwise return false.
	 *
	 * @param string $key the property name.
	 *
	 * @return mixed|false
	 */
	public function get_token_property( $key ) {
		$token_payload = $this->get_token_payload();
		if ( ! isset( $token_payload[ $key ] ) ) {
			return false;
		}
		return $token_payload[ $key ];
	}

	/**
	 * The user is visiting with a subscriber token cookie.
	 *
	 * This is theoretically where the cookie JWT signature verification
	 * thing will happen.
	 *
	 * How to obtain one of these (or what exactly it is) is
	 * still a WIP (see api/auth branch)
	 *
	 * @inheritDoc
	 *
	 * @param array $valid_plan_ids List of valid plan IDs.
	 * @param array $access_level Access level for content.
	 *
	 * @return bool Whether the user can view the content
	 */
	public function visitor_can_view_content( $valid_plan_ids, $access_level ) {
		global $current_user;
		$old_user = $current_user; // backup the current user so we can set the current user to the token user for paywall purposes

		$payload        = $this->get_token_payload();
		$is_valid_token = ! empty( $payload );

		if ( $is_valid_token && isset( $payload['user_id'] ) ) {
			// set the current user to the payload's user id
			// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
			$current_user = get_user_by( 'id', $payload['user_id'] );
		}

		$is_blog_subscriber = false;
		$is_paid_subscriber = false;
		$subscriptions      = array();

		if ( $is_valid_token ) {
			/**
			 * Allow access to the content if:
			 *
			 * Active: user has a valid subscription
			 */
			$is_blog_subscriber = in_array(
				$payload['blog_sub'],
				array(
					self::BLOG_SUB_ACTIVE,
				),
				true
			);
			$subscriptions      = (array) $payload['subscriptions'];
			$is_paid_subscriber = static::validate_subscriptions( $valid_plan_ids, $subscriptions );

			// Only attempt a refresh in the specific stale-end_date case: the token already carries a
			// subscription whose product_id matches one of the required plans, but validation failed
			// (which, given the match, can only be because end_date is in the past). This excludes
			// free subscribers, tier mismatches, and cancellations from triggering an HTTP call on
			// every render.
			if (
				! $is_paid_subscriber
				&& ! empty( $valid_plan_ids )
				&& $this->token_has_matching_product( $valid_plan_ids, $subscriptions )
			) {
				$fresh_payload = $this->refresh_token_payload();
				if ( ! empty( $fresh_payload ) ) {
					$payload            = $fresh_payload;
					$is_blog_subscriber = isset( $payload['blog_sub'] ) && self::BLOG_SUB_ACTIVE === $payload['blog_sub'];
					$subscriptions      = isset( $payload['subscriptions'] ) ? (array) $payload['subscriptions'] : array();
					$is_paid_subscriber = static::validate_subscriptions( $valid_plan_ids, $subscriptions );
				}
			}
		}

		$has_access = $this->user_has_access( $access_level, $is_blog_subscriber, $is_paid_subscriber, get_the_ID(), $subscriptions );
		// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
		$current_user = $old_user;
		return $has_access;
	}

	/**
	 * Retrieves the email of the currently authenticated subscriber.
	 *
	 * @return string The email address of the current user.
	 */
	public function get_subscriber_email() {
		$email = $this->get_token_property( 'blog_subscriber' );
		if ( empty( $email ) ) {
			return '';
		}
		return $email;
	}

	/**
	 * Returns true if the current authenticated user is subscribed to the current site.
	 *
	 * @return boolean
	 */
	public function is_current_user_subscribed() {
		return $this->get_token_property( 'blog_sub' ) === 'active';
	}

	/**
	 * Returns true if the current authenticated user has a pending subscription to the current site.
	 *
	 * @return bool
	 */
	abstract public function is_current_user_pending_subscriber(): bool;

	/**
	 * Return if the user has access to the content depending on the access level and the user rights
	 *
	 * @param string $access_level Post or blog access level.
	 * @param bool   $is_blog_subscriber Is user a subscriber of the blog.
	 * @param bool   $is_paid_subscriber Is user a paid subscriber of the blog.
	 * @param int    $post_id Post ID.
	 * @param array  $user_abbreviated_subscriptions User subscription abbreviated.
	 *
	 * @return bool Whether the user has access to the content.
	 */
	protected function user_has_access( $access_level, $is_blog_subscriber, $is_paid_subscriber, $post_id, $user_abbreviated_subscriptions ) {

		if ( is_user_logged_in() && current_user_can( 'edit_post', $post_id ) ) {
			// Admin has access
			$has_access = true;
		} else {
			switch ( $access_level ) {
				case self::POST_ACCESS_LEVEL_EVERYBODY:
				default:
					$has_access = true;
					break;
				case self::POST_ACCESS_LEVEL_SUBSCRIBERS:
					$has_access = $is_blog_subscriber || $is_paid_subscriber;
					break;
				case self::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS_ALL_TIERS:
					$has_access = $is_paid_subscriber;
					break;
				case self::POST_ACCESS_LEVEL_PAID_SUBSCRIBERS:
					$has_access = $is_paid_subscriber &&
						! $this->maybe_gate_access_for_user_if_post_tier( $post_id, $user_abbreviated_subscriptions );
					break;
			}
		}

		do_action( 'earn_user_has_access', $access_level, $has_access, $is_blog_subscriber, $is_paid_subscriber, $post_id );
		return $has_access;
	}

	/**
	 * Check post access for tiers.
	 *
	 * @param int   $post_id Current post id.
	 * @param array $user_abbreviated_subscriptions User subscription abbreviated.
	 *
	 * @return bool
	 */
	private function maybe_gate_access_for_user_if_post_tier( $post_id, $user_abbreviated_subscriptions ) {
		$tier_id = intval(
			get_post_meta( $post_id, META_NAME_FOR_POST_TIER_ID_SETTINGS, true )
		);

		if ( ! $tier_id ) {
			return false;
		}

		return $this->maybe_gate_access_for_user_if_tier( $tier_id, $user_abbreviated_subscriptions );
	}

	/**
	 * Get all plans id that make access valid for a post with this tier id.
	 *
	 * @param int $tier_id Newsletter tier post ID.
	 *
	 * @return array|WP_Error
	 */
	public static function get_valid_plan_ids_for_tier( int $tier_id ) {
		// Valid plans are:
		// - monthly plan with ID $tier_id
		// - yearly plan related to this $tier_id (in meta jetpack_memberships_tier)
		// - monthly tiers with same currency and price same or higher than original tier
		// - yearly plans that are more expensive than the yearly plan linked to the original tier

		$valid_plan_ids = array();

		$all_plans = \Jetpack_Memberships::get_all_plans();

		// Let's get the current tier
		$tier = null;
		foreach ( $all_plans as $post ) {
			if ( $post->ID === $tier_id ) {
				$tier = $post;
				break;
			}
		}

		if ( $tier === null ) {
			// We have an error
			return new WP_Error( 'related-plan-not-found', 'The plan related to the tier cannot be found' );
		}

		$tier_price      = self::find_metadata( $tier, 'jetpack_memberships_price' );
		$tier_currency   = self::find_metadata( $tier, 'jetpack_memberships_currency' );
		$tier_product_id = self::find_metadata( $tier, 'jetpack_memberships_product_id' );

		if ( $tier_price === null || $tier_currency === null || $tier_product_id === null ) {
			// There is an issue with the meta
			return new WP_Error( 'wrong-data-plan-not-found', 'The plan related to the tier is missing data' );
		}

		$valid_plan_ids[] = $tier_id;

		$tier_price = floatval( $tier_price );

		// At this point we know the post is
		$annual_tier = null;
		foreach ( $all_plans as $plan ) {
			if ( intval( self::find_metadata( $plan, 'jetpack_memberships_tier' ) ) === $tier_id ) {
				$annual_tier = $plan;
				break;
			}
		}

		$annual_tier_price = null;
		if ( ! empty( $annual_tier ) ) {
			$annual_tier_price = floatval( self::find_metadata( $annual_tier, 'jetpack_memberships_price' ) );
			$valid_plan_ids[]  = $annual_tier->ID;
		}

		foreach ( $all_plans as $post ) {
			if ( in_array( $post->ID, $valid_plan_ids, true ) ) {
				continue;
			}

			$plan_price    = self::find_metadata( $post, 'jetpack_memberships_price' );
			$plan_currency = self::find_metadata( $post, 'jetpack_memberships_currency' );
			$plan_interval = self::find_metadata( $post, 'jetpack_memberships_interval' );

			if ( $plan_price === null || $plan_currency === null || $plan_interval === null ) {
				// There is an issue with the meta
				continue;
			}

			$plan_price = floatval( $plan_price );

			if ( $tier_currency !== $plan_currency ) {
				// For now, we don't count if there are different currency (not sure how to convert price in a pure JP env)
				continue;
			}

			if ( ( $plan_interval === '1 month' && $plan_price >= $tier_price ) ||
				( $annual_tier_price !== null && $plan_interval === '1 year' && $plan_price >= $annual_tier_price )
			) {
				$valid_plan_ids [] = $post->ID;
			}
		}

		return $valid_plan_ids;
	}

	/**
	 * Find metadata in post
	 *
	 * @param WP_Post|object $post        Post.
	 * @param string         $meta_key    Meta to retrieve.
	 *
	 * @return mixed|null
	 */
	private static function find_metadata( $post, $meta_key ) {

		if ( $post instanceof WP_Post ) {
			return $post->{$meta_key};
		}

		foreach ( $post->metadata as $meta ) {
			if ( $meta->key === $meta_key ) {
				return $meta->value;
			}
		}

		return null;
	}

	/**
	 * Check access for tier.
	 *
	 * @param int   $tier_id Tier id.
	 * @param array $user_abbreviated_subscriptions User subscription abbreviated.
	 *
	 * @return bool
	 */
	public function maybe_gate_access_for_user_if_tier( $tier_id, $user_abbreviated_subscriptions ) {

		$plan_ids = \Jetpack_Memberships::get_all_newsletter_plan_ids();

		if ( ! in_array( $tier_id, $plan_ids, true ) ) {
			// If the tier is not in the plans, we bail
			return false;
		}

		// We now need the tier price and currency, and the same for the annual price (if available)
		$all_plans = \Jetpack_Memberships::get_all_plans();
		$tier      = null;
		foreach ( $all_plans as $post ) {
			if ( $post->ID === $tier_id ) {
				$tier = $post;
				break;
			}
		}

		if ( $tier === null ) {
			return false;
		}

		$tier_price        = self::find_metadata( $tier, 'jetpack_memberships_price' );
		$tier_currency     = self::find_metadata( $tier, 'jetpack_memberships_currency' );
		$tier_product_id   = self::find_metadata( $tier, 'jetpack_memberships_product_id' );
		$annual_tier_price = $tier_price * 12;

		if ( $tier_price === null || $tier_currency === null || $tier_product_id === null ) {
			// There is an issue with the meta
			return false;
		}

		$tier_price = floatval( $tier_price );

		// At this point we know the post is
		$annual_tier_id = null;
		$annual_tier    = null;
		foreach ( $all_plans as $plan ) {
			if ( intval( self::find_metadata( $plan, 'jetpack_memberships_tier' ) ) === $tier_id ) {
				$annual_tier = $plan;
				break;
			}
		}

		$annual_tier_price = null;
		if ( ! empty( $annual_tier ) ) {
			$annual_tier_id    = $annual_tier->ID;
			$annual_tier_price = floatval( self::find_metadata( $annual_tier, 'jetpack_memberships_price' ) );
		}

		foreach ( $user_abbreviated_subscriptions as $subscription_plan_id => $details ) {
			$details = (array) $details;

			if ( ! self::subscription_grants_access( $details ) ) {
				// Subscription not active anymore (its end_date day has fully passed).
				continue;
			}

			$subscription_post = null;
			foreach ( $all_plans as $plan ) {
				if ( intval( self::find_metadata( $plan, 'jetpack_memberships_product_id' ) ) === intval( $subscription_plan_id ) ) {
					$subscription_post = $plan;
					break;
				}
			}

			if ( empty( $subscription_post ) ) {
				// No post linked to this plan
				continue;
			}

			// Comp grants linked to a plan on this site bypass the tier price comparison.
			if ( ! empty( $details['is_comp'] ) ) {
				return false;
			}

			$subscription_post_id = $subscription_post->ID;

			if ( $subscription_post_id === $tier_id || $subscription_post_id === $annual_tier_id ) {
				// User is subscribed to the right tier
				return false;
			}

			$subscription_price    = self::find_metadata( $subscription_post, 'jetpack_memberships_price' );
			$subscription_currency = self::find_metadata( $subscription_post, 'jetpack_memberships_currency' );
			$subscription_interval = self::find_metadata( $subscription_post, 'jetpack_memberships_interval' );

			if ( $subscription_price === null || $subscription_currency === null || $subscription_interval === null ) {
				// There is an issue with the meta
				continue;
			}

			$subscription_price = floatval( $subscription_price );

			if ( $tier_currency !== $subscription_currency ) {
				// For now, we don't count if there are different currency (not sure how to convert price in a pure JP env)
				continue;
			}

			if ( ( $subscription_interval === '1 month' && $subscription_price >= $tier_price ) ||
					( $annual_tier_price !== null && $subscription_interval === '1 year' && $subscription_price >= $annual_tier_price )
			) {
				// One subscription is more expensive than the minimum set by the post' selected tier
				return false;
			}
		}
		return true; // No user subscription is more expensive than the post's tier price...
	}

	/**
	 * Decode the given token.
	 *
	 * @param string $token Token to decode.
	 *
	 * @return array|false
	 */
	public function decode_token( $token ) {
		if ( empty( $token ) ) {
			return false;
		}

		try {
			$key = $this->get_key();
			return $key ? (array) JWT::decode( $token, $key, array( 'HS256' ) ) : false;
		} catch ( \Exception $exception ) {
			return false;
		}
	}

	/**
	 * Get the key for decoding the auth token.
	 *
	 * @return string|false
	 */
	abstract public function get_key();

	// phpcs:disable
	/**
	 * Get the URL to access the protected content.
	 *
	 * @param string $mode Access mode (either "subscribe" or "login").
	 */
	public function access_url( $mode = 'subscribe', $permalink = null ) {
		global $wp;
		if ( empty( $permalink ) ) {
			$permalink = get_permalink();
			if ( empty( $permalink ) ) {
				$permalink = add_query_arg( $wp->query_vars, home_url( $wp->request ) );
			}
		}

		$login_url = $this->get_rest_api_token_url( $this->get_site_id(), $permalink );
		return $login_url;
	}
	// phpcs:enable

	/**
	 * Get the token stored in the auth cookie.
	 *
	 * @return ?string
	 */
	private function token_from_cookie() {
		if ( isset( $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ] ) ) {
			// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
			return $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ];
		}
	}

	/**
	 * Check whether the JWT_TOKEN cookie is set
	 *
	 * @return bool
	 */
	public static function has_token_from_cookie() {
		return isset( $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ] ) && ! empty( $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ] );
	}

	/**
	 * Store the auth cookie.
	 *
	 * Updates `$_COOKIE` in memory so subsequent code in the same request (e.g. another
	 * Premium Content block on the same post) reads the new value and doesn't re-trigger
	 * a refresh against a now-stale value. The Set-Cookie header is emitted via the
	 * standard `setcookie()` — on Atomic / wpcom the response is output-buffered so this
	 * still works during `the_content`; on stricter self-hosted setups the header may
	 * silently be dropped after output starts, in which case the browser keeps the prior
	 * cookie value and the refresh fires again on the next visit (correct degradation).
	 *
	 * @param  string $token Auth token.
	 * @return void
	 */
	private function set_token_cookie( $token ) {
		if ( empty( $token ) ) {
			return;
		}

		$_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ] = $token;

		if ( defined( 'TESTING_IN_JETPACK' ) && TESTING_IN_JETPACK ) {
			return;
		}

		if ( ! headers_sent() ) {
			// phpcs:ignore Jetpack.Functions.SetCookie.FoundNonHTTPOnlyFalse
			setcookie( self::JWT_AUTH_TOKEN_COOKIE_NAME, $token, strtotime( '+1 month' ), '/', '', is_ssl(), false );
		}
	}

	/**
	 * Clear the auth cookie. Mirrors set_token_cookie(): updates `$_COOKIE` for
	 * in-request consistency, then emits a clearing Set-Cookie header.
	 */
	public static function clear_token_cookie() {
		unset( $_COOKIE[ self::JWT_AUTH_TOKEN_COOKIE_NAME ] );

		if ( defined( 'TESTING_IN_JETPACK' ) && TESTING_IN_JETPACK ) {
			return;
		}

		if ( ! headers_sent() ) {
			// phpcs:ignore Jetpack.Functions.SetCookie.FoundNonHTTPOnlyFalse
			setcookie( self::JWT_AUTH_TOKEN_COOKIE_NAME, '', 1, '/', '', is_ssl(), false );
		}
	}

	/**
	 * Get the token if present in the current request.
	 *
	 * @return ?string
	 */
	private function token_from_request() {
		$token = null;
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( isset( $_GET['token'] ) && is_string( $_GET['token'] ) ) {
			// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended
			if ( preg_match( '/^[a-zA-Z0-9\-_]+?\.[a-zA-Z0-9\-_]+?\.([a-zA-Z0-9\-_]+)?$/', $_GET['token'], $matches ) ) {
				// token matches a valid JWT token pattern.
				$token = reset( $matches );
			}
		}
		return $token;
	}

	/**
	 * Return true if an abbreviated subscription currently grants access.
	 *
	 * Access is granted through the end of the subscription's end_date day
	 * (23:59:59 UTC), not the exact end_date timestamp. Memberships store
	 * end_date as the precise purchase timestamp, while billing renews via a
	 * deferred day-0 job that can run several hours after that timestamp. Since
	 * the wider platform already treats end-of-day as the formal expiration
	 * time for subscriptions, expiring at EOD here keeps a same-day auto-renewal
	 * from cutting off access before its renewal completes, and aligns Paid
	 * Content with the rest of WordPress.com / Jetpack.
	 *
	 * Cancelled and otherwise inactive subscriptions never reach this check —
	 * they are dropped by the `'active' === status` filter in
	 * abbreviate_subscriptions() — so this only ever extends an already-active
	 * subscription to the end of its final day.
	 *
	 * @param object|array $subscription Abbreviated subscription (see abbreviate_subscriptions()).
	 *
	 * @return bool
	 */
	protected static function subscription_grants_access( $subscription ) {
		$subscription = (array) $subscription;
		if ( empty( $subscription['end_date'] ) ) {
			return false;
		}

		$end = is_int( $subscription['end_date'] ) ? $subscription['end_date'] : strtotime( $subscription['end_date'] );
		if ( false === $end ) {
			return false;
		}

		// Expire at the end of the end_date's day (UTC), matching the platform's
		// formal expiration convention rather than the exact purchase timestamp.
		$end_of_day = strtotime( gmdate( 'Y-m-d 23:59:59', $end ) . ' UTC' );

		return $end_of_day >= time();
	}

	/**
	 * Return true if any ID/date pairs are valid. Otherwise false.
	 *
	 * @param int[]    $valid_plan_ids List of valid plan IDs.
	 * @param object[] $token_subscriptions : ID must exist in the provided <code>$valid_subscriptions</code> parameter.
	 *                                                            The provided end date needs to fall on or after today,
	 *                                                            i.e. access lasts through the end of the end_date day (UTC).
	 *
	 * @return bool
	 */
	public static function validate_subscriptions( array $valid_plan_ids, array $token_subscriptions ) {
		// Create a list of product_ids to compare against.
		$product_ids = array();
		foreach ( $valid_plan_ids as $plan_id ) {
			$product_id = (int) get_post_meta( $plan_id, 'jetpack_memberships_product_id', true );
			if ( isset( $product_id ) ) {
				$product_ids[] = $product_id;
			}
		}

		foreach ( $token_subscriptions as $product_id => $token_subscription ) {
			if ( in_array( intval( $product_id ), $product_ids, true ) ) {
				if ( static::subscription_grants_access( $token_subscription ) ) {
					return true;
				}
			}
		}
		return false;
	}

	/**
	 * Get the URL of the JWT endpoint.
	 *
	 * @param  int    $site_id Site ID.
	 * @param  string $redirect_url URL to redirect after checking the token validity.
	 * @return string URL of the JWT endpoint.
	 */
	private function get_rest_api_token_url( $site_id, $redirect_url ) {
		// The redirect url might have a part URL encoded but not the whole URL.
		$redirect_url = rawurldecode( $redirect_url );
		return sprintf( '%smemberships/jwt?site_id=%d&redirect_url=%s', self::REST_URL_ORIGIN, $site_id, rawurlencode( $redirect_url ) );
	}

	/**
	 * Report the subscriptions as an ID => [ 'end_date' => ]. mapping
	 *
	 * @param array $subscriptions_from_bd List of subscriptions from BD.
	 *
	 * @return array<int, array>
	 */
	public static function abbreviate_subscriptions( $subscriptions_from_bd ) {

		if ( empty( $subscriptions_from_bd ) ) {
			return array();
		}

		$subscriptions = array();
		foreach ( $subscriptions_from_bd as $subscription ) {
			// We are picking the expiry date that is the most in the future.
			if (
				'active' === $subscription['status'] && (
					! isset( $subscriptions[ $subscription['product_id'] ] ) ||
					empty( $subscription['end_date'] ) || // Special condition when subscription has no expiry date - we will default to a year from now for the purposes of the token.
					strtotime( $subscription['end_date'] ) > strtotime( (string) $subscriptions[ $subscription['product_id'] ]->end_date )
				)
			) {
				$subscriptions[ $subscription['product_id'] ]           = new \stdClass();
				$subscriptions[ $subscription['product_id'] ]->end_date = empty( $subscription['end_date'] ) ? ( time() + 365 * 24 * 3600 ) : $subscription['end_date'];
				if ( ! empty( $subscription['is_comp'] ) ) {
					$subscriptions[ $subscription['product_id'] ]->is_comp = true;
				}
			}
		}
		return $subscriptions;
	}
}
