<?php
/**
 * Engine: renderer. Obtains the final front-end HTML for a content item via
 * the "private door" - a self-request to the item's own URL carrying a
 * single-use token (the SSAA pattern, generalized).
 *
 * The door does NOT bypass any WordPress behaviour for published content; the
 * page is served through the completely normal front-end pipeline. The token's
 * purposes are: (1) marking the request as an engine render, and (2) authorising
 * add-on rendering behaviour via the '{prefix}_render_request_authorised'
 * extension point (premium draft/private rendering hooks in there).
 *
 * Generalized from SSAA's post-only renderer: the door token maps to a TARGET
 * - 'post:ID' for a real post, 'url:sha1' for a synthesized URL (archives,
 * paginated lists) that has no post. fetch_post() and fetch_url() are the two
 * entry points; the door validates that the presented token resolves to the
 * requested target either way.
 *
 * Fallback: if the self-request is blocked (Cloudflare challenge on proxied
 * DNS, or loopback/NAT-hairpin failure), retry against 127.0.0.1 with a Host
 * header.
 *
 * VERIFICATION OWED (no-guessing rule): behaviour on a Cloudflare-proxied site
 * and the https/SNI characteristics of the loopback fallback must be verified
 * on live sites (LIHA Gate C). See link-image-health-audit/docs/DESIGN-liha.md.
 *
 * CANONICAL ENGINE SOURCE - edit here in _shared/shared-render-engine/src/, re-run sync.sh.
 *
 * @package plugupp-render-engine
 */

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

class SSAA_Engine_Renderer {

	/** Default per-render HTTP timeout in seconds (config 'render_timeout',
	 * filter '{prefix}_render_timeout'). */
	const FETCH_TIMEOUT = 15;

	/** Token lifetime in seconds. Tokens are single-use regardless. */
	const TOKEN_TTL = 60;

	// -------------------------------------------------------------------------
	// The door (incoming side)
	// -------------------------------------------------------------------------

	/**
	 * Validate door requests. Requests WITHOUT the render query var are
	 * untouched. Requests with it must carry a valid single-use token that
	 * resolves to the requested target, or the request dies with 403 - the door
	 * never leaks anything on bad input.
	 */
	public static function guard_door() {
		$qv_render = SSAA_Engine::key( 'render' );
		$qv_token  = SSAA_Engine::key( 'token' );

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the single-use token IS the credential; this is not a form action.
		if ( ! isset( $_GET[ $qv_render ] ) ) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- see above.
		$render = sanitize_text_field( wp_unslash( $_GET[ $qv_render ] ) );
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- see above.
		$token = isset( $_GET[ $qv_token ] ) ? sanitize_text_field( wp_unslash( $_GET[ $qv_token ] ) ) : '';

		$expected = self::expected_target( $render );

		if ( '' === $expected || ! self::consume_token( $token, $expected ) ) {
			SSAA_Logger::info( 'render_door_rejected', array( 'ref' => $expected ) );
			wp_die(
				esc_html__( 'Invalid render request.', 'search-social-appearance-audit' ),
				'',
				array( 'response' => 403 )
			);
		}

		/**
		 * The door request is authorised. Extension point: add-on files hook
		 * this to adjust how the target is served (e.g. premium draft/private
		 * rendering).
		 *
		 * @param string $target The engine target ('post:ID' or 'url:hash').
		 * @param string $render The raw render query value.
		 */
		do_action( SSAA_Engine::key( 'render_request_authorised' ), $expected, $render );
	}

	/**
	 * Derive the expected target from a raw render query value. A 40-char hex
	 * string is a URL hash; an all-digit value is a post ID. Anything else is
	 * invalid (returns '').
	 *
	 * @param string $render Raw render query value.
	 * @return string Target string or '' when invalid.
	 */
	private static function expected_target( $render ) {
		if ( preg_match( '/^[a-f0-9]{40}$/', $render ) ) {
			return 'url:' . $render;
		}
		if ( ctype_digit( $render ) && (int) $render > 0 ) {
			return 'post:' . absint( $render );
		}
		return '';
	}

	/**
	 * Issue a single-use render token for a target.
	 *
	 * @param string $target Engine target ('post:ID' or 'url:hash').
	 * @return string
	 */
	private static function create_token( $target ) {
		$token = bin2hex( random_bytes( 16 ) );
		set_transient( self::token_transient( $token ), (string) $target, self::TOKEN_TTL );
		return $token;
	}

	/**
	 * Validate and consume (delete) a token. Valid only when it exists, has not
	 * expired, and was issued for this exact target.
	 *
	 * @param string $token    Presented token.
	 * @param string $expected Expected target.
	 * @return bool
	 */
	private static function consume_token( $token, $expected ) {
		if ( '' === $token || ! preg_match( '/^[a-f0-9]{32}$/', $token ) ) {
			return false;
		}

		$stored = get_transient( self::token_transient( $token ) );
		delete_transient( self::token_transient( $token ) ); // Single use, even on mismatch.

		return false !== $stored && '' !== (string) $stored && (string) $stored === (string) $expected;
	}

	/**
	 * Transient key for a token, namespaced by consumer prefix.
	 *
	 * @param string $token Token.
	 * @return string
	 */
	private static function token_transient( $token ) {
		return SSAA_Engine::prefix() . '_rt_' . $token;
	}

	// -------------------------------------------------------------------------
	// The fetch (outgoing side)
	// -------------------------------------------------------------------------

	/**
	 * Fetch the final rendered HTML for a post.
	 *
	 * @param int $post_id Post ID.
	 * @return array|WP_Error See do_fetch() for the success shape.
	 */
	public static function fetch_post( $post_id ) {
		$post_id = absint( $post_id );
		$post    = get_post( $post_id );

		if ( ! $post ) {
			return new WP_Error(
				'engine_target_not_found',
				__( 'Content item no longer exists.', 'search-social-appearance-audit' ),
				array( 'retryable' => false )
			);
		}

		$permalink = get_permalink( $post );
		if ( ! $permalink ) {
			return new WP_Error(
				'engine_target_not_found',
				__( 'Content item has no URL.', 'search-social-appearance-audit' ),
				array( 'retryable' => false )
			);
		}

		return self::do_fetch( $permalink, (string) $post_id, SSAA_Engine::post_ref( $post_id ), array( 'post_id' => $post_id ) );
	}

	/**
	 * Fetch the final rendered HTML for a synthesized URL that has no post
	 * (homepage-as-blog, taxonomy/date archives, archive pagination). The URL
	 * must be a real, publicly reachable front-end URL on this site.
	 *
	 * @param string $url  Absolute URL.
	 * @param array  $opts Reserved for future per-fetch options.
	 * @return array|WP_Error See do_fetch() for the success shape.
	 */
	public static function fetch_url( $url, array $opts = array() ) {
		// $opts['speculative']: the URL is computed/guessed (e.g. synthesized
		// archive pagination) and may legitimately not exist, so a 404/410 is
		// benign - logged at INFO and returned as engine_render_target_absent
		// rather than an error the caller treats as a scan failure.
		$speculative = ! empty( $opts['speculative'] );
		$url = (string) $url;

		if ( '' === $url || ! wp_http_validate_url( $url ) ) {
			return new WP_Error(
				'engine_target_not_found',
				__( 'No valid URL to render.', 'search-social-appearance-audit' ),
				array( 'retryable' => false )
			);
		}

		$hash = sha1( $url );
		return self::do_fetch( $url, $hash, 'url:' . $hash, array( 'ref' => 'url:' . $hash ), $speculative );
	}

	/**
	 * Shared fetch path for both entry points.
	 *
	 * @param string $permalink The front-end URL to render.
	 * @param string $render    The render query value (post ID or url hash).
	 * @param string $target    The engine target ('post:ID' or 'url:hash').
	 * @param array  $log_ctx   Base logging context (post_id or ref).
	 * @return array|WP_Error {
	 *     @type string $html        Full response body.
	 *     @type int    $http_status Final HTTP status.
	 *     @type string $via         'direct' or 'loopback'.
	 *     @type int    $duration_ms Total fetch duration.
	 * }
	 */
	private static function do_fetch( $permalink, $render, $target, array $log_ctx, $speculative = false ) {
		$started  = microtime( true );
		// Carry the real target URL in every log line for this fetch, so render
		// errors name the page that failed (the ref alone is just a hash).
		$log_ctx['url'] = $permalink;

		// Maintenance guard (0.4.0): every render is a SELF-request, so when
		// THIS site is in maintenance mode (core/plugin update writing the
		// .maintenance file) the request can only return the 503 maintenance
		// page. Detect it locally - one stat() call - BEFORE issuing the
		// request, and hand the queue a DEFERRABLE outcome so the whole scan
		// pauses and resumes, instead of burning retries against a condition
		// that self-heals in minutes (the 2026-07-28 grovehousefarm incident).
		// Checked per request, not per batch: maintenance can begin mid-batch
		// inside a long-running process that predates the file.
		if ( self::site_in_maintenance() ) {
			SSAA_Logger::info( 'render_deferred_maintenance', $log_ctx );
			return new WP_Error(
				'engine_render_deferred',
				__( 'The site is in maintenance mode (an update is running); the scan will pause and resume automatically.', 'search-social-appearance-audit' ),
				array(
					'retryable'     => false,
					'deferrable'    => true,
					'defer_seconds' => 90,
				)
			);
		}

		$url      = self::door_url( $permalink, $render, $target );
		$response = wp_remote_get( $url, self::request_args( $target ) );

		// A redirect cannot simply be followed (0.5.0). guard_door() runs on
		// parse_request priority 0, BEFORE redirect_canonical, so the
		// REDIRECTING hop already consumed the single-use token; the followed
		// hop then presents a spent one and the door answers 403. Every
		// discovered URL that redirects was therefore unrenderable. On a
		// customer site one stale tag archive (301 -> home) failed this way on
		// five consecutive scans, and because a render failure marks the scan
		// degraded it also disabled change detection and triggered a futile
		// recovery scan every time.
		//
		// Simply following it with a fresh token would be WORSE than the
		// failure: the destination's content would be stored under the SOURCE's
		// ref, so the homepage's links would be recorded as that tag archive's.
		// What the redirect MEANS therefore decides the outcome.
		if ( ! is_wp_error( $response ) ) {
			$code = (int) wp_remote_retrieve_response_code( $response );
			if ( $code >= 300 && $code < 400 ) {
				$location = self::abs_location( $permalink, wp_remote_retrieve_header( $response, 'location' ) );

				if ( '' !== $location && self::same_page( $permalink, $location ) ) {
					// The same page in canonical clothing - a trailing slash, a
					// scheme upgrade, a www. Take it once more at the address
					// the site prefers, with a fresh token.
					SSAA_Logger::info( 'render_canonical_retry', array_merge( $log_ctx, array(
						'redirect_to' => $location,
						'http_status' => $code,
					) ) );
					$response = wp_remote_get(
						self::door_url( $location, $render, $target ),
						self::request_args( $target )
					);
					$code = is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response );
				}

				// Still redirecting, or pointing at a different page: this URL
				// is not a page in its own right. That is not a render FAILURE,
				// so it is reported like a vanished target and the consumer
				// drops it without marking the scan degraded.
				if ( $code >= 300 && $code < 400 ) {
					$final = is_wp_error( $response )
						? $location
						: self::abs_location( $permalink, wp_remote_retrieve_header( $response, 'location' ) );
					SSAA_Logger::info( 'render_redirected', array_merge( $log_ctx, array(
						'redirect_to' => $final,
						'http_status' => $code,
					) ) );
					return new WP_Error(
						'engine_render_redirected',
						__( 'The address redirects elsewhere, so it is not a page of its own to audit.', 'search-social-appearance-audit' ),
						array(
							'retryable'   => false,
							'http_status' => $code,
							'redirect_to' => $final,
						)
					);
				}
			}
		}

		// Direct fetch succeeded.
		if ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) ) {
			return self::success( $response, 'direct', $started, $log_ctx );
		}

		// Decide whether the loopback fallback could help: transport errors
		// (NAT hairpin, DNS) and challenge-style blocks; not ordinary 4xx/5xx
		// from WordPress itself.
		$should_fallback = is_wp_error( $response ) || self::looks_like_challenge( $response );

		/** Disable or force the loopback fallback per-site. */
		$should_fallback = (bool) apply_filters( SSAA_Engine::key( 'loopback_fallback_enabled' ), $should_fallback, $response, $target );

		if ( $should_fallback ) {
			$loopback = self::fetch_loopback( $permalink, $render, $target );
			if ( ! is_wp_error( $loopback ) && 200 === wp_remote_retrieve_response_code( $loopback ) ) {
				SSAA_Logger::info( 'render_loopback_used', $log_ctx );
				return self::success( $loopback, 'loopback', $started, $log_ctx );
			}
		}

		// Build the structured failure.
		if ( is_wp_error( $response ) ) {
			SSAA_Logger::error( 'render_request_failed', array_merge( $log_ctx, array(
				'error' => $response->get_error_message(),
			) ) );
			return new WP_Error(
				'engine_render_request_failed',
				sprintf(
					/* translators: %s: transport error detail */
					__( 'The page could not be fetched: %s', 'search-social-appearance-audit' ),
					$response->get_error_message()
				),
				array( 'retryable' => true )
			);
		}

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

		if ( self::looks_like_challenge( $response ) ) {
			SSAA_Logger::error( 'render_blocked_challenge', array_merge( $log_ctx, array(
				'http_status' => $status,
			) ) );
			return new WP_Error(
				'engine_render_blocked',
				__( 'The request was blocked by the site\'s security layer (e.g. a Cloudflare challenge). The direct fallback also failed.', 'search-social-appearance-audit' ),
				array(
					'http_status' => $status,
					'retryable'   => true,
				)
			);
		}

		if ( $speculative && in_array( $status, array( 404, 410 ), true ) ) {
			// A speculative/synthesized target that simply does not exist (e.g. an
			// over-estimated archive pagination page). Benign, not an error: log at
			// INFO and let the caller skip it.
			SSAA_Logger::info( 'render_target_absent', array_merge( $log_ctx, array(
				'http_status' => $status,
			) ) );
			return new WP_Error(
				'engine_render_target_absent',
				__( 'The page does not exist.', 'search-social-appearance-audit' ),
				array( 'http_status' => $status, 'retryable' => false )
			);
		}

		// 503 / 429 are DEFERRABLE (0.4.0): the site is telling us to come back
		// later (maintenance window we saw start mid-fetch, or rate limiting).
		// Honour Retry-After when sent (WP core sends Retry-After: 600 on the
		// maintenance page), clamped so one long header cannot idle a scan -
		// the queue re-checks and re-defers as long as the condition holds.
		if ( in_array( $status, array( 503, 429 ), true ) ) {
			SSAA_Logger::info( 'render_deferred_http', array_merge( $log_ctx, array(
				'http_status'  => $status,
				'body_snippet' => self::error_body_snippet( $response ),
			) ) );
			$retry_after = (int) wp_remote_retrieve_header( $response, 'retry-after' );
			return new WP_Error(
				'engine_render_deferred',
				sprintf(
					/* translators: %d: HTTP status code */
					__( 'The site asked us to retry later (HTTP %d); the scan will pause and resume automatically.', 'search-social-appearance-audit' ),
					$status
				),
				array(
					'http_status'   => $status,
					'retryable'     => false,
					'deferrable'    => true,
					'defer_seconds' => ( $retry_after > 0 ) ? min( 180, max( 30, $retry_after ) ) : 90,
				)
			);
		}

		SSAA_Logger::error( 'render_http_error', array_merge( $log_ctx, array(
			'http_status'  => $status,
			// A short, sanitised slice of the response body. When the page
			// fataled (theme/plugin error, WP critical-error screen) and the
			// host exposes it, this captures the actual "Fatal error: ... on
			// line N" so the cause is visible in the Logs tab without server
			// access. Empty when the host returns a blank/again-generic body.
			'body_snippet' => self::error_body_snippet( $response ),
		) ) );
		return new WP_Error(
			'engine_render_http_error',
			sprintf(
				/* translators: %d: HTTP status code */
				__( 'The page returned HTTP %d.', 'search-social-appearance-audit' ),
				$status
			),
			array(
				'http_status' => $status,
				'retryable'   => $status >= 500, // Server errors are usually transient; 4xx are not.
			)
		);
	}

	/**
	 * Whether a renderer WP_Error is worth re-trying later. Reads the
	 * 'retryable' flag the renderer set in the error data; a convenience for
	 * consumer handlers building their queue outcome.
	 *
	 * @param WP_Error $error Renderer error.
	 * @return bool
	 */
	public static function is_retryable( $error ) {
		if ( ! is_wp_error( $error ) ) {
			return false;
		}
		$data = (array) $error->get_error_data();
		return ! empty( $data['retryable'] );
	}

	/**
	 * Whether a renderer WP_Error means "the whole scan should pause and
	 * resume later" (site maintenance mode, 503/429 with the site asking us
	 * to come back). Distinct from retryable: a deferrable outcome pauses the
	 * QUEUE without consuming the item's retry budget.
	 *
	 * @param WP_Error $error Renderer error.
	 * @return bool
	 */
	public static function is_deferrable( $error ) {
		if ( ! is_wp_error( $error ) ) {
			return false;
		}
		$data = (array) $error->get_error_data();
		return ! empty( $data['deferrable'] );
	}

	/**
	 * Suggested pause length for a deferrable renderer error (seconds).
	 *
	 * @param WP_Error $error Renderer error.
	 * @return int
	 */
	public static function defer_seconds( $error ) {
		if ( ! is_wp_error( $error ) ) {
			return 90;
		}
		$data = (array) $error->get_error_data();
		return isset( $data['defer_seconds'] ) ? max( 30, (int) $data['defer_seconds'] ) : 90;
	}

	/**
	 * Whether THIS site is in WordPress maintenance mode right now (a core /
	 * plugin / theme update is holding the .maintenance file). Checked before
	 * every self-request: maintenance can begin mid-batch inside a
	 * long-running process that started before the file existed, and PHP's
	 * stat cache would otherwise keep answering from before it appeared - the
	 * clearstatcache() on the single path is what makes the per-request check
	 * actually work in that process.
	 *
	 * @return bool
	 */
	public static function site_in_maintenance() {
		$file = ABSPATH . '.maintenance';
		clearstatcache( true, $file );
		if ( function_exists( 'wp_is_maintenance_mode' ) ) {
			return wp_is_maintenance_mode();
		}
		// Pre-WP-5.5 fallback (below our floor, kept for safety): mirror
		// core's own freshness rule - the file only counts for 10 minutes.
		if ( ! file_exists( $file ) ) {
			return false;
		}
		$upgrading = 0;
		include $file;
		return ( time() - (int) $upgrading ) < 10 * MINUTE_IN_SECONDS;
	}

	/**
	 * Extract a short, log-safe snippet from an error response body. Prefers an
	 * explicit PHP fatal/parse message when one is present (the gold for
	 * diagnosing a render 500); otherwise returns a tag-stripped,
	 * whitespace-collapsed slice so the log still shows what came back.
	 *
	 * @param array $response wp_remote_get result (not a WP_Error).
	 * @return string Empty string when the body is empty.
	 */
	private static function error_body_snippet( $response ) {
		$body = (string) wp_remote_retrieve_body( $response );
		if ( '' === $body ) {
			return '';
		}

		$text = trim( (string) preg_replace( '/\s+/', ' ', wp_strip_all_tags( $body ) ) );
		if ( '' === $text ) {
			return '';
		}

		// Prefer an explicit error line if the response exposed one.
		if ( preg_match( '/(?:Fatal error|Parse error|Uncaught)[^\r\n]*/i', $text, $m ) ) {
			$text = trim( $m[0] );
		}

		$limit = 300;
		if ( function_exists( 'mb_strlen' ) && mb_strlen( $text ) > $limit ) {
			return mb_substr( $text, 0, $limit ) . '...';
		}
		if ( strlen( $text ) > $limit ) {
			return substr( $text, 0, $limit ) . '...';
		}
		return $text;
	}

	/**
	 * Door URL: target URL + render query value + a fresh single-use token.
	 *
	 * @param string $permalink Target front-end URL.
	 * @param string $render    Render query value.
	 * @param string $target    Engine target.
	 * @return string
	 */
	private static function door_url( $permalink, $render, $target ) {
		return add_query_arg(
			array(
				SSAA_Engine::key( 'render' ) => $render,
				SSAA_Engine::key( 'token' )  => self::create_token( $target ),
			),
			$permalink
		);
	}

	/**
	 * Loopback fallback: same path and query against 127.0.0.1 with a Host
	 * header. Bypasses proxied DNS (Cloudflare) and NAT-hairpin failures.
	 * Issues a fresh token (the original may or may not have been consumed).
	 *
	 * NOTE: with https, SNI will be 127.0.0.1 and cert verification is disabled;
	 * vhost routing via the Host header works on common single-host setups but
	 * is one of the live-verification items (Gate C).
	 *
	 * @param string $permalink Target front-end URL.
	 * @param string $render    Render query value.
	 * @param string $target    Engine target.
	 * @return array|WP_Error wp_remote_get result.
	 */
	private static function fetch_loopback( $permalink, $render, $target ) {
		$host = wp_parse_url( $permalink, PHP_URL_HOST );
		if ( ! $host ) {
			return new WP_Error( 'engine_loopback_no_host', 'No host in target URL.' );
		}

		$url = self::door_url( $permalink, $render, $target );
		// Swap only the host; scheme, path and query stay identical so the
		// rendered output (canonical scheme etc.) is not skewed.
		$url = preg_replace( '#^(https?://)' . preg_quote( $host, '#' ) . '#i', '${1}127.0.0.1', $url, 1 );

		$args                    = self::request_args( $target );
		$args['sslverify']       = false;
		$args['headers']['Host'] = $host;

		return wp_remote_get( $url, $args );
	}

	/**
	 * Shared request args (config 'render_timeout' / 'render_user_agent' /
	 * 'fetch_args'; filter '{prefix}_render_request_args').
	 *
	 * @param string $target Engine target.
	 * @return array
	 */
	private static function request_args( $target, $redirection = 0 ) {
		$args = array(
			'timeout'     => (int) apply_filters( SSAA_Engine::key( 'render_timeout' ), (int) SSAA_Engine::config( 'render_timeout', self::FETCH_TIMEOUT ), $target ),
			'redirection' => (int) $redirection,
			'user-agent'  => self::user_agent(),
			'headers'     => array(
				'Accept' => 'text/html,application/xhtml+xml',
			),
		);

		$extra = (array) SSAA_Engine::config( 'fetch_args', array() );
		if ( $extra ) {
			$args = array_merge( $args, $extra );
		}

		$args = (array) apply_filters( SSAA_Engine::key( 'render_request_args' ), $args, $target );

		// Authoritative AFTER the filter: door requests must not be followed by
		// the HTTP client (see do_fetch). A site filter setting redirection back
		// would silently reintroduce the spent-token 403.
		$args['redirection'] = (int) $redirection;

		return $args;
	}

	/**
	 * Resolve a Location header against the URL that produced it.
	 *
	 * @param string $base     Requested URL.
	 * @param string $location Location header value.
	 * @return string Absolute URL, or '' when unusable.
	 */
	private static function abs_location( $base, $location ) {
		$location = trim( (string) $location );
		if ( '' === $location ) {
			return '';
		}
		if ( preg_match( '#^https?://#i', $location ) ) {
			return $location;
		}
		$p = wp_parse_url( $base );
		if ( ! $p || empty( $p['scheme'] ) || empty( $p['host'] ) ) {
			return '';
		}
		$origin = $p['scheme'] . '://' . $p['host'] . ( isset( $p['port'] ) ? ':' . $p['port'] : '' );
		if ( 0 === strpos( $location, '//' ) ) {
			return $p['scheme'] . ':' . $location;
		}
		if ( 0 === strpos( $location, '/' ) ) {
			return $origin . $location;
		}
		$dir = preg_replace( '#/[^/]*$#', '/', isset( $p['path'] ) ? $p['path'] : '/' );
		return $origin . ( '' === $dir ? '/' : $dir ) . $location;
	}

	/**
	 * Whether two URLs address the SAME page, differing only in canonical
	 * clothing: scheme, a leading www., and the trailing slash. Query strings
	 * are ignored because the door's own arguments live there.
	 *
	 * @param string $a First URL.
	 * @param string $b Second URL.
	 * @return bool
	 */
	private static function same_page( $a, $b ) {
		$norm = static function ( $url ) {
			$p = wp_parse_url( (string) $url );
			if ( ! $p || empty( $p['host'] ) ) {
				return '';
			}
			$host = preg_replace( '#^www\.#i', '', strtolower( $p['host'] ) );
			$path = isset( $p['path'] ) ? rtrim( $p['path'], '/' ) : '';
			return $host . $path;
		};
		$na = $norm( $a );
		return '' !== $na && $na === $norm( $b );
	}

	/**
	 * The render user-agent. Plainly identifies the render door so site owners
	 * can whitelist their own checker. Config 'render_user_agent' overrides.
	 *
	 * @return string
	 */
	private static function user_agent() {
		$ua = (string) SSAA_Engine::config( 'render_user_agent', '' );
		if ( '' !== $ua ) {
			return $ua;
		}
		$slug    = (string) SSAA_Engine::config( 'slug', 'search-social-appearance-audit' );
		$version = (string) SSAA_Engine::config( 'version', SSAA_Engine::VERSION );
		return sprintf( '%s/%s (render; +%s)', $slug, $version, home_url( '/' ) );
	}

	/**
	 * Build the success payload and log it.
	 *
	 * @param array  $response wp_remote_get result.
	 * @param string $via      'direct' or 'loopback'.
	 * @param float  $started  microtime(true) at fetch start.
	 * @param array  $log_ctx  Base logging context.
	 * @return array
	 */
	private static function success( $response, $via, $started, array $log_ctx ) {
		$duration = (int) round( ( microtime( true ) - $started ) * 1000 );

		SSAA_Logger::debug( 'render_fetched', array_merge( $log_ctx, array(
			'via'         => $via,
			'duration_ms' => $duration,
		) ) );

		return array(
			'html'        => (string) wp_remote_retrieve_body( $response ),
			'http_status' => (int) wp_remote_retrieve_response_code( $response ),
			'via'         => $via,
			'duration_ms' => $duration,
		);
	}

	/**
	 * Whether a non-200 response looks like a security-layer challenge rather
	 * than a WordPress response.
	 *
	 * @param array $response wp_remote_get result (not a WP_Error).
	 * @return bool
	 */
	public static function looks_like_challenge( $response ) {
		$status = (int) wp_remote_retrieve_response_code( $response );
		if ( ! in_array( $status, array( 403, 429, 503 ), true ) ) {
			return false;
		}

		$server   = strtolower( (string) wp_remote_retrieve_header( $response, 'server' ) );
		$cf_ray   = (string) wp_remote_retrieve_header( $response, 'cf-ray' );
		$cf_mitig = (string) wp_remote_retrieve_header( $response, 'cf-mitigated' );

		return ( false !== strpos( $server, 'cloudflare' ) ) || '' !== $cf_ray || '' !== $cf_mitig;
	}
}
