👁 查看:disable-comments.php
路径:/home/forge/kingkrunch.com/wp/wp-content/plugins/disable-comments/disable-comments.php
大小:219.6 KB · 修改:2026-09-30 14:36:39 · 权限:0644 · 可写
<?php

/**
 * Plugin Name: Disable Comments
 * Plugin URI: https://wordpress.org/plugins/disable-comments/
 * Description: Allows administrators to globally disable comments on their site. Comments can be disabled according to post type. You could bulk delete comments using Tools.
 * Version: 2.9.0
 * Author: WPDeveloper
 * Author URI: https://wpdeveloper.com
 * License: GPL-3.0+
 * License URI: https://www.gnu.org/licenses/gpl-3.0.html
 * Text Domain: disable-comments
 * Domain Path: /languages/
 *
 * @package Disable_Comments
 */

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

class Disable_Comments {
	const DB_VERSION         = 8;
	const BLOCKED_STATS_OPTION = 'disable_comments_blocked_stats';
	/** Option recording when counting started. */
	const BLOCKED_SINCE_OPTION = 'disable_comments_blocked_since';
	/**
	 * Per-user meta recording that the review prompt was dismissed.
	 *
	 * User meta rather than an option: dismissal belongs to the person who
	 * dismissed it, not to the site. It also survives plugin updates, which
	 * a version-stamped option would not.
	 */
	const REVIEW_DISMISSED_META = 'disable_comments_review_dismissed';
	/** Option holding the moment a bulk delete succeeded. */
	const REVIEW_TRIGGER_OPTION = 'disable_comments_review_trigger';
	/**
	 * Terms the conditional-rules picker asks for at a time.
	 *
	 * A page size, not a limit: the picker searches and pages, so a taxonomy
	 * larger than this is still reachable in full. Sized to fill a dropdown
	 * without making the first keystroke wait on a query that reads thousands
	 * of rows.
	 */
	const TERM_PAGE_SIZE = 50;
	/**
	 * How many matching comments the delete preview shows.
	 *
	 * Enough to recognise what the delete is about to take, not so many that
	 * the panel becomes the comments screen. The full set is what the CSV
	 * backup beside the button is for.
	 */
	const DELETE_PREVIEW_SAMPLE_SIZE = 10;
	/** Transient holding the current scan's one-time token. */
	const SCAN_TOKEN_TRANSIENT = 'disable_comments_scan_token';
	/** Query arg carrying that token on the scanned request. */
	const SCAN_QUERY_ARG       = 'dc_scan';
	/**
	 * Format version of the settings export payload.
	 *
	 * Bumped when the shape changes, so a future release can migrate an old
	 * file rather than silently misapplying it.
	 */
	const EXPORT_SCHEMA_VERSION = 1;
	private static $instance = null;
	private $options;
	public  $networkactive;
	public  $tracker;
	public  $is_CLI;
	public  $sitewide_settings;
	public  $setup_notice_flag;
	private $modified_types = array();
	/**
	 * Blocked attempts seen in this request, flushed once on shutdown.
	 *
	 * Incrementing an option per blocked request would put a write on the
	 * exact path a spam flood hammers, so counting stays in memory until the
	 * request is over.
	 *
	 * @var array
	 */
	private $blocked_pending = array();
	/**
	 * Comments removed by the last delete run in this request.
	 *
	 * apply_delete_comments() already works this out to decide whether the
	 * run earned a review prompt; it is kept so the AJAX response can report
	 * it, because the log string it returns is prose and cannot be counted on.
	 *
	 * @var int
	 */
	private $last_deleted_count = 0;
	/** Findings accumulated during a scanned front-end request. */
	private $scan_report = array();

	public static function get_instance() {
		if (is_null(self::$instance)) {
			self::$instance = new self;
		}
		return self::$instance;
	}

	function __construct() {
		define('DC_VERSION', '2.9.0');
		define('DC_PLUGIN_SLUG', 'disable_comments_settings');
		define('DC_PLUGIN_ROOT_PATH', dirname(__FILE__));
		define('DC_PLUGIN_VIEWS_PATH', DC_PLUGIN_ROOT_PATH . '/views/');
		define('DC_PLUGIN_ROOT_URI', plugins_url("/", __FILE__));
		define('DC_ASSETS_URI', DC_PLUGIN_ROOT_URI . 'assets/');

		// save settings
		add_action('wp_ajax_disable_comments_save_settings', array($this, 'disable_comments_settings'));
		add_action('wp_ajax_disable_comments_delete_comments', array($this, 'delete_comments_settings'));
		add_action('wp_ajax_disable_comments_preview_delete', array($this, 'preview_delete_comments'));
		add_action('wp_ajax_disable_comments_export_comments', array($this, 'export_comments_download'));
		add_action('wp_ajax_get_sub_sites', array($this, 'get_sub_sites'));
		add_action('wp_ajax_disable_comments_get_terms', array($this, 'get_taxonomy_terms'));
		add_action('wp_ajax_disable_comments_reset_blocked_stats', array($this, 'reset_blocked_stats_ajax'));
		add_action('wp_ajax_disable_comments_dismiss_review', array($this, 'dismiss_review_prompt'));
		add_action('wp_ajax_disable_comments_scan_theme', array($this, 'scan_theme_conflict'));
		add_action('wp_ajax_disable_comments_export_settings', array($this, 'export_settings_download'));
		add_action('wp_ajax_disable_comments_import_settings', array($this, 'import_settings_ajax'));

		// Including cli.php
		if (defined('WP_CLI') && WP_CLI) {
			add_action('init', array($this, 'enable_cli'), 9999);
		}

		// Expose plugin state to the Abilities API (WordPress 6.9+), so AI
		// agents and MCP clients can query how comments are configured.
		add_action('wp_abilities_api_categories_init', array($this, 'register_ability_categories'));
		add_action('wp_abilities_api_init', array($this, 'register_abilities'));

		// are we network activated?
		$this->networkactive = (is_multisite() && array_key_exists(plugin_basename(__FILE__), (array) get_site_option('active_sitewide_plugins')));
		$this->is_CLI = defined('WP_CLI') && WP_CLI;

		$this->sitewide_settings = get_site_option('disable_comments_sitewide_settings', false);
		// Load options.
		// Uses is_network_admin_ajax_context() (routing hint, not capability
		// check) because current_user_can() is unavailable during plugin
		// construction — pluggable.php hasn't loaded yet. This only controls
		// which options table is READ (site vs blog) — writes are always
		// gated by capability checks in the AJAX handlers and settings_page().
		if ($this->networkactive && ($this->is_network_admin_ajax_context() || $this->sitewide_settings !== '1')) {
			$this->options = get_site_option('disable_comments_options', array());
			$this->options['disabled_sites'] = $this->get_disabled_sites();

			$blog_id = get_current_blog_id();
			if (
				!$this->is_network_admin_ajax_context() && (
					empty($this->options['disabled_sites']) ||
					// if site disabled
					empty($this->options['disabled_sites']["site_$blog_id"])
				)
			) {
				$this->options = [
					'remove_everywhere' => false,
					'disabled_post_types' => array(),
					'extra_post_types' => array(),
					'disabled_sites' => array(),
					'remove_xmlrpc_comments' => 0,
					'remove_rest_API_comments' => 0,
					'show_existing_comments' => false,
					'allowed_comment_types' => array(),
					'blocked_comment_types' => array(),
					'settings_saved' => true,
					'db_version' => $this->options['db_version']
				];
			}
		} else {
			$this->options = get_option('disable_comments_options', array());
			$not_configured = empty($this->options) || empty($this->options['settings_saved']);

			if (is_multisite() && $not_configured && $this->sitewide_settings == '1') {
				$this->options = get_site_option('disable_comments_options', array());
				$this->options['is_network_options'] = true;
			}
		}


		// If it looks like first run, check compat.
		if (empty($this->options)) {
			$this->check_compatibility();
		}

		$this->options['sitewide_settings'] = ($this->sitewide_settings == '1');

		// Upgrade DB if necessary.
		$this->check_db_upgrades();
		$this->check_upgrades();

		add_action('plugins_loaded', [$this, 'init_filters']);
		add_action('wp_loaded', [$this, 'start_plugin_usage_tracking']);

		// Add Site Health integration
		add_filter('debug_information', array($this, 'add_site_health_info'));
	}

	/**
	 * Routing hint: is this request from the network-admin screen?
	 *
	 * During AJAX, WP's is_network_admin() is always false, so the JS
	 * appends ?is_network_admin=1 to ajaxurl (value set server-side in
	 * admin_enqueue_scripts via is_network_admin()). The GET param is
	 * client-supplied and therefore forgeable — never use this method
	 * alone for authorization. Always pair with can_network_admin_ajax_context()
	 * or an explicit current_user_can() check.
	 */
	private function is_network_admin_ajax_context() {
		if (!$this->networkactive) {
			return false;
		}
		if (is_network_admin()) {
			return true;
		}
		if (defined('DOING_AJAX') && DOING_AJAX && is_multisite() && isset($_GET['is_network_admin'])) {
			$param = sanitize_text_field(wp_unslash($_GET['is_network_admin']));
			return $param === '1';
		}
		return false;
	}

	/**
	 * Capability-gated network-admin context check.
	 *
	 * Returns true only when the request appears to come from the
	 * network-admin screen AND the current user holds
	 * manage_network_plugins. Safe for authorization decisions.
	 */
	private function can_network_admin_ajax_context() {
		if ($this->is_network_admin_ajax_context() && current_user_can('manage_network_plugins')) {
			return true;
		}

		return false;
	}

	/**
	 * Enable CLI
	 * @since 2.0.0
	 */
	public function enable_cli() {
		require_once DC_PLUGIN_ROOT_PATH . "/includes/cli.php";
		new Disable_Comment_Command($this);
	}

	/**
	 * Load the Abilities API integration.
	 *
	 * Guarded on the API being present so nothing changes on WordPress < 6.9,
	 * where these hooks never fire anyway.
	 *
	 * @since 2.8.0
	 * @return bool True when the integration is available and loaded.
	 */
	private function load_abilities() {
		if (!function_exists('wp_register_ability') || !function_exists('wp_register_ability_category')) {
			return false;
		}
		require_once DC_PLUGIN_ROOT_PATH . '/includes/abilities.php';
		return true;
	}

	/**
	 * Register the plugin's ability category with the Abilities API.
	 *
	 * @since 2.8.0
	 * @return void
	 */
	public function register_ability_categories() {
		if ($this->load_abilities()) {
			disable_comments_register_ability_categories();
		}
	}

	/**
	 * Register the plugin's abilities with the Abilities API.
	 *
	 * @since 2.8.0
	 * @return void
	 */
	public function register_abilities() {
		if ($this->load_abilities()) {
			disable_comments_register_abilities();
		}
	}

	public function admin_notice() {
		if ($this->tracker instanceof DisableComments_Plugin_Tracker) {
			if (isset($this->setup_notice_flag) && $this->setup_notice_flag === true) {
				return;
			}
			$current_screen = get_current_screen()->id;
			$has_caps = $this->networkactive && is_network_admin() ? current_user_can('manage_network_plugins') : current_user_can('manage_options');
			// if( ! in_array( $current_screen, ['settings_page_disable_comments_settings', 'settings_page_disable_comments_settings-network']) && $has_caps ) {
			if ($has_caps && in_array($current_screen, ['dashboard-network', 'dashboard'])) {
				$this->tracker->notice();
			}
		}
	}

	public function start_plugin_usage_tracking() {
		if ($this->networkactive && !$this->options['sitewide_settings']) {
			$this->tracker = null;
			return;
		}
		if (!class_exists('DisableComments_Plugin_Tracker')) {
			include_once(DC_PLUGIN_ROOT_PATH . '/includes/class-plugin-usage-tracker.php');
		}
		$tracker = $this->tracker = DisableComments_Plugin_Tracker::get_instance(__FILE__, [
			'opt_in' => true,
			'goodbye_form' => true,
			'item_id' => 'b0112c9030af6ba53de4'
		]);
		$tracker->set_notice_options(array(
			'notice' => __('Want to help make Disable Comments even better?', 'disable-comments'),
			'extra_notice' => __('We collect non-sensitive diagnostic data and plugin usage information. Your site URL, WordPress & PHP version, plugins & themes and email address to send you the discount coupon. This data lets us make sure this plugin always stays compatible with the most popular plugins and themes. No spam, I promise.', 'disable-comments'),
		));
		$tracker->init();
	}

	private function check_compatibility() {
		if (version_compare($GLOBALS['wp_version'], '4.7', '<')) {
			require_once(ABSPATH . 'wp-admin/includes/plugin.php');
			deactivate_plugins(__FILE__);

			// @phpcs:ignore WordPress.Security.NonceVerification.Recommended
			if (isset($_GET['action']) && ($_GET['action'] == 'activate' || $_GET['action'] == 'error_scrape')) {
				// translators: %s: WordPress version no.
				exit(sprintf(esc_html__('Disable Comments requires WordPress version %s or greater.', 'disable-comments'), '4.7'));
			}
		}
	}

	private function check_db_upgrades() {
		$old_ver = isset($this->options['db_version']) ? $this->options['db_version'] : 0;
		if ($old_ver < self::DB_VERSION) {
			if ($old_ver < 2) {
				// upgrade options from version 0.2.1 or earlier to 0.3.
				$this->options['disabled_post_types'] = get_option('disable_comments_post_types', array());
				delete_option('disable_comments_post_types');
			}
			if ($old_ver < 5) {
				// simple is beautiful - remove multiple settings in favour of one.
				$this->options['remove_everywhere'] = isset($this->options['remove_admin_menu_comments']) ? $this->options['remove_admin_menu_comments'] : false;
				foreach (array('remove_admin_menu_comments', 'remove_admin_bar_comments', 'remove_recent_comments', 'remove_discussion', 'remove_rc_widget') as $v) {
					unset($this->options[$v]);
				}
			}
			if ($old_ver < 7 && function_exists('get_sites')) {
				$this->options['disabled_sites'] = [];
				$dc_options = get_site_option('disable_comments_options', array());

				foreach (get_sites(['number' => 0, 'fields' => 'ids']) as $blog_id) {
					if (isset($dc_options['disabled_sites'])) {
						$this->options['disabled_sites']["site_$blog_id"] = in_array($blog_id, $dc_options['disabled_sites']);
					} else {
						$this->options['disabled_sites']["site_$blog_id"] = true;
					}
				}
				$this->options['disabled_sites'] = $this->get_disabled_sites();
			}

			if ($old_ver < 8) {
				// Add new show_existing_comments option with default value false
				// This maintains backward compatibility - existing behavior is preserved
				$this->options['show_existing_comments'] = false;
			}

			foreach (array('remove_everywhere', 'extra_post_types', 'show_existing_comments') as $v) {
				if (!isset($this->options[$v])) {
					$this->options[$v] = false;
				}
			}

			$this->options['db_version'] = self::DB_VERSION;
			$this->update_options($this->networkactive);
		}
	}

	public function check_upgrades() {
		$dc_version = get_option('disable_comment_version');
		if (version_compare($dc_version, '2.3.1', '<')) {
			if ($this->is_remove_everywhere()) {
				update_option('show_avatars', true);
			}
		}
		if (!$dc_version || $dc_version != DC_VERSION) {
			update_option('disable_comment_version', DC_VERSION);
		}
	}

	private function update_options($is_network_ctx = false) {
		if ($this->networkactive && $is_network_ctx) {
			update_site_option('disable_comments_options', $this->options);
		} else {
			update_option('disable_comments_options', $this->options);
		}
	}

	/**
	 * Read the settings as they are stored, for the store a write would target.
	 *
	 * $this->options is the *effective* configuration for the current request,
	 * which is not always what is on disk. On a network-wide install the
	 * constructor replaces it with a blank, everything-enabled config whenever
	 * the current blog is not among the network's disabled_sites and the
	 * request is not a network admin one - which is every WP-CLI request, since
	 * is_network_admin_ajax_context() has no GET parameter to read there.
	 *
	 * Export would then write that blank out as if it were the network's
	 * settings, and import would take it as the baseline for every field the
	 * incoming file omits - so a partial import of one flag would quietly reset
	 * the rest of the network to defaults. Both need the stored row.
	 *
	 * The routing deliberately mirrors update_options() line for line: an
	 * import's baseline has to be the same row the import will write, or the
	 * diff it reports is a diff against something else.
	 *
	 * @param bool $is_network_ctx Whether the operation targets network storage.
	 * @return array Stored settings, empty if nothing has been saved yet.
	 */
	private function get_stored_settings($is_network_ctx = false) {
		if ($this->networkactive && $is_network_ctx) {
			return (array) get_site_option('disable_comments_options', array());
		}

		return (array) get_option('disable_comments_options', array());
	}

	/**
	 * Purges front-end page caches after a change to what the site renders.
	 *
	 * Disabling comments changes the HTML of every page that carries a comment
	 * form or count, but a full-page cache keeps serving the old markup until
	 * each entry expires. On a live nginx FastCGI host this was reproducible:
	 * after saving, `x-cache: HIT` responses still contained the comment form
	 * while a cache-busted request did not. Comments really were off — visitors
	 * just could not tell.
	 *
	 * Called from the save and delete handlers rather than from
	 * update_options(), deliberately. Those handlers run on `wp_ajax_*` or
	 * WP-CLI, long after `plugins_loaded`, so every cache plugin has already
	 * registered its listeners. update_options() is also reached from
	 * check_db_upgrades() during plugin construction, where firing these would
	 * be too early for anything to hear them.
	 *
	 * Only *page* caches are purged. The object cache (Redis/Memcached) is left
	 * alone on purpose: settings are read through options that WordPress already
	 * invalidates on write, so flushing a shared object cache would stampede a
	 * busy site's origin for no benefit.
	 *
	 * Each integration is guarded — a caching plugin that renames or drops its
	 * API must never turn "settings saved" into a fatal error.
	 *
	 * @since 2.8.0
	 * @return void
	 */
	public function purge_page_caches($blog_ids = array()) {
		// On a network the purge has to run *inside* each affected site. Several
		// integrations (WP Rocket, SiteGround Optimizer, W3 Total Cache) only
		// clear the site they are called from, so purging once from the network
		// admin would leave every subsite serving deleted comments, stale counts,
		// or an old comment form.
		if (!empty($blog_ids) && is_multisite() && function_exists('switch_to_blog')) {
			foreach (array_unique(array_map('intval', (array) $blog_ids)) as $blog_id) {
				switch_to_blog($blog_id);
				$this->purge_current_site_page_caches();
				restore_current_blog();
			}
			return;
		}

		$this->purge_current_site_page_caches();
	}

	/**
	 * Purges page caches for the site that is currently switched in.
	 *
	 * Split out from purge_page_caches() so the network loop can reuse it
	 * without re-entering the switching logic.
	 *
	 * @since 2.8.0
	 * @return void
	 */
	private function purge_current_site_page_caches() {
		/**
		 * Fires when Disable Comments has changed what the front end renders.
		 *
		 * On a network this fires once per affected site, with that site
		 * switched in, so `get_current_blog_id()` inside the handler is the site
		 * being purged. Hosts, caching plugins, and CDN integrations can hook
		 * this to clear their own layer. Fired before the bundled integrations
		 * below so a handler can act first.
		 *
		 * @since 2.8.0
		 */
		do_action('disable_comments_purge_caches');

		if (function_exists('wp_cache_clear_cache')) {
			wp_cache_clear_cache(); // WP Super Cache.
		}
		if (function_exists('w3tc_flush_posts')) {
			w3tc_flush_posts(); // W3 Total Cache — page cache only, not the whole stack.
		}
		if (function_exists('rocket_clean_domain')) {
			rocket_clean_domain(); // WP Rocket.
		}
		if (function_exists('sg_cachepress_purge_cache')) {
			sg_cachepress_purge_cache(); // SiteGround Optimizer.
		}

		// Action-based integrations. do_action() with no listener is a no-op, so
		// these are safe to fire unconditionally and stay correct for a plugin
		// that registers its listener late.
		do_action('litespeed_purge_all');        // LiteSpeed Cache.
		do_action('rt_nginx_helper_purge_all');  // Nginx Helper.
		do_action('breeze_clear_all_cache');     // Breeze.
		do_action('wphb_clear_page_cache');      // Hummingbird.
	}

	/**
	 * Sites whose rendered pages a settings save has just invalidated.
	 *
	 * A save from the network admin changes what every site in the network
	 * renders, so every site's page cache is stale — not just the one the
	 * request happened to run on. Outside a network context this is the current
	 * site alone, which purge_page_caches() handles by default.
	 *
	 * @since 2.8.0
	 * @param bool $is_network_ctx Whether the save came from a network admin screen.
	 * @return array Blog IDs to purge. Empty means "just the current site".
	 */
	private function get_purge_blog_ids($is_network_ctx) {
		if (!$is_network_ctx || !is_multisite() || !function_exists('get_sites')) {
			return array();
		}

		$blog_ids = get_sites(array('number' => 0, 'fields' => 'ids'));

		/**
		 * Filters the sites purged after a network-wide settings change.
		 *
		 * Defaults to every site in the network, which is correct but O(sites).
		 * A very large network whose cache layer already purges network-wide
		 * from a single call can narrow this list.
		 *
		 * @since 2.8.0
		 * @param array $blog_ids Blog IDs about to be purged.
		 */
		return (array) apply_filters('disable_comments_purge_blog_ids', $blog_ids);
	}

	public function get_disabled_sites($default = false) {
		$disabled_sites = ['all' => true];
		foreach (get_sites(['number' => 0, 'fields' => 'ids']) as $blog_id) {
			$disabled_sites["site_{$blog_id}"] = true;
		}
		if ($default) {
			return $disabled_sites;
		}

		$this->options['disabled_sites'] = isset($this->options['disabled_sites']) ? $this->options['disabled_sites'] : [];
		$this->options['disabled_sites'] = wp_parse_args($this->options['disabled_sites'], $disabled_sites);
		$disabled_sites = $this->options['disabled_sites'];
		unset($disabled_sites['all']);
		if (in_array(false, $disabled_sites)) {
			$this->options['disabled_sites']['all'] = false;
		} else {
			$this->options['disabled_sites']['all'] = true;
		}
		return $this->options['disabled_sites'];
	}

	// public function get_disabled_count(){
	// 	$disabled_sites = isset($this->options['disabled_sites']) ? $this->options['disabled_sites'] : [];
	// 	unset($disabled_sites['all']);
	// 	return array_sum($disabled_sites);
	// }

	/**
	 * Get an array of disabled post type.
	 */
	public function get_disabled_post_types() {
		$types = $this->options['disabled_post_types'];
		// Not all extra_post_types might be registered on this particular site.
		if ($this->networkactive && !empty($this->options['extra_post_types'])) {
			foreach ((array) $this->options['extra_post_types'] as $extra) {
				if (post_type_exists($extra)) {
					$types[] = $extra;
				}
			}
		}
		return $types;
	}

	/**
	 * Check whether comments have been disabled on a given post type.
	 */
	private function is_exclude_by_role() {
		if (!empty($this->options['enable_exclude_by_role']) && !empty($this->options['exclude_by_role'])) {
			if (is_user_logged_in()) {
				$user = wp_get_current_user();
				$roles = (array) $user->roles;
				$diff = array_intersect($this->options['exclude_by_role'], $roles);
				if (count($diff) || (in_array("administrator", $this->options['exclude_by_role']) && is_super_admin())) {
					return true;
				}
			} else if (in_array('logged-out-users', $this->options['exclude_by_role'])) {
				return true;
			}
		}
		return false;
	}
	/**
	 * Endpoint-level comment blocking state.
	 *
	 * These settings are independent of the post-type configuration: either can
	 * block comments over its transport while post types remain untouched. A
	 * consumer that only inspects post-type settings would report comments as
	 * fully enabled while REST comment creation returns 403.
	 *
	 * REST blocking has two sources — the dedicated toggle *and* global
	 * "disable everywhere" mode, whose branch in init_filters() installs the
	 * same rest_pre_dispatch/rest_endpoints/rest_comment_query filters. Either
	 * one results in a 403 for non-allowlisted comment requests, so both count.
	 * XML-RPC has only the dedicated toggle; global mode does not touch it.
	 *
	 * Reported role-independently, matching how the rest of the site's
	 * configuration is described.
	 *
	 * @since 2.8.0
	 * @return array {
	 *     @type bool $rest   Whether REST API comment endpoints are blocked.
	 *     @type bool $xmlrpc Whether XML-RPC comment methods are removed.
	 * }
	 */
	public function get_endpoint_blocking_state() {
		$rest_toggle = isset($this->options['remove_rest_API_comments']) && intval($this->options['remove_rest_API_comments']) === 1;
		return array(
			'rest'   => $rest_toggle || $this->is_remove_everywhere_configured(),
			'xmlrpc' => isset($this->options['remove_xmlrpc_comments']) && intval($this->options['remove_xmlrpc_comments']) === 1,
		);
	}

	/**
	 * Comment types that stay enabled even when comments are disabled.
	 *
	 * The allowlist (e.g. WordPress 6.9+ "note" comments) is preserved in
	 * comment queries, counted separately, and permitted through REST even in
	 * "disable everywhere" mode. Consumers describing the site's comment state
	 * must disclose it, otherwise "comments are disabled" reads as absolute
	 * when it is not.
	 *
	 * @since 2.8.0
	 * @return array List of allowed comment type slugs.
	 */
	public function get_allowed_comment_types_list() {
		$allowed = $this->get_allowed_comment_types();
		return is_array($allowed) ? array_values($allowed) : array();
	}

	/**
	 * Whether the site is *configured* to disable comments everywhere.
	 *
	 * Unlike is_remove_everywhere(), this reports the stored setting regardless
	 * of the current user's role exemption. Consumers that describe the site's
	 * configuration (such as the Abilities API integration) need the
	 * role-independent value; consumers deciding whether to filter a given
	 * request must keep using is_remove_everywhere().
	 *
	 * @since 2.8.0
	 * @return bool True when the global "disable everywhere" setting is on.
	 */
	/**
	 * Whether every comment-capable post type on this site is actually closed.
	 *
	 * "Every public post type is ticked" is not the same as "comments are off
	 * everywhere". get_all_post_types() — and so the settings screen — only
	 * lists `public` post types, but a non-public post type can support comments
	 * too. Tick every box and that type stays open; switch on the global setting
	 * and it is closed. Only the second is genuinely site-wide.
	 *
	 * Detected by looking for any post type that *still* supports comments and
	 * is not in the disabled list. The plugin removes comment support from the
	 * types it closes, so whatever still supports comments is precisely what it
	 * has not closed — including non-public and late-registered types the
	 * settings screen never shows.
	 *
	 * Not valid for the global setting: under "remove everywhere" the plugin
	 * closes types without necessarily having stripped support from ones
	 * registered after its filters ran. Callers must check
	 * is_remove_everywhere_configured() first.
	 *
	 * @since 2.8.0
	 * @return bool True when no comment-capable post type is left open.
	 */
	/**
	 * Disabled post types, limited to ones that actually exist right now.
	 *
	 * The stored selection outlives the post types in it. Disable comments on a
	 * CPT, then deactivate the plugin that registered it, and the slug stays in
	 * the option forever — so a status report would advertise a post type the
	 * site no longer has, while get_all_post_types() correctly omits it.
	 *
	 * FOR REPORTING ONLY. Never use this to decide which types to filter:
	 * get_disabled_post_types() is consulted while filters are being installed,
	 * before CPTs have registered on `init`, and dropping unregistered types
	 * there would leave comments open on every custom post type.
	 *
	 * @since 2.8.0
	 * @return array Disabled post type slugs that are currently registered.
	 */
	public function get_disabled_post_types_registered() {
		$types = $this->get_disabled_post_types();
		$types = is_array($types) ? $types : array();

		$existing = array();
		foreach ($types as $type) {
			if (post_type_exists($type)) {
				$existing[] = $type;
			}
		}

		return array_values($existing);
	}

	public function is_every_comment_capable_type_disabled() {
		$disabled = $this->get_disabled_post_types();
		$disabled = is_array($disabled) ? $disabled : array();

		foreach (get_post_types(array(), 'names') as $post_type) {
			if (post_type_supports($post_type, 'comments') && !in_array($post_type, $disabled, true)) {
				return false;
			}
		}

		return true;
	}

	public function is_remove_everywhere_configured() {
		return !empty($this->options['remove_everywhere']);
	}

	/**
	 * Role-exclusion state for the current request.
	 *
	 * Role exclusion is a per-user override: when the current user matches an
	 * excluded role, comments are left open for them even though the site is
	 * configured to disable them. Consumers that report configuration (such as
	 * the Abilities API integration) need to disclose this, otherwise the
	 * reported status is misleading for exempt users.
	 *
	 * @since 2.8.0
	 * @return array {
	 *     @type bool $enabled  Whether role-based exclusion is configured at all.
	 *     @type bool $excluded Whether the *current* user is exempt.
	 * }
	 */
	public function get_role_exclusion_state() {
		$enabled = !empty($this->options['enable_exclude_by_role']) && !empty($this->options['exclude_by_role']);
		return array(
			'enabled'  => (bool) $enabled,
			'excluded' => (bool) $this->is_exclude_by_role(),
		);
	}

	private function is_remove_everywhere() {
		if ($this->is_exclude_by_role()) {
			return false;
		}
		if (isset($this->options['remove_everywhere'])) {
			return $this->options['remove_everywhere'];
		}
		return false;
	}

	/**
	 * Check whether comments have been disabled on a given post type.
	 */
	private function is_post_type_disabled($type) {
		if ($this->is_exclude_by_role()) {
			return false;
		}
		return $type && in_array($type, $this->get_disabled_post_types());
	}

	/**
	 * Is the conditional-rules layer switched on and carrying anything?
	 *
	 * Cheap enough to call from a per-post filter: it only looks at options
	 * already in memory, and short-circuits every rule evaluation when the
	 * feature is off - which is the overwhelmingly common case.
	 *
	 * @return bool
	 */
	public function has_conditional_rules() {
		if (empty($this->options['enable_conditional_rules'])) {
			return false;
		}

		return !empty($this->options['conditional_rules']) || $this->get_auto_close_days() > 0;
	}

	/**
	 * Configured auto-close window in days, or 0 when disabled.
	 *
	 * @return int
	 */
	public function get_auto_close_days() {
		if (empty($this->options['enable_conditional_rules'])) {
			return 0;
		}

		$days = isset($this->options['auto_close_days']) ? (int) $this->options['auto_close_days'] : 0;

		return $days > 0 ? $days : 0;
	}

	/**
	 * The saved conditional rules, always as a list.
	 *
	 * @return array
	 */
	public function get_conditional_rules() {
		if (empty($this->options['enable_conditional_rules']) || empty($this->options['conditional_rules'])) {
			return array();
		}

		return array_values((array) $this->options['conditional_rules']);
	}

	/**
	 * Is any active rule an "enable" exception?
	 *
	 * Asked by anything that wants to state comments are off everywhere. One
	 * enable rule means some post is deliberately still open, so the claim
	 * cannot be made — whether or not the rule currently matches anything,
	 * because "no post accepts comments" and "an exception exists that may
	 * match tomorrow" should not both be reported as a site-wide shutdown.
	 *
	 * @since 2.9.0
	 * @return bool
	 */
	public function has_enable_conditional_rules() {
		foreach ($this->get_conditional_rules() as $rule) {
			if (isset($rule['action']) && 'enable' === $rule['action']) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Does one rule apply to this post?
	 *
	 * Existence is checked here rather than at save time: a taxonomy from a
	 * temporarily deactivated plugin should stop matching, not be silently
	 * dropped from the stored configuration.
	 *
	 * @param array $rule    A sanitized rule.
	 * @param int   $post_id Post being evaluated.
	 * @return bool
	 */
	private function conditional_rule_matches($rule, $post_id) {
		$type = isset($rule['type']) ? $rule['type'] : '';

		if ('taxonomy' === $type) {
			$taxonomy = isset($rule['taxonomy']) ? $rule['taxonomy'] : '';
			$terms    = isset($rule['terms']) ? array_filter((array) $rule['terms']) : array();

			if ('' === $taxonomy || empty($terms) || !taxonomy_exists($taxonomy)) {
				return false;
			}

			return (bool) has_term($terms, $taxonomy, $post_id);
		}

		if ('template' === $type) {
			$template = isset($rule['template']) ? $rule['template'] : '';

			if ('' === $template) {
				return false;
			}

			return $template === get_page_template_slug($post_id);
		}

		return false;
	}

	/**
	 * Has this post passed the auto-close window?
	 *
	 * @param int $post_id Post being evaluated.
	 * @return bool
	 */
	private function is_past_auto_close($post_id) {
		$days = $this->get_auto_close_days();

		if ($days < 1) {
			return false;
		}

		$published = get_post_time('U', true, $post_id);

		if (empty($published)) {
			return false;
		}

		return (time() - (int) $published) > ($days * DAY_IN_SECONDS);
	}

	/**
	 * Resolve the conditional rules for one post.
	 *
	 * An "enable" rule is an exception and wins outright, so "disable
	 * everywhere except Announcements" is expressible without enumerating
	 * every other term. An exception also survives the auto-close window -
	 * a post you deliberately kept open stays open.
	 *
	 * @param int $post_id Post being evaluated.
	 * @return string 'enable', 'disable', or '' when no rule applies.
	 */
	private function match_conditional_rules($post_id) {
		$result = '';

		foreach ($this->get_conditional_rules() as $rule) {
			if (!$this->conditional_rule_matches($rule, $post_id)) {
				continue;
			}

			if (isset($rule['action']) && 'enable' === $rule['action']) {
				return 'enable';
			}

			$result = 'disable';
		}

		if ('' === $result && $this->is_past_auto_close($post_id)) {
			$result = 'disable';
		}

		return $result;
	}

	/**
	 * Are comments disabled for this specific post?
	 *
	 * The base answer comes from the global toggle and the post-type list;
	 * conditional rules then override it. This is the single decision the
	 * per-post filters ask, so comments_open(), the comment count and the
	 * existing-comment list can never disagree with each other.
	 *
	 * @param int $post_id Post being evaluated.
	 * @return bool
	 */
	public function is_disabled_for_post($post_id) {
		if ($this->is_exclude_by_role()) {
			return false;
		}

		$disabled = $this->is_remove_everywhere() || $this->is_post_type_disabled(get_post_type($post_id));

		if (!$this->has_conditional_rules()) {
			return $disabled;
		}

		$match = $this->match_conditional_rules($post_id);

		if ('enable' === $match) {
			return false;
		}

		if ('disable' === $match) {
			return true;
		}

		return $disabled;
	}

	/**
	 * Normalise submitted conditional rules.
	 *
	 * Anything that does not describe a complete, actionable rule is dropped
	 * rather than stored half-formed - a rule with no terms would otherwise
	 * sit in the options looking active while matching nothing.
	 *
	 * @param mixed $rules Raw submitted rules.
	 * @return array Sanitized list.
	 */
	/**
	 * Rewrite taxonomy rules for travel between sites.
	 *
	 * Rules are stored with term IDs, which are rows in one site's terms table
	 * and mean nothing anywhere else. Writing them into an export file is worse
	 * than leaving the rules out: term 104 exists on the destination too, as
	 * something entirely unrelated, so the import silently closes comments on
	 * the wrong category while the one the operator meant stays open.
	 *
	 * Slugs are what travel. A term that cannot be resolved is dropped, and a
	 * taxonomy rule left with no terms is dropped whole, because such a rule
	 * matches nothing and would only sit in the UI looking meaningful.
	 *
	 * The export format is new in 2.9.0, so there are no term-ID files in the
	 * wild to stay compatible with.
	 *
	 * @param array $rules Stored rules, taxonomy terms as IDs.
	 * @return array Portable rules, taxonomy terms as slugs.
	 */
	private function rules_to_portable($rules) {
		$out = array();

		foreach ((array) $rules as $rule) {
			if (!isset($rule['type']) || 'taxonomy' !== $rule['type']) {
				$out[] = $rule;
				continue;
			}

			$taxonomy = isset($rule['taxonomy']) ? $rule['taxonomy'] : '';
			$slugs    = array();

			foreach ((array) (isset($rule['terms']) ? $rule['terms'] : array()) as $term_id) {
				$term = get_term((int) $term_id, $taxonomy);

				if ($term && !is_wp_error($term) && '' !== $term->slug) {
					$slugs[] = $term->slug;
				}
			}

			if (empty($slugs)) {
				continue;
			}

			$rule['terms'] = array_values(array_unique($slugs));
			$out[]         = $rule;
		}

		return $out;
	}

	/**
	 * Resolve a portable rule set against this site's terms.
	 *
	 * The inverse of rules_to_portable(). Runs before sanitize_conditional_rules(),
	 * which intval()s terms and would turn every slug into 0.
	 *
	 * @param array $rules Portable rules, taxonomy terms as slugs.
	 * @return array Rules for storage, taxonomy terms as IDs on this site.
	 */
	private function rules_from_portable($rules) {
		$out = array();

		foreach ((array) $rules as $rule) {
			if (!is_array($rule) || !isset($rule['type']) || 'taxonomy' !== $rule['type']) {
				$out[] = $rule;
				continue;
			}

			$taxonomy = isset($rule['taxonomy']) ? sanitize_key($rule['taxonomy']) : '';
			$ids      = array();

			foreach ((array) (isset($rule['terms']) ? $rule['terms'] : array()) as $slug) {
				if (!is_scalar($slug)) {
					continue;
				}

				$term = get_term_by('slug', sanitize_title((string) $slug), $taxonomy);

				if ($term && !is_wp_error($term)) {
					$ids[] = (int) $term->term_id;
				}
			}

			// A term the destination does not have cannot be matched, so the
			// rule is dropped rather than stored pointing at nothing.
			if (empty($ids)) {
				continue;
			}

			$rule['terms'] = array_values(array_unique($ids));
			$out[]         = $rule;
		}

		return $out;
	}

	private function sanitize_conditional_rules($rules) {
		$clean = array();

		foreach ((array) $rules as $rule) {
			if (!is_array($rule)) {
				continue;
			}

			$type = isset($rule['type']) ? sanitize_key($rule['type']) : '';

			if ('taxonomy' !== $type && 'template' !== $type) {
				continue;
			}

			$action = (isset($rule['action']) && 'enable' === $rule['action']) ? 'enable' : 'disable';
			$entry  = array(
				'type'   => $type,
				'action' => $action,
			);

			if ('taxonomy' === $type) {
				$taxonomy = isset($rule['taxonomy']) ? sanitize_key($rule['taxonomy']) : '';
				$terms    = isset($rule['terms']) ? array_values(array_filter(array_map('intval', (array) $rule['terms']))) : array();

				if ('' === $taxonomy || empty($terms)) {
					continue;
				}

				$entry['taxonomy'] = $taxonomy;
				$entry['terms']    = $terms;
			} else {
				$template = isset($rule['template']) ? sanitize_text_field($rule['template']) : '';

				if ('' === $template) {
					continue;
				}

				$entry['template'] = $template;
			}

			$clean[] = $entry;
		}

		return $clean;
	}

	/**
	 * Is blocked-attempt counting switched on?
	 *
	 * A very high traffic site may not want the shutdown write at all. The
	 * filter is the supported way off; there is no setting, because a setting
	 * to stop counting is a setting nobody would find.
	 *
	 * @return bool
	 */
	public function blocked_stats_enabled() {
		return (bool) apply_filters('disable_comments_count_blocked_attempts', true);
	}

	/**
	 * Record one blocked attempt.
	 *
	 * In-memory only - see flush_blocked_stats() for the single write.
	 *
	 * @param string $vector One of 'comment', 'trackback', 'rest'.
	 */
	public function count_blocked_attempt($vector) {
		if (!$this->blocked_stats_enabled()) {
			return;
		}

		if (!isset($this->blocked_pending[$vector])) {
			$this->blocked_pending[$vector] = 0;
		}

		$this->blocked_pending[$vector]++;
	}

	/**
	 * Blocked-attempt totals, normalised.
	 *
	 * @return array {
	 *     @type int   $since  Timestamp counting started from.
	 *     @type array $counts Vector => count.
	 * }
	 */
	public function get_blocked_stats() {
		$since = (int) get_option(self::BLOCKED_SINCE_OPTION, 0);

		if ($since < 1) {
			// Falling back to time() without storing it made the start date
			// re-compute on every read, so a site that had not blocked
			// anything yet reported "counting since" today, then tomorrow, then
			// the day after - which reads as a counter that keeps resetting
			// itself. The first read is as good a moment as any to fix it, and
			// it is the first moment anybody could have noticed.
			$since = $this->stamp_blocked_since();
		}

		$counts = array();
		foreach ($this->get_blocked_vectors() as $vector => $label) {
			$counts[$vector] = (int) get_option($this->get_blocked_vector_option($vector), 0);
		}

		return array(
			'since'  => $since,
			'counts' => $counts,
			'total'  => array_sum($counts),
		);
	}

	/**
	 * Fix the moment counting started, and keep it.
	 *
	 * Returns the timestamp that is now stored, which is not always the one
	 * this call proposed: a request that blocked something first has already
	 * stamped it, and its timestamp is the true one.
	 *
	 * @return int Unix timestamp counting is measured from.
	 */
	private function stamp_blocked_since() {
		$now = time();

		// add_option(), not update_option(): this must never move a start date
		// that is already recorded, and its failure is how a race with
		// flush_blocked_stats() is detected rather than silently won.
		if (add_option(self::BLOCKED_SINCE_OPTION, $now, '', 'yes')) {
			return $now;
		}

		$stored = (int) get_option(self::BLOCKED_SINCE_OPTION, 0);

		if ($stored > 0) {
			return $stored;
		}

		// A row exists holding something that is not a timestamp, so the read
		// above will keep falling through to "now" on every request - the same
		// drifting date, from corrupt data rather than a missing option. The
		// only value being overwritten here is one that never meant anything.
		update_option(self::BLOCKED_SINCE_OPTION, $now, 'yes');

		return $now;
	}

	/**
	 * The vectors we can count, and what to call them.
	 *
	 * Deliberately does not include XML-RPC. wp.newComment is removed from the
	 * server rather than rejected, so there is no dispatch to observe, and
	 * pingback.ping is refused inside core's own method without firing anything
	 * we can hang a counter on. A counter permanently reading zero would be
	 * read as "no attempts", which is a different and false claim - so the
	 * trackback vector is named for the wp-trackback.php flow it actually
	 * covers rather than claiming pingbacks too.
	 *
	 * @return array Vector => translated label.
	 */
	public function get_blocked_vectors() {
		return array(
			'comment'   => __('Comment form submissions', 'disable-comments'),
			'trackback' => __('Trackbacks', 'disable-comments'),
			'rest'      => __('REST API comment requests', 'disable-comments'),
		);
	}

	/**
	 * Write this request's blocked attempts, once.
	 *
	 * At most one write per request that actually blocked something - not per
	 * request. The option is autoloaded because it is a handful of integers and
	 * autoloading removes a read query from the path that has to write it.
	 */
	public function flush_blocked_stats() {
		global $wpdb;

		if (empty($this->blocked_pending)) {
			return;
		}

		$pending               = $this->blocked_pending;
		$this->blocked_pending = array();

		// Read-modify-write would drop increments whenever two blocked requests
		// shut down together - which is exactly what a spam burst looks like,
		// and exactly when this number is supposed to be meaningful. Each
		// vector is its own numeric option incremented in one statement, so the
		// database does the addition and concurrent writers cannot clobber each
		// other.
		foreach ($pending as $vector => $increment) {
			$option = $this->get_blocked_vector_option($vector);

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
			$updated = $wpdb->query(
				$wpdb->prepare(
					"UPDATE $wpdb->options SET option_value = option_value + %d WHERE option_name = %s",
					(int) $increment,
					$option
				)
			);

			if (!$updated) {
				// No row yet. Two requests racing to be the first would both
				// land here and only one add_option() can win the unique
				// option_name index, silently losing the other's increment - so
				// the loser retries the UPDATE against the row the winner just
				// created rather than dropping its count.
				if (!add_option($option, (int) $increment, '', 'yes')) {
					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
					$wpdb->query(
						$wpdb->prepare(
							"UPDATE $wpdb->options SET option_value = option_value + %d WHERE option_name = %s",
							(int) $increment,
							$option
						)
					);
				}
			}

			wp_cache_delete($option, 'options');
		}

		// Alloptions caches the whole set, so the individual deletes above are
		// not enough on a site using it.
		wp_cache_delete('alloptions', 'options');

		if (!get_option(self::BLOCKED_SINCE_OPTION)) {
			add_option(self::BLOCKED_SINCE_OPTION, time(), '', 'yes');
		}
	}

	/**
	 * Option name holding one vector's running total.
	 *
	 * @param string $vector Vector key.
	 * @return string
	 */
	private function get_blocked_vector_option($vector) {
		return self::BLOCKED_STATS_OPTION . '_' . $vector;
	}

	/**
	 * Start counting again from now.
	 */
	public function reset_blocked_stats() {
		$this->blocked_pending = array();

		foreach (array_keys($this->get_blocked_vectors()) as $vector) {
			update_option($this->get_blocked_vector_option($vector), 0, 'yes');
		}

		update_option(self::BLOCKED_SINCE_OPTION, time(), 'yes');
	}

	/**
	 * AJAX: reset the counters.
	 *
	 * Agencies hand sites over; the previous owner's numbers are noise.
	 */
	public function reset_blocked_stats_ajax() {
		$nonce = (isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '');

		if (!wp_verify_nonce($nonce, 'disable_comments_save_settings')) {
			wp_send_json_error(array('message' => __('Invalid request.', 'disable-comments')), 403);
		}

		if (!current_user_can('manage_options')) {
			wp_send_json_error(array('message' => __('Insufficient permissions.', 'disable-comments')), 403);
		}

		$this->reset_blocked_stats();

		wp_send_json_success($this->get_blocked_stats());
	}

	/**
	 * A comment submission core rejected because we closed comments.
	 *
	 * Fired from core's `comment_closed`, which runs only on a real POST to
	 * wp-comments-post.php - never on render.
	 */
	public function record_blocked_comment($post_id = 0) {
		// core fires this whenever a submission is refused, including posts
		// closed by their own comment_status or by another plugin. Crediting
		// ourselves for those would make the number meaningless.
		if (!$this->closed_by_this_plugin($post_id, 'comment')) {
			return;
		}

		$this->count_blocked_attempt('comment');
	}

	/**
	 * Is this plugin the reason comments are closed on this post?
	 *
	 * @param int $post_id Post being submitted to.
	 * @return bool
	 */
	private function closed_by_this_plugin($post_id, $vector = 'comment') {
		$post_id = (int) $post_id;

		if ($post_id < 1) {
			return false;
		}

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

		// is_disabled_for_post(), not the base settings on their own. A
		// taxonomy, template or age rule can be the only reason a post is
		// closed, and asking only whether the global toggle or the post-type
		// list covers it meant every attempt blocked purely by a rule went
		// uncounted — invisible in the totals on exactly the sites configured
		// with rules and nothing else.
		if (!$this->is_disabled_for_post($post_id)) {
			return false;
		}

		// The post is covered, but this particular post may already have
		// been closed on its own - by its own status, by core's
		// close_comments_for_old_posts, or by another plugin. Our filters would
		// have closed it anyway, so we cannot tell from the result alone; ask
		// the stored status, which is what we do not touch.
		//
		// The status has to match the attempt: a post with comment_status
		// closed and ping_status open rejects a comment on its own, and the
		// open ping status says nothing about that.
		$post = get_post($post_id);

		if (!$post) {
			return false;
		}

		$status = ('trackback' === $vector) ? $post->ping_status : $post->comment_status;

		return 'open' === $status;
	}

	/**
	 * A trackback aimed at a post whose pings we closed.
	 *
	 * `pre_trackback_post` fires just before core's own pings_open() check, so
	 * the same question is asked here to avoid counting ones that go through.
	 *
	 * @param int $post_id Target post.
	 */
	public function record_blocked_trackback($post_id) {
		if (pings_open($post_id)) {
			return;
		}

		// Same reasoning as record_blocked_comment(): pings closed natively, or
		// by somebody else, are not ours to claim.
		if (!$this->closed_by_this_plugin($post_id, 'trackback')) {
			return;
		}

		$this->count_blocked_attempt('trackback');
	}

	public function init_filters() {
		if ($this->blocked_stats_enabled()) {
			// Core fires these only on real submissions, never on render.
			// Priority/args: core passes the post id, which is what decides
			// whether this plugin is the reason it was refused.
			add_action('comment_closed', array($this, 'record_blocked_comment'), 10, 1);
			add_action('pre_trackback_post', array($this, 'record_blocked_trackback'));
			add_action('shutdown', array($this, 'flush_blocked_stats'));
		}

		// Inert unless the request carries a token this site issued seconds
		// ago, so an ordinary visitor never reaches any of it.
		if (!is_admin()) {
			$this->maybe_arm_scan_probe();
		}

		// These need to happen now.
		if ($this->is_remove_everywhere()) {
			add_action('widgets_init', array($this, 'disable_rc_widget'));
			add_filter('wp_headers', array($this, 'filter_wp_headers'));
			add_action('template_redirect', array($this, 'filter_query'), 9);   // before redirect_canonical.

			// Admin bar filtering has to happen here since WP 3.6.
			add_action('template_redirect', array($this, 'filter_admin_bar'));
			add_action('admin_init', array($this, 'filter_admin_bar'));

			// Disable Comments REST API Endpoint (but allow notes)
			add_filter('rest_endpoints', array($this, 'filter_rest_endpoints'));
			add_filter('rest_pre_dispatch', array($this, 'filter_rest_comment_dispatch'), 10, 3);
			add_filter('rest_comment_query', array($this, 'filter_rest_comment_query'), 10, 2);
		}

		// remove create comment via xmlrpc
		if (isset($this->options['remove_xmlrpc_comments']) && intval($this->options['remove_xmlrpc_comments']) === 1) {
			add_filter('xmlrpc_methods', array($this, 'disable_xmlrc_comments'));
		}
		// rest API Comment Block (but allow notes)
		if (isset($this->options['remove_rest_API_comments']) && intval($this->options['remove_rest_API_comments']) === 1) {
			add_filter('rest_endpoints', array($this, 'filter_rest_endpoints'));
			add_filter('rest_pre_insert_comment', array($this, 'disable_rest_API_comments'), 10, 2);
			add_filter('rest_pre_dispatch', array($this, 'filter_rest_comment_dispatch'), 10, 3);
			add_filter('rest_comment_query', array($this, 'filter_rest_comment_query'), 10, 2);
		}

		// Comment types closed by the blocklist. Registered on the strength of
		// the stored option alone: whether the caller is exempt is decided
		// inside the callbacks, which run long after the current user has been
		// resolved. Note the deliberate absence of a comments_open() filter -
		// see reject_blocked_comment_type().
		if ($this->has_blocked_comment_types()) {
			add_filter('preprocess_comment', array($this, 'reject_blocked_comment_type'), 20);
			add_filter('rest_pre_insert_comment', array($this, 'reject_blocked_comment_type_rest'), 20, 2);
		}

		// These can happen later.
		add_action('wp_loaded', array($this, 'init_wploaded_filters'));
		// Disable "Latest comments" block in Gutenberg.
		add_action('enqueue_block_editor_assets', array($this, 'filter_gutenberg_blocks'));
		// settings page assets
		add_action('admin_enqueue_scripts', array($this, 'settings_page_assets'));

		if (!$this->networkactive || $this->options['sitewide_settings']) {
			add_filter('comment_status_links', function ($status_links) {
				$status_links['disable_comments'] = sprintf("<a href='" . $this->settings_page_url() . "'>%s</a>", __("Disable Comments", 'disable-comments'));
				return $status_links;
			});
		}
	}

	public function init_wploaded_filters() {
		$disabled_post_types = $this->get_disabled_post_types();
		if (!empty($disabled_post_types) && !$this->is_exclude_by_role()) {
			foreach ($disabled_post_types as $type) {
				// we need to know what native support was for later.
				if (post_type_supports($type, 'comments')) {
					$this->modified_types[] = $type;
					// Keep comments support if show_existing_comments is enabled
					// or if there are allowed comment types that need to be displayed
					if (empty($this->options['show_existing_comments']) && !$this->has_allowed_comment_types()) {
						remove_post_type_support($type, 'comments');
					}
					remove_post_type_support($type, 'trackbacks');
				}
			}
		} elseif (is_admin() && !$this->is_configured()) {
			/**
			 * It is possible that $disabled_post_types is empty if other
			 * plugins have disabled comments. Hence we also check for
			 * remove_everywhere. If you still get a warning you probably
			 * shouldn't be using this plugin.
			 */
			add_action('all_admin_notices', array($this, 'setup_notice'));
		}

		// Conditional rules are evaluated per post, so they need these filters
		// even when no post type is ticked and the global toggle is off -
		// otherwise a site configured purely with rules silently does nothing.
		if ($this->is_remove_everywhere() || $this->has_conditional_rules() || (!empty($disabled_post_types) && !$this->is_exclude_by_role())) {
			add_filter('comments_array', array($this, 'filter_existing_comments'), 20, 2);
			add_filter('comments_open', array($this, 'filter_comment_status'), 20, 2);
			add_filter('pings_open', array($this, 'filter_comment_status'), 20, 2);
			add_filter('get_comments_number', array($this, 'filter_comments_number'), 20, 2);
		}

		// A store that closed reviews should stop displaying the review UI, or
		// customers are invited to submit something the blocklist will refuse.
		// WooCommerce's own "enable reviews" switch is not usable for this: it
		// strips comments support from the product post type, and WooCommerce
		// then closes comments_open() for every product - taking the other
		// comment types on products down with it, which is precisely what this
		// setting exists to keep working.
		//
		// The reviews already in the database are left alone. Untick the
		// setting and they are all still there.
		if (
			$this->is_woocommerce_active()
			&& $this->is_comment_type_blocked($this->get_review_comment_type())
			&& !$this->is_exempt_from_blocklist()
		) {
			// Before woocommerce_sort_product_tabs(), which runs at 99.
			add_filter('woocommerce_product_tabs', array($this, 'remove_woocommerce_reviews_tab'), 98);
			// The star rating in the product summary links to #reviews, which
			// the line above just removed from the page.
			remove_action('woocommerce_single_product_summary', 'woocommerce_template_single_rating', 10);
		}

		// Filters for the admin only.
		if (is_admin()) {
			add_action('all_admin_notices', array($this, 'admin_notice'));
			if ($this->networkactive && is_network_admin()) {
				add_action('network_admin_menu', array($this, 'settings_menu'));
				add_action('network_admin_menu', array($this, 'tools_menu'));
				add_filter('network_admin_plugin_action_links', array($this, 'plugin_actions_links'), 10, 2);
			} elseif (!$this->networkactive || $this->options['sitewide_settings']) {
				add_action('admin_menu', array($this, 'settings_menu'));
				add_action('admin_menu', array($this, 'tools_menu'));
				add_filter('plugin_action_links', array($this, 'plugin_actions_links'), 10, 2);
				if (is_multisite()) {    // We're on a multisite setup, but the plugin isn't network activated.
					register_deactivation_hook(__FILE__, array($this, 'single_site_deactivate'));
				}
			}
			add_action('admin_notices', array($this, 'discussion_notice'));
			// Gated on our own screens inside should_show_review_prompt(), not
			// by the hook - admin_notices fires everywhere. Network admin pages
			// fire network_admin_notices instead, so allowlisting the -network
			// screen ids achieves nothing without this second registration.
			add_action('admin_notices', array($this, 'review_prompt'));
			add_action('network_admin_notices', array($this, 'review_prompt'));
			add_filter('plugin_row_meta', array($this, 'set_plugin_meta'), 10, 2);

			if ($this->is_remove_everywhere()) {
				add_action('admin_menu', array($this, 'filter_admin_menu'), 9999);  // do this as late as possible.
				add_action('admin_print_styles-index.php', array($this, 'admin_css'));
				add_action('admin_print_styles-profile.php', array($this, 'admin_css'));
				add_action('wp_dashboard_setup', array($this, 'filter_dashboard'));
				add_filter('pre_option_default_pingback_flag', '__return_zero');
			}
		}
		// Filters for front end only.
		else {
			add_action('template_redirect', array($this, 'check_comment_template'));

			if ($this->is_remove_everywhere()) {
				add_filter('feed_links_show_comments_feed', '__return_false');
			}
		}
	}

	// public function get_option( $key, $default = false ){
	// 	return $this->networkactive ? get_site_option( $key, $default ) : get_option( $key, $default );
	// }
	// public function update_option( $option, $value ){
	// 	return $this->networkactive ? update_site_option( $option, $value ) : update_option( $option, $value );
	// }
	// public function delete_option( $option ){
	// 	return $this->networkactive ? delete_site_option( $option ) : delete_option( $option );
	// }

	/**
	 * Replace the theme's comment template with a blank one.
	 * To prevent this, define DISABLE_COMMENTS_REMOVE_COMMENTS_TEMPLATE
	 * and set it to True
	 */
	public function check_comment_template() {
		// is_disabled_for_post(), not the global settings: an "enable" rule has
		// to reach here too. Otherwise comments_open() reports open, the theme
		// asks for the comments template, and we hand it the empty one - so the
		// advertised exception produces a post with no comment form on it.
		if (is_singular() && $this->is_disabled_for_post(get_queried_object_id())) {
			if (!defined('DISABLE_COMMENTS_REMOVE_COMMENTS_TEMPLATE') || DISABLE_COMMENTS_REMOVE_COMMENTS_TEMPLATE == true) {
				// Kill the comments template unless:
				// - show_existing_comments is enabled, OR
				// - there are allowed comment types that need to be displayed
				if (empty($this->options['show_existing_comments']) && !$this->has_allowed_comment_types()) {
					add_filter('comments_template', array($this, 'dummy_comments_template'), 20);
				}
			}
			// Remove comment-reply script for themes that include it indiscriminately.
			wp_deregister_script('comment-reply');
			// feed_links_extra inserts a comments RSS link.
			remove_action('wp_head', 'feed_links_extra', 3);
		}
	}

	public function dummy_comments_template() {
		return dirname(__FILE__) . '/views/comments.php';
	}

	public function is_xmlrpc_rest() {
		// remove create comment via xmlrpc
		if (isset($this->options['remove_xmlrpc_comments']) && intval($this->options['remove_xmlrpc_comments']) === 1) {
			return true;
		}
		// rest API Comment Block
		if (isset($this->options['remove_rest_API_comments']) && intval($this->options['remove_rest_API_comments']) === 1) {
			return true;
		}
		return false;
	}

	/**
	 * Remove the X-Pingback HTTP header
	 */
	public function filter_wp_headers($headers) {
		unset($headers['X-Pingback']);
		return $headers;
	}

	/**
	 * remove method wp.newComment
	 */
	public function disable_xmlrc_comments($methods) {
		unset($methods['wp.newComment']);
		return $methods;
	}

	public function disable_rest_API_comments($prepared_comment, $request) {
		// Allow comment types in the allowlist (e.g., WordPress 6.9+ block notes)
		if ($this->is_allowed_comment_type_request($request)) {
			return $prepared_comment;
		}

		// A conditional exception keeps comments open on this post, so a REST
		// submission to it must go through as well - blocking it here would
		// make comments_open() a lie for every API client.
		//
		// Only when the block comes from the global toggle or the post-type
		// list, though. "Disable comments via REST API" is an explicit
		// site-wide decision about the API itself, not about any one post, so a
		// per-post exception must not quietly switch it back on.
		if ($this->rest_blocking_is_conditional()) {
			$post_id = $this->get_request_post_id($request, $prepared_comment);
			if ($post_id && !$this->is_disabled_for_post($post_id)) {
				return $prepared_comment;
			}
		}


		$this->count_blocked_attempt('rest');

		return;
	}

	/**
	 * Get the list of allowed comment types from settings
	 *
	 * @return array Array of allowed comment types
	 */
	private function get_allowed_comment_types() {
		if (!isset($this->options['allowed_comment_types']) || !is_array($this->options['allowed_comment_types'])) {
			return array(); // Default: all special comment types disabled
		}

		// A type ticked on both lists is a contradiction: "keep this working
		// where comments are off" and "close this where comments are on". The
		// blocklist wins - this is a plugin for switching comments off, so the
		// restrictive reading is the safe one. Resolving it in the single
		// reader means the delete tool, the REST allowance and the status
		// report cannot end up disagreeing with each other.
		$blocked = $this->get_blocked_comment_types();
		if (empty($blocked)) {
			return $this->options['allowed_comment_types'];
		}

		return array_values(array_diff($this->options['allowed_comment_types'], $blocked));
	}

	/**
	 * Check if any comment types are enabled in the allowlist
	 *
	 * @return bool True if there are allowed comment types, false otherwise
	 */
	private function has_allowed_comment_types() {
		$allowed_types = $this->get_allowed_comment_types();
		return !empty($allowed_types);
	}

	/**
	 * Check if a specific comment type is allowed (enabled in the allowlist)
	 *
	 * @param string $comment_type The comment type to check
	 * @return bool True if the comment type is allowed, false otherwise
	 */
	private function is_comment_type_allowed($comment_type) {
		$allowed_types = $this->get_allowed_comment_types();
		return in_array($comment_type, $allowed_types, true);
	}

	/**
	 * Get the list of blocked comment types from settings
	 *
	 * The blocklist is the mirror image of the allowlist. The allowlist keeps
	 * named types working where comments are off; the blocklist closes named
	 * types where comments are on. Both exist because comments_open() is one
	 * boolean per post and cannot split a comment type from the post type
	 * carrying it - a WooCommerce store that wants product reviews closed while
	 * other comments on products stay open has no way to say so otherwise.
	 *
	 * @since 2.9.1
	 * @return array Array of blocked comment types
	 */
	private function get_blocked_comment_types() {
		if (!isset($this->options['blocked_comment_types']) || !is_array($this->options['blocked_comment_types'])) {
			return array(); // Default: nothing is closed by type
		}
		return $this->options['blocked_comment_types'];
	}

	/**
	 * Check if any comment types are closed by the blocklist
	 *
	 * @since 2.9.1
	 * @return bool True if there are blocked comment types, false otherwise
	 */
	private function has_blocked_comment_types() {
		$blocked_types = $this->get_blocked_comment_types();
		return !empty($blocked_types);
	}

	/**
	 * Check if a specific comment type is closed by the blocklist
	 *
	 * @since 2.9.1
	 * @param string $comment_type The comment type to check
	 * @return bool True if the comment type is blocked, false otherwise
	 */
	private function is_comment_type_blocked($comment_type) {
		$blocked_types = $this->get_blocked_comment_types();
		return in_array($comment_type, $blocked_types, true);
	}

	/**
	 * Comment types that are closed even where comments are open.
	 *
	 * The counterpart to get_allowed_comment_types_list(). Consumers describing
	 * the site's comment state must disclose it: "comments are open on
	 * products" reads as absolute when it is not, and an agent that submits a
	 * review on the strength of it gets a 403 it could have predicted.
	 *
	 * @since 2.9.1
	 * @return array List of blocked comment type slugs.
	 */
	public function get_blocked_comment_types_list() {
		$blocked = $this->get_blocked_comment_types();
		return is_array($blocked) ? array_values($blocked) : array();
	}

	/**
	 * The effective type of a comment being submitted.
	 *
	 * WordPress has written '' for the default type historically and 'comment'
	 * since 5.5, and both still arrive. Normalising here means the blocklist
	 * compares like with like no matter which one a caller sent.
	 *
	 * @since 2.9.1
	 * @param mixed $comment_type Raw comment type from a submission payload.
	 * @return string
	 */
	private function normalize_comment_type($comment_type) {
		$comment_type = is_string($comment_type) ? trim($comment_type) : '';
		return ('' === $comment_type) ? 'comment' : $comment_type;
	}

	/**
	 * Is this caller exempt from the blocklist?
	 *
	 * Two exemptions, both deliberate:
	 *
	 * - The plugin's own role exclusion. Someone the site has exempted from
	 *   comment disabling is exempt from this too, or the setting would mean
	 *   something different depending on which screen it is read from.
	 * - Anyone who can moderate comments. Closing a comment type is a statement
	 *   about what the public may submit, not about what a moderator may add by
	 *   hand: the rest of the plugin leaves back-end comment creation alone,
	 *   and wp_die()-ing the Reply button on wp-admin's comment screen would be
	 *   a bug, not a feature. Such a user can untick the setting anyway.
	 *
	 * @since 2.9.1
	 * @return bool
	 */
	private function is_exempt_from_blocklist() {
		if ($this->is_exclude_by_role()) {
			return true;
		}

		return function_exists('current_user_can') && current_user_can('moderate_comments');
	}

	/**
	 * Refuse a comment whose type the site has closed.
	 *
	 * Runs on preprocess_comment at priority 20, which matters: WooCommerce
	 * rewrites every default-type front-end comment on a product into a
	 * 'review' from priority 1, so anything earlier would judge the type before
	 * WooCommerce had finished deciding it.
	 *
	 * comments_open() is deliberately left alone. Closing it for the product
	 * would close every other comment type on that product too, which is the
	 * exact thing this setting exists to avoid.
	 *
	 * @since 2.9.1
	 * @param array $commentdata Comment data on its way to wp_insert_comment().
	 * @return array Unchanged when the type is not blocked; execution ends otherwise.
	 */
	public function reject_blocked_comment_type($commentdata) {
		if (!is_array($commentdata) || !$this->has_blocked_comment_types()) {
			return $commentdata;
		}

		$comment_type = $this->normalize_comment_type(isset($commentdata['comment_type']) ? $commentdata['comment_type'] : '');

		if (!$this->is_comment_type_blocked($comment_type) || $this->is_exempt_from_blocklist()) {
			return $commentdata;
		}

		$this->count_blocked_attempt('comment');

		wp_die(
			esc_html__('Sorry, this kind of comment is no longer being accepted on this item.', 'disable-comments'),
			esc_html__('Comment Type Closed', 'disable-comments'),
			array('response' => 403)
		);
	}

	/**
	 * Refuse a REST-created comment whose type the site has closed.
	 *
	 * The REST controller calls wp_insert_comment() directly, so it never
	 * reaches preprocess_comment and needs its own guard. Note that WooCommerce
	 * does not rewrite types on this path either: a review arrives over REST
	 * only when the caller asked for one by name.
	 *
	 * @since 2.9.1
	 * @param array|mixed     $prepared_comment Comment data prepared for the database.
	 * @param WP_REST_Request $request          The request.
	 * @return array|WP_Error|mixed
	 */
	public function reject_blocked_comment_type_rest($prepared_comment, $request) {
		// An earlier filter (disable_rest_API_comments) may already have
		// refused this request and returned nothing. Passing that through
		// unchanged keeps its refusal intact instead of masking it.
		if (!is_array($prepared_comment) || !$this->has_blocked_comment_types()) {
			return $prepared_comment;
		}

		$comment_type = $this->normalize_comment_type(isset($prepared_comment['comment_type']) ? $prepared_comment['comment_type'] : '');

		if (!$this->is_comment_type_blocked($comment_type) || $this->is_exempt_from_blocklist()) {
			return $prepared_comment;
		}

		$this->count_blocked_attempt('rest');

		return new WP_Error(
			'disable_comments_type_closed',
			__('Sorry, this kind of comment is no longer being accepted on this item.', 'disable-comments'),
			array('status' => 403)
		);
	}

	/**
	 * Drop WooCommerce's Reviews tab from the product page.
	 *
	 * Registered only while the review type is blocked. The tab renders both
	 * the review list and the review form, so removing it is what stops a store
	 * inviting a submission it is about to refuse.
	 *
	 * @since 2.9.1
	 * @param array $tabs Product tabs.
	 * @return array
	 */
	public function remove_woocommerce_reviews_tab($tabs) {
		if (is_array($tabs)) {
			unset($tabs['reviews']);
		}

		return $tabs;
	}

	/**
	 * Get available comment type options for the "Enable Certain Comment Types" UI
	 *
	 * This function returns a list of known special comment types that users can enable,
	 * regardless of whether any comments of those types currently exist in the database.
	 *
	 * IMPORTANT: WordPress does not provide a formal API for registering or retrieving
	 * comment types (unlike post types with get_post_types()). Comment types are simply
	 * arbitrary string values stored in the wp_comments table. Therefore, we maintain
	 * a curated list of known special comment types that plugins commonly use.
	 *
	 * This function returns only predefined known types plus any types added via the
	 * 'disable_comments_known_comment_types' filter hook.
	 *
	 * @return array Associative array of comment_type => label
	 */
	/**
	 * Is WooCommerce running on this site?
	 *
	 * Every WooCommerce touch-point in this plugin goes through here. A store
	 * plugin being absent, or renaming its API, must never turn "settings
	 * saved" into a fatal - the same rule the cache integrations follow.
	 *
	 * @return bool
	 */
	public function is_woocommerce_active() {
		return class_exists('WooCommerce');
	}

	/**
	 * The post type WooCommerce stores products under.
	 *
	 * @return string
	 */
	public function get_product_post_type() {
		return 'product';
	}

	/**
	 * The comment type WooCommerce stores reviews under.
	 *
	 * @return string
	 */
	public function get_review_comment_type() {
		return 'review';
	}

	/**
	 * How product reviews stand right now.
	 *
	 * Reviews are comments on the product post type, so they are governed by
	 * the same settings as everything else - but a store owner does not think
	 * of them that way, and looking for "reviews" in a list of post types
	 * finds nothing. This is the answer in their language.
	 *
	 * Deliberately role-independent: the Abilities schema promises that every
	 * field except excluded_for_current_user describes the site's
	 * configuration, and this ability is only callable by settings-capable
	 * users, who are exactly the ones a role exemption usually covers. Asking
	 * "are reviews off?" must not answer "not for you".
	 *
	 * @return array {
	 *     @type bool   $woocommerce_active  True when WooCommerce is running.
	 *     @type bool   $reviews_disabled    True when customers cannot leave a review.
	 *     @type bool   $reviews_allowlisted True when existing reviews stay readable.
	 *     @type bool   $reviews_blocklisted True when the review type is closed on its own,
	 *                                       leaving other comments on products open.
	 *     @type string $disabled_by         'woocommerce', 'disable-comments', or ''.
	 * }
	 */
	public function get_product_review_status() {
		if (!$this->is_woocommerce_active()) {
			return array(
				'woocommerce_active'  => false,
				'reviews_disabled'    => false,
				'reviews_allowlisted' => false,
				'reviews_blocklisted' => false,
				'disabled_by'         => '',
			);
		}

		// WooCommerce has its own switch. With it off there are no reviews to
		// leave no matter what this plugin says, and reporting them "enabled"
		// would be simply false.
		$wc_enabled = ('yes' === get_option('woocommerce_enable_reviews', 'yes'));

		// The blocklist closes the review type by name, without closing the
		// product. It is the only one of these three that leaves other comments
		// on products working, but for the question this function answers -
		// "can a customer leave a review?" - it counts exactly like the others.
		$blocklisted = $this->is_comment_type_blocked($this->get_review_comment_type());

		$ours = $blocklisted
			|| $this->is_remove_everywhere_configured()
			|| in_array($this->get_product_post_type(), (array) $this->get_disabled_post_types(), true);

		// The allowlist keeps allowlisted types readable and permits them
		// through REST. It does NOT reopen the comment form: comments_open()
		// is one boolean per post, so a product with comments disabled has no
		// review form regardless of the allowlist. Reporting these separately
		// is the only honest thing to do.
		$allowlisted = in_array($this->get_review_comment_type(), (array) $this->get_allowed_comment_types_list(), true);

		if (!$wc_enabled) {
			$disabled_by = 'woocommerce';
		} elseif ($ours) {
			$disabled_by = 'disable-comments';
		} else {
			$disabled_by = '';
		}

		return array(
			'woocommerce_active'  => true,
			'reviews_disabled'    => (bool) (!$wc_enabled || $ours),
			'reviews_allowlisted' => (bool) $allowlisted,
			'reviews_blocklisted' => (bool) $blocklisted,
			'disabled_by'         => $disabled_by,
		);
	}

	public function get_available_comment_type_options() {
		// Predefined known special comment types with descriptive labels
		// These are shown even if no comments of these types exist yet in the database
		//
		// Note: WordPress does not have a formal comment type registration API,
		// so this list is maintained manually based on common plugin usage.
		$known_types = array(
			'note' => __('Notes - WordPress 6.9+ (note)', 'disable-comments'),
		);

		// Offer reviews before any exist. Discovery is driven off rows already
		// in the comments table, so a new store could not choose to keep
		// reviews working until somebody had already left one.
		if ($this->is_woocommerce_active()) {
			$known_types[$this->get_review_comment_type()] = __('Product reviews - WooCommerce (review)', 'disable-comments');
		}

		/**
		 * Filter the list of known comment types shown in the "Enable Certain Comment Types" UI
		 *
		 * Plugins can add their own comment types to this list so users can enable them
		 * even before any comments of those types exist in the database.
		 *
		 * Example:
		 *   add_filter( 'disable_comments_known_comment_types', function( $types ) {
		 *       $types['my_custom_type'] = __( 'My Custom Comment Type', 'my-plugin' );
		 *       return $types;
		 *   } );
		 *
		 * @param array $known_types Associative array of comment_type => label
		 */
		return apply_filters('disable_comments_known_comment_types', $known_types);
	}

	/**
	 * Check if a REST API request is for an allowed comment type
	 *
	 * @param WP_REST_Request $request The REST API request object
	 * @return bool True if the request is for an allowed comment type, false otherwise
	 */
	private function is_allowed_comment_type_request($request = null) {
		$comment_type = null;

		// Check if we have a request object
		if (!$request) {
			// Check global $_REQUEST for type parameter
			if (isset($_REQUEST['type'])) {
				$comment_type = sanitize_text_field(wp_unslash($_REQUEST['type']));
			}
			// Check if we're in a REST API context
			elseif (defined('REST_REQUEST') && REST_REQUEST) {
				global $wp;
				if (isset($wp->query_vars['type'])) {
					$comment_type = sanitize_text_field($wp->query_vars['type']);
				}
			}
		} else {
			// Check the request object for type parameter
			$type = $request->get_param('type');
			if ($type) {
				$comment_type = $type;
			}

			// Check the request body for type parameter (for POST requests)
			if (!$comment_type) {
				$body = $request->get_body_params();
				if (isset($body['type'])) {
					$comment_type = $body['type'];
				}
			}

			// Check JSON body for type parameter
			if (!$comment_type) {
				$json = $request->get_json_params();
				if (isset($json['type'])) {
					$comment_type = $json['type'];
				}
			}

			// For UPDATE requests (PUT/PATCH), check if the existing comment is an allowed type
			// WordPress doesn't send the type parameter when updating, only the ID and content
			if (!$comment_type) {
				$comment_id = $request->get_param('id');
				if ($comment_id) {
					$comment = get_comment($comment_id);
					if ($comment && isset($comment->comment_type)) {
						$comment_type = $comment->comment_type;
					}
				}
			}

			// For DELETE requests, extract comment ID from the route path
			// The comment ID is only in the URL (e.g., /wp/v2/comments/123), not in request params
			if (!$comment_type && $request->is_method('DELETE')) {
				$route_parts = explode('/', $request->get_route());
				$comment_id = end($route_parts);

				// Ensure we have a numeric comment ID
				if (is_numeric($comment_id)) {
					$comment = get_comment((int) $comment_id);
					if ($comment && isset($comment->comment_type)) {
						$comment_type = $comment->comment_type;
					}
				}
			}
		}

		// Check if the comment type is in the allowlist
		if ($comment_type && $this->is_comment_type_allowed($comment_type)) {
			return true;
		}

		return false;
	}

	/**
	 * Issue a 403 for all comment feed requests.
	 */
	public function filter_query() {
		if (is_comment_feed()) {
			wp_die(esc_html__('Comments are closed.', 'disable-comments'), '', array('response' => 403));
		}
	}

	/**
	 * Remove comment links from the admin bar.
	 */
	public function filter_admin_bar() {
		if (is_admin_bar_showing()) {
			// Remove comments links from admin bar.
			remove_action('admin_bar_menu', 'wp_admin_bar_comments_menu', 60);
			if (is_multisite()) {
				add_action('admin_bar_menu', array($this, 'remove_network_comment_links'), 500);
			}
		}
	}

	/**
	 * Remove the comments endpoint for the REST API
	 * But allow WordPress 6.9+ block notes (type=note) to work
	 */
	public function filter_rest_endpoints($endpoints) {
		// Don't remove endpoints entirely - instead we'll use permission callbacks
		// and other filters to block regular comments while allowing notes

		// We still need to add a filter to block non-note requests
		// This is handled by rest_pre_dispatch filter added in init_filters

		return $endpoints;
	}

	/**
	 * Filter REST API comment requests to block comments except allowed types
	 *
	 * @param mixed $result Response to replace the requested version with
	 * @param WP_REST_Server $server Server instance
	 * @param WP_REST_Request $request Request used to generate the response
	 * @return mixed
	 */
	/**
	 * May a per-post rule reopen REST comment access?
	 *
	 * Only when rules are active AND the block is coming from the global
	 * toggle or the post-type list. If the administrator explicitly ticked
	 * "Disable comments via REST API", that is a statement about the API
	 * rather than about any particular post, and no exception overrides it.
	 *
	 * @return bool
	 */
	private function rest_blocking_is_conditional() {
		if (!$this->has_conditional_rules()) {
			return false;
		}

		return empty($this->options['remove_rest_API_comments']);
	}

	/**
	 * Which post is a comment REST request about?
	 *
	 * Returns 0 when it cannot be determined - a listing request, say - so the
	 * caller falls back to the site-wide decision rather than guessing.
	 *
	 * @param WP_REST_Request $request          The request.
	 * @param array|object    $prepared_comment Prepared comment, when there is one.
	 * @return int Post id, or 0.
	 */
	private function get_request_post_id($request, $prepared_comment = null) {
		if (is_array($prepared_comment) && !empty($prepared_comment['comment_post_ID'])) {
			return (int) $prepared_comment['comment_post_ID'];
		}

		if (is_object($prepared_comment) && !empty($prepared_comment->comment_post_ID)) {
			return (int) $prepared_comment->comment_post_ID;
		}

		if (!is_object($request) || !method_exists($request, 'get_param')) {
			return 0;
		}

		$post_id = $request->get_param('post');

		if (is_scalar($post_id) && (int) $post_id > 0) {
			return (int) $post_id;
		}

		// Item routes - GET/PUT/DELETE /wp/v2/comments/<id> - carry an id and
		// no post, so this used to answer 0 and the caller rejected them even
		// when an "enable" rule keeps that comment's post open. Reading or
		// editing a comment on a post the site has deliberately reopened has
		// to work, or the exception only half exists.
		//
		// This can only widen what a rule already permits: the caller still
		// asks is_disabled_for_post() about whatever post comes back.
		$comment_id = $request->get_param('id');

		if (is_scalar($comment_id) && (int) $comment_id > 0) {
			$comment = get_comment((int) $comment_id);

			if ($comment && !empty($comment->comment_post_ID)) {
				return (int) $comment->comment_post_ID;
			}
		}

		return 0;
	}

	/**
	 * Did this REST request try to change something?
	 *
	 * Written as "not a read" rather than as a list of write verbs, so a
	 * method nobody thought of counts rather than silently not counting. GET,
	 * HEAD and OPTIONS are the safe methods HTTP defines; everything else -
	 * POST, PUT, PATCH, DELETE - is an attempt to write. DELETE is included
	 * deliberately: a refused attempt to delete a comment is still an attempt
	 * the plugin turned away.
	 *
	 * Anything that is not a request object counts, so a caller that cannot be
	 * inspected is treated the way this filter treated everything before.
	 *
	 * @since 2.9.1
	 * @param WP_REST_Request|mixed $request The request being dispatched.
	 * @return bool
	 */
	private function is_rest_write_request($request) {
		if (!is_object($request) || !method_exists($request, 'get_method')) {
			return true;
		}

		$method = strtoupper((string) $request->get_method());

		return !in_array($method, array('GET', 'HEAD', 'OPTIONS'), true);
	}

	public function filter_rest_comment_dispatch($result, $server, $request) {
		// Somebody upstream already rejected this - authentication, rate
		// limiting, a security plugin. WordPress keeps running later filters
		// anyway, so without this we would both replace their error with ours
		// and count their rejection as one of our blocks.
		if (is_wp_error($result)) {
			return $result;
		}

		// Only filter comment-related routes
		$route = $request->get_route();
		if (strpos($route, '/wp/v2/comments') === false) {
			return $result;
		}

		// Allow requests for comment types in the allowlist to pass through
		if ($this->is_allowed_comment_type_request($request)) {
			return $result;
		}

		// Likewise for a post an exception rule keeps open - but again only
		// when the dedicated REST toggle is not what is doing the blocking.
		if ($this->rest_blocking_is_conditional()) {
			$post_id = $this->get_request_post_id($request);
			if ($post_id && !$this->is_disabled_for_post($post_id)) {
				return $result;
			}
		}

		// Counted only when something tried to WRITE. The block below still
		// covers reads - that part is unchanged - but a blocked GET is not an
		// "attempt" in the sense the counter is read: an admin looks at that
		// number as spam pressure, and a crawler or uptime check polling
		// /wp/v2/comments would pad it with traffic that never tried to leave
		// a comment. It also handed an unauthenticated caller a way to force a
		// database write on every request, since a non-empty tally is flushed
		// on shutdown.
		if ($this->is_rest_write_request($request)) {
			$this->count_blocked_attempt('rest');
		}

		// Block all other comment requests
		return new WP_Error(
			'rest_comment_disabled',
			__('Comments are disabled.', 'disable-comments'),
			array('status' => 403)
		);
	}

	/**
	 * Filter comment queries in REST API to allow only allowed comment types
	 *
	 * @param array $prepared_args Array of arguments for WP_Comment_Query
	 * @param WP_REST_Request $request The REST API request
	 * @return array
	 */
	public function filter_rest_comment_query($prepared_args, $request) {
		// If this is a request for an allowed comment type, allow it
		if ($this->is_allowed_comment_type_request($request)) {
			return $prepared_args;
		}

		// A post an exception keeps open must return its comments here too.
		// The pre-dispatch filter already lets the request through; forcing an
		// empty result set afterwards would hand back a 200 with nothing in it,
		// which is a subtler kind of wrong than a 403.
		if ($this->rest_blocking_is_conditional()) {
			$post_id = $this->get_request_post_id($request);
			if ($post_id && !$this->is_disabled_for_post($post_id)) {
				return $prepared_args;
			}
		}

		// For non-allowed requests, return empty results
		// by setting an impossible condition
		$prepared_args['comment__in'] = array(0);

		return $prepared_args;
	}

	/**
	 * Determines if scripts should be enqueued
	 */
	public function filter_gutenberg_blocks($hook) {
		global $post;
		if ($this->is_remove_everywhere() || (isset($post->post_type) && $this->is_post_type_disabled($post->post_type))) {
			return $this->disable_comments_script();
		}
	}

	/**
	 * Enqueues scripts
	 */
	public function disable_comments_script() {
		wp_enqueue_script('disable-comments-gutenberg', plugin_dir_url(__FILE__) . 'assets/js/disable-comments.js', array(), DC_VERSION, true);
	}

	/**
	 * Enqueues Scripts for Settings Page
	 */
	public function settings_page_assets($hook_suffix) {
		// The review prompt is not a settings-page feature. review_prompt() is
		// hooked to admin_notices AND network_admin_notices, and renders on
		// every screen get_own_screen_ids() covers - the Tools page and the
		// -network variants included. Its dismiss handler used to ship only
		// inside the settings bundle below, so on those other screens nothing
		// listened: "No thanks", the click-through and the notice's own X all
		// appeared to work and the prompt returned on the next page load.
		//
		// Keyed off should_show_review_prompt() rather than the hook suffix, so
		// the script loads exactly where the notice does and nowhere else. It
		// runs before admin_notices and reads the same option and user meta, so
		// the two cannot disagree within a request.
		if ($this->should_show_review_prompt()) {
			wp_enqueue_script(
				'disable-comments-review-prompt',
				DC_ASSETS_URI . 'js/review-prompt.js',
				array('jquery'),
				DC_VERSION,
				true
			);
		}

		if (
			$hook_suffix === 'settings_page_' . DC_PLUGIN_SLUG ||
			$hook_suffix === 'options-general_' . DC_PLUGIN_SLUG
		) {
			// css
			wp_enqueue_style('sweetalert2', DC_ASSETS_URI . 'css/sweetalert2.min.css', [], DC_VERSION);
			// wp_enqueue_style('pagination',  DC_ASSETS_URI . 'css/pagination.css', [], false);
			wp_enqueue_style('disable-comments-style', DC_ASSETS_URI . 'css/style.css', [], DC_VERSION);
			wp_enqueue_style('select2', DC_ASSETS_URI . 'css/select2.min.css', [], DC_VERSION);
			// js
			wp_enqueue_script('sweetalert2', DC_ASSETS_URI . 'js/sweetalert2.all.min.js', array('jquery'), DC_VERSION, true);
			wp_enqueue_script('pagination', DC_ASSETS_URI . 'js/pagination.min.js', array('jquery'), DC_VERSION, true);
			wp_enqueue_script('select2', DC_ASSETS_URI . 'js/select2.min.js', array('jquery'), DC_VERSION, true);
			wp_enqueue_script('disable-comments-scripts', DC_ASSETS_URI . 'js/disable-comments-settings-scripts.js', array('jquery', 'select2', 'pagination', 'sweetalert2', 'wp-i18n'), DC_VERSION, true);
			wp_localize_script(
				'disable-comments-scripts',
				'disableCommentsObj',
				array(
					'save_action'      => 'disable_comments_save_settings',
					'delete_action'    => 'disable_comments_delete_comments',
					'settings_URI'     => $this->settings_page_url(),
					'_nonce'           => wp_create_nonce('disable_comments_save_settings'),
					'is_network_admin' => is_network_admin() ? '1' : '0',
				)
			);
			wp_set_script_translations('disable-comments-scripts', 'disable-comments');
		} else {
			// notice css
			wp_enqueue_style('disable-comments-notice', DC_ASSETS_URI . 'css/notice.css', [], DC_VERSION);
		}
	}

	/**
	 * Remove comment links from the admin bar in a multisite network.
	 */
	public function remove_network_comment_links($wp_admin_bar) {
		if ($this->networkactive && is_user_logged_in()) {
			foreach ((array) $wp_admin_bar->user->blogs as $blog) {
				$wp_admin_bar->remove_menu('blog-' . $blog->userblog_id . '-c');
			}
		} else {
			// We have no way to know whether the plugin is active on other sites, so only remove this one.
			$wp_admin_bar->remove_menu('blog-' . get_current_blog_id() . '-c');
		}
	}

	public function discussion_notice() {
		$disabled_post_types = $this->get_disabled_post_types();
		if (get_current_screen()->id == 'options-discussion' && !empty($disabled_post_types)) {
			$names_escaped = array();
			foreach ($disabled_post_types as $type) {
				$names_escaped[$type] = esc_html(get_post_type_object($type)->labels->name);
			}

			// translators: %s: disabled post types.
			echo '<div class="notice notice-warning"><p>' . sprintf(esc_html__('Note: The <em>Disable Comments</em> plugin is currently active, and comments are completely disabled on: %s. Many of the settings below will not be applicable for those post types.', 'disable-comments'), implode(esc_html__(', ', 'disable-comments'), $names_escaped)) . '</p></div>';
		}
	}

	/**
	 * Return context-aware settings page URL
	 */
	private function settings_page_url() {
		$base = $this->networkactive && is_network_admin() ? network_admin_url('settings.php') : admin_url('options-general.php');
		return add_query_arg('page', DC_PLUGIN_SLUG, $base);
	}

	/**
	 * Return context-aware tools page URL
	 */
	private function tools_page_url() {
		$base = $this->networkactive && is_network_admin() ? network_admin_url('settings.php') : admin_url('tools.php');
		return add_query_arg('page', 'disable_comments_tools', $base);
	}


	public function setup_notice() {
		$current_screen = get_current_screen()->id;
		if (!in_array($current_screen, ['dashboard-network', 'dashboard'])) {
			return;
		}
		$hascaps = $this->networkactive && is_network_admin() ? current_user_can('manage_network_plugins') : current_user_can('manage_options');
		if ($this->networkactive && !is_network_admin() && !$this->options['sitewide_settings']) {
			$hascaps = false;
		}
		if ($hascaps) {
			$this->setup_notice_flag = true;
			// translators: %s: URL to Disabled Comment settings page.
			$html = sprintf(__('The <strong>Disable Comments</strong> plugin is active, but isn\'t configured to do anything yet. Visit the <a href="%s">configuration page</a> to choose which post types to disable comments on.', 'disable-comments'), esc_attr($this->settings_page_url()));
			// phpcs:ignore PluginCheck.CodeAnalysis.ImageFunctions.NonEnqueuedImage
			echo wp_kses_post('<div class="notice dc-text__block disable__comment__alert mb30"><img height="30" src="' . esc_url(DC_ASSETS_URI . 'img/icon-logo.png') . '" alt=""><p>' . $html . '</p></div>');
		}
	}

	public function filter_admin_menu() {
		global $pagenow;

		if (empty($this->options['show_existing_comments'])) {
			if ($pagenow == 'comment.php' || $pagenow == 'edit-comments.php') {
				wp_die(esc_html__('Comments are closed.', 'disable-comments'), '', array('response' => 403));
			}

			remove_menu_page('edit-comments.php');
		}

		if (!$this->discussion_settings_allowed()) {
			if ($pagenow == 'options-discussion.php') {
				wp_die(esc_html__('Comments are closed.', 'disable-comments'), '', array('response' => 403));
			}

			remove_submenu_page('options-general.php', 'options-discussion.php');
		}
	}

	public function filter_dashboard() {
		remove_meta_box('dashboard_recent_comments', 'dashboard', 'normal');
	}

	public function admin_css() {
		echo '<style>
			#dashboard_right_now .comment-count,
			#dashboard_right_now .comment-mod-count,
			#latest-comments,
			#welcome-panel .welcome-comments,
			.user-comment-shortcuts-wrap {
				display: none !important;
			}
		</style>';
	}

	public function filter_existing_comments($comments, $post_id) {
		$comments_disabled = $this->is_disabled_for_post($post_id);

		// If comments are disabled but show_existing_comments is enabled, return existing comments
		if ($comments_disabled && !empty($this->options['show_existing_comments'])) {
			$comments_disabled = false;
		}

		// If comments are disabled, filter out regular comments but keep allowed comment types
		if ($comments_disabled && !empty($comments)) {
			$filtered_comments = array();
			foreach ($comments as $comment) {
				// Keep comment types that are in the allowlist even when comments are disabled
				if (isset($comment->comment_type) && $this->is_comment_type_allowed($comment->comment_type)) {
					$filtered_comments[] = $comment;
				}
			}
			return $filtered_comments;
		}

		// Default behavior: return all comments if not disabled
		return $comments;
	}

	public function filter_comment_status($open, $post_id) {
		return ($this->is_disabled_for_post($post_id) ? false : $open);
	}

	public function filter_comments_number($count, $post_id) {
		$comments_disabled = $this->is_disabled_for_post($post_id);

		// If comments are disabled but show_existing_comments is enabled, return actual count
		if ($comments_disabled && !empty($this->options['show_existing_comments'])) {
			return $count;
		}

		// If comments are disabled but there are allowed comment types, count only those types
		if ($comments_disabled && $this->has_allowed_comment_types()) {
			return $this->count_allowed_comment_types($post_id);
		}

		return $comments_disabled ? 0 : $count;
	}

	/**
	 * Count comments of allowed types for a specific post
	 *
	 * @param int $post_id The post ID
	 * @return int The count of comments matching allowed types
	 */
	private function count_allowed_comment_types($post_id) {
		$allowed_types = $this->get_allowed_comment_types();
		if (empty($allowed_types)) {
			return 0;
		}

		$comments = get_comments(array(
			'post_id' => $post_id,
			'type__in' => $allowed_types,
			'status' => 'approve',
			'count' => true,
		));

		return (int) $comments;
	}

	public function disable_rc_widget() {
		unregister_widget('WP_Widget_Recent_Comments');
		/**
		 * The widget has added a style action when it was constructed - which will
		 * still fire even if we now unregister the widget... so filter that out
		 */
		add_filter('show_recent_comments_widget_style', '__return_false');
	}

	public function set_plugin_meta($links, $file) {
		static $plugin;
		$plugin = plugin_basename(__FILE__);
		if ($file == $plugin) {
			$links[] = '<a href="https://github.com/WPDevelopers/disable-comments">GitHub</a>';
		}
		return $links;
	}

	/**
	 * Add links to Settings page
	 */
	public function plugin_actions_links($links, $file) {
		static $plugin;
		$plugin = plugin_basename(__FILE__);
		if ($file == $plugin && current_user_can('manage_options')) {
			array_unshift(
				$links,
				sprintf('<a href="%s">%s</a>', esc_attr($this->settings_page_url()), __('Settings', 'disable-comments')),
				sprintf('<a href="%s">%s</a>', esc_attr($this->tools_page_url()), __('Tools', 'disable-comments'))
			);
		}

		return $links;
	}

	public function settings_menu() {
		$title = _x('Disable Comments', 'settings menu title', 'disable-comments');
		if ($this->networkactive && is_network_admin()) {
			add_submenu_page('settings.php', $title, $title, 'manage_network_plugins', DC_PLUGIN_SLUG, array($this, 'settings_page'));
		} elseif (!$this->networkactive || $this->options['sitewide_settings']) {
			add_submenu_page('options-general.php', $title, $title, 'manage_options', DC_PLUGIN_SLUG, array($this, 'settings_page'));
		}
	}

	public function tools_menu() {
		$title = __('Delete Comments', 'disable-comments');
		$hook = '';
		if ($this->networkactive && is_network_admin()) {
			$hook = add_submenu_page('settings.php', $title, $title, 'manage_network_plugins', 'disable_comments_tools', array($this, 'tools_page'));
		} elseif (!$this->networkactive || $this->options['sitewide_settings']) {
			$hook = add_submenu_page('tools.php', $title, $title, 'manage_options', 'disable_comments_tools', array($this, 'tools_page'));
		}
		add_action('load-' . $hook, array($this, 'redirectToMainSettingsPage'));
	}

	public function redirectToMainSettingsPage() {
		wp_safe_redirect($this->settings_page_url() . '#delete');
		exit;
	}

	public function get_all_comments_number() {
		global $wpdb;
		if (is_network_admin() && function_exists('get_sites') && class_exists('WP_Site_Query')) {
			$count = 0;
			$sites = get_sites([
				'number' => 0,
				'fields' => 'ids',
			]);
			foreach ($sites as $blog_id) {
				switch_to_blog($blog_id);
				$count += $this->__get_comment_count();
				restore_current_blog();
			}
			return $count;
		} else {
			return $this->__get_comment_count();
		}
	}

	public function get_all_comment_types($exclude_allowed = true) {
		if ($this->networkactive && is_network_admin() && function_exists('get_sites')) {
			$comment_types = [];
			$sites = get_sites([
				'number' => 0,
				'fields' => 'ids',
			]);
			foreach ($sites as $blog_id) {
				switch_to_blog($blog_id);
				$comment_types = array_merge($this->_get_all_comment_types($exclude_allowed), $comment_types);
				restore_current_blog();
			}
			return $comment_types;
		} else {
			return $this->_get_all_comment_types($exclude_allowed);
		}
	}
	public function _get_all_comment_types($exclude_allowed = true) {
		global $wpdb;
		$commenttypes = array();
		// we need fresh data in every call.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery -- We need to count comments across multiple sites
		$commenttypes_query = $wpdb->get_results("SELECT DISTINCT comment_type FROM $wpdb->comments", ARRAY_A);
		if (!empty($commenttypes_query) && is_array($commenttypes_query)) {
			foreach ($commenttypes_query as $entry) {
				$value = $entry['comment_type'];
				// Exclude comment types that are in the allowlist from deletable comment types
				// These are protected and should not appear in the "Delete Certain Comment Types" interface
				if ($exclude_allowed && $this->is_comment_type_allowed($value)) {
					continue;
				}
				if ('' === $value) {
					$commenttypes['default'] = __('Default (no type)', 'disable-comments');
				} elseif ($this->is_woocommerce_active() && $this->get_review_comment_type() === $value) {
					// "Review (review)" tells a store owner nothing about what
					// they are about to delete.
					$commenttypes[$value] = __('Product reviews - WooCommerce (review)', 'disable-comments');
				} else {
					$commenttypes[$value] = ucwords(str_replace('_', ' ', $value)) . ' (' . $value . ')';
				}
			}
		}
		return $commenttypes;
	}

	public function get_all_post_types($network = false) {
		$typeargs = array('public' => true);
		if ($network || $this->networkactive && is_network_admin()) {
			$typeargs['_builtin'] = true;   // stick to known types for network.
		}
		$types = get_post_types($typeargs, 'objects');
		foreach (array_keys($types) as $type) {
			if (!in_array($type, $this->modified_types) && !post_type_supports($type, 'comments')) {   // the type doesn't support comments anyway.
				unset($types[$type]);
			}
		}
		return $types;
	}

	public function get_roles($selected) {
		$roles = [
			[
				"id" => 'logged-out-users',
				"text" => __('Logged out users', 'disable-comments'),
				"selected" => in_array('logged-out-users', (array) $selected),
			]
		];
		$editable_roles = array_reverse(get_editable_roles());
		foreach ($editable_roles as $role => $details) {
			$roles[] = [
				"id" => esc_attr($role),
				"text" => esc_html(translate_user_role($details['name'])),
				"selected" => in_array($role, (array) $selected),
			];
		}
		return $roles;
	}

	public function tools_page() {
		return;
	}

	public function settings_page() {
		// Belt-and-suspenders: add_submenu_page already gates on capability,
		// but verify here too so a direct URL request can never render the page.
		$required_cap = $this->networkactive && is_network_admin() ? 'manage_network_plugins' : 'manage_options';
		if (!current_user_can($required_cap)) {
			wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'disable-comments'), 403);
		}

		$avatar_status = '-1';
		if ($this->can_network_admin_ajax_context()) {
			$show_avatars = [];
			$sites = get_sites([
				'number' => 0,
				'fields' => 'ids',
			]);
			foreach ($sites as $blog_id) {
				switch_to_blog($blog_id);
				$show_avatars[] = (int) get_option('show_avatars', '0');
				restore_current_blog();
			}
			if (count($show_avatars) == array_sum($show_avatars)) {
				$avatar_status = '0';
			} elseif (0 == array_sum($show_avatars)) {
				$avatar_status = '1';
			}
		}

		include_once DC_PLUGIN_VIEWS_PATH . 'settings.php';
	}

	public function get_sub_sites() {
		$nonce = (isset($_REQUEST['nonce']) ? sanitize_text_field(wp_unslash($_REQUEST['nonce'])) : '');
		if (!wp_verify_nonce($nonce, 'disable_comments_save_settings')) {
			wp_send_json(['data' => [], 'totalNumber' => 0]);
		}
		// Listing subsites is always a network-level operation on multisite —
		// require manage_network_plugins regardless of how the plugin is activated
		// (network-wide or per-site). A per-site admin must never enumerate all
		// network sites. On single-site installs manage_options suffices.
		$required_cap = is_multisite() ? 'manage_network_plugins' : 'manage_options';
		if (!current_user_can($required_cap)) {
			wp_send_json(['data' => [], 'totalNumber' => 0]);
		}

		$_sub_sites = [];
		$type = isset($_GET['type']) ? sanitize_text_field(wp_unslash($_GET['type'])) : 'disabled';
		$search = isset($_GET['search']) ? sanitize_text_field(wp_unslash($_GET['search'])) : '';
		$pageSize = isset($_GET['pageSize']) ? sanitize_text_field(wp_unslash($_GET['pageSize'])) : 50;
		$pageNumber = isset($_GET['pageNumber']) ? sanitize_text_field(wp_unslash($_GET['pageNumber'])) : 1;
		$offset = ($pageNumber - 1) * $pageSize;
		$sub_sites = get_sites([
			'number' => $pageSize,
			'offset' => $offset,
			'search' => $search,
			'fields' => 'ids',
		]);
		$totalNumber = get_sites([
			// 'number' => $pageSize,
			// 'offset' => $offset,
			'search' => $search,
			'count' => true,
		]);

		if ($type == 'disabled') {
			$disabled_site_options = isset($this->options['disabled_sites']) ? $this->options['disabled_sites'] : [];
		} else { // if($type == 'delete')
			$disabled_site_options = $this->get_disabled_sites(true);
		}

		foreach ($sub_sites as $sub_site_id) {
			$blog = get_blog_details($sub_site_id);
			$is_checked = checked(!empty($disabled_site_options["site_$sub_site_id"]), true, false);
			$_sub_sites[] = [
				'site_id' => $sub_site_id,
				'is_checked' => $is_checked,
				'blogname' => $blog->blogname,
			];
		}
		wp_send_json(['data' => $_sub_sites, 'totalNumber' => $totalNumber]);
	}

	/**
	 * AJAX: terms for one taxonomy, for the conditional-rules term picker.
	 *
	 * Reads nothing a user with manage_options cannot already see on the
	 * taxonomy screens, but it is gated all the same - an endpoint that
	 * enumerates every term on the site is not something to leave open.
	 */
	public function get_taxonomy_terms() {
		$nonce = (isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '');

		if (!wp_verify_nonce($nonce, 'disable_comments_save_settings')) {
			wp_send_json_error(array('message' => __('Invalid request.', 'disable-comments')), 403);
		}

		if (!current_user_can('manage_options')) {
			wp_send_json_error(array('message' => __('Insufficient permissions.', 'disable-comments')), 403);
		}

		$taxonomy = (isset($_POST['taxonomy']) ? sanitize_key(wp_unslash($_POST['taxonomy'])) : '');
		// sanitize_text_field(), not sanitize_key(): this is matched against
		// term names, which have spaces, accents and capitals in them.
		$search   = (isset($_POST['search']) ? sanitize_text_field(wp_unslash($_POST['search'])) : '');
		$page     = (isset($_POST['page']) ? (int) $_POST['page'] : 1);

		wp_send_json_success($this->get_taxonomy_terms_page($taxonomy, $search, $page));
	}

	/**
	 * One page of terms for the conditional-rules picker.
	 *
	 * The picker used to be handed the taxonomy's first 200 terms and nothing
	 * else. On a shop with a few thousand product categories that meant every
	 * term after those 200 was simply unreachable for a new rule - visible on
	 * the taxonomy screen, listed by the REST API, and absent from the one
	 * control that needed it. Raising the number would have moved the ceiling
	 * rather than removed it, so the picker searches and pages instead.
	 *
	 * Kept apart from the AJAX wrapper above so the query can be exercised
	 * directly; the wrapper owns the nonce and capability checks.
	 *
	 * @param string $taxonomy Taxonomy name.
	 * @param string $search   Optional. Fragment to match against term names.
	 * @param int    $page     Optional. 1-based page of results.
	 * @return array {
	 *     @type string $taxonomy The taxonomy answered for. Echoed back because
	 *                            two quick changes of taxonomy can land out of
	 *                            order, and terms rendered under the wrong one
	 *                            save as term IDs that do not belong to it.
	 *     @type array  $terms    List of array('id' => int, 'name' => string).
	 *     @type bool   $more     Whether a further page exists.
	 * }
	 */
	public function get_taxonomy_terms_page($taxonomy, $search = '', $page = 1) {
		$empty = array(
			'taxonomy' => (string) $taxonomy,
			'terms'    => array(),
			'more'     => false,
		);

		if ('' === $taxonomy || !taxonomy_exists($taxonomy)) {
			return $empty;
		}

		$page = max(1, (int) $page);

		$args = array(
			'taxonomy'   => $taxonomy,
			'hide_empty' => false,
			'orderby'    => 'name',
			'order'      => 'ASC',
			// id=>name, because nothing here needs a WP_Term: on a taxonomy
			// with thousands of rows the objects are the expensive part.
			'fields'     => 'id=>name',
			// One row past the page. Whether another page exists is then
			// answered by this query rather than a second counting one.
			'number'     => self::TERM_PAGE_SIZE + 1,
			'offset'     => ($page - 1) * self::TERM_PAGE_SIZE,
		);

		$search = trim((string) $search);
		if ('' !== $search) {
			$args['search'] = $search;
		}

		$terms = get_terms($args);

		if (is_wp_error($terms) || empty($terms)) {
			return $empty;
		}

		$more = (count($terms) > self::TERM_PAGE_SIZE);
		if ($more) {
			array_pop($terms);
		}

		$out = array();
		foreach ($terms as $term_id => $name) {
			$out[] = array(
				'id'   => (int) $term_id,
				'name' => $name,
			);
		}

		return array(
			'taxonomy' => (string) $taxonomy,
			'terms'    => $out,
			'more'     => $more,
		);
	}

	/**
	 * Screens this plugin owns.
	 *
	 * The review prompt renders on these and nowhere else. Two 1-star reviews
	 * in May 2025 were about promotion appearing in the post editor and around
	 * the admin; both were revised to 5 stars once it was pulled. This list is
	 * the guarantee that does not happen again, so keep it exact.
	 *
	 * @return array Screen ids.
	 */
	private function get_own_screen_ids() {
		$ids = array(
			'settings_page_' . DC_PLUGIN_SLUG,
			'options-general_page_' . DC_PLUGIN_SLUG,
			'tools_page_disable_comments_tools',
			'settings_page_disable_comments_tools',
		);

		// On a network-activated install WordPress suffixes screen ids with
		// "-network". Without these a super admin who just cleared comments
		// across the whole network is the one person never asked.
		foreach ($ids as $id) {
			$ids[] = $id . '-network';
		}

		return $ids;
	}

	/**
	 * Are we on one of this plugin's own screens?
	 *
	 * @return bool
	 */
	private function is_own_screen() {
		if (!function_exists('get_current_screen')) {
			return false;
		}

		$screen = get_current_screen();

		if (!$screen || empty($screen->id)) {
			return false;
		}

		return in_array($screen->id, $this->get_own_screen_ids(), true);
	}

	/**
	 * Note that something worth being pleased about just happened.
	 *
	 * Called after a bulk delete completes. Activation deliberately does not
	 * call this: the user has done nothing yet and has no basis for an opinion.
	 *
	 * @param int $deleted How many comments were removed.
	 */
	public function record_review_trigger($deleted = 0) {
		if ($deleted < 1) {
			return;
		}

		update_option(
			self::REVIEW_TRIGGER_OPTION,
			array(
				'at'      => time(),
				'deleted' => (int) $deleted,
			),
			false
		);
	}

	/**
	 * Should this user see the review prompt right now?
	 *
	 * Every condition here is a reason not to show it. That asymmetry is the
	 * point: the cost of showing this in the wrong place is far higher than
	 * the cost of never showing it at all.
	 *
	 * @return bool
	 */
	public function should_show_review_prompt() {
		/**
		 * Filter whether the review prompt may be shown at all.
		 *
		 * @param bool $show Whether to consider showing the prompt.
		 */
		if (!apply_filters('disable_comments_show_review_prompt', true)) {
			return false;
		}

		if (!is_user_logged_in() || !current_user_can('manage_options')) {
			return false;
		}

		// Our screens only. Never the dashboard, never the post editor, never
		// a site-wide admin notice.
		if (!$this->is_own_screen()) {
			return false;
		}

		// Dismissed means dismissed. Permanently, for this user.
		if (get_user_meta(get_current_user_id(), self::REVIEW_DISMISSED_META, true)) {
			return false;
		}

		$trigger = get_option(self::REVIEW_TRIGGER_OPTION, array());

		// No successful action yet, so there is nothing to be pleased about.
		if (empty($trigger['at']) || empty($trigger['deleted'])) {
			return false;
		}

		return true;
	}

	/**
	 * Render the review prompt.
	 */
	public function review_prompt() {
		if (!$this->should_show_review_prompt()) {
			return;
		}

		$trigger = get_option(self::REVIEW_TRIGGER_OPTION, array());
		$deleted = isset($trigger['deleted']) ? (int) $trigger['deleted'] : 0;

		?>
		<div class="notice notice-info is-dismissible disable-comments-review-prompt"
			id="disable_comments_review_prompt"
			data-nonce="<?php echo esc_attr(wp_create_nonce('disable_comments_save_settings')); ?>">
			<p>
				<?php
				printf(
					/* translators: %s: number of comments deleted. */
					esc_html(_n('Disable Comments just cleared %s comment for you.', 'Disable Comments just cleared %s comments for you.', $deleted, 'disable-comments')),
					'<strong>' . esc_html(number_format_i18n($deleted)) . '</strong>'
				);
				?>
				<?php esc_html_e('If it saved you some time, a quick review helps other people find it.', 'disable-comments'); ?>
			</p>
			<p>
				<a href="https://wordpress.org/support/plugin/disable-comments/reviews/#new-post"
					class="button button-primary"
					target="_blank"
					rel="noopener noreferrer"
					data-dc-review="leave">
					<?php esc_html_e('Leave a review', 'disable-comments'); ?>
				</a>
				<button type="button" class="button-link" data-dc-review="dismiss">
					<?php esc_html_e('No thanks, don\'t ask again', 'disable-comments'); ?>
				</button>
			</p>
		</div>
		<?php
	}

	/**
	 * AJAX: record that this user does not want to be asked again.
	 */
	public function dismiss_review_prompt() {
		$nonce = (isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '');

		if (!wp_verify_nonce($nonce, 'disable_comments_save_settings')) {
			wp_send_json_error(array('message' => __('Invalid request.', 'disable-comments')), 403);
		}

		if (!is_user_logged_in()) {
			wp_send_json_error(array('message' => __('Insufficient permissions.', 'disable-comments')), 403);
		}

		update_user_meta(get_current_user_id(), self::REVIEW_DISMISSED_META, time());

		wp_send_json_success();
	}

	/**
	 * Hook the self-report into a scanned front-end request.
	 *
	 * Called from init_filters(). Everything here is inert unless the request
	 * carries a token this site issued moments ago, so an ordinary visitor
	 * never touches any of it.
	 */
	public function maybe_arm_scan_probe() {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$token = isset($_GET[self::SCAN_QUERY_ARG]) ? sanitize_text_field(wp_unslash($_GET[self::SCAN_QUERY_ARG])) : '';

		if ('' === $token) {
			return;
		}

		// Keyed by the token itself, so two administrators scanning at once do
		// not overwrite each other's slot and then both fail validation.
		$expected = get_transient(self::SCAN_TOKEN_TRANSIENT . '_' . md5($token));

		// hash_equals: the token is a secret for the lifetime of one scan, and
		// a timing oracle on it is free to avoid.
		if (empty($expected) || !hash_equals((string) $expected, $token)) {
			return;
		}

		// Record what the theme asked for. Our own filter runs at priority 20;
		// this sits later so it sees the final resolved path either way.
		add_filter('comments_template', array($this, 'record_scanned_comments_template'), 999);
		add_action('wp_footer', array($this, 'print_scan_marker'), 9999);
		add_action('shutdown', array($this, 'print_scan_marker'), 9999);
	}

	/**
	 * Remember which comments template was resolved during a scan.
	 *
	 * @param string $template Resolved template path.
	 * @return string Unmodified.
	 */
	public function record_scanned_comments_template($template) {
		$this->scan_report['comments_template'] = (string) $template;
		$this->scan_report['template_called']   = true;

		return $template;
	}

	/**
	 * Emit the scan's findings as an HTML comment.
	 *
	 * Printed from inside the scanned request, which is the only place that
	 * can say whether the theme called comments_template() at all - the thing
	 * the FAQ has been describing in prose for a decade.
	 */
	public function print_scan_marker() {
		if (!empty($this->scan_report['printed'])) {
			return;
		}

		$this->scan_report['printed'] = true;

		$report = array(
			'template_called'   => !empty($this->scan_report['template_called']),
			'comments_template' => isset($this->scan_report['comments_template']) ? $this->scan_report['comments_template'] : '',
			'dummy_used'        => isset($this->scan_report['comments_template'])
				&& $this->scan_report['comments_template'] === $this->dummy_comments_template(),
			'comments_open'     => is_singular() ? (bool) comments_open(get_queried_object_id()) : null,
			// These two make check_comment_template() deliberately leave the
			// real template in place, so comment markup on the page is expected
			// rather than a theme defeating us.
			'preserved'         => !empty($this->options['show_existing_comments']) || $this->has_allowed_comment_types(),
		);

		echo "\n<!--disable-comments-scan:" . wp_json_encode($report) . ":disable-comments-scan-->\n";
	}

	/**
	 * Markup that means a comment UI reached the page.
	 *
	 * Matched against class and id attributes rather than visible text, so it
	 * does not depend on the site's language.
	 *
	 * @return array Signal key => list of needles.
	 */
	private function get_comment_markup_signals() {
		return array(
			// Anchored to a tag boundary and an attribute name. A bare
			// substring search matches ".comment-form" in inline CSS, a
			// selector in inline JS, or the word inside a text node, and would
			// report a co-operating theme as broken.
			'comment_form' => array(
				'/<form[^>]+id=["\']commentform["\']/i',
				'/<form[^>]+class=["\'][^"\']*\bcomment-form\b/i',
				'/<div[^>]+id=["\']respond["\']/i',
			),
			'comment_list' => array(
				'/<(ol|ul)[^>]+class=["\'][^"\']*\bcomment-list\b/i',
				'/<(div|section)[^>]+id=["\']comments["\']/i',
			),
			'reply_script' => array(
				'/<script[^>]+comment-reply(\.min)?\.js/i',
			),
		);
	}

	/**
	 * Strip the parts of a response that are not rendered markup.
	 *
	 * Inline styles and scripts routinely mention .comment-form and
	 * .comment-list. Matching them would make every theme that styles its
	 * comment area look like it was rendering one.
	 *
	 * @param string $body Response body.
	 * @return string Body with script and style blocks removed.
	 */
	private function strip_non_markup($body) {
		$body = preg_replace('#<script\b[^>]*>.*?</script>#is', '', $body);
		$body = preg_replace('#<style\b[^>]*>.*?</style>#is', '', $body);
		$body = preg_replace('#<!--(?!\s*disable-comments-scan).*?-->#s', '', $body);

		return (string) $body;
	}

	/**
	 * Scan one URL and work out whether the theme is co-operating.
	 *
	 * @param string $url Permalink to scan.
	 * @return array|WP_Error Findings, or an error when the page could not be fetched.
	 */
	public function run_theme_scan($url) {
		$token = wp_generate_password(20, false);
		$slot  = self::SCAN_TOKEN_TRANSIENT . '_' . md5($token);
		set_transient($slot, $token, 2 * MINUTE_IN_SECONDS);

		// A page cache serves identical output no matter what changed, so an
		// un-busted request proves nothing. See CLAUDE.md - a full front-end
		// pass once returned identical results across four different states
		// purely as a cache artefact.
		$request_url = add_query_arg(
			array(
				self::SCAN_QUERY_ARG => $token,
				'dc_cb'              => time(),
			),
			$url
		);

		$response = wp_remote_get(
			$request_url,
			array(
				'timeout'     => 15,
				'redirection' => 3,
				'sslverify'   => false,
				'headers'     => array('Cache-Control' => 'no-cache'),
			)
		);

		delete_transient($slot);

		if (is_wp_error($response)) {
			// Loopback requests are blocked on plenty of hosts. That is a
			// "could not check", never a pass and never a fatal.
			return new WP_Error(
				'dc_scan_unreachable',
				sprintf(
					/* translators: %s: error message from the HTTP request. */
					__('Could not load the front end to check it (%s). Some hosts block a site from requesting its own pages; that is not a problem with your theme.', 'disable-comments'),
					$response->get_error_message()
				)
			);
		}

		$code = (int) wp_remote_retrieve_response_code($response);

		if ($code < 200 || $code >= 300) {
			return new WP_Error(
				'dc_scan_http_error',
				sprintf(
					/* translators: %d: HTTP status code. */
					__('The front end returned HTTP %d, so there was nothing to check.', 'disable-comments'),
					$code
				)
			);
		}

		$body = (string) wp_remote_retrieve_body($response);

		$report = array();
		if (preg_match('/<!--disable-comments-scan:(.*?):disable-comments-scan-->/s', $body, $matches)) {
			$decoded = json_decode($matches[1], true);
			if (is_array($decoded)) {
				$report = $decoded;
			}
		}

		$markup = $this->strip_non_markup($body);

		$found = array();
		foreach ($this->get_comment_markup_signals() as $signal => $patterns) {
			foreach ($patterns as $pattern) {
				if (preg_match($pattern, $markup)) {
					$found[] = $signal;
					break;
				}
			}
		}

		$theme = wp_get_theme();

		return array(
			'url'               => $url,
			'reached'           => !empty($report),
			'template_called'   => !empty($report['template_called']),
			'comments_template' => isset($report['comments_template']) ? $report['comments_template'] : '',
			'dummy_used'        => !empty($report['dummy_used']),
			'preserved'         => !empty($report['preserved']),
			'comments_open'     => isset($report['comments_open']) ? $report['comments_open'] : null,
			'markup_found'      => $found,
			'theme'             => $theme ? $theme->get('Name') : '',
			'theme_comments_php' => $this->locate_theme_comments_template(),
			// A cached response is why "nothing I change makes any difference".
			// Surface it rather than letting it silently invalidate the result.
			'x_cache'           => wp_remote_retrieve_header($response, 'x-cache'),
			'http_code'         => $code,
		);
	}

	/**
	 * The active theme's own comments.php, if it has one.
	 *
	 * locate_template() falls back to core's theme-compat copy when the theme
	 * has none, and telling somebody to edit a file in wp-includes is worse
	 * advice than telling them nothing.
	 *
	 * @return string Absolute path, or '' when the theme provides none.
	 */
	private function locate_theme_comments_template() {
		$found = locate_template('comments.php');

		if (empty($found)) {
			return '';
		}

		foreach (array(get_stylesheet_directory(), get_template_directory()) as $dir) {
			if (0 === strpos($found, $dir)) {
				return $found;
			}
		}

		return '';
	}

	/**
	 * Turn raw findings into a verdict and something worth reading.
	 *
	 * @param array $scan Result of run_theme_scan().
	 * @return array Scan plus `verdict` and `message`.
	 */
	public function interpret_theme_scan($scan) {
		$has_markup = !empty($scan['markup_found']);

		if (!$scan['reached']) {
			// Without the marker we cannot tell a co-operating theme from a
			// cached page, so we say so instead of guessing.
			$scan['verdict'] = 'inconclusive';
			$scan['message'] = __('The page loaded, but this plugin could not report from inside it. That usually means a page cache served an older copy. Try again, or clear your cache first.', 'disable-comments');

			return $scan;
		}

		if (false === $scan['comments_open'] && !$has_markup) {
			$scan['verdict'] = 'clean';
			$scan['message'] = __('Comments are off on this page and your theme is respecting that. Nothing to fix.', 'disable-comments');

			return $scan;
		}

		// "Show existing comments" and the comment-type allowlist both tell
		// this plugin to leave the real template alone, so comment markup here
		// is the setting working - not the theme defeating it. Saying otherwise
		// would send someone editing their theme to fix something they asked
		// for.
		if (!empty($scan['preserved'])) {
			$scan['verdict'] = 'preserved_by_setting';
			$scan['message'] = __('Comments are off on this page, but you have chosen to keep existing comments (or certain comment types) visible — so the comment area is still rendered on purpose. If you did not expect that, check "Show Existing Comments" and "Enable Certain Comment Types" in the settings.', 'disable-comments');

			return $scan;
		}

		if (true === $scan['comments_open']) {
			$scan['verdict'] = 'not_disabled';
			$scan['message'] = __('Comments are still open on this page, so there is nothing for the theme to hide yet. Check the settings above before reading anything into this.', 'disable-comments');

			return $scan;
		}

		// Comments are closed and the theme rendered a comment UI anyway.
		if (!$scan['template_called']) {
			$scan['verdict'] = 'theme_ignores_template';
			$scan['message'] = sprintf(
				/* translators: 1: theme name, 2: template file path. */
				__('%1$s never calls comments_template(), so this plugin has no way to replace what it renders. The comment markup is being output directly by the theme. Look in %2$s, or in the single-post template that draws the comment area.', 'disable-comments'),
				$scan['theme'],
				$scan['theme_comments_php'] ? $scan['theme_comments_php'] : 'the theme\'s template files'
			);

			return $scan;
		}

		if (!$scan['dummy_used']) {
			$scan['verdict'] = 'template_overridden';
			$scan['message'] = sprintf(
				/* translators: 1: theme name, 2: resolved template path. */
				__('%1$s calls comments_template(), but something is overriding the empty template this plugin substitutes. The template actually rendered was %2$s.', 'disable-comments'),
				$scan['theme'],
				$scan['comments_template']
			);

			return $scan;
		}

		$scan['verdict'] = 'markup_outside_template';
		$scan['message'] = sprintf(
			/* translators: %s: theme name. */
			__('This plugin replaced the comments template successfully, but comment markup is still on the page — so %s is drawing part of the comment area outside comments_template(). That part has to be removed in the theme.', 'disable-comments'),
			$scan['theme']
		);

		return $scan;
	}

	/**
	 * How much a verdict matters, for picking which page to report.
	 *
	 * @param string $verdict Verdict key.
	 * @return int Higher is more serious.
	 */
	private function scan_verdict_rank($verdict) {
		$order = array(
			'clean'                   => 0,
			'preserved_by_setting'    => 1,
			'not_disabled'            => 2,
			'inconclusive'            => 3,
			'markup_outside_template' => 4,
			'template_overridden'     => 5,
			'theme_ignores_template'  => 6,
		);

		return isset($order[$verdict]) ? $order[$verdict] : 0;
	}

	/**
	 * A published post the plugin currently closes comments on.
	 *
	 * Scanning a page where comments are open would prove nothing, so the
	 * scanner picks a target rather than trusting whatever it is handed.
	 *
	 * @return int|false Post id, or false when there is nothing suitable.
	 */
	public function find_scan_target() {
		$targets = $this->find_scan_targets();

		return empty($targets) ? false : $targets[0];
	}

	/**
	 * One published post per disabled post type.
	 *
	 * Scanning a single arbitrary post is how a scanner reports "clean" while
	 * the page the user is actually complaining about is broken: themes
	 * commonly hand different post types to different templates, and only one
	 * of them may be drawing its own comment area. One representative per
	 * disabled type is the smallest set that cannot miss that.
	 *
	 * @param int $limit Maximum number of posts to return.
	 * @return array Post ids.
	 */
	public function find_scan_targets($limit = 5) {
		$disabled = $this->get_disabled_post_types();

		if ($this->is_remove_everywhere()) {
			$disabled = array_keys($this->get_all_post_types());
		}

		if (empty($disabled)) {
			// A rules-only site has nothing disabled by type, so the scanner
			// used to answer "no disabled content" to the very people most
			// likely to need it — someone whose taxonomy or age rule closed a
			// post and whose theme still draws a comment form on it.
			//
			// Only reached where the old code returned an empty list, so no
			// configuration that works today changes behaviour. The candidate
			// pool is capped because this walks posts rather than types.
			if (!$this->has_conditional_rules()) {
				return array();
			}

			$targets = array();

			// From both ends of the archive, not just the newest posts.
			// get_posts() defaults to newest-first, and an age rule closes the
			// OLDEST posts: a site with fifty posts inside the age window
			// returned fifty posts none of which were closed, so the scanner
			// reported nothing to scan while every older post was shut. A
			// taxonomy or template rule can match anywhere, which the
			// newest-first batch samples. Keyed by id so a small site does not
			// examine the same post twice.
			$candidates = array();

			foreach (array('DESC', 'ASC') as $order) {
				$batch = get_posts(
					array(
						'post_type'        => array_keys($this->get_all_post_types()),
						'post_status'      => 'publish',
						'numberposts'      => 50,
						'orderby'          => 'date',
						'order'            => $order,
						'suppress_filters' => false,
					)
				);

				foreach ($batch as $post) {
					$candidates[(int) $post->ID] = $post;
				}
			}

			foreach ($candidates as $candidate) {
				if (count($targets) >= $limit) {
					break;
				}

				if ($this->is_disabled_for_post($candidate->ID)) {
					$targets[] = (int) $candidate->ID;
				}
			}

			return $targets;
		}

		$targets = array();

		foreach (array_values($disabled) as $post_type) {
			if (count($targets) >= $limit) {
				break;
			}

			$posts = get_posts(
				array(
					'post_type'        => $post_type,
					'post_status'      => 'publish',
					'numberposts'      => 1,
					'suppress_filters' => false,
				)
			);

			if (!empty($posts)) {
				$targets[] = (int) $posts[0]->ID;
			}
		}

		return $targets;
	}

	/**
	 * AJAX: run the theme conflict scan.
	 */
	public function scan_theme_conflict() {
		$nonce = (isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '');

		if (!wp_verify_nonce($nonce, 'disable_comments_save_settings')) {
			wp_send_json_error(array('message' => __('Invalid request.', 'disable-comments')), 403);
		}

		$is_network_ctx = $this->is_network_admin_ajax_context();

		// The scan makes the site request its own pages. Gate it like the rest
		// of this plugin's admin actions.
		if ($is_network_ctx) {
			if (!$this->can_network_admin_ajax_context()) {
				wp_send_json_error(array('message' => __('Insufficient permissions.', 'disable-comments')), 403);
			}
		} elseif (!current_user_can('manage_options')) {
			wp_send_json_error(array('message' => __('Insufficient permissions.', 'disable-comments')), 403);
		}

		$post_ids = $this->find_scan_targets();

		if (empty($post_ids)) {
			wp_send_json_error(
				array('message' => __('There is no published content with comments disabled to check yet. Disable comments on a post type that has published content, then run this again.', 'disable-comments')),
				400
			);
		}

		$results = array();
		$worst   = null;

		foreach ($post_ids as $post_id) {
			$scan = $this->run_theme_scan(get_permalink($post_id));

			if (is_wp_error($scan)) {
				// A loopback that fails once will fail for every page, so there
				// is nothing to learn from continuing.
				wp_send_json_error(array('message' => $scan->get_error_message()), 200);
			}

			$scan            = $this->interpret_theme_scan($scan);
			$scan['post_id'] = $post_id;
			$results[]       = $scan;

			// Report the page with the real problem, not whichever happened to
			// be checked first - a "clean" verdict from one template is exactly
			// how a scanner tells somebody there is nothing to fix while the
			// page they are looking at is broken.
			if (null === $worst || $this->scan_verdict_rank($scan['verdict']) > $this->scan_verdict_rank($worst['verdict'])) {
				$worst = $scan;
			}
		}

		$worst['checked']     = count($results);
		$worst['all_results'] = $results;

		wp_send_json_success($worst);
	}

	/**
	 * Settings that travel between sites, and how to clean each one.
	 *
	 * Anything not listed here is site-specific or internal and is deliberately
	 * not portable: `disabled_sites` names blog ids that mean nothing
	 * elsewhere, `db_version` and `settings_saved` are bookkeeping, and
	 * `sitewide_settings` lives in a network option rather than these.
	 *
	 * @return array Option key => type ('bool', 'int', 'keys', 'strings').
	 */
	private function get_portable_settings_map() {
		return array(
			'remove_everywhere'        => 'bool',
			'disabled_post_types'      => 'keys',
			'extra_post_types'         => 'keys',
			'remove_xmlrpc_comments'   => 'int',
			'remove_rest_API_comments' => 'int',
			'show_existing_comments'   => 'bool',
			'allowed_comment_types'    => 'keys',
			'blocked_comment_types'    => 'keys',
			'enable_exclude_by_role'   => 'bool',
			'exclude_by_role'          => 'keys',
			// Conditional rules ship in the same release as this exporter, and
			// leaving them out made the file quietly incomplete: the screen
			// says the settings were copied, the destination gets no taxonomy,
			// template or age rules, and behaves differently for reasons
			// nothing on either site explains.
			'enable_conditional_rules' => 'bool',
			'conditional_rules'        => 'rules',
			'auto_close_days'          => 'int',
		);
	}

	/**
	 * Coerce one imported value to the type its option expects.
	 *
	 * An import is an uploaded file: every value is treated as hostile, and
	 * anything that cannot be coerced becomes the empty form of its type
	 * rather than reaching the options as-is.
	 *
	 * @param mixed  $value Raw value.
	 * @param string $type  Type from get_portable_settings_map().
	 * @return mixed Sanitized value.
	 */
	private function sanitize_portable_value($value, $type) {
		if ('bool' === $type) {
			// PHP treats every non-empty string except "0" as true, so a
			// producer that encoded booleans as text would have
			// "remove_everywhere":"false" disable comments everywhere. This
			// importer already coerces malformed types; it has to coerce this
			// one correctly too.
			if (is_string($value)) {
				return in_array(strtolower(trim($value)), array('1', 'true', 'yes', 'on'), true);
			}

			return (bool) $value;
		}

		if ('int' === $type) {
			return (int) $value;
		}

		if ('rules' === $type) {
			// Straight through the same sanitiser the settings screen uses, so
			// an imported rule cannot be shaped in a way a saved one could not.
			// It already tolerates null and non-arrays, which is what a missing
			// field arrives as.
			return array_values($this->sanitize_conditional_rules($value));
		}

		if ('keys' === $type) {
			if (!is_array($value)) {
				return array();
			}

			$clean = array();
			foreach ($value as $item) {
				if (!is_scalar($item)) {
					continue;
				}
				$key = sanitize_key($item);
				if ('' !== $key) {
					$clean[] = $key;
				}
			}

			return array_values(array_unique($clean));
		}

		return '';
	}

	/**
	 * The current configuration as a portable payload.
	 *
	 * @return array
	 */
	public function export_settings($is_network_ctx = false) {
		$settings = array();
		$stored   = $this->get_stored_settings($is_network_ctx);

		foreach ($this->get_portable_settings_map() as $key => $type) {
			$value          = isset($stored[$key]) ? $stored[$key] : null;
			$settings[$key] = $this->sanitize_portable_value($value, $type);

			if ('conditional_rules' === $key) {
				$settings[$key] = $this->rules_to_portable($settings[$key]);
			}
		}

		return array(
			'schema_version' => self::EXPORT_SCHEMA_VERSION,
			'plugin_version' => defined('DC_VERSION') ? DC_VERSION : '',
			'exported_at'    => gmdate('c'),
			'settings'       => $settings,
		);
	}

	/**
	 * Read an import payload, whatever form it arrived in.
	 *
	 * @param mixed $payload JSON string or already-decoded array.
	 * @return array|WP_Error Sanitized settings, or an error naming the problem.
	 */
	private function parse_settings_payload($payload) {
		if (is_string($payload)) {
			$payload = json_decode($payload, true);
		}

		if (!is_array($payload)) {
			return new WP_Error('dc_import_invalid', __('That file is not valid JSON.', 'disable-comments'));
		}

		if (!isset($payload['settings']) || !is_array($payload['settings'])) {
			return new WP_Error('dc_import_no_settings', __('That file does not contain Disable Comments settings.', 'disable-comments'));
		}

		$schema = isset($payload['schema_version']) ? (int) $payload['schema_version'] : 0;

		if ($schema > self::EXPORT_SCHEMA_VERSION) {
			return new WP_Error(
				'dc_import_newer_schema',
				sprintf(
					/* translators: 1: schema version in the file, 2: schema version this plugin understands. */
					__('That file was written by a newer version of Disable Comments (format %1$d; this site understands %2$d). Update the plugin first.', 'disable-comments'),
					$schema,
					self::EXPORT_SCHEMA_VERSION
				)
			);
		}

		$clean = array();
		foreach ($this->get_portable_settings_map() as $key => $type) {
			if (!array_key_exists($key, $payload['settings'])) {
				continue;
			}
			$incoming = $payload['settings'][$key];

			// Before sanitising, not after: sanitize_conditional_rules()
			// intval()s terms, which would turn every portable slug into 0.
			if ('conditional_rules' === $key) {
				$incoming = $this->rules_from_portable($incoming);
			}

			$clean[$key] = $this->sanitize_portable_value($incoming, $type);
		}

		if (empty($clean)) {
			return new WP_Error('dc_import_no_known_keys', __('That file contains no settings this version recognises.', 'disable-comments'));
		}

		return $clean;
	}

	/**
	 * The full portable configuration this import would leave behind.
	 *
	 * Built before anything is diffed or written, because normalisation can
	 * move values between fields: a preview computed from the raw payload
	 * describes values that were never stored.
	 *
	 * @param array $incoming       Sanitized incoming settings.
	 * @param bool  $is_network_ctx Whether this import targets network storage.
	 * @return array Portable key => value.
	 */
	private function build_import_target($incoming, $is_network_ctx) {
		$target = array();

		// Role exclusion is a per-site rule and the network settings screen
		// deliberately does not offer it. Letting a single-site export write it
		// into the network option would apply an invisible rule to every
		// subsite - "exclude logged-out users" leaves comments open to the
		// public everywhere, with nothing on the network screen to explain why.
		$per_site_only = array('enable_exclude_by_role', 'exclude_by_role');

		// Everything the payload omits is carried over from what is stored,
		// not from the effective config this request happens to be running
		// with - see get_stored_settings().
		$stored = $this->get_stored_settings($is_network_ctx);

		foreach ($this->get_portable_settings_map() as $key => $type) {
			if ($is_network_ctx && in_array($key, $per_site_only, true)) {
				$target[$key] = $this->sanitize_portable_value(
					isset($stored[$key]) ? $stored[$key] : null,
					$type
				);
				continue;
			}

			$current      = isset($stored[$key]) ? $stored[$key] : null;
			$target[$key] = array_key_exists($key, $incoming)
				? $incoming[$key]
				: $this->sanitize_portable_value($current, $type);
		}

		// Only when the payload actually carries post types. Re-splitting the
		// site's existing lists on an unrelated partial import would drop any
		// disabled custom type that happens to be unregistered right now -
		// silently re-enabling its comments once it is registered again.
		$touches_post_types = array_key_exists('disabled_post_types', $incoming)
			|| array_key_exists('extra_post_types', $incoming);

		if ($touches_post_types) {
			$target = $this->normalize_post_type_fields($target, $is_network_ctx);
		}

		return $target;
	}

	/**
	 * Re-split post-type fields for this site's activation mode.
	 *
	 * The two fields mean different things depending on how the plugin is
	 * activated: get_disabled_post_types() only merges `extra_post_types` when
	 * network active, and a network context restricts `disabled_post_types` to
	 * built-ins. Copying both across verbatim loses custom post types in either
	 * direction, so both lists are merged and re-split by what this site can
	 * actually answer.
	 *
	 * @param array $target         Portable values being assembled.
	 * @param bool  $is_network_ctx Whether this import targets network storage.
	 * @return array $target with the two post-type fields rewritten.
	 */
	private function normalize_post_type_fields($target, $is_network_ctx) {
		$all   = array_values(array_unique(array_merge(
			isset($target['disabled_post_types']) ? (array) $target['disabled_post_types'] : array(),
			isset($target['extra_post_types']) ? (array) $target['extra_post_types'] : array()
		)));
		$known = array_keys($this->get_all_post_types($is_network_ctx));

		$target['disabled_post_types'] = array_values(array_intersect($all, $known));

		$leftover = array_values(array_diff($all, $known));

		if ($this->networkactive) {
			// Network storage is where a custom slug belongs; it is applied
			// per-site by get_disabled_post_types() wherever it is registered.
			$target['extra_post_types'] = $leftover;
		} else {
			// A single site has nowhere to keep a slug it does not register.
			// diff_settings() reports these as unknown_post_types.
			$target['extra_post_types'] = array();
		}

		return $target;
	}

	/**
	 * What would this import change?
	 *
	 * Diffed against the normalized target rather than the raw payload, so the
	 * preview and the applied report both describe values that will actually
	 * be stored.
	 *
	 * @param array $target   The configuration this import would leave behind.
	 * @param array $incoming The sanitized payload, for the unknown-type report.
	 * @return array {
	 *     @type array $changes            Key => array(from, to) for values that differ.
	 *     @type array $unknown_post_types Slugs the destination site does not register.
	 * }
	 */
	private function diff_settings($target, $incoming, $is_network_ctx = false) {
		$changes            = array();
		$unknown_post_types = array();
		// The same stored row build_import_target() worked from. Diffing
		// against the effective config instead would report every field the
		// constructor blanked as a change this file is about to make, when the
		// file leaves it exactly as it is.
		$stored             = $this->get_stored_settings($is_network_ctx);

		foreach ($target as $key => $value) {
			$current = isset($stored[$key]) ? $stored[$key] : null;
			$current = $this->sanitize_portable_value($current, $this->get_portable_settings_map_type($key));

			if ($current !== $value) {
				$changes[$key] = array(
					'from' => $current,
					'to'   => $value,
				);
			}
		}

		// A post type present on the source and missing here is the single most
		// likely way an import quietly does less than the user expects, so it
		// is surfaced rather than dropped. Only slugs the payload actually
		// asked for - values retained from this site are not "incoming".
		$requested = array_merge(
			isset($incoming['disabled_post_types']) ? (array) $incoming['disabled_post_types'] : array(),
			isset($incoming['extra_post_types']) ? (array) $incoming['extra_post_types'] : array()
		);

		if (!empty($requested)) {
			$registered = array_keys($this->get_all_post_types());

			foreach (array_unique($requested) as $slug) {
				if (!in_array($slug, $registered, true)) {
					$unknown_post_types[] = $slug;
				}
			}
		}

		return array(
			'changes'            => $changes,
			'unknown_post_types' => $unknown_post_types,
		);
	}

	/**
	 * Declared type for one portable setting.
	 *
	 * @param string $key Option key.
	 * @return string Type, or '' when the key is not portable.
	 */
	private function get_portable_settings_map_type($key) {
		$map = $this->get_portable_settings_map();

		return isset($map[$key]) ? $map[$key] : '';
	}

	/**
	 * Apply an import.
	 *
	 * @param mixed $payload        JSON string or decoded array.
	 * @param bool  $dry_run        When true, report the diff and write nothing.
	 * @param bool  $is_network_ctx Whether this runs in a network admin context.
	 * @return array|WP_Error Diff plus an `applied` flag, or an error.
	 */
	public function import_settings($payload, $dry_run = false, $is_network_ctx = false) {
		$incoming = $this->parse_settings_payload($payload);

		if (is_wp_error($incoming)) {
			return $incoming;
		}

		$target = $this->build_import_target($incoming, $is_network_ctx);
		$diff   = $this->diff_settings($target, $incoming, $is_network_ctx);

		if ($dry_run) {
			$diff['applied'] = false;
			return $diff;
		}

		// Nothing to write, so nothing to purge. Applying an identical file
		// used to rewrite the options and invalidate every subsite's page cache
		// while reporting no changes.
		if (empty($diff['changes'])) {
			$diff['applied'] = false;

			return $diff;
		}

		// Compose onto the stored row, not onto $this->options: update_options()
		// writes the whole array, so starting from a blanked effective config
		// would persist its defaults for every key outside the portable map -
		// disabled_sites among them, wiping the network's per-site exclusions
		// as a side effect of importing one flag.
		$stored = $this->get_stored_settings($is_network_ctx);

		foreach ($target as $key => $value) {
			$stored[$key] = $value;
		}

		$stored['db_version']     = self::DB_VERSION;
		$stored['settings_saved'] = true;

		$this->options = $stored;

		$this->update_options($is_network_ctx);

		// An import changes what the front end renders, exactly like a save.
		$this->purge_page_caches($this->get_purge_blog_ids($is_network_ctx));

		$diff['applied'] = true;

		return $diff;
	}

	/**
	 * AJAX: download the current settings as JSON.
	 */
	public function export_settings_download() {
		$nonce = (isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '');

		if (!wp_verify_nonce($nonce, 'disable_comments_save_settings')) {
			wp_send_json_error(array('message' => __('Invalid request.', 'disable-comments')), 403);
		}

		// Same gate as the import. The context comes from a GET parameter, so a
		// subsite administrator holding a valid nonce from their own screen can
		// ask for the network context - and the constructor will already have
		// loaded the network option by the time we get here. Reading it needs
		// the network capability, exactly as writing it does.
		$is_network_ctx = $this->is_network_admin_ajax_context();

		if (!current_user_can($this->get_required_import_cap($is_network_ctx))) {
			wp_send_json_error(array('message' => __('Insufficient permissions.', 'disable-comments')), 403);
		}

		nocache_headers();
		header('Content-Type: application/json; charset=' . get_option('blog_charset'));
		header('Content-Disposition: attachment; filename=disable-comments-settings-' . gmdate('Y-m-d') . '.json');

		echo wp_json_encode($this->export_settings($is_network_ctx));

		exit;
	}

	/**
	 * AJAX: preview or apply an uploaded settings file.
	 */
	public function import_settings_ajax() {
		$nonce = (isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '');

		if (!wp_verify_nonce($nonce, 'disable_comments_save_settings')) {
			wp_send_json_error(array('message' => __('Invalid request.', 'disable-comments')), 403);
		}

		$is_network_ctx = $this->is_network_admin_ajax_context();

		// An import writes the plugin's configuration, so it is gated exactly
		// like a save - including the network sitewide lock.
		if (!current_user_can($this->get_required_import_cap($is_network_ctx))) {
			wp_send_json_error(array('message' => __('Insufficient permissions.', 'disable-comments')), 403);
		}

		// Not sanitize_text_field(): this is a JSON document and that would
		// mangle it. It is validated key by key in parse_settings_payload()
		// instead, which is the only thing that makes it safe.
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
		$payload = isset($_POST['payload']) ? wp_unslash($_POST['payload']) : '';
		$dry_run = !empty($_POST['dry_run']);

		$result = $this->import_settings($payload, $dry_run, $is_network_ctx);

		if (is_wp_error($result)) {
			wp_send_json_error(array('message' => $result->get_error_message()), 400);
		}

		wp_send_json_success($result);
	}

	/**
	 * Capability required to import settings in the current context.
	 *
	 * @param bool $is_network_ctx Whether the request came from a network admin screen.
	 * @return string Capability name.
	 */
	private function get_required_import_cap($is_network_ctx) {
		if ($is_network_ctx) {
			return 'manage_network_plugins';
		}

		if ($this->networkactive && $this->sitewide_settings === '1') {
			return 'manage_network_plugins';
		}

		return 'manage_options';
	}

	public function get_form_array_escaped($_args = array()) {
		$formArray = [];
		if (!empty($_args)) {
			$formArray = wp_parse_args($_args);
		}
		// nonce is verified in the calling function
		// phpcs:ignore WordPress.Security.NonceVerification.Missing
		else if (isset($_POST['data'])) {
			// need to use wp_parse_args before map_deep sanitize_text_field
			// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing
			$formArray = map_deep(wp_parse_args(wp_unslash($_POST['data'])), 'sanitize_text_field');
		}
		return $formArray;
	}

	public function disable_comments_settings($_args = array()) {
		$nonce = (isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '');
		if (($this->is_CLI && !empty($_args)) || wp_verify_nonce($nonce, 'disable_comments_save_settings')) {
			// Resolve context ONCE — used for both cap check and save routing.
			$is_network_ctx = $this->is_network_admin_ajax_context();

			if (!$this->is_CLI) {
				if ($is_network_ctx) {
					// Network admin context → must be super admin.
					$required_cap = 'manage_network_plugins';
				} elseif ($this->networkactive && $this->sitewide_settings === '1') {
					// Sitewide lock is on → only super admin may write.
					$required_cap = 'manage_network_plugins';
				} else {
					// Subsite or single-site → manage_options suffices.
					$required_cap = 'manage_options';
				}
				if (!current_user_can($required_cap)) {
					wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
				}
			}

			$this->apply_settings($this->get_form_array_escaped($_args), $is_network_ctx, $this->is_CLI);
		}
		if (!$this->is_CLI) {
			wp_send_json_success(array('message' => __('Saved', 'disable-comments')));
			wp_die();
		}
	}

	/**
	 * Write a settings payload. Does not verify permission and does not respond.
	 *
	 * disable_comments_settings() is an AJAX endpoint: it authenticates, calls
	 * this, then terminates the request with a JSON envelope. Any caller that
	 * has done its own authentication — the Abilities API, for one — has to
	 * come in here instead, or it gets wp_die()'d before it can return a
	 * result, having changed nothing.
	 *
	 * @param array $formArray         Parsed settings payload.
	 * @param bool  $is_network_ctx    Whether to route storage to the network option.
	 * @param bool  $preserve_existing Keep the current value of anything the
	 *                                 payload omits, rather than clearing it.
	 *                                 The settings screen posts every field, so
	 *                                 it wants false; a partial programmatic
	 *                                 update wants true.
	 * @return void
	 */
	public function apply_settings($formArray, $is_network_ctx = false, $preserve_existing = false) {
		{
			$old_options = $this->options;
			$this->options = [];
			if ($preserve_existing) {
				$this->options = $old_options;
			}

			if ($is_network_ctx && function_exists('get_sites') && empty($formArray['sitewide_settings'])) {
				$formArray['disabled_sites'] = isset($formArray['disabled_sites']) ? $formArray['disabled_sites'] : [];
				$this->options['disabled_sites'] = isset($old_options['disabled_sites']) ? $old_options['disabled_sites'] : [];
				$this->options['disabled_sites'] = array_merge($this->options['disabled_sites'], $formArray['disabled_sites']);
			} elseif ($is_network_ctx && !empty($formArray['sitewide_settings'])) {
				$this->options['disabled_sites'] = $old_options['disabled_sites'];
			}

			if (isset($formArray['mode'])) {
				$this->options['remove_everywhere'] = (sanitize_text_field($formArray['mode']) == 'remove_everywhere');
			}
			$post_types = $this->get_all_post_types($is_network_ctx);

			if ($this->options['remove_everywhere']) {
				$disabled_post_types = array_keys($post_types);
			} else {
				$disabled_post_types = (isset($formArray['disabled_types']) ? array_map('sanitize_key', (array) $formArray['disabled_types']) : ($preserve_existing && isset($this->options['disabled_post_types']) ? $this->options['disabled_post_types'] : []));
			}

			$disabled_post_types = array_intersect($disabled_post_types, array_keys($post_types));
			$this->options['disabled_post_types'] = $disabled_post_types;

			// Extra custom post types.
			if ($this->networkactive && isset($formArray['extra_post_types'])) {
				$extra_post_types = array_filter(array_map('sanitize_key', explode(',', $formArray['extra_post_types'])));
				$this->options['extra_post_types'] = array_diff($extra_post_types, array_keys($post_types)); // Make sure we don't double up builtins.
			}

			if ($is_network_ctx && isset($formArray['sitewide_settings'])) {
				update_site_option('disable_comments_sitewide_settings', $formArray['sitewide_settings']);
			}

			if (isset($formArray['disable_avatar'])) {
				if ($is_network_ctx) {
					if ($formArray['disable_avatar'] == '0' || $formArray['disable_avatar'] == '1') {
						$sites = get_sites([
							'number' => 0,
							'fields' => 'ids',
						]);
						foreach ($sites as $blog_id) {
							switch_to_blog($blog_id);
							update_option('show_avatars', (bool) !$formArray['disable_avatar']);
							restore_current_blog();
						}
					}
				} else {
					update_option('show_avatars', (bool) !$formArray['disable_avatar']);
				}
			}

			if (isset($formArray['enable_exclude_by_role'])) {
				$this->options['enable_exclude_by_role'] = $formArray['enable_exclude_by_role'];
			}
			if (isset($formArray['exclude_by_role'])) {
				$this->options['exclude_by_role'] = $formArray['exclude_by_role'];
			}

			// xml rpc
			$this->options['remove_xmlrpc_comments'] = (isset($formArray['remove_xmlrpc_comments']) ? intval($formArray['remove_xmlrpc_comments']) : ($preserve_existing && isset($this->options['remove_xmlrpc_comments']) ? $this->options['remove_xmlrpc_comments'] : 0));
			// rest api comments
			$this->options['remove_rest_API_comments'] = (isset($formArray['remove_rest_API_comments']) ? intval($formArray['remove_rest_API_comments']) : ($preserve_existing && isset($this->options['remove_rest_API_comments']) ? $this->options['remove_rest_API_comments'] : 0));
			// show existing comments
			$this->options['show_existing_comments'] = (isset($formArray['show_existing_comments']) ? (bool) $formArray['show_existing_comments'] : ($preserve_existing && isset($this->options['show_existing_comments']) ? $this->options['show_existing_comments'] : false));

			// conditional rules: taxonomy / template overrides plus auto-close
			//
			// $preserve_existing, not $this->is_CLI. These three fallbacks were
			// written when WP-CLI was the only partial-update caller and the
			// two flags meant the same thing. They no longer do: an Abilities
			// API set-status is a partial update that is explicitly NOT CLI, so
			// keying on is_CLI meant a call changing only the base mode wiped
			// the site's whole rules configuration — enable off, rules emptied,
			// auto-close reset — without saying so.
			$this->options['enable_conditional_rules'] = (isset($formArray['enable_conditional_rules']) ? (bool) $formArray['enable_conditional_rules'] : ($preserve_existing && isset($old_options['enable_conditional_rules']) ? $old_options['enable_conditional_rules'] : false));

			if (isset($formArray['conditional_rules'])) {
				$this->options['conditional_rules'] = $this->sanitize_conditional_rules($formArray['conditional_rules']);
			} elseif ($preserve_existing && isset($old_options['conditional_rules'])) {
				$this->options['conditional_rules'] = $old_options['conditional_rules'];
			} else {
				$this->options['conditional_rules'] = array();
			}

			$this->options['auto_close_days'] = (isset($formArray['auto_close_days']) ? max(0, intval($formArray['auto_close_days'])) : ($preserve_existing && isset($old_options['auto_close_days']) ? (int) $old_options['auto_close_days'] : 0));

			// allowed comment types (opt-in allowlist)
			if (isset($formArray['allowed_comment_types']) && is_array($formArray['allowed_comment_types'])) {
				// Sanitize and validate the allowed comment types
				$this->options['allowed_comment_types'] = array_map('sanitize_key', $formArray['allowed_comment_types']);
			} elseif ($preserve_existing && isset($old_options['allowed_comment_types'])) {
				// A partial update must not silently un-protect the comment
				// types someone deliberately allowlisted. The settings screen
				// always posts this field, so it still clears on a real save.
				$this->options['allowed_comment_types'] = $old_options['allowed_comment_types'];
			} else {
				// Default: empty array (all special comment types disabled)
				$this->options['allowed_comment_types'] = array();
			}

			// blocked comment types (opt-in blocklist - the reverse of the
			// allowlist: these close even where comments are open)
			if (isset($formArray['blocked_comment_types']) && is_array($formArray['blocked_comment_types'])) {
				$this->options['blocked_comment_types'] = array_map('sanitize_key', $formArray['blocked_comment_types']);
			} elseif ($preserve_existing && isset($old_options['blocked_comment_types'])) {
				// Same reasoning as the allowlist above: a partial update must
				// not silently reopen a comment type someone closed.
				$this->options['blocked_comment_types'] = $old_options['blocked_comment_types'];
			} else {
				// Default: empty array (nothing closed by type)
				$this->options['blocked_comment_types'] = array();
			}

			// Nothing stops someone ticking the same type in both lists. Store
			// the resolution rather than the contradiction, so an export, an
			// import and a status report cannot describe a state the plugin
			// does not actually implement. get_allowed_comment_types() applies
			// the same precedence at read time, for settings written before
			// this ran.
			if (!empty($this->options['blocked_comment_types'])) {
				$this->options['allowed_comment_types'] = array_values(
					array_diff((array) $this->options['allowed_comment_types'], $this->options['blocked_comment_types'])
				);
			}

			$this->options['db_version'] = self::DB_VERSION;
			$this->options['settings_saved'] = true;
			// save settings
			$this->update_options($is_network_ctx);

			// A cached page keeps serving the old comment form otherwise, so the
			// setting looks ignored to every visitor until the cache expires.
			// A network save invalidates every site, not just this one.
			$this->purge_page_caches($this->get_purge_blog_ids($is_network_ctx));
		}
	}

	public function is_configured() {
		$disabled_post_types = $this->get_disabled_post_types();

		if (empty($disabled_post_types) && empty($this->options['remove_everywhere']) && empty($this->options['remove_rest_API_comments']) && empty($this->options['remove_xmlrpc_comments']) && !$this->has_conditional_rules() && !$this->has_blocked_comment_types()) {
			return false;
		}
		return true;
	}

	public function delete_comments_settings($_args = array(), $ceilings = null) {
		global $deletedPostTypeNames;
		$log = '';
		$nonce = (isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '');

		if (($this->is_CLI && !empty($_args)) || wp_verify_nonce($nonce, 'disable_comments_save_settings')) {
			// Resolve context ONCE — used for both cap check and deletion routing.
			$is_network_ctx = $this->is_network_admin_ajax_context();

			if (!$this->is_CLI) {
				if (!current_user_can($this->get_required_delete_cap($is_network_ctx))) {
					wp_send_json_error(['message' => 'Insufficient permissions.'], 403);
				}
			}

			$log = $this->apply_delete_comments($this->get_form_array_escaped($_args), $is_network_ctx, $_args, $ceilings);
		}
		// message
		$deletedPostTypeNames = array_unique((array) $deletedPostTypeNames);
		$message = (count($deletedPostTypeNames) == 0 ? $log . '.' : $log . ' for ' . implode(", ", $deletedPostTypeNames) . '.');
		if (!$this->is_CLI) {
			// `deleted` so the screen can tell "removed nothing" from "removed
			// something" without parsing the prose in `message`. A run that
			// matched nothing has no aftermath to re-render.
			wp_send_json_success(array(
				'message' => $message,
				'deleted' => $this->get_last_deleted_count(),
			));
			wp_die();
		} else {
			return $log;
		}
	}

	/**
	 * Run a deletion. Does not verify permission and does not respond.
	 *
	 * Same split as apply_settings(): delete_comments_settings() is the AJAX
	 * endpoint, this is the work it does once the caller is authenticated.
	 *
	 * @param array $formArray      Parsed delete payload.
	 * @param bool  $is_network_ctx Whether to route deletion across the network.
	 * @param array $_args          Original args, threaded to the inner helper.
	 * @return string Log message.
	 */
	/**
	 * How many comments the last delete in this request removed.
	 *
	 * Zero both when nothing matched and when no delete has run, which is the
	 * same thing to every caller: nothing was removed.
	 *
	 * @since 2.9.1
	 * @return int
	 */
	public function get_last_deleted_count() {
		return (int) $this->last_deleted_count;
	}

	public function apply_delete_comments($formArray, $is_network_ctx = false, $_args = array(), $ceilings = null) {
		$log = '';

		// Counted before and after rather than inferred from the log string,
		// so the review prompt can name a real number. It lives here rather
		// than in the AJAX wrapper because this is the function that actually
		// deletes — which means a delete driven through the Abilities API is
		// counted too, and it is just as much a completed bulk delete.
		$deleted_count = 0;

		{
			if ($is_network_ctx && function_exists('get_sites') && class_exists('WP_Site_Query')) {
				$sites = get_sites([
					'number' => 0,
					'fields' => 'ids',
				]);
				foreach ($sites as $blog_id) {
					// $formArray['disabled_sites'] ids don't include "site_" prefix.
					if (!empty($formArray['disabled_sites']) && !empty($formArray['disabled_sites']["site_$blog_id"])) {
						switch_to_blog($blog_id);
						if (!$this->can_manage_current_site()) {
							restore_current_blog();
							continue;
						}
						// Measured inside the switch, like the purge below it:
						// each subsite has its own comments table, so counting
						// around the whole loop would only ever see the site
						// the request landed on — reporting zero for a
						// deletion that emptied every subsite.
						$before = $this->count_all_comments();

						$log = $this->delete_comments($_args, $is_network_ctx, $ceilings);

						$deleted_count += max(0, $before - $this->count_all_comments());

						// Purge while this site is still switched in: per-site
						// integrations only clear the site they run in, so a
						// purge after the loop would miss every subsite.
						$this->purge_page_caches();
						restore_current_blog();
					}
				}
			} else {
				$before = $this->count_all_comments();

				$log = $this->delete_comments($_args, $is_network_ctx, $ceilings);

				$deleted_count = max(0, $before - $this->count_all_comments());

				// Deleted comments stay visible in cached pages, and so do
				// their counts, so the same purge applies here.
				$this->purge_page_caches();
			}
		}

		$this->last_deleted_count = (int) $deleted_count;

		// A completed bulk delete is the one moment the plugin has visibly
		// earned something. Recorded here, shown later on our own screens.
		if (!empty($deleted_count)) {
			$this->record_review_trigger($deleted_count);
		}

		return $log;
	}

	/**
	 * Capability required to delete comments in the current context.
	 *
	 * Shared by the delete handler, the dry-run preview and the CSV export so
	 * a caller can never reach the data through a weaker gate than the one
	 * guarding the deletion itself.
	 *
	 * @param bool $is_network_ctx Whether the request came from a network admin screen.
	 * @return string Capability name.
	 */
	private function get_required_delete_cap($is_network_ctx) {
		if ($is_network_ctx) {
			// Network admin context -> must be super admin.
			return 'manage_network_plugins';
		}
		if ($this->networkactive && $this->sitewide_settings === '1') {
			// Sitewide lock is on -> only super admin may write.
			return 'manage_network_plugins';
		}
		return 'manage_options';
	}

	/**
	 * Resolve a delete request into the set of rows it matches.
	 *
	 * One place decides what each delete mode actually selects. The preview
	 * count, the CSV export and the deletion all read their WHERE clause from
	 * here, so the number shown to the user is by construction the number of
	 * rows the delete will remove - not an estimate from a similar query.
	 *
	 * Each target is:
	 *   label     - human-readable name, used in the summary and breakdown
	 *   join      - SQL joining $wpdb->comments (aliased `comments`) to posts
	 *   where     - SQL predicate carrying %s placeholders
	 *   params    - values for those placeholders
	 *   post_type - post type slug when the target is scoped to one, else ''
	 *
	 * @param array $formArray      Parsed form data.
	 * @param bool  $is_network_ctx Whether this runs in a network admin context.
	 * @return array List of targets; empty when the mode matches nothing.
	 */
	private function get_delete_targets($formArray, $is_network_ctx = false) {
		global $wpdb;

		$targets = array();

		if (!isset($formArray['delete_mode'])) {
			return $targets;
		}

		$mode          = $formArray['delete_mode'];
		$types         = $this->get_all_post_types($is_network_ctx);
		$commenttypes  = $this->get_all_comment_types();
		$allowed_types = $this->get_allowed_comment_types();

		// Allowed comment types (WP 6.9+ notes, and anything a plugin adds to
		// the allowlist) are opt-in-preserved. They must survive every mode
		// that is not explicitly naming them.
		$exclude_allowed = '';
		$exclude_params  = array();
		if (!empty($allowed_types)) {
			$placeholders    = implode(', ', array_fill(0, count($allowed_types), '%s'));
			$exclude_allowed = " AND comments.comment_type NOT IN ($placeholders)";
			$exclude_params  = $allowed_types;
		}

		$post_join = " INNER JOIN $wpdb->posts posts ON comments.comment_post_ID=posts.ID";

		if ($mode == 'delete_everywhere') {
			$targets[] = array(
				'label'     => __('All comments', 'disable-comments'),
				'join'      => '',
				'where'     => '1=1' . $exclude_allowed,
				'params'    => $exclude_params,
				'post_type' => '',
			);
		} elseif ($mode == 'selected_delete_types') {
			$delete_post_types = empty($formArray['delete_types']) ? array() : (array) $formArray['delete_types'];
			$delete_post_types = array_intersect($delete_post_types, array_keys($types));

			// Extra custom post types.
			if ($this->networkactive && !empty($formArray['delete_extra_post_types'])) {
				$delete_extra_post_types = array_filter(array_map('sanitize_key', explode(',', $formArray['delete_extra_post_types'])));
				$delete_extra_post_types = array_diff($delete_extra_post_types, array_keys($types));    // Make sure we don't double up builtins.
				$delete_post_types = array_merge($delete_post_types, $delete_extra_post_types);
			}

			// Unique last, so it covers the free-text field too: a repeated
			// slug - --types=post,post, or book,book typed into the network
			// screen's extra-post-types box - would otherwise build two
			// identical targets, and the preview and export would count every
			// matching comment twice while the delete removes it once.
			$delete_post_types = array_values(array_unique($delete_post_types));

			foreach ($delete_post_types as $delete_post_type) {
				$post_type_object = get_post_type_object($delete_post_type);
				$post_type_label  = $post_type_object ? $post_type_object->labels->name : $delete_post_type;

				$targets[] = array(
					'label'     => $post_type_label,
					'join'      => $post_join,
					'where'     => 'posts.post_type = %s' . $exclude_allowed,
					'params'    => array_merge(array($delete_post_type), $exclude_params),
					'post_type' => $delete_post_type,
				);
			}
		} elseif ($mode == 'selected_delete_comment_types') {
			$delete_comment_types = empty($formArray['delete_comment_types']) ? array() : (array) $formArray['delete_comment_types'];
			// get_all_comment_types() already drops allowed types, so an
			// allowlisted type cannot be selected here in the first place.
			// Unique for the same reason as the post types above.
			$delete_comment_types = array_values(array_unique(array_intersect($delete_comment_types, array_keys($commenttypes))));

			foreach ($delete_comment_types as $delete_comment_type) {
				$targets[] = array(
					'label'     => $commenttypes[$delete_comment_type],
					'join'      => '',
					'where'     => 'comments.comment_type = %s',
					'params'    => array($delete_comment_type),
					'post_type' => '',
				);
			}
		} elseif ($mode == 'delete_spam') {
			$targets[] = array(
				'label'     => __('Spam comments', 'disable-comments'),
				'join'      => '',
				'where'     => 'comments.comment_approved = %s' . $exclude_allowed,
				'params'    => array_merge(array('spam'), $exclude_params),
				'post_type' => '',
			);
		}

		return $targets;
	}

	/**
	 * Build a statement for one target, prepared when it carries parameters.
	 *
	 * @param string $prefix Everything up to and including the FROM clause.
	 * @param array  $target A target from get_delete_targets().
	 * @param string $suffix Optional trailing SQL (ORDER BY, LIMIT).
	 * @param array  $extra  Extra placeholder values appended after the target's.
	 * @return string SQL, prepared where necessary.
	 */
	private function target_signature($target) {
		// Identity of one target, stable across the two separate
		// get_delete_targets() calls the export and the delete each make.
		//
		// Deliberately not the array index: the two calls would have to agree
		// on ordering forever for that to hold, and if they ever stopped
		// agreeing the mistake would be silent and would delete the wrong
		// rows. A signature that does not match simply is not found, and an
		// unfound target deletes nothing.
		return md5($target['where'] . '|' . wp_json_encode($target['params']));
	}

	private function build_target_query($prefix, $target, $suffix = '', $extra = array()) {
		global $wpdb;

		$sql    = $prefix . $target['join'] . ' WHERE ' . $target['where'] . $suffix;
		$params = array_merge($target['params'], $extra);

		if (!empty($params)) {
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
			$sql = $wpdb->prepare($sql, $params);
		}

		return $sql;
	}

	/**
	 * Count the comments a delete request would remove, without removing them.
	 *
	 * @param array $_args          Form data (WP-CLI / programmatic callers).
	 * @param bool  $is_network_ctx Whether this runs in a network admin context.
	 * @return array {
	 *     @type int   $total     Total rows matched.
	 *     @type array $breakdown Label => count, one entry per target.
	 * }
	 */
	public function count_comments_for_delete($_args = array(), $is_network_ctx = false, $sample_size = 0) {
		$formArray = $this->get_form_array_escaped($_args);

		// Opt-in, and off by default: the WP-CLI dry run and the abilities'
		// matched count read the total and nothing else, and neither should
		// pay for a query whose rows they discard.
		$sample_size = max(0, (int) $sample_size);

		// The deletion switches into each selected subsite. A preview that
		// counted only the current blog would promise a number the delete does
		// not honour - which defeats the point of having a preview.
		if ($this->is_network_delete($is_network_ctx)) {
			$blog_ids  = $this->get_selected_delete_blog_ids($formArray, $is_network_ctx);
			$breakdown = array();
			$sample    = array();
			$total     = 0;

			foreach ($blog_ids as $blog_id) {
				switch_to_blog($blog_id);

				// Same check the export and the delete make. Counting a site
				// the delete will skip tells the operator more will be removed
				// than can be, and discloses a total from a site they hold no
				// rights on.
				if (!$this->can_manage_current_site()) {
					restore_current_blog();
					continue;
				}

				$site = $this->count_comments_on_current_site($formArray, $is_network_ctx);

				// Inside the capability check above, deliberately: a row is a
				// comment's author and text, which is a great deal more than
				// the count this guard already withholds.
				$site_rows = array();
				if ($sample_size > count($sample)) {
					$site_name = get_bloginfo('name');

					foreach ($this->sample_comments_on_current_site($formArray, $is_network_ctx, $sample_size - count($sample)) as $row) {
						// Comment and post ids are site-local, so a network
						// preview that did not name the site would list rows
						// nobody could place.
						$row['site'] = $site_name;
						$site_rows[] = $row;
					}
				}

				restore_current_blog();

				$total += $site['total'];
				$sample = array_merge($sample, $site_rows);

				foreach ($site['breakdown'] as $label => $count) {
					$breakdown[$label] = (isset($breakdown[$label]) ? $breakdown[$label] : 0) + $count;
				}
			}

			return array(
				'total'     => $total,
				'breakdown' => $breakdown,
				'sample'    => $sample,
			);
		}

		$preview           = $this->count_comments_on_current_site($formArray, $is_network_ctx);
		$preview['sample'] = $this->sample_comments_on_current_site($formArray, $is_network_ctx, $sample_size);

		return $preview;
	}

	/**
	 * A few of the comments a delete would actually remove.
	 *
	 * The button says "Preview what will be deleted" and the answer was a
	 * number. A count is not a review: somebody who reads one and confirms has
	 * checked how many rows will go, not which, and this operation has no undo
	 * and no trash to recover from. So the preview shows the comments
	 * themselves - who wrote them, when, on what, and enough of the text to
	 * recognise.
	 *
	 * Newest first, because a delete that is about to take something
	 * unintended is most often taking something recent.
	 *
	 * This discloses nothing new: the caller already holds the capability the
	 * delete itself requires, and the CSV backup on the same screen hands them
	 * every column, addresses and IP addresses included.
	 *
	 * @param array $formArray      Parsed delete payload.
	 * @param bool  $is_network_ctx Whether this is a network admin request.
	 * @param int   $limit          How many rows to collect at most.
	 * @return array List of array('author', 'date', 'post', 'excerpt').
	 */
	private function sample_comments_on_current_site($formArray, $is_network_ctx, $limit) {
		global $wpdb;

		$limit = (int) $limit;

		if ($limit < 1) {
			return array();
		}

		$targets = $this->get_delete_targets($formArray, $is_network_ctx);
		$sample  = array();

		foreach ($targets as $target) {
			$remaining = $limit - count($sample);

			if ($remaining < 1) {
				break;
			}

			$select = "SELECT comments.comment_author, comments.comment_date, comments.comment_content, comments.comment_post_ID FROM $wpdb->comments comments";
			$sql    = $this->build_target_query(
				$select,
				$target,
				' ORDER BY comments.comment_date DESC LIMIT ' . (int) $remaining
			);

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
			$rows = $wpdb->get_results($sql, ARRAY_A);

			// A failed read here would quietly shorten the list, and a short
			// list reads as "this is all of it".
			$this->assert_query_succeeded();

			foreach ((array) $rows as $row) {
				$author = trim((string) $row['comment_author']);

				$sample[] = array(
					'author'  => ('' === $author) ? __('Anonymous', 'disable-comments') : $author,
					'date'    => mysql2date(get_option('date_format'), $row['comment_date']),
					'post'    => get_the_title((int) $row['comment_post_ID']),
					// Tags stripped before truncating: wp_html_excerpt counts
					// markup toward its budget, so a comment wrapped in a link
					// would come back cut to nothing worth reading.
					'excerpt' => wp_html_excerpt(wp_strip_all_tags((string) $row['comment_content']), 100, '…'),
				);
			}
		}

		return $sample;
	}

	/**
	 * The subsites a network delete would actually touch.
	 *
	 * Mirrors the loop in delete_comments_settings(). Returns an empty array
	 * whenever the deletion would run against the current site only, so the
	 * caller can take the simple path.
	 *
	 * @param array $formArray      Parsed delete payload.
	 * @param bool  $is_network_ctx Whether this is a network admin request.
	 * @return array Blog ids.
	 */
	private function get_selected_delete_blog_ids($formArray, $is_network_ctx) {
		if (!$is_network_ctx || !function_exists('get_sites') || !class_exists('WP_Site_Query')) {
			return array();
		}

		$selected = array();
		foreach (get_sites(array('number' => 0, 'fields' => 'ids')) as $blog_id) {
			if (!empty($formArray['disabled_sites']["site_$blog_id"])) {
				$selected[] = (int) $blog_id;
			}
		}

		// Returns an empty array only for a non-network request. A network
		// request with nothing ticked returns an empty list too, but the caller
		// distinguishes them via is_network_delete() - the deletion loops over
		// the selection and touches nothing, so preview and export must scope
		// to nothing as well rather than silently reporting the current site.
		return $selected;
	}

	private function can_manage_current_site() {
		// Whether the current user may act on whichever blog is switched in.
		//
		// One helper for all three network loops - preview, export, delete -
		// because they have to agree. They are the same operation seen at
		// three moments, and each time they have drifted apart the preview or
		// the export has ended up covering a site the delete refuses to touch.
		return (is_super_admin() || current_user_can('manage_options'));
	}

	/**
	 * Is this a network-scoped delete request?
	 *
	 * @param bool $is_network_ctx Whether this is a network admin request.
	 * @return bool
	 */
	private function is_network_delete($is_network_ctx) {
		return (bool) ($is_network_ctx && function_exists('get_sites') && class_exists('WP_Site_Query'));
	}

	/**
	 * Highest comment id written per blog by the last export, or null.
	 *
	 * @var array|null
	 */
	private $last_export_ceilings = null;

	/**
	 * The ceiling the last export reached, for the delete that follows it.
	 *
	 * Null when no export has run in this request — meaning the delete is not
	 * working from a backup and has nothing to constrain itself to.
	 *
	 * @return array|null blog_id => highest comment id exported.
	 */
	public function get_last_export_ceilings() {
		return $this->last_export_ceilings;
	}

	private function assert_query_succeeded() {
		global $wpdb;

		// wpdb answers a failed read the same way it answers an empty table:
		// get_results() gives an empty array, get_var() gives null. Every
		// caller here is deciding either what to tell the operator will be
		// deleted, or whether a backup is complete enough to delete against —
		// so "the replica went away" must never arrive as "there is nothing
		// there". last_error is the only thing that tells them apart.
		//
		// wpdb::query() calls flush() before it runs, which clears last_error,
		// so this cannot pick up a stale failure from an earlier query.
		if ('' !== $wpdb->last_error) {
			throw new RuntimeException('Database error while reading comments: ' . $wpdb->last_error);
		}
	}

	/**
	 * Count a delete request's matches on whichever site is current.
	 *
	 * @param array $formArray      Parsed delete payload.
	 * @param bool  $is_network_ctx Whether this is a network admin request.
	 * @return array total + breakdown.
	 */
	private function count_comments_on_current_site($formArray, $is_network_ctx) {
		global $wpdb;

		$targets = $this->get_delete_targets($formArray, $is_network_ctx);

		$breakdown = array();
		$total     = 0;

		foreach ($targets as $target) {
			$sql = $this->build_target_query("SELECT COUNT(*) FROM $wpdb->comments comments", $target);
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
			$count = (int) $wpdb->get_var($sql);
			// A failed count reads as zero, and zero is the number the
			// operator confirms the delete against.
			$this->assert_query_succeeded();

			$label             = $target['label'];
			$breakdown[$label] = (isset($breakdown[$label]) ? $breakdown[$label] : 0) + $count;
			$total            += $count;
		}

		return array(
			'total'     => $total,
			'breakdown' => $breakdown,
		);
	}

	/**
	 * Make a value safe to hand to a spreadsheet.
	 *
	 * A comment body is attacker-controlled text. Excel and Sheets execute a
	 * cell that opens with =, +, - or @, so exporting one verbatim turns a
	 * backup into a payload delivery mechanism. The usual guard is a leading
	 * apostrophe, which those programs treat as "the rest is text".
	 *
	 * But this file is also offered as a backup to restore from, and a guard
	 * that cannot be undone corrupts what it protects. Prefixing alone is
	 * ambiguous: a body of +1 and a body of '+1 both come out as '+1, so a
	 * restore cannot tell which it started from.
	 *
	 * So an apostrophe is escaped by doubling it, exactly as the format
	 * already does with quotes. The rule to reverse it is: if the value starts
	 * with an apostrophe, drop that one character. +1 becomes '+1 and restores
	 * to +1; '+1 becomes ''+1 and restores to '+1. Every value round-trips.
	 *
	 * @param string $value Raw cell value.
	 * @return string Value safe to write, and reversible by the rule above.
	 */
	public function csv_escape($value) {
		$value = (string) $value;

		if ('' === $value) {
			return $value;
		}

		$first = substr($value, 0, 1);

		// "'" is here for the round trip, not for the spreadsheet: without it
		// the guard is a one-way transformation.
		if (false !== strpos("=+-@'\t\r", $first)) {
			return "'" . $value;
		}

		return $value;
	}

	/**
	 * Undo csv_escape(), for anyone restoring from one of these files.
	 *
	 * Public so a restore script does not have to reimplement the rule and get
	 * it subtly wrong.
	 *
	 * @param string $value Value as it appears in the CSV.
	 * @return string The original value.
	 */
	public function csv_unescape($value) {
		$value = (string) $value;

		if ('' !== $value && "'" === substr($value, 0, 1)) {
			return substr($value, 1);
		}

		return $value;
	}

	/**
	 * Format one CSV record.
	 *
	 * Written by hand rather than with fputcsv(), whose backslash escape
	 * character RFC 4180 has no concept of. fputcsv() doubles a quote for the
	 * enclosure but leaves a backslash in front of it alone, so a comment
	 * containing \" - a regex, a pasted code snippet - is written as \"",
	 * where a conforming reader takes the backslash for ordinary text and the
	 * quote after it for the end of the field. Every column past that one
	 * shifts, and the CLI can go on to delete the comments against a backup
	 * that no longer lines up. Passing '' disables the escape, but only on
	 * PHP 7.4 and up, and this file has to run back to 5.6.
	 *
	 * So: quote every field, double any quote inside it, and nothing else is
	 * special. That is the whole of the format.
	 *
	 * One caveat for anyone reading this file back: PHP's own fgetcsv() and
	 * str_getcsv() default to that same non-standard backslash escape, so
	 * they need an explicit '' escape to read conforming CSV. Spreadsheet
	 * software and other languages' parsers need nothing.
	 *
	 * @param array $fields Values for one record.
	 * @return string The record, terminated.
	 */
	private function csv_write($handle, $line) {
		$expected = strlen($line);
		$written  = fwrite($handle, $line);

		// fwrite() reports a short count as an integer, not false: a disk that
		// fills mid-row returns the bytes it managed. Treating that as success
		// leaves a truncated backup the CLI then deletes against, so anything
		// short of the whole line is a failure.
		return (false !== $written && $written === $expected);
	}

	/**
	 * Format one CSV record.
	 *
	 * See csv_line()'s companion csv_write() for why writes are length-checked.
	 *
	 * @param array $fields Values for one record.
	 * @return string The record, terminated.
	 */
	private function csv_line($fields) {
		$out = array();

		foreach ($fields as $field) {
			$out[] = '"' . str_replace('"', '""', (string) $field) . '"';
		}

		return implode(',', $out) . "\n";
	}

	/**
	 * Comment meta for a batch of comments, keyed by comment id.
	 *
	 * Fetched per batch rather than per row: one query for five hundred
	 * comments instead of five hundred.
	 *
	 * @param array $comment_ids Comment ids in this batch.
	 * @return array comment_ID => array of meta_key => list of values.
	 */
	private function get_comment_meta_for_export($comment_ids) {
		global $wpdb;

		$comment_ids = array_values(array_filter(array_map('intval', (array) $comment_ids)));

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

		$placeholders = implode(', ', array_fill(0, count($comment_ids), '%d'));

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT comment_id, meta_key, meta_value FROM $wpdb->commentmeta WHERE comment_id IN ($placeholders)",
				$comment_ids
			),
			ARRAY_A
		);

		// delete_comments_for_target() removes these rows too. Exporting a
		// batch with its metadata silently missing, and counting it as backed
		// up, loses exactly what the file exists to preserve.
		$this->assert_query_succeeded();

		$out = array();
		foreach ((array) $rows as $row) {
			$id = (int) $row['comment_id'];

			if (!isset($out[$id])) {
				$out[$id] = array();
			}

			// A key can legitimately repeat, so values are lists.
			$out[$id][$row['meta_key']][] = $row['meta_value'];
		}

		return $out;
	}

	/**
	 * Core comment columns carried by the export.
	 *
	 * @return array Column names, in wp_comments order.
	 */
	private function get_exported_comment_columns() {
		return array(
			'comment_ID',
			'comment_post_ID',
			'comment_author',
			'comment_author_email',
			'comment_author_url',
			'comment_author_IP',
			'comment_date',
			'comment_date_gmt',
			'comment_content',
			'comment_karma',
			'comment_approved',
			'comment_agent',
			'comment_type',
			'comment_parent',
			'user_id',
		);
	}

	/**
	 * Export columns written without the spreadsheet guard.
	 *
	 * Deliberately the inverse of a list of "free-text" columns to escape.
	 * That is how comment_author_IP came to be written raw: an allowlist
	 * protects only what somebody remembered to put in it, and every column
	 * this export gains later is unprotected until somebody remembers again.
	 * Naming the exceptions instead makes a new column safe by default, and
	 * wrong only in the harmless direction.
	 *
	 * The exceptions are the columns wp_comments types as integers and
	 * datetimes. MySQL will not store a leading =, +, - or @ in any of them, so
	 * the guard could never fire; leaving them raw keeps a restore that does
	 * not call csv_unescape() byte-exact. Every other column is a varchar or
	 * text field holding whatever wrote it - including comment_author_IP, which
	 * is only ever an address by convention.
	 *
	 * @since 2.9.1
	 * @return array Column names to write unescaped.
	 */
	private function get_unescaped_export_columns() {
		return array(
			'comment_ID',
			'comment_post_ID',
			'comment_date',
			'comment_date_gmt',
			'comment_karma',
			'comment_parent',
			'user_id',
		);
	}

	/**
	 * Write the comments a delete request matches to an open stream as CSV.
	 *
	 * Rows are fetched in batches and keyed off the last id seen rather than
	 * an offset: the sites that reach for this tool are the ones holding
	 * hundreds of thousands of spam rows, and loading that into memory to
	 * "back it up" would take the site down instead.
	 *
	 * @param resource $handle         Open, writable stream.
	 * @param array    $_args          Form data.
	 * @param bool     $is_network_ctx Whether this runs in a network admin context.
	 * @return int Number of rows written.
	 */
	public function stream_comments_csv($handle, $_args = array(), $is_network_ctx = false) {
		$formArray = $this->get_form_array_escaped($_args);

		// Every core comment column, not a display-friendly subset. This file
		// is offered as a backup taken before an irreversible delete, so it has
		// to be able to reconstruct the rows: comment_parent carries thread
		// structure and user_id carries registered-user attribution, and
		// without them a "backup" restores a flat list of anonymous comments.
		//
		// blog_id leads because comment and post ids are site-local and collide
		// across subsites - a network export without it cannot be attributed
		// back to a site once the comments are gone.
		// A fresh export starts a fresh ceiling; a delete must never be
		// constrained by what some earlier export in this request reached.
		$this->last_export_ceilings = array();

		$columns = array_merge(
			array('blog_id'),
			$this->get_exported_comment_columns(),
			// delete_comments_for_target() removes the matching commentmeta
			// rows as well, so a file that omits them cannot restore
			// everything the operation destroyed - ratings, moderation state
			// and any plugin's own fields. JSON keeps it to one column while
			// staying machine-readable.
			array('comment_meta', 'post_title')
		);

		if (!$this->csv_write($handle, $this->csv_line($columns))) {
			throw new RuntimeException('Could not write the comment export.');
		}

		// A backup that omits the subsites the delete is about to empty is not
		// a backup, so the export follows the same site routing.
		if ($this->is_network_delete($is_network_ctx)) {
			$written  = 0;
			$blog_ids = $this->get_selected_delete_blog_ids($formArray, $is_network_ctx);

			foreach ($blog_ids as $blog_id) {
				switch_to_blog($blog_id);

				// The same per-site check delete_comments_settings() makes
				// before emptying a subsite. Without it the two disagree, and
				// the direction they disagree in is the bad one: a user who
				// holds manage_network_plugins but not manage_options on this
				// subsite cannot delete its comments, yet could export them —
				// author names, email addresses and IPs — straight out of a
				// site they have no rights on.
				//
				// Skipping here also keeps the pair consistent: a site the
				// export passed over records no ceiling, and a target with no
				// ceiling deletes nothing.
				//
				// No WP-CLI carve-out, deliberately, because the delete loop
				// has none either: both branches only run when the caller
				// passed a network context, and the CLI never does. A carve-out
				// here would only be a hole the delete does not have.
				if (!$this->can_manage_current_site()) {
					restore_current_blog();
					continue;
				}

				$written += $this->stream_comments_for_current_site($handle, $formArray, $is_network_ctx);
				restore_current_blog();
			}

			return $written;
		}

		return $this->stream_comments_for_current_site($handle, $formArray, $is_network_ctx);
	}

	/**
	 * Write the matching comments on whichever site is current.
	 *
	 * @param resource $handle         Open, writable stream.
	 * @param array    $formArray      Parsed delete payload.
	 * @param bool     $is_network_ctx Whether this is a network admin request.
	 * @return int Rows written.
	 */
	private function stream_comments_for_current_site($handle, $formArray, $is_network_ctx) {
		global $wpdb;

		$targets    = $this->get_delete_targets($formArray, $is_network_ctx);
		$batch_size = 500;
		$written    = 0;
		$blog_id    = (int) get_current_blog_id();

		// Resolved once: the innermost loop below runs per comment, and the
		// sites that reach for this tool hold hundreds of thousands of them.
		$raw_columns = $this->get_unescaped_export_columns();

		foreach ($targets as $target) {
			$last_id = 0;
			$ceiling = 0;

			do {
				$select = "SELECT comments.* FROM $wpdb->comments comments";
				$sql    = $this->build_target_query(
					$select,
					$target,
					' AND comments.comment_ID > %d ORDER BY comments.comment_ID ASC LIMIT ' . (int) $batch_size,
					array($last_id)
				);

				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
				$rows = $wpdb->get_results($sql, ARRAY_A);

				// Before the empty check, not after: a failed read is
				// indistinguishable from the end of the batches, and treating
				// it as the end produces a short or header-only file that the
				// CLI then reports as a complete backup and deletes against.
				$this->assert_query_succeeded();

				if (empty($rows)) {
					break;
				}

				$meta = $this->get_comment_meta_for_export(wp_list_pluck($rows, 'comment_ID'));

				foreach ($rows as $row) {
					$last_id = (int) $row['comment_ID'];

					$title = get_the_title($row['comment_post_ID']);

					$line = array(get_current_blog_id());

					foreach ($this->get_exported_comment_columns() as $column) {
						$value = isset($row[$column]) ? $row[$column] : '';
						// Guarded unless the column is one of the few that
						// cannot carry a payload - see
						// get_unescaped_export_columns() for why the test runs
						// this way round.
						$line[] = in_array($column, $raw_columns, true)
							? $value
							: $this->csv_escape($value);
					}

					$comment_meta = isset($meta[(int) $row['comment_ID']]) ? $meta[(int) $row['comment_ID']] : array();
					$line[] = $this->csv_escape(empty($comment_meta) ? '' : wp_json_encode($comment_meta));
					$line[] = $this->csv_escape($title);

					$ok = $this->csv_write($handle, $this->csv_line($line));

					// A disk that fills up mid-export must not produce a
					// truncated file the caller then treats as a complete
					// backup and deletes against.
					if (!$ok) {
						throw new RuntimeException('Could not write the comment export.');
					}

					$written++;

					if ($last_id > $ceiling) {
						$ceiling = $last_id;
					}
				}
			} while (count($rows) === $batch_size);

			// comment_ID is AUTO_INCREMENT, so anything inserted after this
			// target finished sorts above its ceiling, and capping its delete
			// there keeps it to rows the file contains.
			//
			// Per target, not per blog. Targets stream one after another, so a
			// blog-wide maximum lets a later target raise an earlier one's cap:
			// finish exporting posts at id 1000, a new post comment arrives as
			// 1001 unexported, then a page comment at 1002 is exported and
			// lifts the blog ceiling to 1002 — and the delete takes 1001 with
			// it. Each target is bounded by what that target actually read.
			$signature = $this->target_signature($target);
			$current   = isset($this->last_export_ceilings[$blog_id][$signature])
				? $this->last_export_ceilings[$blog_id][$signature]
				: 0;
			$this->last_export_ceilings[$blog_id][$signature] = max($current, $ceiling);
		}

		return $written;
	}

	/**
	 * CSV for the comments a delete request matches, as a string.
	 *
	 * Convenience wrapper around stream_comments_csv() for WP-CLI and tests.
	 * The download handler streams instead of calling this.
	 *
	 * @param array $_args          Form data.
	 * @param bool  $is_network_ctx Whether this runs in a network admin context.
	 * @return string CSV payload.
	 */
	public function export_comments_csv($_args = array(), $is_network_ctx = false) {
		$handle = fopen('php://temp', 'r+');
		if (false === $handle) {
			return '';
		}

		$this->stream_comments_csv($handle, $_args, $is_network_ctx);

		rewind($handle);
		$csv = stream_get_contents($handle);
		fclose($handle);

		return $csv;
	}

	/**
	 * AJAX: how many comments would this delete remove?
	 */
	public function preview_delete_comments() {
		$nonce = (isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '');

		if (!wp_verify_nonce($nonce, 'disable_comments_save_settings')) {
			wp_send_json_error(array('message' => __('Invalid request.', 'disable-comments')), 403);
		}

		$is_network_ctx = $this->is_network_admin_ajax_context();

		if (!current_user_can($this->get_required_delete_cap($is_network_ctx))) {
			wp_send_json_error(array('message' => __('Insufficient permissions.', 'disable-comments')), 403);
		}

		$preview = $this->count_comments_for_delete(array(), $is_network_ctx, self::DELETE_PREVIEW_SAMPLE_SIZE);

		wp_send_json_success($preview);
	}

	/**
	 * AJAX: download the matching comments as CSV before deleting them.
	 */
	public function export_comments_download() {
		$nonce = (isset($_POST['nonce']) ? sanitize_text_field(wp_unslash($_POST['nonce'])) : '');

		if (!wp_verify_nonce($nonce, 'disable_comments_save_settings')) {
			wp_send_json_error(array('message' => __('Invalid request.', 'disable-comments')), 403);
		}

		$is_network_ctx = $this->is_network_admin_ajax_context();

		if (!current_user_can($this->get_required_delete_cap($is_network_ctx))) {
			wp_send_json_error(array('message' => __('Insufficient permissions.', 'disable-comments')), 403);
		}

		nocache_headers();
		header('Content-Type: text/csv; charset=' . get_option('blog_charset'));
		header('Content-Disposition: attachment; filename=disable-comments-export-' . gmdate('Y-m-d-His') . '.csv');

		$handle = fopen('php://output', 'w');
		if (false !== $handle) {
			$this->stream_comments_csv($handle, array(), $is_network_ctx);
			fclose($handle);
		}

		exit;
	}

	/**
	 * Delete the rows one target matches, meta first.
	 *
	 * @param array $target A target from get_delete_targets().
	 */
	private function recalculate_comment_counts($post_type = null) {
		global $wpdb;

		// Only reached when an export ceiling was in effect. The usual path
		// sets comment_count to zero because the delete emptied the target
		// outright; a ceiling deliberately leaves the comments that arrived
		// after the export behind, and zeroing anyway hides them from every
		// count-based display on the site.
		//
		// Counts approved comments only, which is what core's own
		// wp_update_comment_count_now() means by comment_count.
		$count = "(SELECT COUNT(*) FROM $wpdb->comments comments WHERE comments.comment_post_ID = posts.ID AND comments.comment_approved = '1')";

		if (null === $post_type) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
			$wpdb->query("UPDATE $wpdb->posts posts SET comment_count = $count");

			return;
		}

		// Deliberately without the post_author != 0 filter the zeroing query
		// alongside this one carries. That filter decides which posts the
		// delete bothers to blank; this query is fixing a number that is now
		// wrong, and a post with no author needs an accurate count just as
		// much as any other. Inheriting it left authorless posts reporting a
		// count for comments that are gone.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
		$wpdb->query($wpdb->prepare("UPDATE $wpdb->posts posts SET comment_count = $count WHERE posts.post_type = %s", $post_type));
	}

	private function delete_comments_for_target($target, $max_id = null) {
		global $wpdb;

		// An export, if one ran, has already read every row it is going to.
		// A comment arriving between that last read and this delete is not in
		// the file, so it must survive: comment_ID is AUTO_INCREMENT, which
		// makes a ceiling on the id an exact description of "what the backup
		// saw". Without an export there is no ceiling and this is a no-op.
		//
		// The window this does NOT close: an older comment that changes into
		// the matching set during it — an existing comment marked as spam
		// after the export read past it — keeps its low id and is still
		// removed without being in the file. Closing that needs a lock or a
		// transaction across both operations, which is a bigger change than
		// this one and not obviously worth it for a maintenance tool.
		$ceiling_sql    = '';
		$ceiling_params = array();

		if (null !== $max_id) {
			$ceiling_sql    = ' AND comments.comment_ID <= %d';
			$ceiling_params = array((int) $max_id);
		}

		// Meta first: once the comments are gone, the join that identifies
		// their meta rows no longer matches anything.
		$meta_sql = $this->build_target_query(
			"DELETE cmeta FROM $wpdb->commentmeta cmeta INNER JOIN $wpdb->comments comments ON cmeta.comment_id=comments.comment_ID",
			$target,
			$ceiling_sql,
			$ceiling_params
		);
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
		$wpdb->query($meta_sql);

		$sql = $this->build_target_query("DELETE comments FROM $wpdb->comments comments", $target, $ceiling_sql, $ceiling_params);
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.NotPrepared
		$wpdb->query($sql);
	}

	private function delete_comments($_args, $is_network_ctx = false, $ceilings = null) {
		global $wpdb;
		global $deletedPostTypeNames;

		// When an export ran first, this delete is the second half of a
		// "back it up, then remove it" pair, and it must not remove anything
		// the file does not contain. A blog absent from the map exported
		// nothing, so its ceiling is zero and it deletes nothing.
		//
		// Null means no export ran: an ordinary delete, uncapped.
		$blog_ceilings = null;
		if (is_array($ceilings)) {
			$blog_id       = (int) get_current_blog_id();
			$blog_ceilings = isset($ceilings[$blog_id]) ? (array) $ceilings[$blog_id] : array();
		}

		$formArray = $this->get_form_array_escaped($_args);
		$targets   = $this->get_delete_targets($formArray, $is_network_ctx);

		if (empty($targets)) {
			return '';
		}

		$mode  = $formArray['delete_mode'];
		$types = $this->get_all_post_types($is_network_ctx);

		foreach ($targets as $target) {
			$max_id = null;
			if (null !== $blog_ceilings) {
				$signature = $this->target_signature($target);
				// A target the export never recorded exported nothing, so it
				// deletes nothing.
				$max_id = isset($blog_ceilings[$signature]) ? (int) $blog_ceilings[$signature] : 0;
			}

			$this->delete_comments_for_target($target, $max_id);

			if ('selected_delete_types' === $mode) {
				if (null === $max_id) {
					// Nothing was spared, so every post of this type is empty.
					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
					$wpdb->query($wpdb->prepare("UPDATE $wpdb->posts SET comment_count = 0 WHERE post_author != 0 AND post_type = %s", $target['post_type']));
				} else {
					$this->recalculate_comment_counts($target['post_type']);
				}
			}

			if ('selected_delete_types' === $mode || 'selected_delete_comment_types' === $mode) {
				$deletedPostTypeNames[] = $target['label'];
			}
		}

		if ('delete_everywhere' === $mode) {
			// $blog_ceilings, not $max_id: that one is per target and scoped to
			// the loop above, and reading it out here would just be whatever
			// the last iteration happened to leave behind. The question here is
			// only whether an export ran at all.
			if (null === $blog_ceilings) {
				// Update comment counts
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
				$wpdb->query("UPDATE $wpdb->posts SET comment_count = 0");
			} else {
				$this->recalculate_comment_counts();
			}
		} elseif ('selected_delete_comment_types' === $mode) {
			// Update comment_count on post_types
			foreach ($types as $key => $value) {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
				$comment_count = $wpdb->get_var($wpdb->prepare("SELECT COUNT(comments.comment_ID) FROM $wpdb->comments comments INNER JOIN $wpdb->posts posts ON comments.comment_post_ID=posts.ID WHERE posts.post_type = %s", $key));
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
				$wpdb->query($wpdb->prepare("UPDATE $wpdb->posts SET comment_count = %d WHERE post_author != 0 AND post_type = %s", $comment_count, $key));
			}
		}

		$this->optimize_table($wpdb->commentmeta);
		$this->optimize_table($wpdb->comments);

		$log = ('delete_spam' === $mode)
			? __('All spam comments have been deleted.', 'disable-comments')
			: __('All comments have been deleted', 'disable-comments');

		delete_transient('wc_count_comments');
		return $log;
	}

	/**
	 * Total rows in the comments table.
	 *
	 * @return int
	 */
	private function count_all_comments() {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
		return (int) $wpdb->get_var("SELECT COUNT(*) FROM $wpdb->comments");
	}

	private function discussion_settings_allowed() {
		if (defined('DISABLE_COMMENTS_ALLOW_DISCUSSION_SETTINGS') && DISABLE_COMMENTS_ALLOW_DISCUSSION_SETTINGS == true) {
			return true;
		}
	}

	public function single_site_deactivate() {
		// for single sites, delete the options upon deactivation, not uninstall.
		delete_option('disable_comments_options');
		$this->delete_blocked_stats_options();

		// The settings this trigger was recorded against are gone, so a
		// reactivation would greet the user with "we just cleared N comments"
		// about a delete belonging to the previous configuration.
		//
		// The per-user dismissal deliberately does not go with it: somebody
		// who said "don't ask again" must not be asked again because an admin
		// toggled the plugin off and on.
		delete_option(self::REVIEW_TRIGGER_OPTION);
	}

	/**
	 * Remove every option the blocked-attempt counters created.
	 *
	 * These are autoloaded and per-site. Left behind they are both stale
	 * plugin data and, on a reinstall, someone else's counts presented as
	 * current telemetry.
	 *
	 * @return void
	 */
	public function delete_blocked_stats_options() {
		foreach (array_keys($this->get_blocked_vectors()) as $vector) {
			delete_option($this->get_blocked_vector_option($vector));
		}

		delete_option(self::BLOCKED_SINCE_OPTION);
	}

	/**
	 * We need fresh data in every call. Called after switching to blog in loop.
	 *
	 * @return int The number of comments.
	 */
	protected function __get_comment_count() {
		global $wpdb;

		// Exclude allowed comment types from the count since they cannot be deleted
		// and should not be displayed in the "Total Comments" count in the Delete Comments tab
		$allowed_types = $this->get_allowed_comment_types();

		if (empty($allowed_types)) {
			// No allowed types, count all comments
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
			return $wpdb->get_var("SELECT COUNT(comment_id) FROM $wpdb->comments");
		}

		// Build exclusion query for allowed comment types
		$placeholders = implode(', ', array_fill(0, count($allowed_types), '%s'));
		$query = $wpdb->prepare(
			"SELECT COUNT(comment_id) FROM $wpdb->comments WHERE comment_type NOT IN ($placeholders)",
			$allowed_types
		);

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
		return $wpdb->get_var($query);
	}

	/**
	 * Optimize a given table in the WordPress database.
	 *
	 * @param string $table_name The name of the table to optimize.
	 */
	protected function optimize_table($table_name) {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
		return $wpdb->query("OPTIMIZE TABLE " . esc_sql($table_name));
	}

	/**
	 * Truncate a given table in the WordPress database.
	 *
	 * @param string $table_name The name of the table to truncate.
	 */
	protected function truncate_table($table_name) {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
		return $wpdb->query("TRUNCATE TABLE " . esc_sql($table_name));
	}

	/**
	 * Get the current site-wide comment status as a descriptive string.
	 *
	 * This function analyzes the current Disable Comments plugin configuration
	 * and returns a string describing which content types have comments disabled.
	 *
	 * @return string The current comment status:
	 *                - 'all' if comments are disabled site-wide for all content types
	 *                - 'posts' if comments are disabled only for posts
	 *                - 'pages' if comments are disabled only for pages
	 *                - 'posts,pages' if comments are disabled for both posts and pages
	 *                - 'custom_type_name' for other specific content types
	 *                - 'multiple' if multiple specific types are disabled (not all)
	 *                - 'none' if comments are not disabled anywhere
	 *
	 * @since 2.5.2
	 */
	public function get_current_comment_status() {
		try {
			// Handle case where plugin is not properly initialized
			if (empty($this->options)) {
				return 'none';
			}

			// Check if comments are disabled everywhere
			if ($this->is_remove_everywhere()) {
				return 'all';
			}

			// Get disabled post types. Reporting-only, so unregistered slugs left
			// behind by a deactivated CPT plugin are excluded — they would
			// otherwise be summarised as though the type still existed.
			$disabled_post_types = $this->get_disabled_post_types_registered();

			// If no post types are disabled, comments are enabled everywhere
			if (empty($disabled_post_types)) {
				return 'none';
			}

			// Get all available post types that support comments
			$all_post_types = $this->get_all_post_types();
			$all_post_type_keys = array_keys($all_post_types);

			// Check if all available post types are disabled
			if (count($disabled_post_types) >= count($all_post_type_keys)) {
				$missing_types = array_diff($all_post_type_keys, $disabled_post_types);
				if (empty($missing_types)) {
					return 'all';
				}
			}

			// Handle specific common cases
			if (count($disabled_post_types) === 1) {
				$disabled_type = $disabled_post_types[0];

				// Return the specific post type name for single disabled types
				switch ($disabled_type) {
					case 'post':
						return 'posts';
					case 'page':
						return 'pages';
					default:
						// For custom post types, return the post type slug
						return $disabled_type;
				}
			}

			// Handle multiple specific post types
			if (count($disabled_post_types) === 2 &&
				in_array('post', $disabled_post_types) &&
				in_array('page', $disabled_post_types)) {
				return 'posts,pages';
			}

			// For other combinations, return 'multiple' to indicate partial disabling
			return 'multiple';
		} catch (Exception $e) {
			// Error handling - return safe default
			if (defined('WP_DEBUG') && WP_DEBUG) {
				// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging for WP_DEBUG mode
				error_log('Disable Comments: Error in get_current_comment_status() - ' . $e->getMessage());
			}
			return 'none';
		}
	}

	/**
	 * Get detailed comment status information including API restrictions.
	 *
	 * This function provides comprehensive information about comment restrictions
	 * including post type restrictions, API-level restrictions, network settings,
	 * role exclusions, and comment counts.
	 *
	 * @return array Associative array with detailed status information:
	 *               - 'status' => Main status (same as get_current_comment_status())
	 *               - 'disabled_post_types' => Array of disabled post type slugs
	 *               - 'disabled_post_type_labels' => Array of disabled post type labels
	 *               - 'remove_everywhere' => Boolean indicating global disable
	 *               - 'xmlrpc_disabled' => Boolean indicating XML-RPC comments disabled
	 *               - 'rest_api_disabled' => Boolean indicating REST API comments disabled
	 *               - 'total_post_types' => Total number of available post types
	 *               - 'is_configured' => Boolean indicating if plugin is configured
	 *               - 'total_comments' => Total number of comments in database
	 *               - 'network_active' => Boolean indicating if plugin is network activated
	 *               - 'sitewide_settings' => Site-wide settings status
	 *               - 'role_exclusion_enabled' => Boolean indicating if role exclusions are enabled
	 *               - 'excluded_roles' => Array of excluded role slugs
	 *               - 'excluded_role_labels' => Array of human-readable excluded role names
	 *
	 * @since 2.5.2
	 */
	public function get_detailed_comment_status() {
		try {
			$status = $this->get_current_comment_status();
			$disabled_post_types = $this->get_disabled_post_types_registered();
			$all_post_types = $this->get_all_post_types();

			// Get human-readable labels for disabled post types
			$disabled_labels = array();
			foreach ($disabled_post_types as $post_type) {
				if (isset($all_post_types[$post_type])) {
					$disabled_labels[] = $all_post_types[$post_type]->labels->name;
				} else {
					// Fallback for custom post types not in the main list
					$post_type_obj = get_post_type_object($post_type);
					$disabled_labels[] = $post_type_obj ? $post_type_obj->labels->name : $post_type;
				}
			}

			// Get total comments count
			$total_comments = $this->get_all_comments_number();

			// Determine site-wide settings status
			$sitewide_settings = 'not_applicable';
			if ($this->networkactive) {
				$sitewide_settings = isset($this->options['sitewide_settings']) && $this->options['sitewide_settings'] ?
					'enabled' : 'disabled';
			}

			// Process role-based exclusion information
			$role_exclusion_enabled = isset($this->options['enable_exclude_by_role']) && $this->options['enable_exclude_by_role'];
			$excluded_roles = isset($this->options['exclude_by_role']) ? $this->options['exclude_by_role'] : array();

			// Get human-readable role names
			$excluded_role_labels = array();
			if ($role_exclusion_enabled && !empty($excluded_roles)) {
				$editable_roles = get_editable_roles();

				foreach ($excluded_roles as $role) {
					if ($role === 'logged-out-users') {
						$excluded_role_labels[] = __('Logged out users', 'disable-comments');
					} elseif (isset($editable_roles[$role])) {
						$excluded_role_labels[] = translate_user_role($editable_roles[$role]['name']);
					} else {
						$excluded_role_labels[] = $role;
					}
				}
			}

			return array(
				'status' => $status,
				'disabled_post_types' => $disabled_post_types,
				'disabled_post_type_labels' => $disabled_labels,
				// Same vocabulary as the get-status ability, deliberately: the
				// two describe one site, and a Site Health panel disagreeing
				// with the API about it is worse than either being silent.
				// Counts follow get_conditional_rules(), so a rule stored while
				// the feature is off reports as zero - nothing is enforcing it.
				'conditional_rules_enabled' => $this->has_conditional_rules(),
				'conditional_rules_count' => count($this->get_conditional_rules()),
				'auto_close_days' => $this->get_auto_close_days(),
				'remove_everywhere' => $this->is_remove_everywhere(),
				'xmlrpc_disabled' => !empty($this->options['remove_xmlrpc_comments']),
				'rest_api_disabled' => !empty($this->options['remove_rest_API_comments']),
				'show_existing_comments' => !empty($this->options['show_existing_comments']),
				'total_post_types' => count($all_post_types),
				'is_configured' => $this->is_configured(),
				'total_comments' => $total_comments,
				'network_active' => $this->networkactive,
				'sitewide_settings' => $sitewide_settings,
				'role_exclusion_enabled' => $role_exclusion_enabled,
				'excluded_roles' => $excluded_roles,
				'excluded_role_labels' => $excluded_role_labels
			);
		} catch (Exception $e) {
			// Error handling - return safe defaults
			if (defined('WP_DEBUG') && WP_DEBUG) {
				// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug logging for WP_DEBUG mode
				error_log('Disable Comments: Error in get_detailed_comment_status() - ' . $e->getMessage());
			}
			return array(
				'status' => 'none',
				'disabled_post_types' => array(),
				'disabled_post_type_labels' => array(),
				'conditional_rules_enabled' => false,
				'conditional_rules_count' => 0,
				'auto_close_days' => 0,
				'remove_everywhere' => false,
				'xmlrpc_disabled' => false,
				'rest_api_disabled' => false,
				'show_existing_comments' => false,
				'total_post_types' => 0,
				'is_configured' => false,
				'total_comments' => 0,
				'network_active' => false,
				'sitewide_settings' => 'not_applicable',
				'role_exclusion_enabled' => false,
				'excluded_roles' => array(),
				'excluded_role_labels' => array()
			);
		}
	}
	/**
	 * Add Disable Comments information to WordPress Site Health Info panel.
	 *
	 * This method integrates the plugin's status information into WordPress's
	 * built-in Site Health system for easy debugging and site overview.
	 *
	 * @param array $debug_info The debug information array.
	 * @return array Modified debug information array.
	 *
	 * @since 2.5.2
	 */
	public function add_site_health_info($debug_info) {
		$data = $this->get_detailed_comment_status();

		// Create the main status description
		$status_descriptions = array(
			'all' => __('Comments are disabled site-wide for all content types', 'disable-comments'),
			'posts' => __('Comments are disabled only for blog posts', 'disable-comments'),
			'pages' => __('Comments are disabled only for pages', 'disable-comments'),
			'posts,pages' => __('Comments are disabled for both posts and pages', 'disable-comments'),
			'multiple' => __('Comments are disabled for multiple specific content types', 'disable-comments'),
			'none' => __('Comments are enabled everywhere', 'disable-comments'),
		);

		// translators: %s: disabled post types.
		$other_status_description = sprintf(__('Comments are disabled for: %s', 'disable-comments'), $data['status']);
		$status_description = isset($status_descriptions[$data['status']]) ?
			$status_descriptions[$data['status']] :
			$other_status_description;

		// Every sentence above describes the global and per-post-type settings
		// only. With rules live they are the starting point rather than the
		// answer - a site with nothing disabled globally still closes comments
		// on every post a rule matches, and this section read "Comments are
		// enabled everywhere" while that was happening.
		if (!empty($data['conditional_rules_enabled'])) {
			$status_description = sprintf(
				/* translators: %s: sentence describing the global and post-type settings. */
				__('%s. Conditional rules then close or reopen comments on individual posts', 'disable-comments'),
				$status_description
			);
		}

		// Format site-wide settings value
		$sitewide_settings_labels = array(
			'enabled' => __('Enabled', 'disable-comments'),
			'disabled' => __('Disabled', 'disable-comments'),
			'not_applicable' => __('Not applicable', 'disable-comments'),
		);

		// Build the fields array using data from get_detailed_comment_status()
		$fields = array(
			'status' => array(
				'label' => __('Comment Status', 'disable-comments'),
				'value' => $status_description,
			),
			'plugin_configured' => array(
				'label' => __('Plugin Configured', 'disable-comments'),
				'value' => $data['is_configured'] ? __('Yes', 'disable-comments') : __('No', 'disable-comments'),
			),
			'total_comments' => array(
				'label' => __('Total Comments', 'disable-comments'),
				'value' => number_format_i18n($data['total_comments']),
			),
			'global_disable' => array(
				'label' => __('Global Disable Active', 'disable-comments'),
				'value' => $data['remove_everywhere'] ? __('Yes', 'disable-comments') : __('No', 'disable-comments'),
			),
			'disabled_post_type_count' => array(
				'label' => __('Disabled Post Types Count', 'disable-comments'),
				'value' => sprintf('%d of %d', count($data['disabled_post_types']), $data['total_post_types']),
			),
			'disabled_post_types' => array(
				'label' => __('Disabled Post Types', 'disable-comments'),
				'value' => !empty($data['disabled_post_type_labels']) ?
					implode(', ', $data['disabled_post_type_labels']) :
					__('None', 'disable-comments'),
			),
			'xmlrpc_comments' => array(
				'label' => __('XML-RPC Comments', 'disable-comments'),
				'value' => $data['xmlrpc_disabled'] ? __('Disabled', 'disable-comments') : __('Enabled', 'disable-comments'),
			),
			'rest_api_comments' => array(
				'label' => __('REST API Comments', 'disable-comments'),
				'value' => $data['rest_api_disabled'] ? __('Disabled', 'disable-comments') : __('Enabled', 'disable-comments'),
			),
			'show_existing_comments' => array(
				'label' => __('Show Existing Comments', 'disable-comments'),
				'value' => $data['show_existing_comments'] ? __('Yes', 'disable-comments') : __('No', 'disable-comments'),
			),
			'network_active' => array(
				'label' => __('Network Active', 'disable-comments'),
				'value' => $data['network_active'] ? __('Yes', 'disable-comments') : __('No', 'disable-comments'),
			),
			'sitewide_settings' => array(
				'label' => __('Site-wide Settings', 'disable-comments'),
				'value' => $sitewide_settings_labels[$data['sitewide_settings']],
			),
			'role_exclusion_enabled' => array(
				'label' => __('Role-based Exclusions', 'disable-comments'),
				'value' => $data['role_exclusion_enabled'] ? __('Enabled', 'disable-comments') : __('Disabled', 'disable-comments'),
			),
			'excluded_roles' => array(
				'label' => __('Excluded Roles', 'disable-comments'),
				'value' => !empty($data['excluded_role_labels']) ?
					implode(', ', $data['excluded_role_labels']) :
					__('None', 'disable-comments'),
			),
			// Reported whether or not they are on. Somebody comparing two
			// sites, or reading a support ticket's Site Health paste, needs
			// "rules: off" said out loud rather than inferred from a missing
			// line.
			'conditional_rules' => array(
				'label' => __('Conditional Rules', 'disable-comments'),
				'value' => $data['conditional_rules_enabled'] ?
					__('Enabled', 'disable-comments') :
					__('Disabled', 'disable-comments'),
			),
			'conditional_rules_count' => array(
				'label' => __('Conditional Rules Configured', 'disable-comments'),
				'value' => number_format_i18n($data['conditional_rules_count']),
			),
			'auto_close_days' => array(
				'label' => __('Auto-close Comments After', 'disable-comments'),
				'value' => $data['auto_close_days'] > 0 ?
					sprintf(
						/* translators: %s: number of days. */
						_n('%s day', '%s days', $data['auto_close_days'], 'disable-comments'),
						number_format_i18n($data['auto_close_days'])
					) :
					__('No age limit', 'disable-comments'),
			),
		);

		// Blocked attempts. Site Health is where an admin auditing a site
		// looks, and it is the one screen that shows the plugin is still
		// doing something rather than sitting idle.
		if ($this->blocked_stats_enabled()) {
			$blocked = $this->get_blocked_stats();

			$fields['blocked_since'] = array(
				'label' => __('Counting Blocked Attempts Since', 'disable-comments'),
				'value' => date_i18n(get_option('date_format'), $blocked['since']),
			);

			foreach ($this->get_blocked_vectors() as $vector => $label) {
				$fields['blocked_' . $vector] = array(
					// translators: %s: name of the blocked request type.
					'label' => sprintf(__('Blocked: %s', 'disable-comments'), $label),
					'value' => number_format_i18n($blocked['counts'][$vector]),
				);
			}
		}

		// Add the section to Site Health
		$debug_info['disable-comments'] = array(
			'label' => __('Disable Comments', 'disable-comments'),
			'description' => __('Complete overview of comment disable settings and configuration.', 'disable-comments'),
			'fields' => $fields,
		);

		return $debug_info;
	}
}

Disable_Comments::get_instance();