<?php
/**
 * Script and Content Blocker.
 *
 * Modifies page output to block third-party scripts and iframes until consent.
 *
 * @package SureCookie\Inc\Modules\ScriptBlocking
 * @since 0.0.1
 */

namespace SureCookie\Inc\Modules\ScriptBlocking;

use SureCookie\Inc\Modules\SiteScanner\SaasClient;
use SureCookie\Inc\Traits\GetInstance;

if ( ! defined( 'ABSPATH' ) ) {
	exit; // Exit if accessed directly.
}

/**
 * Blocker
 *
 * Handles output buffering, script modification, and iframe placeholders.
 *
 * @since 0.0.1
 */
class Blocker {
	use GetInstance;

	/**
	 * Script `type` values the browser executes. Mirrors the allowlist in
	 * `src/utils/consentManager.js` so blocker output round-trips correctly.
	 */
	private const EXECUTABLE_SCRIPT_TYPES = [ 'text/javascript', 'module', 'application/javascript', 'application/ecmascript', 'text/ecmascript', 'importmap', 'speculationrules' ];

	/**
	 * Reserved blocking category for newly-detected trackers held by the Pro
	 * Compliance Guard. Resources in this category are blocked until an admin
	 * reviews them and are NEVER released by visitor consent. Mirrored in
	 * `src/utils/consentManager.js` (`isAllowed`) and the Pro Guard
	 * (`Guard::QUARANTINE_CATEGORY`).
	 */
	private const QUARANTINE_CATEGORY = 'quarantine';

	/**
	 * Known scripts instance.
	 *
	 * @var Known_Scripts
	 */
	private Known_Scripts $known_scripts;

	/**
	 * Per-request cache of iframe-pattern lookup map (shared across
	 * block_iframes / block_embeds / block_objects).
	 *
	 * @var array<string, array{name: string, category: string, label: string}>|null
	 */
	private ?array $iframe_patterns_cache = null;

	/**
	 * Constructor.
	 *
	 * @since 0.0.1
	 */
	private function __construct() {
		$this->known_scripts = Known_Scripts::get_instance();
		$this->register_hooks();
	}

	/**
	 * Capture the resolved template, process it, and return a blank template.
	 *
	 * @param string|null $template Resolved template path.
	 * @since 0.0.1
	 * @return string
	 */
	public function intercept_template( ?string $template ): string {
		if ( ! $this->should_process() || $template === null || $template === '' || ! file_exists( $template ) ) {
			return $template ?? '';
		}

		$buffer = $this->capture_template_output( $template );

		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Full page HTML needs to be output after processing.
		echo $this->process_buffer( $buffer );

		return SURECOOKIE_DIR . 'inc/modules/script-blocking/empty-template.php';
	}

	/**
	 * Process the output buffer.
	 *
	 * @param string $buffer HTML content.
	 * @since 0.0.1
	 * @return string Modified HTML.
	 */
	public function process_buffer( string $buffer ): string {
		// Skip empty buffers.
		if ( empty( $buffer ) ) {
			return $buffer;
		}

		// Check if this is HTML content.
		if ( ! $this->is_html( $buffer ) ) {
			return $buffer;
		}

		// Block scripts and embedded content (iframe/embed/object) if blocking is enabled.
		if ( Utils::is_blocking_enabled() ) {
			$buffer = $this->block_scripts( $buffer );
			$buffer = $this->block_iframes( $buffer );
			$buffer = $this->block_embeds( $buffer );
			$buffer = $this->block_objects( $buffer );
		}

		return $buffer;
	}

	/**
	 * Register WordPress hooks.
	 *
	 * @since 0.0.1
	 * @return void
	 */
	private function register_hooks(): void {
		add_filter( 'template_include', [ $this, 'intercept_template' ], PHP_INT_MAX );
	}

	/**
	 * Check if blocking should run.
	 *
	 * @since 0.0.1
	 * @return bool
	 */
	private function should_process(): bool {
		// Check if blocking feature is enabled.
		if ( ! Utils::is_blocking_enabled() ) {
			return false;
		}

		// Skip in admin area.
		if ( is_admin() ) {
			return false;
		}

		// Skip AJAX requests.
		if ( wp_doing_ajax() ) {
			return false;
		}

		// Skip REST API requests.
		if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
			return false;
		}

		// Skip feeds.
		if ( is_feed() ) {
			return false;
		}

		// Check content type header.
		$headers_list = headers_list();
		foreach ( $headers_list as $header ) {
			if ( stripos( $header, 'Content-Type:' ) === 0 ) {
				// Skip JSON responses.
				if ( stripos( $header, 'application/json' ) !== false ) {
					return false;
				}
				// Skip XML responses.
				if ( stripos( $header, 'application/xml' ) !== false || stripos( $header, 'text/xml' ) !== false ) {
					return false;
				}
			}
		}

		// Check geo-location rules - bypass blocking if user is not in a configured region -- This ensures script/content blocking respects the same geo-targeting rules as the consent banner.
		if ( ! Utils::should_process_based_on_geo() ) {
			return false;
		}

		// Allow the SaaS scanner to bypass blocking so it sees the full unblocked page.
		if ( $this->is_scan_bypass_request() ) {
			return false;
		}

		/**
		 * Filter whether blocking should run.
		 *
		 * @since 0.0.1
		 * @param bool $should_process Whether to process the output.
		 */
		return apply_filters( 'surecookie_should_block_scripts', true );
	}

	/**
	 * Capture template output and close the buffer in the same scope.
	 *
	 * @param string $template Template path.
	 * @throws \Throwable When template loading fails.
	 * @since 0.0.1
	 * @return string
	 */
	private function capture_template_output( string $template ): string {
		ob_start();

		try {
			load_template( $template, true );
			return (string) ob_get_clean();
		} catch ( \Throwable $throwable ) {
			if ( ob_get_level() > 0 ) {
				ob_end_clean();
			}

			throw $throwable;
		}
	}

	/**
	 * Check if buffer is HTML content.
	 *
	 * @param string $buffer Content to check.
	 * @since 0.0.1
	 * @return bool
	 */
	private function is_html( string $buffer ): bool {
		$trimmed = ltrim( $buffer );

		// Check if starts with HTML-like content.
		if ( empty( $trimmed ) ) {
			return false;
		}

		// Skip JSON (starts with { or [).
		if ( $trimmed[0] === '{' || $trimmed[0] === '[' ) {
			return false;
		}

		// Should start with < for HTML/XML.
		if ( $trimmed[0] !== '<' ) {
			return false;
		}

		// Check for HTML doctype or html tag.
		if ( preg_match( '/^<!DOCTYPE\s+html|^<html/i', $trimmed ) ) {
			return true;
		}

		// Check for common HTML tags.
		if ( preg_match( '/^<(head|body|div|script|style|meta|link)/i', $trimmed ) ) {
			return true;
		}

		return true;
	}

	/**
	 * Find and block matching scripts in HTML.
	 *
	 * @param string $html HTML content.
	 * @since 0.0.1
	 * @return string Modified HTML.
	 */
	private function block_scripts( string $html ): string {
		$all_scripts = $this->known_scripts->get_all_scripts();

		if ( empty( $all_scripts ) ) {
			return $html;
		}

		// Build patterns for matching.
		$patterns = $this->build_script_patterns( $all_scripts );

		if ( empty( $patterns ) ) {
			return $html;
		}

		// Use regex to find and modify script tags.
		$result = preg_replace_callback(
			'/<script\b([^>]*)>(.*?)<\/script>/is',
			function ( $matches ) use ( $patterns ) {
				return $this->process_script_tag( $matches, $patterns );
			},
			$html
		);

		return $result ?? $html;
	}

	/**
	 * Get the iframe-pattern lookup map, computed once per request.
	 *
	 * Shared by block_iframes / block_embeds / block_objects so the known-scripts
	 * payload is iterated only once per HTTP response. Cache is kept on the
	 * Blocker singleton, which is instantiated fresh per PHP request.
	 *
	 * @since 0.0.1-beta.2
	 * @return array<string, array{name: string, category: string, label: string}>
	 */
	private function get_iframe_patterns(): array {
		if ( $this->iframe_patterns_cache !== null ) {
			return $this->iframe_patterns_cache;
		}

		$all_scripts = $this->known_scripts->get_all_scripts();

		if ( empty( $all_scripts ) ) {
			$this->iframe_patterns_cache = [];
			return $this->iframe_patterns_cache;
		}

		$this->iframe_patterns_cache = $this->build_iframe_patterns( $all_scripts );

		return $this->iframe_patterns_cache;
	}

	/**
	 * Find and block matching iframes in HTML.
	 *
	 * @param string $html HTML content.
	 * @since 0.0.1
	 * @return string Modified HTML.
	 */
	private function block_iframes( string $html ): string {
		$patterns = $this->get_iframe_patterns();

		if ( empty( $patterns ) ) {
			return $html;
		}

		// Use regex to find and modify iframe tags.
		$result = preg_replace_callback(
			'/<iframe\b([^>]*)(?:>(.*?)<\/iframe>|\s*\/>)/is',
			function ( $matches ) use ( $patterns ) {
				return $this->process_iframe_tag( $matches, $patterns );
			},
			$html
		);

		return $result ?? $html;
	}

	/**
	 * Find and block matching <embed> tags in HTML.
	 *
	 * Reuses the `iframes[]` pattern array from blocking-scripts.json since
	 * <iframe>, <embed>, and <object> share URL semantics.
	 *
	 * @param string $html HTML content.
	 * @since 0.0.1-beta.2
	 * @return string Modified HTML.
	 */
	private function block_embeds( string $html ): string {
		$patterns = $this->get_iframe_patterns();

		if ( empty( $patterns ) ) {
			return $html;
		}

		// <embed> is always self-closing in practice.
		$result = preg_replace_callback(
			'/<embed\b([^>]*)\/?>/is',
			function ( $matches ) use ( $patterns ) {
				return $this->process_embed_tag( $matches, $patterns );
			},
			$html
		);

		return $result ?? $html;
	}

	/**
	 * Find and block matching <object> tags in HTML.
	 *
	 * Handles both `<object data="…">…</object>`, legacy
	 * `<object><param name="movie|src|url" value="…"></object>` (Flash-style),
	 * AND self-closed XHTML-style `<object … />`.
	 *
	 * @param string $html HTML content.
	 * @since 0.0.1-beta.2
	 * @return string Modified HTML.
	 */
	private function block_objects( string $html ): string {
		$patterns = $this->get_iframe_patterns();

		if ( empty( $patterns ) ) {
			return $html;
		}

		// Covers both self-closed `<object … />` and full `<object …>…</object>` forms,
		// mirroring how block_iframes() handles its two shapes.
		$result = preg_replace_callback(
			'/<object\b([^>]*?)(?:\s*\/>|>(.*?)<\/object>)/is',
			function ( $matches ) use ( $patterns ) {
				return $this->process_object_tag( $matches, $patterns );
			},
			$html
		);

		return $result ?? $html;
	}

	/**
	 * Build patterns for script matching.
	 *
	 * @param array<string, array<string, mixed>> $all_scripts Known scripts by category.
	 * @since 0.0.1
	 * @return array<string, array{name: string, category: string, label: string}>
	 */
	private function build_script_patterns( array $all_scripts ): array {
		$patterns = [];

		foreach ( $all_scripts as $category => $services ) {
			if ( ! is_array( $services ) ) {
				continue;
			}

			foreach ( $services as $service_key => $service ) {
				if ( empty( $service['scripts'] ) || ! is_array( $service['scripts'] ) ) {
					continue;
				}

				foreach ( $service['scripts'] as $pattern ) {
					$patterns[ $pattern ] = [
						'name'     => $service_key,
						'category' => $category,
						'label'    => $service['label'] ?? $service_key,
					];
				}
			}
		}

		return $patterns;
	}

	/**
	 * Build patterns for iframe matching.
	 *
	 * @param array<string, array<string, mixed>> $all_scripts Known scripts by category.
	 * @since 0.0.1
	 * @return array<string, array{name: string, category: string, label: string}>
	 */
	private function build_iframe_patterns( array $all_scripts ): array {
		$patterns = [];

		foreach ( $all_scripts as $category => $services ) {
			if ( ! is_array( $services ) ) {
				continue;
			}

			foreach ( $services as $service_key => $service ) {
				if ( empty( $service['iframes'] ) || ! is_array( $service['iframes'] ) ) {
					continue;
				}

				foreach ( $service['iframes'] as $pattern ) {
					$patterns[ $pattern ] = [
						'name'     => $service_key,
						'category' => $category,
						'label'    => $service['label'] ?? $service_key,
					];
				}
			}
		}

		return $patterns;
	}

	/**
	 * Process a single script tag.
	 *
	 * @param array<int, string>                                                  $matches  Regex matches.
	 * @param array<string, array{name: string, category: string, label: string}> $patterns Pattern mappings.
	 * @since 0.0.1
	 * @return string Modified script tag.
	 */
	private function process_script_tag( array $matches, array $patterns ): string {
		$full_tag   = $matches[0];
		$attributes = $matches[1];
		$content    = $matches[2] ?? '';

		// Skip if already blocked.
		if ( strpos( $attributes, 'data-surecookie-category' ) !== false ) {
			return $full_tag;
		}

		$type = $this->extract_script_type( $attributes );
		if ( $type === 'text/plain' ) {
			return $full_tag;
		}
		if ( $type !== null && ! in_array( $type, self::EXECUTABLE_SCRIPT_TYPES, true ) ) {
			return $full_tag;
		}

		// Extract src attribute.
		$src = '';
		if ( preg_match( '/src\s*=\s*["\']([^"\']+)["\']/i', $attributes, $src_match ) ) {
			$src = $src_match[1];
		}

		// Try to match against known scripts.
		$match_result = $this->match_pattern( $src, $content, $patterns );

		if ( $match_result === null ) {
			return $full_tag;
		}

		// Check if this script should be skipped.
		$skip = apply_filters(
			'surecookie_skip_script',
			false,
			$src,
			$match_result['name'],
			$match_result['category']
		);

		if ( $skip ) {
			return $full_tag;
		}

		// Skip required category.
		if ( $this->can_category_skip( $match_result['category'] ) ) {
			return $full_tag;
		}

		// Modify the script tag.
		return $this->modify_script_tag(
			$attributes,
			$content,
			$src,
			$match_result['name'],
			$match_result['category']
		);
	}

	/**
	 * Process a single iframe tag.
	 *
	 * @param array<int, string>                                                  $matches  Regex matches.
	 * @param array<string, array{name: string, category: string, label: string}> $patterns Pattern mappings.
	 * @since 0.0.1
	 * @return string Modified iframe tag with placeholder.
	 */
	private function process_iframe_tag( array $matches, array $patterns ): string {
		$full_tag   = $matches[0];
		$attributes = $matches[1];

		// Skip if already blocked.
		if ( strpos( $attributes, 'data-surecookie-src' ) !== false ) {
			return $full_tag;
		}

		// Extract src attribute.
		$src = '';
		if ( preg_match( '/src\s*=\s*["\']([^"\']+)["\']/i', $attributes, $src_match ) ) {
			$src = $src_match[1];
		}

		if ( empty( $src ) ) {
			return $full_tag;
		}

		// Try to match against known iframe patterns.
		$match_result = $this->match_pattern( $src, '', $patterns );

		if ( $match_result === null ) {
			return $full_tag;
		}

		// Check if this iframe should be skipped.
		$skip = apply_filters(
			'surecookie_skip_iframe',
			false,
			$src,
			$match_result['name'],
			$match_result['category']
		);

		if ( $skip ) {
			return $full_tag;
		}

		// Skip required category.
		if ( $this->can_category_skip( $match_result['category'] ) ) {
			return $full_tag;
		}

		// Build blocked iframe with placeholder.
		return $this->build_embedded_placeholder(
			'iframe',
			$attributes,
			$src,
			$match_result['name'],
			$match_result['category'],
			$match_result['label']
		);
	}

	/**
	 * Process a single <embed> tag.
	 *
	 * @param array<int, string>                                                  $matches  Regex matches.
	 * @param array<string, array{name: string, category: string, label: string}> $patterns Pattern mappings.
	 * @since 0.0.1-beta.2
	 * @return string Modified embed tag with placeholder, or original tag if not blocked.
	 */
	private function process_embed_tag( array $matches, array $patterns ): string {
		$full_tag   = $matches[0];
		$attributes = $matches[1];

		// Skip if already blocked.
		if ( strpos( $attributes, 'data-surecookie-src' ) !== false ) {
			return $full_tag;
		}

		// Extract src attribute.
		$src = '';
		if ( preg_match( '/src\s*=\s*["\']([^"\']+)["\']/i', $attributes, $src_match ) ) {
			$src = $src_match[1];
		}

		if ( empty( $src ) ) {
			return $full_tag;
		}

		// Same-origin skip — don't wrap legitimate in-house PDFs/SVGs.
		if ( $this->is_same_origin_url( $src ) ) {
			return $full_tag;
		}

		$match_result = $this->match_pattern( $src, '', $patterns );

		if ( $match_result === null ) {
			return $full_tag;
		}

		/**
		 * Filter: Allow bypassing <embed> blocking for a specific URL/service.
		 *
		 * @param bool   $skip     Whether to skip blocking (default false).
		 * @param string $src      <embed> src URL.
		 * @param string $name     Matched service key.
		 * @param string $category Matched service category.
		 * @since 0.0.1-beta.2
		 */
		$skip = apply_filters(
			'surecookie_skip_embed',
			false,
			$src,
			$match_result['name'],
			$match_result['category']
		);

		if ( $skip ) {
			return $full_tag;
		}

		if ( $this->can_category_skip( $match_result['category'] ) ) {
			return $full_tag;
		}

		return $this->build_embedded_placeholder(
			'embed',
			$attributes,
			$src,
			$match_result['name'],
			$match_result['category'],
			$match_result['label']
		);
	}

	/**
	 * Process a single <object> tag.
	 *
	 * @param array<int, string>                                                  $matches  Regex matches.
	 * @param array<string, array{name: string, category: string, label: string}> $patterns Pattern mappings.
	 * @since 0.0.1-beta.2
	 * @return string Modified object tag with placeholder, or original tag if not blocked.
	 */
	private function process_object_tag( array $matches, array $patterns ): string {
		$full_tag   = $matches[0];
		$attributes = $matches[1];
		$inner      = $matches[2] ?? '';

		// Skip if already blocked.
		if ( strpos( $attributes, 'data-surecookie-data' ) !== false ) {
			return $full_tag;
		}

		// Extract data attribute (primary URL source).
		$data = '';
		if ( preg_match( '/(?<![-\w])data\s*=\s*["\']([^"\']+)["\']/i', $attributes, $data_match ) ) {
			$data = $data_match[1];
		}

		// If no data= attribute, look for <param name="movie|src|url" value="..."> child.
		if ( empty( $data ) && ! empty( $inner ) ) {
			if ( preg_match( '/<param\b[^>]*\bname\s*=\s*["\'](?:movie|src|url)["\'][^>]*\bvalue\s*=\s*["\']([^"\']+)["\']/is', $inner, $param_match ) ) {
				$data = $param_match[1];
			} elseif ( preg_match( '/<param\b[^>]*\bvalue\s*=\s*["\']([^"\']+)["\'][^>]*\bname\s*=\s*["\'](?:movie|src|url)["\']/is', $inner, $param_match ) ) {
				$data = $param_match[1];
			}
		}

		if ( empty( $data ) ) {
			return $full_tag;
		}

		// Same-origin skip.
		if ( $this->is_same_origin_url( $data ) ) {
			return $full_tag;
		}

		$match_result = $this->match_pattern( $data, '', $patterns );

		if ( $match_result === null ) {
			return $full_tag;
		}

		/**
		 * Filter: Allow bypassing <object> blocking for a specific URL/service.
		 *
		 * @param bool   $skip     Whether to skip blocking (default false).
		 * @param string $data     <object> resource URL.
		 * @param string $name     Matched service key.
		 * @param string $category Matched service category.
		 * @since 0.0.1-beta.2
		 */
		$skip = apply_filters(
			'surecookie_skip_object',
			false,
			$data,
			$match_result['name'],
			$match_result['category']
		);

		if ( $skip ) {
			return $full_tag;
		}

		if ( $this->can_category_skip( $match_result['category'] ) ) {
			return $full_tag;
		}

		// Neutralize any <param> child that carries the URL so the browser
		// doesn't refetch Flash/legacy media while the object is hidden.
		$neutralized_inner = $this->neutralize_object_params( $inner );

		return $this->build_embedded_placeholder(
			'object',
			$attributes,
			$data,
			$match_result['name'],
			$match_result['category'],
			$match_result['label'],
			$neutralized_inner
		);
	}

	/**
	 * Rename `name` attribute on <param> elements carrying Flash-style URLs so
	 * the hidden <object> placeholder doesn't still fetch them. Restoration in
	 * consentManager.js renames the attribute back when consent is given.
	 *
	 * @param string $inner Inner HTML of the <object> tag.
	 * @since 0.0.1-beta.2
	 * @return string Neutralized inner HTML.
	 */
	private function neutralize_object_params( string $inner ): string {
		if ( trim( $inner ) === '' ) {
			return $inner;
		}

		$result = preg_replace_callback(
			'/<param\b([^>]*)(\/?>)/is',
			static function ( $matches ) {
				$attrs   = $matches[1];
				$closing = $matches[2];

				if ( preg_match( '/name\s*=\s*["\'](?:movie|src|url)["\']/i', $attrs ) !== 1 ) {
					return $matches[0];
				}

				// phpcs:ignore Generic.PHP.ForbiddenFunctions.FoundWithAlternative -- No /e modifier used, safe replacement.
				$new_attrs = preg_replace(
					'/(^|\s)name(\s*=)/i',
					'$1data-surecookie-param-name$2',
					$attrs
				) ?? $attrs;

				return '<param' . $new_attrs . $closing;
			},
			$inner
		);

		return $result ?? $inner;
	}

	/**
	 * Check whether a URL has the same host as the current site.
	 *
	 * Used to avoid wrapping first-party PDFs/SVGs embedded via <embed>/<object>.
	 * Uses exact-host match (no eTLD+1 / Public Suffix List lookup).
	 *
	 * @param string $url Resource URL.
	 * @since 0.0.1-beta.2
	 * @return bool True if URL is same-origin (exact host), relative, or a data: URI.
	 */
	private function is_same_origin_url( string $url ): bool {
		if ( $url === '' ) {
			return false;
		}

		// Non-HTTP schemes and relative URLs — not third-party tracking.
		if ( preg_match( '/^(?:about|blob|data|file|javascript|mailto|tel):/i', $url ) === 1 ) {
			return true;
		}

		// Protocol-relative URL — normalize so wp_parse_url resolves host.
		if ( strpos( $url, '//' ) === 0 ) {
			$url = 'http:' . $url;
		}

		// Relative URLs (no scheme) are inherently same-origin.
		if ( preg_match( '/^https?:/i', $url ) !== 1 ) {
			return true;
		}

		$embed_host = wp_parse_url( $url, PHP_URL_HOST );
		$site_host  = wp_parse_url( home_url(), PHP_URL_HOST );

		if ( empty( $embed_host ) || empty( $site_host ) ) {
			return false;
		}

		return strcasecmp( (string) $embed_host, (string) $site_host ) === 0;
	}

	/**
	 * Check if a category can be skipped.
	 *
	 * @param string $category Category name.
	 * @since 0.0.1
	 * @return bool True if category can be skipped, false otherwise.
	 */
	private function can_category_skip( string $category ): bool {
		$skippable_categories = apply_filters( 'surecookie_skippable_categories', [ 'essential' ] );
		if ( in_array( $category, $skippable_categories, true ) ) {
			return true;
		}
		return false;
	}

	/**
	 * Match a URL against known patterns.
	 *
	 * @param string                                                              $src      URL to match.
	 * @param string                                                              $content  Content to match.
	 * @param array<string, array{name: string, category: string, label: string}> $patterns Pattern mappings.
	 * @since 0.0.1
	 * @return array{name: string, category: string, label: string}|null Match result or null.
	 */
	private function match_pattern( string $src, string $content, array $patterns ): ?array {
		foreach ( $patterns as $pattern => $info ) {
			// Match in src.
			if ( ! empty( $src ) && strpos( $src, $pattern ) !== false ) {
				return $info;
			}

			// Match in inline content.
			if ( ! empty( $content ) && strpos( $content, $pattern ) !== false ) {
				return $info;
			}
		}

		return null;
	}

	/**
	 * Modify a script tag to block it.
	 *
	 * @param string $attributes Original attributes string.
	 * @param string $content    Inline script content.
	 * @param string $src        Original src attribute.
	 * @param string $name       Service name.
	 * @param string $category   Category name.
	 * @since 0.0.1
	 * @return string Modified script tag.
	 */
	private function modify_script_tag( string $attributes, string $content, string $src, string $name, string $category ): string {
		// Map API category names to SureCookie category names.
		$mapped_category = $this->map_category( $category );

		$type          = $this->extract_script_type( $attributes );
		$original_type = $type !== null && in_array( $type, self::EXECUTABLE_SCRIPT_TYPES, true ) ? $type : 'none';

		// Strip any type attribute (quoted or unquoted HTML5 form).
		$new_attributes = preg_replace( '/\s*type\s*=\s*(?:"[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $attributes ) ?? $attributes; // phpcs:ignore Generic.PHP.ForbiddenFunctions.FoundWithAlternative -- No /e modifier used, safe replacement.

		// Remove src attribute (will be stored in data attribute).
		if ( ! empty( $src ) ) {
			$new_attributes = preg_replace( '/\s*src\s*=\s*["\'][^"\']*["\']/i', '', $new_attributes ) ?? $new_attributes; // phpcs:ignore Generic.PHP.ForbiddenFunctions.FoundWithAlternative -- No /e modifier used, safe replacement.
		}

		// Build new script tag.
		$new_tag  = '<script type="text/plain"';
		$new_tag .= ' data-surecookie-category="' . esc_attr( $mapped_category ) . '"';
		$new_tag .= ' data-surecookie-name="' . esc_attr( $name ) . '"';
		$new_tag .= ' data-surecookie-original-type="' . esc_attr( $original_type ) . '"';

		if ( ! empty( $src ) ) {
			$new_tag .= ' data-surecookie-src="' . esc_url( $src ) . '"';
		}

		// Add remaining attributes.
		$new_attributes = trim( (string) $new_attributes );
		if ( ! empty( $new_attributes ) ) {
			$new_tag .= ' ' . $new_attributes;
		}

		$new_tag .= '>' . $content . '</script>';

		return $new_tag;
	}

	/**
	 * Extract the normalized script `type` from a tag's attribute string.
	 *
	 * Handles quoted (`type="module"`, `type='module'`) and unquoted HTML5
	 * (`type=module`) forms, lowercases, trims whitespace, and strips MIME
	 * parameters (`text/javascript; charset=utf-8` → `text/javascript`).
	 *
	 * @param string $attributes Raw attributes string from the opening tag.
	 * @since 0.0.1-beta.2
	 * @return string|null Normalized type, or null when no (or empty) type attribute.
	 */
	private function extract_script_type( string $attributes ): ?string {
		if ( ! preg_match( '/type\s*=(?|\s*"([^"]*)"|\s*\'([^\']*)\'|([^\s>]+))/i', $attributes, $match ) ) {
			return null;
		}
		if ( $match[1] === '' ) {
			return null;
		}
		return strtolower( trim( explode( ';', $match[1], 2 )[0] ) );
	}

	/**
	 * Build an embedded-content placeholder for a blocked iframe/embed/object tag.
	 *
	 * Renders the same overlay UX ("This content is blocked… Accept & Load")
	 * for all three tag types; only the hidden inner element differs. The URL
	 * is stored in `data-surecookie-src` for iframe/embed (both use `src=`) and
	 * `data-surecookie-data` for object (uses `data=` attribute). consentManager.js
	 * restores the URL when the user consents.
	 *
	 * @param string $tag        One of 'iframe', 'embed', 'object'.
	 * @param string $attributes Original tag attributes string.
	 * @param string $url        Original resource URL (src for iframe/embed, data for object).
	 * @param string $name       Matched service key.
	 * @param string $category   Matched category.
	 * @param string $label      Human-readable vendor label.
	 * @param string $inner      For <object>, the neutralized inner HTML. Ignored otherwise.
	 * @since 0.0.1-beta.2
	 * @return string Placeholder HTML.
	 */
	private function build_embedded_placeholder(
		string $tag,
		string $attributes,
		string $url,
		string $name,
		string $category,
		string $label,
		string $inner = ''
	): string {
		$mapped_category = $this->map_category( $category );

		// Normalize tag.
		$tag = in_array( $tag, [ 'iframe', 'embed', 'object' ], true ) ? $tag : 'iframe';

		// Strip the URL attribute from the original attributes. <iframe>/<embed>
		// use src=; <object> uses data=.
		$url_attr       = $tag === 'object' ? 'data' : 'src';
		$new_attributes = preg_replace( '/\s*(?<![-\w])' . $url_attr . '\s*=\s*["\'][^"\']*["\']/i', '', $attributes ) ?? $attributes; // phpcs:ignore Generic.PHP.ForbiddenFunctions.FoundWithAlternative -- No /e modifier used, safe replacement.

		// Extract any existing `style` attribute from the original tag and strip it
		// so we can merge its value with our `display:none;` declaration. Emitting
		// two `style` attributes is invalid HTML — the HTML parser keeps only the
		// first, which would silently discard our `display:none;` and leave the
		// blocked element visible.
		$existing_style = '';
		if ( preg_match( '/style\s*=\s*(?:"([^"]*)"|\'([^\']*)\')/i', (string) $new_attributes, $style_match ) === 1 ) {
			$existing_style = trim( $style_match[1] !== '' ? $style_match[1] : ( $style_match[2] ?? '' ) );
			$new_attributes = preg_replace( '/\s*style\s*=\s*(?:"[^"]*"|\'[^\']*\')/i', '', (string) $new_attributes ) ?? $new_attributes; // phpcs:ignore Generic.PHP.ForbiddenFunctions.FoundWithAlternative -- No /e modifier used, safe replacement.
		}

		// Append `display:none;` so it always wins the cascade for inline styles
		// (later declarations override earlier ones for the same property).
		$combined_style = $existing_style;
		if ( $combined_style !== '' && substr( $combined_style, -1 ) !== ';' ) {
			$combined_style .= ';';
		}
		$combined_style .= 'display:none;';

		// Attribute used to stash the URL on the hidden element for later restore.
		$data_url_attr = $tag === 'object' ? 'data-surecookie-data' : 'data-surecookie-src';

		// Embed dimensions from the tag, used only as a pre-paint fallback size.
		$original_width  = preg_match( '/(?:^|\s)width\s*=\s*["\']?(\d+)/i', $attributes, $w_match ) === 1 ? (int) $w_match[1] : 0;
		$original_height = preg_match( '/(?:^|\s)height\s*=\s*["\']?(\d+)/i', $attributes, $h_match ) === 1 ? (int) $h_match[1] : 0;

		// Fallback only; consentManager.matchPlaceholderSizes() then sets the
		// exact height from the embed's real (often CSS-driven) rendered box.
		if ( $original_height > 0 ) {
			$wrapper_style = sprintf( 'width:100%%;height:%dpx;', $original_height );
		} else {
			$wrapper_style = 'width:100%;min-height:160px;';
		}

		// Outer placeholder wrapper.
		$placeholder  = '<div class="surecookie-placeholder surecookie-placeholder-' . esc_attr( $name ) . '"';
		$placeholder .= ' data-surecookie-name="' . esc_attr( $name ) . '"';
		$placeholder .= ' data-surecookie-category="' . esc_attr( $mapped_category ) . '"';
		if ( $original_width > 0 ) {
			$placeholder .= ' data-surecookie-width="' . esc_attr( (string) $original_width ) . '"';
		}
		if ( $original_height > 0 ) {
			$placeholder .= ' data-surecookie-height="' . esc_attr( (string) $original_height ) . '"';
		}
		$placeholder .= ' style="' . esc_attr( $wrapper_style ) . '"';
		$placeholder .= '>';

		// Placeholder overlay (text + Accept & Load button). Quarantined trackers
		// (Pro Compliance Guard) are held until an admin reviews them and can never
		// be released by visitor consent (see ConsentManager.isAllowed), so the
		// "Accept & Load" button is omitted — it would be a dead control — and the
		// copy stays neutral rather than implying consent would load the content.
		$is_quarantined = $mapped_category === self::QUARANTINE_CATEGORY;
		$placeholder   .= '<div class="surecookie-placeholder-content">';
		$placeholder   .= '<p class="surecookie-placeholder-text">';
		if ( $is_quarantined ) {
			$placeholder .= esc_html__( 'This content is currently blocked.', 'surecookie' );
		} else {
			$placeholder .= sprintf(
				/* translators: %s: Service name (e.g., YouTube, Google Maps) */
				esc_html__( 'This content is blocked because it requires %s cookies.', 'surecookie' ),
				esc_html( $label )
			);
		}
		$placeholder .= '</p>';
		if ( ! $is_quarantined ) {
			$placeholder .= '<button type="button" class="surecookie-placeholder-button" data-surecookie-category="' . esc_attr( $mapped_category ) . '">';
			$placeholder .= esc_html__( 'Accept & Load', 'surecookie' );
			$placeholder .= '</button>';
		}
		$placeholder .= '</div>';

		// Hidden element (restored on consent).
		$placeholder .= '<' . $tag;
		$placeholder .= ' ' . $data_url_attr . '="' . esc_url( $url ) . '"';
		$placeholder .= ' data-surecookie-name="' . esc_attr( $name ) . '"';
		$placeholder .= ' data-surecookie-category="' . esc_attr( $mapped_category ) . '"';

		$trimmed_attrs = trim( (string) $new_attributes );
		if ( $trimmed_attrs !== '' ) {
			$placeholder .= ' ' . $trimmed_attrs;
		}

		$placeholder .= ' style="' . esc_attr( $combined_style ) . '"';

		if ( $tag === 'embed' ) {
			// <embed> is void / self-closing.
			$placeholder .= ' />';
		} else {
			$placeholder .= '>' . $inner . '</' . $tag . '>';
		}

		$placeholder .= '</div>';

		return $placeholder;
	}

	/**
	 * Map API category names to SureCookie category names.
	 *
	 * @param string $category Original category name.
	 * @since 0.0.1
	 * @return string Mapped category name.
	 */
	private function map_category( string $category ): string {
		return apply_filters( 'surecookie_map_category', $category, $category );
	}

	/**
	 * Check if this request is from the SaaS scanner with a valid bypass token.
	 *
	 * @since 0.0.0-alpha.2
	 * @since 0.0.1-beta.2 Strict 64-char hex validation; replaces sanitize_text_field().
	 * @return bool
	 */
	private function is_scan_bypass_request(): bool {
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Strict regex below rejects anything but [0-9a-f]{64}.
		$header_value = $_SERVER['HTTP_X_SURECOOKIE_SCAN'] ?? '';

		if ( ! is_string( $header_value ) || ! preg_match( '/^[0-9a-f]{64}$/i', $header_value ) ) {
			return false;
		}

		$stored_token = get_transient( SaasClient::SCAN_BYPASS_TRANSIENT_KEY );

		// Fail closed if transient is corrupted or returns a non-string (object cache edge cases).
		if ( ! is_string( $stored_token ) || ! preg_match( '/^[0-9a-f]{64}$/i', $stored_token ) ) {
			return false;
		}

		return hash_equals( $stored_token, $header_value );
	}
}
