HEX
Server: nginx
System: Linux pool195-106-36.bur.atomicsites.net 6.12.57+deb12-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.57-1~bpo12+1 (2025-11-17) x86_64
User: (0)
PHP: 8.3.32
Disabled: pcntl_fork
Upload Files
File: /wordpress/mu-plugins/edge-cache/shared/class-edge-cache-purge.php
<?php declare( strict_types = 1 );

/**
 * Detect events that should cause the Edge Cache to purge, enqueue them for shutdown, and manage purging them.
 *
 * NOTE:: This class is designed to be shared between the mu-plugin in Simple and Atomic.
 * Put platform specific actions and filters in the mu-plugin on each platform.
 */
class Edge_Cache_Purge {

	const MAX_URI_COUNT     = 3000;
	const MAX_TERM_PAGES    = 3;
	const MAX_COMMENT_PAGES = 3;

	const PURGE_NOTHING = 0;
	const PURGE_URI     = 1;
	const PURGE_DOMAIN  = 2;

	private static $instance = null;

	private $purge_mode      = self::PURGE_NOTHING; // What kind of thing is setup to purge at shutdown?
	private $purging_enabled = true;                // Can be set to false on domains we can't purge, to shortcut purge checks. e.g.: public-api.

	private $domain_name = null;   // The domain which will be purged during shutdown.
	private $platform    = null;   // Is this 'simple' or 'atomic'?
	private $extra_tags  = [];     // Extra tags to include in logstash info.

	private $actions   = [];  // The set of actions which caused the current purges to be enqueued.
	private $uris      = [];  // The list of uris which will be purged during shutdown.
	private $posts     = [];  // Posts enqueued for purging during shutdown.
	private $terms     = [];  // Because we catch term changes during a cache purge event, we enqueue terms for processing during shutdown.
	private $uri_count = 0;   // The number of uris sent for purging.

	public function __construct( $platform ) {
		$this->platform = $platform;
	}

	/**
	 * Set up hooks to listen for purge events.
	 */
	public function init() {
		// Purge whole cache when major settings (e.g.: theme) are changed.
		add_action( 'switch_theme', [ $this, 'handle_site_change' ], 10, 0 );
		add_action( 'customize_save_after', [ $this, 'handle_site_change' ], 10, 0 );

		// Purge when a post changes.
		add_action( 'delete_attachment', [ $this, 'handle_deleted_post' ], 10, 2 );
		add_action( 'deleted_post', [ $this, 'handle_deleted_post' ], 10, 2 );
		add_action( 'transition_post_status', [ $this, 'handle_transition_post_status' ], PHP_INT_MAX, 3 );

		// Purge when a comment changes.
		add_action( 'comment_post', [ $this, 'handle_comment_post' ], 10, 3 );
		add_action( 'transition_comment_status', [ $this, 'handle_transition_comment_status' ], PHP_INT_MAX, 3 );

		// Special case: post_updated allows us to grab old permalinks when the permalink changes.
		add_action( 'post_updated', [ $this, 'handle_post_updated' ], 10, 3 );

		// Purge when a term cache gets cleared.
		add_action( 'clean_term_cache', [ $this, 'handle_clean_term_cache' ], 10, 3 );

		// Purge whole cache when the blog's visibility is changed.
		add_action( 'update_option_blog_public', [ $this, 'handle_visibility_option_update' ], 10, 3 );
		add_action( 'update_option_wpcom_public_coming_soon', [ $this, 'handle_visibility_option_update' ], 10, 3 );

		// WooCommerce-specific hooks.
		if ( class_exists( 'WC_Product' ) ) {
			add_action( 'woocommerce_before_product_object_save', array( $this, 'handle_woo_before_product_object_save' ), 10, 1 );
		}

		// Allow third parties to trigger purges manually.
		add_action( 'edge_cache_purge_domain', [ $this, 'handle_edge_cache_purge_domain' ], 10, 0 );
		add_action( 'edge_cache_purge_uris', [ $this, 'handle_edge_cache_purge_uris' ], 10, 1 );

		// Execute all purges on shutdown action
		add_action( 'shutdown', [ $this, 'handle_shutdown' ], 10, 0 );
	}

	public function handle_edge_cache_purge_domain() {
		$this->log_purge_action();
		$this->enqueue_purge_domain();
	}

	public function handle_edge_cache_purge_uris( $uris ) {
		$this->log_purge_action();
		$this->enqueue_purge_uri( (array) $uris );
	}

	/**
	 * Action handler for posts getting deleted. Does not queue up the post
	 * for later processing; data around it may be gone by then.
	 *
	 * @param $post_id      int       Post's ID.
	 * @param $post         WP_Post   Post object - optional.
	 */
	public function handle_deleted_post( $post_id, $post = null ) {
		if ( ! $post ) {
			$post = get_post( $post_id );
		}

		// Do not act on unpublished posts.
		if ( 'publish' !== $post->post_status && 'attachment' !== $post->post_type ) {
			return;
		}

		// If this post is queued for later purging, remove it.
		if ( isset( $this->posts[ $post_id ] ) ) {
			unset( $this->posts[ $post_id ] );
		}

		$this->log_purge_action();
		$this->process_post( $post_id, $post, true );
	}

	/**
	 * Action handler for post_updated. It's the only event that gives us insight into an old permalink URL
	 * when a user changes permalinks, so this is focused on grabbing old permalink URLs.
	 */
	public function handle_post_updated( $post_id, $post_after, $post_before ) {
		if ( empty( $post_before ) || 'publish' !== $post_before->post_status ) {
			return;
		}

		$old_permalink = get_permalink( $post_before );
		$new_permalink = get_permalink( $post_after );
		if ( $old_permalink === $new_permalink ) {
			return;
		}

		$this->log_purge_action();
		$this->enqueue_purge_uri(
			[
				$old_permalink,
				user_trailingslashit( trailingslashit( $old_permalink ) . 'feed' ),
			]
		);
	}

	/**
	 * Action handler for transition_post_status - called whenever a post changes.
	 * @param $new_status   string    New post status.
	 * @param $old_status   string    Old post status.
	 * @param $post         WP_Post   Post object.
	 */
	public function handle_transition_post_status( $new_status, $old_status, $post ) {
		if ( ! in_array( 'publish', array( $new_status, $old_status ), true ) || empty( $post ) ) {
			return;
		}

		$this->add_extra_tags(
			[
				'new_status' => $new_status,
				'old_status' => $old_status,
			]
		);

		$this->log_purge_action();
		$this->enqueue_purge_post( $post->ID, $post );
	}

	/**
	 * Action handler for major changes to the site which don't require any args to act on.
	 * e.g.: switch themes, customizer save.
	 */
	public function handle_site_change() {
		$this->log_purge_action();
		$this->enqueue_purge_domain();
	}

	/**
	 * Action handler for clean_term_cache - enqueues a term to be purged during shutdown.
	 *
	 * We don't get the term pages immediately, as this fires in response to a cache purge action.
	 * It may cause extra db hits during pageload if we do that - better to fetch during page shutdown.
	 *
	 * We do not respect requests to clear caches for the entire taxonomy,
	 * as this would be potentially hundreds or thousands of PURGE requests.
	 *
	 * @param array  $ids        An array of term IDs.
	 * @param string $taxonomy   Taxonomy slug.
	 */
	public function handle_clean_term_cache( $ids, $taxonomy, $clean_taxonomy ) {
		if ( ! $this->populate_site_info() ) {
			return;
		}

		$taxonomy_object = get_taxonomy( $taxonomy );
		if ( ! $taxonomy_object ) {
			return;
		}

		if ( false === $taxonomy_object->public
			&& false === $taxonomy_object->publicly_queryable
			&& false === $taxonomy_object->show_in_rest ) {
			return;
		}

		// Keep the blog_id these terms were enqueued for; prevent issues for multisite installs.
		$blog_id = get_current_blog_id();
		$this->terms[ $blog_id ]              = $this->terms[ $blog_id ] ?? [];
		$this->terms[ $blog_id ][ $taxonomy ] = $this->terms[ $blog_id ][ $taxonomy ] ?? [];

		foreach ( $ids as $id ) {
			$this->terms[ $blog_id ][ $taxonomy ][ $id ] = 1;
		}

		$this->log_purge_action();
	}

	/**
	 * Action handler for comment_post. Purge the relevant post if the post is published and the
	 * comment is approved.
	 *
	 * @param $comment_id      int     The comment ID
	 * @param $comment_status  int|str 0=not-approved, 1=approved, 'spam'=spam_comment
	 * @param $comment_data    array   Comment data
	 */
	public function handle_comment_post( $comment_id, $comment_status, $comment_data ) {
		if ( $comment_status != 1 || ! is_array( $comment_data ) || empty( $comment_data['comment_post_ID'] ) ) {
			return;
		}

		$post_id = (int) $comment_data['comment_post_ID'];
		$post    = get_post( $post_id );
		if ( empty( $post ) || $post->post_status !== 'publish' ) {
			return;
		}

		$this->add_extra_tags(
			[
				'new_status' => 'approved',
				'old_status' => 'new',
			]
		);

		$this->log_purge_action();
		$this->enqueue_purge_post( $post_id, $post, false );
	}


	/**
	 * Action handler for transition_comment_status.
	 * Purge the cache if comment is assigned to a post and the status has
	 * changed and the comment is approved
	 *
	 * @param $new_status   int|str      The new comment status.
	 * @param $old_status   int|str      The old comment status.
	 * @param $comment      WP_Comment   Comment object.
	 *
	 */
	public function handle_transition_comment_status( $new_status, $old_status, $comment ) {
		if ( $old_status == $new_status || empty( $comment->comment_post_ID ) ) {
			return;
		}

		if ( 'approved' === $new_status || 'approved' === $old_status ) {
			$this->add_extra_tags(
				[
					'new_status' => $new_status,
					'old_status' => $old_status,
				]
			);

			$post = get_post( (int) $comment->comment_post_ID );
			if ( ! $post ) {
				return;
			}

			if ( $post->post_status !== 'publish' ) {
				return;
			}

			$this->log_purge_action();
			$this->enqueue_purge_post( $post->ID, $post, false );
		}
	}

	/**
	 * Action handler for update_option_* which purges the domain cache when:
	 * - The blog_public option is changed, or
	 * - the wpcom_public_coming_soon option is changed.
	 *
	 * @param $old_value int|null - The previous value of option
	 * @param $value     int|null - The current value of option
	 *
	 * @return bool
	 */
	public function handle_visibility_option_update( $old_value, $value, $option ) {
		// Do nothing if a relevant option isn't being updated.
		if ( ! in_array( $option, [ 'blog_public', 'wpcom_public_coming_soon' ], true ) ) {
			return;
		}

		$this->log_purge_action();
		$this->enqueue_purge_domain();
	}

	/**
	 * Action handle for woocommerce_before_product_object_save.
	 * Enqueue a purge when a WooCommerce product is about to be saved.
	 *
	 * @param WC_Product $product The WooCommerce product object being saved.
	 */
	public function handle_woo_before_product_object_save( $product ) {
		if ( ! class_exists( 'WC_Product' ) || ! $product instanceof WC_Product ) {
			return;
		}

		$changes = $product->get_changes();

		/**
		 * Filter the pending changes on a WooCommerce product before deciding whether to ignore this event.
		 * Use this to ignore metadata changes that don't need a purge. Does not affect modifications to the wp_posts table.
		 *
		 * @param array      $changes Array of changes detected on the product.
		 * @param WC_Product $product The WooCommerce product object being saved.
		 */
		$changes = apply_filters( 'edge_cache_woocommerce_product_changes', $changes, $product );
		if ( empty( $changes ) ) {
			return;
		}

		$post = get_post( $product->get_id() );
		if ( ! $post || $post->post_status !== 'publish' ) {
			return;
		}

		$this->log_purge_action();
		$this->enqueue_purge_post( $post->ID, $post );
	}

	/**
	 * Action handler for the 'shutdown' action. Enacts and enqueued purges.
	 */
	public function handle_shutdown() {
		if ( ( defined( 'WP_IMPORTING' ) && true === WP_IMPORTING )
			|| ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE )
			|| ( defined( 'ISOLATED_TESTING_ENV' ) && ISOLATED_TESTING_ENV ) ) {
			return;
		}

		$this->prepare_enqueued_items();
		$this->execute_enqueued_purges();
	}

	/**
	 * Gather any URLs that need purging as a result of a term cache clear during the request.
	 * This is done during shutdown to avoid loading the reloading term unnecessarily during the request.
	 */
	private function prepare_term_purges() {
		// Skip if purging the whole domain; no need to dig up more URLs.
		if ( $this->purge_mode === self::PURGE_DOMAIN || empty( $this->terms ) ) {
			return;
		}

		// Only purge terms for the current blog_id at shutdown.
		$blog_id = get_current_blog_id();
		if ( ! isset( $this->terms[ $blog_id ] ) ) {
			return;
		}

		$uris = [];
		foreach ( $this->terms[ $blog_id ] as $taxonomy => $ids ) {
			$terms = get_terms(
				[
					'taxonomy'   => $taxonomy,
					'include'    => array_keys( $ids ),
					'hide_empty' => false,
				]
			);

			if ( is_wp_error( $terms ) || ! is_array( $terms ) ) {
				continue;
			}

			foreach ( array_filter( $terms ) as $term ) {
				$uris = array_merge( $uris, $this->get_purge_uris_for_term( $term ) );
			}
		}

		$this->terms = [];
		$this->enqueue_purge_uri( $uris );
	}

	/**
	 * Enqueue purging the whole domain during shutdown. Optionally, provide an $action to log.
	 *
	 * @param $action string|null - If provided, logs the action that lead to this domain purge.
	 */
	public function enqueue_purge_domain( $action = null ) {
		if ( $action ) {
			$this->log_purge_action( $action );
		}

		return $this->setup_purge_mode( self::PURGE_DOMAIN );
	}

	/**
	 * Immediately purge the domain, returning the result.
	 */
	public function purge_domain_now( $action = null ) {
		$this->enqueue_purge_domain( $action );
		return $this->execute_enqueued_purges();
	}

	/**
	 * Immediately purge the given uris, returning the result.
	 */
	public function purge_uris_now( $uris, $action = null ) {
		if ( ! empty( $action ) ) {
			$this->log_purge_action( $action );
		}

		$this->enqueue_purge_uri( $uris );
		return $this->execute_enqueued_purges();
	}

	/**
	 * Normalize URIS; ensure there is always a path, strips GET parameters, standardizes schema
	 * for easy comparison. (Note: schema is irrelevant to actual purges).
	 */
	public function normalize_uri( $uri ) {
		$parts = parse_url( $uri );
		if ( empty( $parts['host'] ) ) {
			return $uri; // Give up if it's not readable.
		}

		return 'https://' . $parts['host'] . ( $parts['path'] ?? '/' );
	}

	/**
	 * Returns true if there is room in the enqueue buffer to store n URIs, false if not.
	 */
	private function can_enqueue_uris( $n ) {
		if ( $this->purge_mode === self::PURGE_DOMAIN ) {
			return false; // No need to enqueue more URIs if we're purging the whole domain.
		}

		if ( ! $this->purging_enabled ) {
			return false; // Purging disabled for this domain.
		}

		if ( ( $this->uri_count + $n ) > self::MAX_URI_COUNT ) {
			return false; // Too many URIs already enqueued.
		}

		return true;
	}

	/**
	 * Enqueue purging a specific uri or set of uris during shutdown.
	 *
	 * @param $uris string|array - The URI or set of URIs to purge.
	 */
	public function enqueue_purge_uri( $uris ) {
		if ( empty( $uris ) ) {
			return;
		}

		$uris = (array) $uris;
		$this->uri_count += count( $uris );

		if ( ! $this->setup_purge_mode( self::PURGE_URI ) ) {
			return;
		}

		foreach ( $uris as $uri ) {
			$this->uris[ $this->normalize_uri( $uri ) ] = 1;
		}

		// If we have too many uris, give up and purge the whole domain.
		if ( count( $this->uris ) > self::MAX_URI_COUNT ) {
			$this->add_extra_tags( [ 'uri_limit' => 'hit' ] );
			$this->enqueue_purge_domain( null ); // null because the wp_action is already counted.
		}
	}

	/**
	 * Enqueue puring all uris related to a specific post.
	 *
	 * Note that the $post argument is optional. If not supplied, get_post will be used to get the post
	 * object. But this argument is needed, as not all post objects are in the cache ready to be read.
	 * e.g.: after clean_post_cache, the post has just been removed from cache so an extra call to get_post is costly.
	 *
	 * @param $post_id       int     - the post_id to look at.
	 * @param $post          WP_Post - Optionally, if the caller already has it, the WP_Post object to use.
	 * @param $include_lists boolean - If set to false, exclude list pages the post appears on (e.g.: tags, categories, archives).
	 */
	private function enqueue_purge_post( $post_id, $post = null, $include_lists = true ) {
		if ( ! $this->setup_purge_mode( self::PURGE_URI ) ) {
			return;
		}

		if ( ! isset( $this->posts[ $post_id ] ) ) {
			$this->posts[ $post_id ] = [];
		}

		if ( $include_lists ) {
			$this->posts[ $post_id ]['lists'] = true;
		}

		if ( $post ) {
			$this->posts[ $post_id ]['post'] = $post;
		}
	}

	/**
	 * Process all enqueued posts, and enqueue their URIs for purging.
	 */
	private function prepare_post_purges() {
		// Only bother if purging enabled and not already purging the whole domain.
		if ( ! $this->setup_purge_mode( self::PURGE_URI ) ) {
			return;
		}

		foreach ( $this->posts as $post_id => $target ) {
			$include_lists = ! empty( $target['lists'] );
			$post          = $target['post'] ?? null;

			$continue = $this->process_post( $post_id, $post, $include_lists );
			if ( ! $continue ) {
				return;
			}
		}

		$this->posts = [];
	}

	/**
	 * Process a single post, enqueueing its URIs for purging.
	 * @param $post_id       int     - The post ID to process.
	 * @param $post          WP_Post - Optionally, if the caller already has it, the WP_Post object to use.
	 * @param $include_lists boolean - True to include list pages the post appears on (e.g.: tags, categories, archives).
	 * @return bool - True if processing should continue, false if it should stop.
	 */
	private function process_post( $post_id, $post = null, $include_lists = true ) {
		if ( empty( $post ) ) {
			$post = get_post( $post_id );
		}

		if ( ! $post || wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
			return true; // Can continue with other posts, even if this one is missing / irrelevant.
		}

		if ( $this->is_site_editor_post_type( $post->post_type ) ) {
			return $this->process_site_editor_post( $post );
		}

		// Exclude post types that are not viewable
		if ( ! is_post_type_viewable( $post->post_type ) ) {
			return true;
		}

		$this->enqueue_purge_uri( $this->get_purge_uris_for_post( $post, $include_lists ) );
		return true;
	}

	/**
	 * Get the 'front-page' post, if one is set. Returns NULL if no front-page post is set.
	 */
	private function get_front_page() {
		$front_page_id = get_option( 'page_on_front' );
		if ( $front_page_id ) {
			return get_post( $front_page_id );
		}

		return null;
	}

	/**
	 * Given a URL, the number of items in the set, and the number of items per page, generate /page/x URLs for purging.
	 *
	 * @param $base_url       string - The base URL to paginate.
	 * @param $item_count     int    - The total number of items.
	 * @param $items_per_page int    - The number of items per page.
	 * @param $page_limit     int    - Optional limit on the number of pages to generate.
	 * @return array - An array of paginated URLs.
	 */
	private function paginate_url( $base_url, $item_count, $items_per_page, $page_limit = null ) {
		global $wp_rewrite;

		$urls = [ $base_url ];
		if ( ! $wp_rewrite->using_permalinks() ) {
			return $urls;
		}

		$paging_suffix = trailingslashit( ltrim( $wp_rewrite->pagination_base, '/' ) );
		$page_number_max = min( $page_limit ?? PHP_INT_MAX, ceil( $item_count / max( 1, $items_per_page ) ) );
		for ( $page_number = 2; $page_number <= $page_number_max; $page_number++ ) {
			$urls[] = user_trailingslashit(
				trailingslashit( $base_url ) . $paging_suffix . $page_number,
				'paged'
			);
		}

		return $urls;
	}

	/**
	 * Given a URL and a number of posts, generate page URLs for purging.
	 */
	private function get_post_page_count( $post_count ) {
		$posts_per_page = (int) get_option( 'posts_per_page' );
		if ( $posts_per_page < 1 ) {
			$posts_per_page = 10; // Default.
		}

		return max( 1, (int) ceil( $post_count / $posts_per_page ) );
	}

	/**
	 * Return the main posts page url - corresponds to the 'home' template.
	 */
	private function get_posts_url() {
		$front_page_id = get_option( 'page_on_front' );
		if ( $front_page_id ) {
			$posts_page_id = get_option( 'page_for_posts' );
			if ( $posts_page_id ) {
				$posts_page = get_post( $posts_page_id );
				if ( $posts_page ) {
					return get_permalink( $posts_page );
				}
			}
		}

		return home_url( '/' );
	}

	/**
	 * Process a post from the site editor. Work out whether it's a single-page template edit, or something that
	 * may affect the whole domain.
	 * @param $post WP_Post - The post to process.
	 * @return bool - True if page purge(s) can continue, false if the whole domain is being purged.
	 */
	private function process_site_editor_post( $post ) {
		$this->log_purge_action( 'site_edit' );
		$this->add_extra_tags( [ 'target' => $post->post_name ] );

		// Check for single-page templates.
		if ( 'wp_template' === $post->post_type ) {
			if ( $post->post_name === 'home' ) {
				// Home template: purge the posts page if possible, otherwise fall back to domain purge.
				$post_count = wp_count_posts()->publish;
				$page_count = $this->get_post_page_count( $post_count );
				if ( $this->can_enqueue_uris( $page_count ) ) {
					$home_url = $this->get_posts_url();
					$this->enqueue_purge_uri( $this->paginate_url( $home_url, $post_count, get_option( 'posts_per_page' ) ) );
					return true;
				}
			} else if ( $post->post_name === 'front-page' ) {
				$target_page = $this->get_front_page();
			} else {
				$target_page_name = substr( $post->post_name, 5 );
				$target_page      = get_page_by_path( $target_page_name );
			}

			// If we can find one single target page for this template, just purge that.
			if ( ! empty( $target_page ) && $target_page->post_type === 'page' && $target_page->post_status === 'publish' ) {
				$this->enqueue_purge_uri( $this->get_purge_uris_for_post( $target_page, false ) );
				return true;
			}
		}

		// Fall back to a domain purge if we can't find a single target page, or if 'home' has too many pages.
		$this->enqueue_purge_domain();
		return false;
	}

	/**
	 * Populate internal structures with site information.
	 */
	private function populate_site_info() {
		if ( empty( $this->domain_name ) ) {
			$domain = $this->platform->get_domain_name();

			if ( empty( $domain ) || ! $this->platform->can_purge_domain( $domain ) ) {
				$this->purging_enabled = false;
				return;
			}

			$this->domain_name = $domain;
		}

		return true;
	}

	/**
	 * Get ready to purge a domain or a set of URIs. Cleans up leftover data and ensures that
	 * details like domain_name are populated.
	 *
	 * @param $purge_type int    - PURGE_DOMAIN or PURGE_URI - what are we trying to purge?
	 *
	 * @return bool - True if the purge mode is set, false if the current purge should be aborted.
	 */
	private function setup_purge_mode( $purge_mode ) {
		// Nothing to do if we can't purge.
		if ( ! $this->purging_enabled ) {
			return false;
		}

		if ( ! $this->populate_site_info() ) {
			return false;
		}

		// Deal with changes in scope of purging (domain vs. uri)
		if ( $this->purge_mode !== self::PURGE_NOTHING && $this->purge_mode !== $purge_mode ) {
			if ( $this->purge_mode === self::PURGE_URI ) {
				$this->uris = [];
			} else if ( $this->purge_mode === self::PURGE_DOMAIN ) {
				// Any URI purges queued after a DOMAIN purge is queued can be ignored.
				return false;
			}
		}

		$this->purge_mode = $purge_mode;

		return true;
	}

	/**
	 * Track an action that has triggered some kind of purging.
	 */
	private function log_purge_action( $action = null ) {
		if ( empty( $action ) ) {
			$action = current_filter();
		}

		$this->actions[ $action ] = ( $this->actions[ $action ] ?? 0 ) + 1;
	}

	/**
	 * Helper method - given a post type, return true if it's from the site editor.
	 */
	private function is_site_editor_post_type( $post_type ) {
		static $fse_post_types = [ 'wp_template', 'wp_template_part', 'wp_global_styles', 'wp_navigation', 'wp_block' ];
		return in_array( $post_type, $fse_post_types, true );
	}

	/**
	 * Given a post permalink and the number of comments on the post, return comment sub-pages
	 * for the post.
	 */
	private function get_comment_subpage_uris( $post_permalink, $comment_count ) {
		global $wp_rewrite;

		if ( ! get_option( 'page_comments' ) ) {
			return [];
		}

		$newest_first      = 'newest' === get_option( 'default_comments_page' );
		$comments_per_page = max( 1, (int) get_option( 'comments_per_page' ) );
		$comment_pages     = ceil( $comment_count / $comments_per_page );

		if ( $newest_first ) {
			// Permalink shows the newest comments.
			$to   = $comment_pages - 1;
			$from = max( 1, $to - self::MAX_COMMENT_PAGES );
		} else {
			// Permalink shows the oldest comments.
			$from = 2;
			$to   = min( $comment_pages, $from + self::MAX_COMMENT_PAGES );
		}

		$uris = [];
		for ( $i = $from; $i <= $to; $i++ ) {
			$uris[] = user_trailingslashit(
				trailingslashit( $post_permalink ) . $wp_rewrite->comments_pagination_base . '-' . $i,
				'commentpaged'
			);
		}

		return $uris;
	}

	/**
	 * Remove the "__trashed" post_name suffix from a list of uris. When a post is trashed,
	 * "__trashed" is added to its post_name - but we want to purge urls without this suffix.
	 *
	 * Pass in a post, so that its name can be used to ensure random other instances of __trashed
	 * are not removed if not a part of a post_name.
	 *
	 * @param $post - WP_Post object to use for the post_name.
	 * @param $uris - Array of URIs to clean up.
	 */
	private function get_untrashed_post_links( $post, $uris ) {
		if ( substr_compare( $post->post_name, '__trashed', -9 ) === 0 ) {
			$trashed   = $post->post_name . '/';
			$untrashed = substr( $post->post_name, 0, -9 ) . '/';
		} else {
			$trashed   = $post->post_name . '__trashed/';
			$untrashed = $post->post_name . '/';
		}

		return array_map(
			function ( $uri ) use ( $trashed, $untrashed ) {
				return str_replace( $trashed, $untrashed, $uri );
			},
			$uris
		);
	}

	/**
	 * Given a post, return the URLs that could be affected by it changing.
	 *
	 * @param $post          WP_Post - Post object to inspect.
	 * @param $include_lists boolean - True (by default) to include all of the list pages the post may appear in. (e.g.: categories).
	 */
	private function get_purge_uris_for_post( $post, $include_lists = true ) {
		$post_type = get_post_type( $post );
		$permalink = get_permalink( $post );
		$is_product = ( 'product' === $post_type && function_exists( 'wc_get_page_permalink' ) );

		$uris = [
			home_url( '/' ),
			$permalink,
		];

		if ( ! $is_product ) {
			$uris[] = get_post_comments_feed_link( $post->ID );
			$uris[] = get_author_posts_url( $post->post_author );
			$uris[] = get_author_feed_link( $post->post_author );
			$uris[] = get_bloginfo_rss( 'rdf_url' );
			$uris[] = get_bloginfo_rss( 'rss_url' );
			$uris[] = get_bloginfo_rss( 'rss2_url' );
			$uris[] = get_bloginfo_rss( 'atom_url' );
			$uris[] = get_bloginfo_rss( 'comments_rss2_url' );
		}

		if ( $is_product && $include_lists ) {
			$shop_page = wc_get_page_permalink( 'shop' );
			if ( $shop_page ) {
				$uris[] = $shop_page;
			}
		}

		if ( 'attachment' === $post_type ) {
			$uris[] = wp_get_attachment_url( $post->ID );
		}

		// Comment pagination URLs.
		$comment_count = get_comments_number( $post );
		$comment_uris  = $this->get_comment_subpage_uris( $permalink, $comment_count );
		$uris          = array_merge( $uris, $comment_uris );

		// Handle any taxonomies this post is a part of.
		if ( $include_lists ) {
			$taxonomies = get_object_taxonomies( $post, 'objects' );
			foreach ( $taxonomies as $taxonomy ) {
				if ( ! $taxonomy instanceof WP_Taxonomy ) {
					continue;
				}

				// Ignore non-visible taxonomies.
				if ( false === $taxonomy->public
					&& false === $taxonomy->publicly_queryable
					&& false === $taxonomy->show_in_rest ) {
					continue;
				}

				// Get applicable terms.
				$taxonomy_name = $taxonomy->name;
				$terms = get_the_terms( $post->ID, $taxonomy_name );
				if ( is_wp_error( $terms ) || ! is_array( $terms ) ) {
					continue;
				}

				// Do a purge for each term.
				foreach ( array_filter( $terms ) as $term ) {
					$uris = array_merge(
						$uris,
						$this->get_purge_uris_for_term( $term )
					);
				}
			}

			$archive_link = get_post_type_archive_link( $post_type );
			if ( ! empty( $archive_link ) && $archive_link !== home_url() ) {
				$uris[] = $archive_link;
			}
		}

		if ( 'post' === $post_type ) {
			$page_for_posts = get_option( 'page_for_posts' );
			if ( $page_for_posts ) {
				$uris[] = get_permalink( $page_for_posts );
			}
		}

		return $this->get_untrashed_post_links( $post, array_filter( $uris ) );
	}

	/**
	 * Get all URIs to be purged for a given term
	 *
	 * @param object $term A WP term object
	 *
	 * @return array An array of URLs to be purged
	 */
	private function get_purge_uris_for_term( $term ) {
		global $wp_rewrite;

		if ( ! ( $term instanceof \WP_Term ) ) {
			return [];
		}

		$term_purge_urls = array();
		$taxonomy_name   = $term->taxonomy;
		$maybe_purge_url = get_term_link( $term, $taxonomy_name );

		if ( is_wp_error( $maybe_purge_url ) ) {
			return [];
		}

		if ( $maybe_purge_url && is_string( $maybe_purge_url ) ) {
			$term_purge_urls[] = $maybe_purge_url;

			// Pagination: but only if using permalinks. ?query-based pagination doesn't need individual purging.
			if ( $wp_rewrite->using_permalinks() ) {
				$posts_per_page           = get_option( 'posts_per_page' );
				$total_public_posts_count = intval( $term->count );
				$paging_suffix            = trailingslashit( ltrim( $wp_rewrite->pagination_base, '/' ) );

				// Limit to up to MAX_TERM_PAGES pages
				$page_number_max = min( self::MAX_TERM_PAGES, ceil( $total_public_posts_count / max( 1, $posts_per_page ) ) );
				for ( $page_number = 2; $page_number <= $page_number_max; $page_number++ ) {
					$term_purge_urls[] = user_trailingslashit(
						trailingslashit( $maybe_purge_url ) . $paging_suffix . $page_number,
						'paged'
					);
				}
			}
		}

		$maybe_purge_feed_url = get_term_feed_link( $term->term_id, $taxonomy_name );
		if ( false !== $maybe_purge_feed_url ) {
			$term_purge_urls[] = $maybe_purge_feed_url;
		}

		return array_filter( $term_purge_urls );
	}

	/**
	 * Purge batcache. Returns a string describing the outcome.
	 */
	public function purge_batcache() {
		global $batcache;

		if ( isset( $batcache ) && is_object( $batcache ) && method_exists( $batcache, 'flush' ) ) {
			if ( ! empty( $this->domain_name ) ) {
				if ( $batcache->flush( 'host', $this->domain_name ) ) {
					return 'success';
				}
			}

			return 'failure';
		}

		return 'disabled';
	}

	/**
	 * Reset the purge system - useful for tests.
	 */
	public function clear_enqueued_purges() {
		$this->domain_name        = null;
		$this->purging_enabled    = true;
		$this->purge_mode         = self::PURGE_NOTHING;
		$this->extra_tags         = [];
		$this->actions            = [];
		$this->uris               = [];
		$this->terms              = [];
		$this->posts              = [];
	}

	/**
	 * Actually execute any purges that are enqueued. Clears the enqueued purge when done.
	 * @return bool - true on success (or if nothing queued), false on failure.
	 */
	public function execute_enqueued_purges() {
		if ( $this->purge_mode === self::PURGE_NOTHING || empty( $this->domain_name ) ) {
			return true;
		}

		if ( $this->purge_mode === self::PURGE_URI && empty( $this->uris ) ) {
			return true;
		}

		// Invalidate batcache before edge-cache
		$batcache_status = $this->purge_batcache();

		// Gather logstash tags.
		$tags = $this->generate_tags( $batcache_status );

		// Perform the edge cache purge.
		if ( $this->purge_mode === self::PURGE_DOMAIN ) {
			$result = $this->platform->purge_domain( $this->domain_name, $tags );
		} else if ( $this->purge_mode === self::PURGE_URI ) {
			$result = $this->platform->purge_uris( array_keys( $this->uris ), $tags );
		}

		$this->clear_enqueued_purges();

		return $result;
	}

	/**
	 * Generate purge info tags for Logstash.
	 */
	private function generate_tags( $batcache_status ) {
		// Collate actions as action(count),action(count),... and count how many actions total.
		$total_actions = 0;
		$actions       = [];
		foreach ( $this->actions as $action => $count ) {
			$total_actions += $count;
			$actions[] = sprintf( '%s(%s)', $action, $count );
		}

		$tags = [
			'platform'     => $this->platform->get_platform_type(),
			'wp_domain'    => $this->domain_name,
			'wp_actions'   => implode( ',', $actions ),
			'wp_action'    => array_keys( $this->actions ),
			'domain_purge' => $this->purge_mode === self::PURGE_DOMAIN ? 'yes' : 'no',
			'urls_cnt'     => $this->uri_count,
			'purge_count'  => $total_actions,
			'batcache'     => $batcache_status,
		];

		$flat_extras = array_map(
			function ( $values ) {
				return implode( ',', $values );
			},
			$this->extra_tags
		);
		$tags = array_merge( $tags, $flat_extras );

		return $tags;
	}

	/**
	 * Add extra tags to the log when purging.
	 */
	public function add_extra_tags( $new_tags ) {
		foreach ( $new_tags as $key => $value ) {
			if ( ! isset( $this->extra_tags[ $key ] ) ) {
				$this->extra_tags[ $key ] = [ $value ];
			} else {
				$this->extra_tags[ $key ][] = $value;
			}
		}
	}

	/**
	 * Returns true if a domain purge is enqueued.
	 */
	public function is_domain_purge_enqueued() {
		return $this->purge_mode === self::PURGE_DOMAIN;
	}

	/**
	 * Force processing all enqueued items.
	 */
	public function prepare_enqueued_items() {
		$this->prepare_post_purges();
		$this->prepare_term_purges();
	}

	/**
	 * Returns a list of URIs enqueued to purge, or false if not purging by URI.
	 * Note: This will process enqueued terms and posts to ensure the uri list is complete.
	 */
	public function get_enqueued_uris() {
		$this->prepare_enqueued_items();

		if ( $this->purge_mode !== self::PURGE_URI ) {
			return false;
		}

		return array_keys( $this->uris );
	}
}