Server IP : 168.119.101.163  /  Your IP : 216.73.217.54
Web Server : Apache/2
System : Linux web02.webzuiver.nl 4.18.0-553.126.2.lve.el8.x86_64 #1 SMP Thu May 28 14:12:30 UTC 2026 x86_64
User : equine ( 1027)
PHP Version : 8.1.23
Disable Function : NONE
MySQL : OFF  |  cURL : ON  |  WGET : OFF  |  Perl : OFF  |  Python : OFF
Directory (0755) :  /home/equine/public_html/wp-content/plugins/complianz-gdpr-premium/cookie/

[  Home  ][  C0mmand  ][  Upload File  ]

Current File : /home/equine/public_html/wp-content/plugins/complianz-gdpr-premium/cookie/class-scan.php
<?php
/**
 * Class scan.
 *
 * @package Complianz
 */

defined( 'ABSPATH' ) or die();

if ( ! class_exists( 'cmplz_scan' ) ) {
	// phpcs:disable PEAR.NamingConventions.ValidClassName.StartWithCapital, PEAR.NamingConventions.ValidClassName.Invalid, Squiz.Commenting.ClassComment.Missing
	class cmplz_scan {
	// phpcs:enable PEAR.NamingConventions.ValidClassName.StartWithCapital, PEAR.NamingConventions.ValidClassName.Invalid, Squiz.Commenting.ClassComment.Missing

		/** Singleton instance.
		 *
		 * @var self
		 */
		private static $_this;

		function __construct() {
			if ( isset( self::$_this ) ) {
				wp_die(
					sprintf(
						'%s is a singleton class and you cannot create a second instance.',
						get_class( $this )
					)
				);
			}
			self::$_this = $this;
			if ( cmplz_scan_in_progress() ) {
				add_action( 'wp_print_footer_scripts', array( $this, 'test_cookies' ), PHP_INT_MAX, 2 );
			}

			add_action( 'cmplz_every_day_hook', array( $this, 'track_cookie_changes' ) );
			add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_assets' ) );
			add_action( 'admin_footer', array( $this, 'run_cookie_scan' ) );
			add_filter( 'cmplz_do_action', array( $this, 'get_scan_progress' ), 10, 3 );
			add_filter( 'cmplz_do_action', array( $this, 'reset_scan' ), 11, 3 );
			add_filter( 'cmplz_every_day_hook', array( $this, 'background_remote_scan' ), 11, 3 );
			add_action( 'admin_init', array( $this, 'register_scan_post_columns' ) );
			add_filter( 'cmplz_warning_types', array( $this, 'add_scan_upsell_warnings' ) );
		}

		static function this() {
			return self::$_this;
		}

		/**
		 * If the remote scan is active, or has started, and we're not on a complianz page, run this on cron in the background
		 *
		 * @return void
		 */
		public function background_remote_scan() {

			if ( ! wp_doing_cron() ) {
				return;
			}

			if ( isset( $_GET['page'] ) && 'complianz' === $_GET['page'] ) {
				return;
			}

			$url = $this->get_next_page_url();
			if ( ! $url ) {
				return;
			}

			if ( 'remote' === $url && ! COMPLIANZ::$wsc_scanner->wsc_scan_completed() ) {
				// As the wsc cookie scan has a wait of 10 seconds on each request, we do this on cron.
				do_action( 'cmplz_remote_cookie_scan' );
			}
		}

		/**
		 * Check if there are any new cookies added
		 */
		public function track_cookie_changes() {
			// Cron exception: this runs on cmplz_every_day_hook — without it the
			// daily cookie change check can never fire (cron has no user).
			if ( ! cmplz_user_can_manage() && ! wp_doing_cron() ) {
				return;
			}

			// only run if all pages are scanned.
			if ( ! $this->scan_complete() ) {
				return;
			}
			// check if anything was changed.
			$new_cookies = COMPLIANZ::$banner_loader->get_cookies( array( 'new' => true ) );
			if ( count( $new_cookies ) > 0 ) {
				$this->set_cookies_changed();
			}
		}

		/**
		 * Set the cookies as having been changed
		 */
		public function set_cookies_changed() {
			update_option( 'cmplz_changed_cookies', 1, false );
		}

		/**
		 * Check if cookies have been changed
		 *
		 * @return bool
		 */
		public function cookies_changed() {
			return ( get_option( 'cmplz_changed_cookies' ) == 1 );
		}

		/**
		 * Delete the transient that contains the pages list
		 *
		 * @param int  $post_id     Post ID being saved (unused, required by hook signature).
		 * @param bool $post_after  Post state after save (unused).
		 * @param bool $post_before Post state before save (unused).
		 */
		public function clear_pages_list( int $post_id, $post_after = false, $post_before = false ) {
			delete_transient( 'cmplz_pages_list' );
		}

		/**
		 * Clean up duplicate cookie names
		 *
		 * @return void
		 */
		public function clear_double_cookienames() {
			if ( ! cmplz_user_can_manage() ) {
				return;
			}
			global $wpdb;

			$languages = COMPLIANZ::$banner_loader->get_supported_languages();
			// first, delete all cookies with a language not in the $languages array.
			$wpdb->query( "DELETE from {$wpdb->prefix}cmplz_cookies where language NOT IN ('" . implode( "','", $languages ) . "')" );
			foreach ( $languages as $language ) {
				$settings = array(
					'language'      => $language,
					'isMembersOnly' => 'all',
				);
				$cookies  = COMPLIANZ::$banner_loader->get_cookies( $settings );
				foreach ( $cookies as $cookie ) {
					$same_name_cookies
						= $wpdb->get_results(
							$wpdb->prepare(
								"select * from {$wpdb->prefix}cmplz_cookies where name = %s and language = %s and serviceID = %s ",
								$cookie->name,
								$language,
								$cookie->serviceID
							)
						);
					if ( count( $same_name_cookies ) > 1 ) {
						array_shift( $same_name_cookies );
						$IDS = wp_list_pluck( $same_name_cookies, 'ID' );
						$sql = implode( ' OR ID =', $IDS );
						$sql = "DELETE from {$wpdb->prefix}cmplz_cookies where ID=" . $sql;
						$wpdb->query( $sql );
					}
				}
				$settings = array(
					'language' => $language,
				);
				$services = COMPLIANZ::$banner_loader->get_services( $settings );
				foreach ( $services as $service ) {
					$same_name_services = $wpdb->get_results( $wpdb->prepare( "select * from {$wpdb->prefix}cmplz_services where name = %s and language = %s", $service->name, $language ) );
					if ( count( $same_name_services ) > 1 ) {
						array_shift( $same_name_services );
						$IDS = wp_list_pluck( $same_name_services, 'ID' );
						$sql = implode( ' OR ID =', $IDS );
						$sql = "DELETE from {$wpdb->prefix}cmplz_services where ID=" . $sql;
						$wpdb->query( $sql );
					}
				}
			}
		}

		/**
		 * Here we add scripts and styles for the wysywig editor on the backend
		 *
		 * @param string $hook Current admin page hook suffix.
		 * */
		public function enqueue_admin_assets( $hook ) {
			if ( isset( $_GET['page'] ) && 'complianz' === $_GET['page'] ) {
				// script to check for ad blockers.
				wp_enqueue_script( 'cmplz-ad-checker', CMPLZ_URL . 'assets/js/ads.js', array(), CMPLZ_VERSION, true );
			}

			if ( 'edit.php' === $hook ) {
				wp_add_inline_style(
					'wp-admin',
					// Cell wrapper.
					'.column-cmplz_scan{width:26ch;}'
					. '.cmplz-scan-cell{display:flex;align-items:center;flex-wrap:wrap;gap:6px 8px;min-height:28px;}'
					// Status pill.
					. '.cmplz-scan-status{display:inline-flex;align-items:center;gap:6px;padding:3px 8px;border-radius:999px;font-size:12px;font-weight:500;line-height:1.4;white-space:nowrap;}'
					. '.cmplz-scan-status span{width:7px;height:7px;border-radius:50%;flex-shrink:0;}'
					. '.cmplz-scan-status--done{color:#166534;background:#dcfce7;}'
					. '.cmplz-scan-status--done span{background:#16a34a;}'
					. '.cmplz-scan-status--queued{color:#92400e;background:#fef3c7;}'
					. '.cmplz-scan-status--queued span{background:#f59e0b;}'
					. '.cmplz-scan-status--pending{color:#475569;background:#f1f5f9;}'
					. '.cmplz-scan-status--pending span{background:#94a3b8;}'
					// Scan button compact.
					. '.cmplz-scan-cell .button-small{height:24px;min-height:24px;padding:0 8px;font-size:12px;line-height:22px;}'
					// Tooltip.
					. '.cmplz-has-tip{position:relative;}'
					. '.cmplz-has-tip::after{content:attr(data-cmplz-tip);position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);'
					. 'background:#1d2327;color:#fff;font-size:12px;line-height:1.4;white-space:normal;max-width:18ch;text-align:center;padding:4px 8px;border-radius:3px;'
					. 'pointer-events:none;opacity:0;transition:opacity .15s;z-index:9999;}'
					. '.cmplz-has-tip:hover::after{opacity:1;}'
					// Scan error inline message.
					. '.cmplz-scan-error{display:block;width:100%;font-size:11px;color:#b91c1c;margin-top:2px;}'
				);
			}
		}

		/**
		 * Get all cookies, and post back to site with ajax.
		 * This script is only inserted when a valid token is passed, so will never run for other visitors than the site admin
		 * */
		public function test_cookies() {
			if ( $this->scan_complete() ) {
				return;
			}

			if ( ! isset( $_GET['complianz_scan_token'] ) || ! isset( $_GET['complianz_id'] ) ) {
				return;
			}

			$token      = sanitize_title( $_GET['complianz_scan_token'] );
			$id         = sanitize_title( $_GET['complianz_id'] );
			$admin_url  = esc_url_raw( rest_url( 'complianz/v1/' ) );
			$nonce      = wp_create_nonce( 'wp_rest' );
			$javascript = cmplz_get_template( 'test-cookies.js' );
			$javascript = str_replace(
				array(
					'{admin_url}',
					'{token}',
					'{id}',
					'{nonce}',
				),
				array(
					esc_url_raw( $admin_url ),
					esc_attr( $token ),
					esc_attr( $id ),
					$nonce,
				),
				$javascript
			);
			?>
			<script>
				<?php echo $javascript; ?>
			</script>
			<?php
		}

		/**
		 * Insert an iframe to retrieve front-end cookies
		 * */
		public function run_cookie_scan(): void {
			if ( ! cmplz_admin_logged_in() ) {
				return;
			}

			if ( get_option( 'cmplz_activation_time' ) > strtotime( '-30 minutes' ) ) {
				return;
			}

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

			if ( ! cmplz_wsc_auth::wsc_is_authenticated() ) {
				return;
			}

			if ( isset( $_GET['complianz_scan_token'] ) ) {
				return;
			}
			// if the last cookie scan date is more than a month ago, we re-scan.
			$last_scan_date = COMPLIANZ::$banner_loader->get_last_cookie_scan_date( true );
			$scan_interval  = apply_filters( 'cmplz_scan_interval', 3 );
			$one_month_ago  = strtotime( '-' . $scan_interval . ' month' );
			if (
				( $one_month_ago > $last_scan_date )
				&& $this->scan_complete()
				&& ! $this->automatic_cookiescan_disabled()
			) {
				$this->reset_pages_list();
			}

			if ( ! $this->scan_complete() ) {
				if ( ! get_option( 'cmplz_synced_cookiedatabase_once' ) ) {
					update_option( 'cmplz_sync_cookies_complete', false );
					update_option( 'cmplz_sync_cookies_after_services_complete', false );
					update_option( 'cmplz_sync_services_complete', false );
					update_option( 'cmplz_synced_cookiedatabase_once', true );
				}

				// store the date.
				$timezone_offset = get_option( 'gmt_offset' );
				$time            = time() + ( 60 * 60 * $timezone_offset );
				update_option( 'cmplz_last_cookie_scan', $time );

				$url = $this->get_next_page_url();
				if ( ! $url || 'remote' === $url ) {
					return;
				}

				if ( strpos( $url, 'complianz_id' ) !== false ) {
					// get the html of this page.
					$response = wp_remote_get( $url );
					if ( ! is_wp_error( $response ) ) {
						$html = $response['body'];
						$this->parse_html( $html );
					}
				}

				// load in iframe so the scripts run.
				echo '<iframe id="cmplz_cookie_scan_frame" class="hidden" src="' . $url . '"></iframe>';
			}
		}

		private function parse_html( $html ) {
			$stored_social_media = cmplz_scan_detected_social_media();
			if ( ! $stored_social_media ) {
				$stored_social_media = array();
			}
			$social_media = COMPLIANZ::$banner_loader->parse_for_social_media( $html );
			$social_media = array_unique( array_merge( $stored_social_media, $social_media ), SORT_REGULAR );
			update_option( 'cmplz_detected_social_media', $social_media );

			$stored_thirdparty_services = cmplz_scan_detected_thirdparty_services();
			if ( ! $stored_thirdparty_services ) {
				$stored_thirdparty_services = array();
			}
			$thirdparty = $this->parse_for_thirdparty_services( $html );
			$thirdparty = array_unique( array_merge( $stored_thirdparty_services, $thirdparty ), SORT_REGULAR );
			update_option( 'cmplz_detected_thirdparty_services', $thirdparty );

			// parse for google analytics and tagmanager, but only if the wizard wasn't completed before.
			// with this data we prefill the settings and give warnings when tracking is doubled.
			if ( ! COMPLIANZ::$banner_loader->wizard_completed_once() ) {
				$this->parse_for_statistics_settings( $html );
			}

			if ( preg_match_all( '/ga\.js/', $html ) > 1
				|| preg_match_all( '/analytics\.js/', $html ) > 1
				|| preg_match_all( '/googletagmanager\.com\/gtm\.js/', $html ) > 1
				|| preg_match_all( '/piwik\.js/', $html ) > 1
				|| preg_match_all( '/matomo\.js/', $html ) > 1
				|| preg_match_all( '/getclicky\.com\/js/', $html ) > 1
				|| preg_match_all( '/mc\.yandex\.ru\/metrika\/watch\.js/', $html ) > 1
			) {
				update_option( 'cmplz_double_stats', true );
			} else {
				delete_option( 'cmplz_double_stats' );
			}

			$stored_stats = cmplz_scan_detected_stats();
			if ( ! $stored_stats ) {
				$stored_stats = array();
			}
			$stats = $this->parse_for_stats( $html );
			$stats = array_unique( array_merge( $stored_stats, $stats ), SORT_REGULAR );
			update_option( 'cmplz_detected_stats', $stats );
		}

		/**
		 * Check a string for statistics
		 *
		 * @param string $html       HTML to parse for statistics markers.
		 * @param bool   $single_key Return a single string instead of an array.
		 *
		 * @return array|string $thirdparty
		 * */
		public function parse_for_stats( $html, $single_key = false ) {
			$stats         = array();
			$stats_markers = COMPLIANZ::$config->stats_markers;
			foreach ( $stats_markers as $key => $markers ) {
				foreach ( $markers as $marker ) {
					if ( $single_key && strpos( $html, $marker ) !== false ) {
						return $key;
					}

					if ( strpos( $html, $marker ) !== false && ! in_array( $key, $stats ) ) {
						if ( $single_key ) {
							return $key;
						}
						$stats[] = $key;
					}
				}
			}
			if ( $single_key ) {
				return false;
			}

			return $stats;
		}

		/**
		 * Run once to retrieve the settings for most used stats tools
		 *
		 * @param string $html HTML to parse for statistics tool settings.
		 */
		private function parse_for_statistics_settings( $html ) {

			if ( strpos( $html, 'gtm.js' ) !== false || strpos( $html, 'gtm.start' ) !== false
			) {
				update_option( 'cmplz_detected_stats_type', true );

				$pattern = '/(\'|")(GTM-[A-Z]{7})(\'|")/i';
				preg_match( $pattern, $html, $matches );
				if ( $matches && isset( $matches[2] ) ) {
					cmplz_update_option_no_hooks( 'gtm_code', sanitize_text_field( $matches[2] ) );
					update_option( 'cmplz_detected_stats_data', true );
					cmplz_update_option( 'compile_statistics', 'google-tag-manager' );
				}
			}

			if ( strpos( $html, 'analytics.js' ) !== false || strpos( $html, 'ga.js' ) !== false || strpos( $html, '_getTracker' ) !== false ) {
				update_option( 'cmplz_detected_stats_type', true );

				$pattern = '/(\'|")(UA-[0-9]{8}-[0-9]{1})(\'|")/i';
				preg_match( $pattern, $html, $matches );
				if ( $matches && isset( $matches[2] ) ) {
					cmplz_update_option( 'ua_code', sanitize_text_field( $matches[2] ) );
					cmplz_update_option( 'compile_statistics', 'google-analytics' );
				}

				// gtag.
				$pattern = '/(\'|")(G-[0-9a-zA-Z]{10})(\'|")/i';
				preg_match( $pattern, $html, $matches );
				if ( $matches && isset( $matches[2] ) ) {
					cmplz_update_option( 'ua_code', sanitize_text_field( $matches[2] ) );
					cmplz_update_option( 'compile_statistics', 'google-analytics' );
				}
				$pattern = '/\'anonymizeIp|anonymize_ip\'|:[ ]{0,1}true/i';
				preg_match( $pattern, $html, $matches );
				if ( $matches ) {
					$value = cmplz_get_option( 'compile_statistics_more_info' );
					if ( ! is_array( $value ) ) {
						$value = array();
					}
					if ( ! in_array( 'ip-addresses-blocked', $value, true ) ) {
						$value[] = 'ip-addresses-blocked';
					}
					cmplz_update_option( 'compile_statistics_more_info', $value );
				}
			}

			if ( strpos( $html, 'piwik.js' ) !== false || strpos( $html, 'matomo.js' ) !== false ) {
				update_option( 'cmplz_detected_stats_type', true );
				$pattern = '/(var u=")((https|http):\/\/.*?)"/i';
				preg_match( $pattern, $html, $matches );
				if ( $matches && isset( $matches[2] ) ) {
					cmplz_update_option( 'matomo_url', sanitize_text_field( $matches[2] ) );
					update_option( 'cmplz_detected_stats_data', true );
				}

				$pattern = '/\[\'setSiteId\', \'([0-9]){1,3}\'\]\)/i';
				preg_match( $pattern, $html, $matches );
				if ( $matches && isset( $matches[1] ) ) {
					cmplz_update_option( 'matomo_site_id', intval( $matches[1] ) );
					update_option( 'cmplz_detected_stats_data', true );
				}

				cmplz_update_option( 'compile_statistics', 'matomo' );
			}

			if ( strpos( $html, 'static.getclicky.com/js' ) !== false ) {
				update_option( 'cmplz_detected_stats_type', true );

				$pattern = '/clicky_site_ids\.push\(([0-9]{1,3})\)/i';
				preg_match( $pattern, $html, $matches );
				if ( $matches && isset( $matches[1] ) ) {
					cmplz_update_option( 'clicky_site_id', intval( $matches[1] ) );
					update_option( 'cmplz_detected_stats_data', true );
					cmplz_update_option( 'compile_statistics', 'clicky' );
				}
			}

			if ( strpos( $html, 'mc.yandex.ru/metrika/watch.js' ) !== false ) {
				update_option( 'cmplz_detected_stats_type', true );

				$pattern = '/w.yaCounter([0-9]{1,10}) = new/i';
				preg_match( $pattern, $html, $matches );
				if ( $matches && isset( $matches[1] ) ) {
					cmplz_update_option( 'yandex_id', intval( $matches[1] ) );
					update_option( 'cmplz_detected_stats_data', true );
					cmplz_update_option( 'compile_statistics', 'yandex' );
				}
			}
		}

		/**
		 * Check a string for third party services
		 *
		 * @param string $html       HTML to parse for third-party service markers.
		 * @param bool   $single_key Return a single string instead of an array.
		 *
		 * @return array|string $thirdparty
		 * */
		public function parse_for_thirdparty_services( $html, $single_key = false ) {
			$thirdparty         = array();
			$thirdparty_markers = COMPLIANZ::$config->thirdparty_service_markers;
			foreach ( $thirdparty_markers as $key => $markers ) {
				foreach ( $markers as $marker ) {
					if ( $single_key && strpos( $html, $marker ) !== false ) {
						return $key;
					}

					if ( strpos( $html, $marker ) !== false && ! in_array( $key, $thirdparty ) ) {
						$thirdparty[] = $key;
					}
				}
			}
			if ( $single_key ) {
				return false;
			}

			return $thirdparty;
		}

		private function get_next_page_url() {
			// Cron has no logged-in user but background_remote_scan() depends on
			// this method — without the cron exception the daily background
			// remote scan can never fire.
			if ( ! cmplz_user_can_manage() && ! wp_doing_cron() ) {
				return '';
			}
			$token = wp_create_nonce( 'complianz_scan_token' );
			$pages = array_filter( $this->pages_to_process() );
			if ( count( $pages ) === 0 ) {
				return false;
			}
			$id_to_process = reset( $pages );

			// in case of remote, we want to wait until the process has completed before moving on to the next.
			if ( 'remote' !== $id_to_process ) {
				$this->set_page_as_processed( $id_to_process );
			} elseif ( COMPLIANZ::$wsc_scanner->wsc_scan_completed() ) {
				$this->set_page_as_processed( $id_to_process );
			}

			switch ( $id_to_process ) {
				case 'remote':
					return 'remote';
				case 'home':
					$url = home_url();
					break;
				case 'loginpage':
					$url = wp_login_url();
					break;
				default:
					$url = get_permalink( $id_to_process );
			}
			$url = add_query_arg(
				array(
					'complianz_scan_token' => $token,
					'complianz_id'         => $id_to_process,
				),
				$url
			);
			if ( is_ssl() ) {
				$url = str_replace( 'http://', 'https://', $url );
			}

			return apply_filters( 'cmplz_next_page_url', $url );
		}

		/**
		 * Get the list of posttypes to process
		 *
		 * @return array
		 */
		public function get_scannable_post_types(): array {
			$post_types = array( 'post', 'page' );
			return apply_filters( 'cmplz_cookiescan_post_types', $post_types );
		}

		/**
		 * All public post types eligible for scanning — the selectable universe,
		 * before any free/premium or user-selection narrowing.
		 *
		 * Not to be confused with get_scannable_post_types(), which returns the
		 * currently active set.
		 * Media-only and Complianz-internal post types are excluded as they
		 * produce meaningless cookie scan results.
		 *
		 * @param bool $with_labels true: slug => plural label (field options); false: slug list.
		 * @return array
		 */
		public function get_public_scannable_post_types( bool $with_labels = false ): array {
			$all = get_post_types( array( 'public' => true ), 'objects' );

			/**
			 * Filters the post types excluded from the scannable universe.
			 *
			 * Excluded types disappear from the "Post types to scan" field options
			 * and from every scan surface (local scanner, WSC batch, scan column).
			 *
			 * @param string[] $excluded Post type slugs to exclude.
			 */
			$excluded = apply_filters(
				'cmplz_scan_excluded_post_types',
				array( 'attachment', 'elementor_font', 'cmplz-dataleak', 'cmplz-processing', 'cookie' )
			);

			foreach ( $excluded as $slug ) {
				unset( $all[ $slug ] );
			}

			if ( ! $with_labels ) {
				return array_keys( $all );
			}

			$options = array();
			foreach ( $all as $slug => $post_type_object ) {
				$options[ $slug ] = $post_type_object->labels->name ?? $slug;
			}
			return $options;
		}

		/**
		 * Return WooCommerce and EDD page IDs to exclude from the local scanner batch.
		 * These pages are handled as fixed pages in pro; in free they must not enter
		 * the generic page batch so the upsell notice remains accurate.
		 *
		 * @return int[]
		 */
		private function get_webshop_page_ids(): array {
			$ids = array();

			if ( class_exists( 'WooCommerce' ) ) {
				$ids = array_merge(
					$ids,
					array_filter(
						array_map(
							'intval',
							array(
								get_option( 'woocommerce_shop_page_id' ),
								get_option( 'woocommerce_cart_page_id' ),
								get_option( 'woocommerce_checkout_page_id' ),
								get_option( 'woocommerce_myaccount_page_id' ),
							)
						)
					)
				);
			}

			if ( class_exists( 'Easy_Digital_Downloads' ) && function_exists( 'edd_get_option' ) ) {
				$ids = array_merge(
					$ids,
					array_filter(
						array_map(
							'intval',
							array(
								edd_get_option( 'purchase_page' ),
								edd_get_option( 'success_page' ),
								edd_get_option( 'failure_page' ),
								edd_get_option( 'purchase_history_page' ),
							)
						)
					)
				);
			}

			return array_values( $ids );
		}

		/**
		 * Get fixed (non-post) pages to include in every scan run.
		 *
		 * 'remote' is only appended when WSC is authenticated; callers that
		 * previously hard-coded ['home','remote'] should use this method.
		 *
		 * @return array
		 */
		private function get_fixed_pages(): array {
			$pages = array( 'home' );
			if ( cmplz_wsc_auth::wsc_is_authenticated() ) {
				$pages[] = 'remote';
			}
			if ( cmplz_get_option( 'wp_admin_access_users' ) === 'yes' ) {
				$pages[] = 'loginpage';
			}
			return apply_filters( 'cmplz_scan_fixed_pages', $pages );
		}

		/**
		 *
		 * Get list of page id's that we want to process this set of scan requests, which weren't included in the scan before
		 *
		 * @return array $pages
		 * *@since 1.0
		 */
		public function get_pages_list_single_run() {
			// Cron exception: background_remote_scan() needs the list to find
			// the 'remote' sentinel; cron requests have no logged-in user.
			if ( ! cmplz_user_can_manage() && ! wp_doing_cron() ) {
				return array();
			}
			$posts = get_transient( 'cmplz_pages_list' );
			if ( ! $posts ) {
				$posts           = $this->get_fixed_pages();
				$post_types      = $this->get_scannable_post_types();
				$not_in          = $this->get_webshop_page_ids();
				$representatives = array();
				$batch_size      = apply_filters( 'cmplz_scan_batch_size', 5 );

				// One post per type first — fast representative sample.
				foreach ( $post_types as $post_type ) {
					$args            = array(
						'post__not_in'   => $not_in,
						'post_type'      => $post_type,
						'posts_per_page' => 1,
						'fields'         => 'ids',
						'meta_query'     => array(
							array(
								'key'     => '_cmplz_scanned_post',
								'compare' => 'NOT EXISTS',
							),
						),
					);
					$new_posts       = get_posts( $args );
					$representatives = array_merge( $representatives, $new_posts );
					$not_in          = array_merge( $not_in, $new_posts );
				}

				$posts = array_merge( $posts, $representatives );

				// Bulk batch per type, skipping the representative posts.
				foreach ( $post_types as $post_type ) {
					$args = apply_filters(
						'cmplz_scan_post_args',
						array(
							'post__not_in'   => $not_in,
							'post_type'      => $post_type,
							'posts_per_page' => $batch_size,
							'fields'         => 'ids',
							'meta_query'     => array(
								array(
									'key'     => '_cmplz_scanned_post',
									'compare' => 'NOT EXISTS',
								),
							),
						),
						$post_type
					);
					// Non-overridable structural constraints — re-enforced after filter.
					$args['fields']       = 'ids';
					$args['post_type']    = $post_type;
					$args['post__not_in'] = $not_in;
					$args['meta_query']   = array(
						array(
							'key'     => '_cmplz_scanned_post',
							'compare' => 'NOT EXISTS',
						),
					);
					$new_posts            = get_posts( $args );
					$posts                = array_merge( $posts, $new_posts );
				}

				if ( count( $posts ) === 0 && ! $this->automatic_cookiescan_disabled() ) {
					/*
					 * No posts found — all batches exhausted.  Reset meta so the
					 * next scan attempt picks them up again.
					 */
					$this->reset_scanned_post_batches();
					$this->reset_pages_list();
				} else {
					foreach ( $posts as $post_id ) {
						if ( is_int( $post_id ) ) {
							update_post_meta( $post_id, '_cmplz_scanned_post', true );
						}
					}
				}

				set_transient( 'cmplz_pages_list', $posts, MONTH_IN_SECONDS );
			}

			return array_filter( $posts );
		}

		/**
		 * Reset the list of pages
		 *
		 * @param bool $delay  Delay the restart of the scan cycle.
		 * @param bool $manual Manual reset always resets; automatic reset is skipped when the automatic scan is disabled.
		 *
		 * @return void
		 *
		 * @since 2.1.5
		 */
		public function reset_pages_list( $delay = false, $manual = false ) {

			if ( ! $manual && $this->automatic_cookiescan_disabled() ) {
				return;
			}

			if ( $manual ) {
				$this->reset_scanned_post_batches();
			}

			if ( $delay ) {
				$current_list    = get_transient( 'cmplz_pages_list' );
				$processed_pages = get_transient( 'cmplz_processed_pages_list' );
				set_transient( 'cmplz_pages_list', $current_list, HOUR_IN_SECONDS );
				set_transient( 'cmplz_processed_pages_list', $processed_pages, HOUR_IN_SECONDS );

			} else {
				delete_transient( 'cmplz_pages_list' );
				delete_transient( 'cmplz_processed_pages_list' );
			}
		}

		/**
		 * The scanned post meta is used to create batches of posts. A batch that is being processed is set to scanned.
		 * This is only reset when all posts have been processed, or if user has disabled automatic scanning, and the manual scan is fired.
		 * */
		public function reset_scanned_post_batches() {
			if ( ! function_exists( 'delete_post_meta_by_key' ) ) {
				require_once ABSPATH . WPINC . '/post.php';
			}
			delete_post_meta_by_key( '_cmplz_scanned_post' );
		}

		/**
		 * Check if the automatic scan is disabled
		 *
		 * @return bool
		 */
		public function automatic_cookiescan_disabled() {
			return cmplz_get_option( 'disable_automatic_cookiescan' ) == 1;
		}


		/**
		 * Get list of pages that were processed before
		 *
		 * @return array $pages
		 */
		public function get_processed_pages_list() {

			$pages = get_transient( 'cmplz_processed_pages_list' );
			if ( ! is_array( $pages ) ) {
				$pages = array();
			}

			return array_filter( $pages );
		}

		/**
		 * Check if the scan is complete
		 *
		 * @return bool
		 * @since 1.0
		 * */
		public function scan_complete() {
			$pages = array_filter( $this->pages_to_process() );
			return count( $pages ) === 0;
		}

		/**
		 *
		 * Get list of pages that still have to be processed
		 *
		 * @return array
		 * @since 1.0
		 */
		private function pages_to_process(): array {

			$pages_list           = $this->get_pages_list_single_run();
			$processed_pages_list = $this->get_processed_pages_list();
			return array_diff( $pages_list, $processed_pages_list );
		}

		/**
		 * Set a page as being processed
		 *
		 * @param int|string $id Post ID or fixed-page sentinel ('home', 'loginpage', 'remote').
		 *
		 * @return void
		 * @since 1.0
		 */
		public function set_page_as_processed( $id ): void {
			// Cron exception: when the background remote scan completes, the
			// 'remote' sentinel must be markable as processed from cron context.
			if ( ! cmplz_user_can_manage() && ! wp_doing_cron() ) {
				return;
			}

			if ( 'home' !== $id && 'loginpage' !== $id && 'remote' !== $id && ! is_numeric( $id ) ) {
				return;
			}

			// Normalize numeric IDs to int so strict in_array works regardless of
			// whether the caller passed a string (REST param) or an integer.
			if ( is_numeric( $id ) ) {
				$id = (int) $id;
			}

			$pages = $this->get_processed_pages_list();
			if ( ! in_array( $id, $pages, true ) ) {
				$pages[]    = $id;
				$expiration = $this->automatic_cookiescan_disabled() ? 10 * YEAR_IN_SECONDS : MONTH_IN_SECONDS;
				set_transient( 'cmplz_processed_pages_list', $pages, $expiration );
			}
		}

		/**
		 * Update the cookie policy date
		 */
		public function update_cookie_policy_date() {
			update_option( 'cmplz_publish_date', time() );

			// also reset the email notification, so it will get sent next year.
			update_option( 'cmplz_update_legal_documents_mail_sent', false );
		}

		/**
		 * Get progress of the current scan to output with ajax
		 *
		 * @param array           $data    Response data passed along the cmplz_do_action filter.
		 * @param string          $action  Requested action; this handler responds to 'scan'.
		 * @param WP_REST_Request $request Incoming REST request.
		 *
		 * @return array
		 */
		public function get_scan_progress( array $data, string $action, WP_REST_Request $request ): array {
			if ( ! cmplz_user_can_manage() ) {
				return array();
			}

			if ( 'get_scan_progress' !== $action ) {
				return $data;
			}

			$data = array(
				'progress'  => 0,
				'next_page' => false,
				'cookies'   => array(),
				'token'     => '',
			);

			if ( ! cmplz_wsc_auth::wsc_is_authenticated() ) {
				return $data;
			}

			$timezone_offset = get_option( 'gmt_offset' );
			$time            = time() + ( 60 * 60 * $timezone_offset );

			update_option( 'cmplz_last_cookie_scan', $time );

			$next_url = $this->get_next_page_url();

			if ( 'remote' === $next_url ) {
				do_action( 'cmplz_remote_cookie_scan' );
				// only proceed to next page if remote scan is complete.
				if ( COMPLIANZ::$wsc_scanner->wsc_scan_completed() ) {
					$next_url = $this->get_next_page_url();
				} else {
					// Don't return 'remote' to React app - it will create iframe with src="remote".
					// Return a data URL that won't cause network requests or 404 errors.
					$next_url = 'data:text/html,<html><body>WSC scan in progress...</body></html>';

					// Mark 'remote' as processed so we can move to next page, but only when NOT in cron context - although it might not be necessary.
					if ( ! wp_doing_cron() ) {
						$this->set_page_as_processed( 'remote' );
					}
				}
			} elseif ( false !== strpos( $next_url, 'complianz_id' ) ) {
				$response = wp_remote_get( $next_url );
				if ( ! is_wp_error( $response ) ) {
					$html = $response['body'];
					$this->parse_html( $html );
				}
			}

			$this->clear_double_cookienames();

			$cookies  = COMPLIANZ::$banner_loader->get_cookies();
			$progress = $this->get_progress_count();
			$total    = count( $cookies );
			$current  = (int) ( $progress / 100 * $total );
			$cookies  = array_slice( $cookies, 0, $current );
			$cookies  = count( $cookies ) > 0 ? wp_list_pluck( $cookies, 'name' ) : array();

			$data['progress']  = $progress;
			$data['next_page'] = $next_url;
			$data['cookies']   = $cookies;
			$data['token']     = wp_create_nonce( 'complianz_scan_token' );

			return $data;
		}

		/**
		 * Rescan after a manual "rescan" command from the user
		 *
		 * @param array           $data    Response data passed along the cmplz_do_action filter.
		 * @param string          $action  Requested action; this handler responds to 'scan'.
		 * @param WP_REST_Request $request Incoming REST request.
		 * @return array
		 */
		public function reset_scan( $data, $action, $request ) {
			if ( ! cmplz_user_can_manage() ) {
				return array();
			}

			if ( 'scan' === $action ) {
				$scan_type = sanitize_title( $request->get_param( 'scan_action' ) );
				if ( 'reset' === $scan_type ) {
					global $wpdb;
					$table_names = array( $wpdb->prefix . 'cmplz_cookies' );
					foreach ( $table_names as $table_name ) {
						if ( $wpdb->get_var( "SHOW TABLES LIKE '$table_name'" ) === $table_name ) {
							$wpdb->query( "TRUNCATE TABLE $table_name" );
						}
					}
					update_option( 'cmplz_detected_social_media', false );
					update_option( 'cmplz_detected_thirdparty_services', false );
					update_option( 'cmplz_detected_stats', false );
				}

				if ( 'reset' === $scan_type || 'restart' === $scan_type ) {
					COMPLIANZ::$wsc_scanner->wsc_scan_reset();
					do_action( 'cmplz_scan_reset' );
					$this->reset_pages_list( false, true );
					COMPLIANZ::$sync->resync();
				}

				$data = array();
			}
			return $data;
		}

		/**
		 * Get progress of the scan in percentage
		 *
		 * @return float
		 */
		public function get_progress_count() {

			$remote_scan_total    = 100;
			$remote_scan_progress = COMPLIANZ::$wsc_scanner->wsc_scan_progress();

			$local_done  = count( $this->get_processed_pages_list() );
			$local_total = count( $this->get_pages_list_single_run() );

			// convert local to a 100 scale
			// prevent division by zero.
			$local_total = 0 === $local_total ? $local_done : $local_total;
			$local_done  = 100 * ( $local_done / $local_total );

			$total = 200;
			$done  = $remote_scan_progress + $local_done;

			$progress = 100 * ( $done / $total );
			if ( $progress > 100 ) {
				$progress = 100;
			}

			return $progress;
		}

		// ── Post list column ──────────────────────────────────────────────────────

		/**
		 * Register the Cookie Scan column for all scannable post types.
		 *
		 * Runs on admin_init (free + premium). Pro extends the list of scannable
		 * post types via the cmplz_cookiescan_post_types filter before this fires.
		 */
		public function register_scan_post_columns(): void {
			foreach ( $this->get_scannable_post_types() as $post_type ) {
				add_filter(
					"manage_{$post_type}_posts_columns",
					static function ( array $columns ): array {
						$columns['cmplz_scan'] = __( 'Complianz Website Scan', 'complianz-gdpr' );
						return $columns;
					}
				);
				add_action(
					"manage_{$post_type}_posts_custom_column",
					array( $this, 'render_scan_post_column' ),
					10,
					2
				);
			}
		}

		/**
		 * Render the Cookie Scan column cell.
		 *
		 * Handles: column guard, publish check, status badge.
		 * Button rendering is delegated via two hooks so free code has zero pro references:
		 *   - cmplz_scan_column_wsc_state filter  → pro populates WSC done/inflight/cooldown state.
		 *   - cmplz_render_scan_column_button action → pro renders the full button cascade.
		 * Free-only installs: hooks unregistered → free upgrade link rendered directly.
		 *
		 * Status badges:
		 *   Scanned     — scan completed.
		 *   In progress — in scan queue.
		 *   Pending     — no scan activity.
		 *
		 * @param string $column  Column name.
		 * @param int    $post_id Post ID.
		 */
		public function render_scan_post_column( string $column, int $post_id ): void {
			if ( 'cmplz_scan' !== $column ) {
				return;
			}

			$is_published = get_post_status( $post_id ) === 'publish';
			$local_done   = (bool) get_post_meta( $post_id, '_cmplz_scanned_post', true );
			$pages_list   = get_transient( 'cmplz_pages_list' );
			$in_queue     = is_array( $pages_list ) && in_array( $post_id, $pages_list, true );

			$wsc_state = apply_filters(
				'cmplz_scan_column_wsc_state',
				array(
					'wsc_done'        => false,
					'wsc_inflight'    => false,
					'in_wsc_cooldown' => false,
					'wsc_scanned_at'  => 0,
					'in_batch_queue'  => false,
				),
				$post_id
			);

			$wsc_done        = $wsc_state['wsc_done'];
			$wsc_inflight    = $wsc_state['wsc_inflight'];
			$in_wsc_cooldown = $wsc_state['in_wsc_cooldown'];
			$in_batch_queue  = $wsc_state['in_batch_queue'];

			// ── Status label ──────────────────────────────────────────────────

			if ( $local_done || $wsc_done ) {
				$label = __( 'Scanned', 'complianz-gdpr' );
				$class = 'cmplz-scan-status--done';
			} elseif ( $in_queue || $wsc_inflight ) {
				$label = __( 'In progress', 'complianz-gdpr' );
				$class = 'cmplz-scan-status--queued';
			} elseif ( $in_batch_queue ) {
				$label = __( 'Queued', 'complianz-gdpr' );
				$class = 'cmplz-scan-status--queued';
			} else {
				$label = __( 'Pending', 'complianz-gdpr' );
				$class = 'cmplz-scan-status--pending';
			}

			echo '<div class="cmplz-scan-cell">';

			if ( $label ) {
				echo '<span class="cmplz-scan-status ' . esc_attr( $class ) . '">'
					. '<span></span>'
					. esc_html( $label )
					. '</span>';
			}

			// ── Button cascade ────────────────────────────────────────────────

			if ( ! $is_published ) {
				echo '</div>';
				return;
			}

			if ( defined( 'cmplz_free' ) ) {
				echo '<a href="' . esc_url( cmplz_get_referral_url( 'menu', 'manual-scan-column-upgrade', 'https://complianz.io/pricing/' ) ) . '" class="button button-small cmplz-has-tip" target="_blank" rel="noopener noreferrer"'
					. ' data-cmplz-tip="' . esc_attr__( 'Upgrade to Complianz Premium to enable per-post website scanning.', 'complianz-gdpr' ) . '">'
					. esc_html__( 'Upgrade', 'complianz-gdpr' )
					. '</a>';
				echo '</div>';
				return;
			}

			do_action( 'cmplz_render_scan_column_button', $post_id, $wsc_state );

			echo '</div>';
		}

		/**
		 * Single source of truth for all scan upsell entries.
		 * Ordered by priority: webshop > cpt > volume.
		 *
		 * Each entry contains all fields consumed by both the warning system
		 * (add_scan_upsell_warnings) and the React alert (get_scan_upsell_data).
		 *
		 * @return array<string, array>
		 */
		private function get_scan_upsell_catalog(): array {
			$defaults = array(
				'icon'        => 'warning',
				'icon_color'  => 'orange',
				'cta_label'   => __( 'Upgrade Now', 'complianz-gdpr' ),
				'cta_url'     => 'https://complianz.io/pricing-subpages/',
				'dismissible' => true,
			);

			return array(
				'webshop' => array_merge(
					$defaults,
					array(
						'condition'    => 'cmplz_site_has_webshop',
						'title'        => __( 'Limited scan coverage', 'complianz-gdpr' ),
						'subtitle'     => __( 'Webshop pages not included', 'complianz-gdpr' ),
						'body'         => __( 'Your webshop pages are not covered by the free Website Scan. Upgrade to cover all pages.', 'complianz-gdpr' ),
						'cta_field_id' => 'scan-site-has-webshop',
					)
				),
				'cpt'     => array_merge(
					$defaults,
					array(
						'condition'    => 'cmplz_site_has_custom_post_types',
						'title'        => __( 'Limited scan coverage', 'complianz-gdpr' ),
						'subtitle'     => __( 'Custom post types not included', 'complianz-gdpr' ),
						'body'         => __( 'You have custom post types that are not covered by the free Website Scan. Upgrade to cover all post types.', 'complianz-gdpr' ),
						'cta_field_id' => 'scan-site-has-cpt',
					)
				),
				'volume'  => array_merge(
					$defaults,
					array(
						'condition'      => 'cmplz_volume_upsell_applies',
						'title_callback' => function () {
							$count = (int) get_transient( 'cmplz_scan_post_count' );
							return $count > 0
								? sprintf( __( 'We found %d posts.', 'complianz-gdpr' ), $count )
								: __( 'Limited scan coverage', 'complianz-gdpr' );
						},
						'subtitle'       => __( "The free Website Scan can't fully cover your site.", 'complianz-gdpr' ),
						'body'           => __( 'To ensure availability, the free Website Scan is limited to 50 posts. Upgrade to premium for full coverage.', 'complianz-gdpr' ),
						'cta_field_id'   => 'scan-site-has-volume',
					)
				),
			);
		}

		/**
		 * Return the highest-priority upsell data object for the current free site, or null.
		 * All fields are defined in the catalog so React renders without owning any copy.
		 *
		 * @return array{code:string,title:string,subtitle:string,body:string,icon:string,icon_color:string,cta_label:string,cta_url:string,cta_field_id:string}|null
		 */
		public function get_scan_upsell_data(): ?array {
			if ( ! defined( 'cmplz_free' ) || ! cmplz_free ) {
				return null;
			}
			foreach ( $this->get_scan_upsell_catalog() as $code => $entry ) {
				if ( ! function_exists( $entry['condition'] ) || ! call_user_func( $entry['condition'] ) ) {
					continue;
				}
				$title = isset( $entry['title_callback'] )
					? call_user_func( $entry['title_callback'] )
					: $entry['title'];
				return array(
					'code'         => $code,
					'title'        => $title,
					'subtitle'     => $entry['subtitle'],
					'body'         => $entry['body'],
					'icon'         => $entry['icon'],
					'icon_color'   => $entry['icon_color'],
					'cta_label'    => $entry['cta_label'],
					'cta_url'      => $entry['cta_url'],
					'cta_field_id' => $entry['cta_field_id'],
				);
			}
			return null;
		}

		/**
		 * Inject scan upsell entries into the global warning system via cmplz_warning_types filter.
		 * Only for free upgraded installs — fresh installs are excluded; catalog body and
		 * cta_url serve as the warning text and link.
		 *
		 * @param array $warnings Existing warning type definitions.
		 * @return array
		 */
		public function add_scan_upsell_warnings( array $warnings ): array {
			if ( ! defined( 'cmplz_free' ) || ! cmplz_free || cmplz_is_new_install() ) {
				return $warnings;
			}
			foreach ( $this->get_scan_upsell_catalog() as $code => $entry ) {
				$warnings[ 'scan-site-has-' . $code ] = array(
					'warning_condition'   => $entry['condition'],
					'urgent'              => $entry['body'],
					'plus_one'            => true,
					'dismissible'         => $entry['dismissible'],
					'include_in_progress' => false,
					'url'                 => $entry['cta_url'],
				);
			}
			return $warnings;
		}
	}

} //class closure