<?php

declare(strict_types=1);

namespace ShieldX\Security;

defined('ABSPATH') || exit;

final class HumanChallenge
{
    private const PASS_COOKIE = 'shieldx_cap_pass';
    private const PASS_TTL_SECONDS = 900;
    private const CHALLENGE_FIELD = 'shieldx_cap_challenge';
    private const CONTEXT_FIELD = 'shieldx_cap_context';
    private const LOGIN_PRELUDE_RULE = 'SHIELDX-LOGIN-PRELUDE';
    private const LOCAL_ENDPOINT_PREFIX = '/shieldx-cap/';
    private const LOCAL_CHALLENGE_COUNT = 20;
    private const LOCAL_CHALLENGE_SIZE = 32;
    private const LOCAL_CHALLENGE_DIFFICULTY = 4;
    private const LOCAL_CHALLENGE_TTL_SECONDS = 600;
    private const LOCAL_VERIFY_TTL_SECONDS = 1200;
    private const LOCAL_REQUEST_MAX_BYTES = 16384;
    private const LOCAL_CHALLENGE_ISSUE_WINDOW_SECONDS = 60;
    private const LOCAL_CHALLENGE_ISSUE_LIMIT_PER_CLIENT = 20;
    private const LOCAL_CHALLENGE_ISSUE_LIMIT_GLOBAL = 240;
    private const LOCAL_CHALLENGE_ISSUE_LOCK_SECONDS = 5;

    /** @var HumanChallengeSettings */
    private $settings;

    /** @var AuditLogger|null */
    private $logger;

    public function __construct(HumanChallengeSettings $settings, ?AuditLogger $logger = null)
    {
        $this->settings = $settings;
        $this->logger = $logger;
    }

    public static function fromWordPress(?AuditLogger $logger = null): self
    {
        return new self(HumanChallengeSettings::fromWordPress(), $logger);
    }

    public function boot(): void
    {
        add_action('init', [$this, 'maybeServeLocalStandaloneEndpoint'], 0);
        add_action('login_init', [$this, 'maybeRunLoginPrelude']);
        add_action('login_enqueue_scripts', [$this, 'enqueueLoginWidgetScript']);
        add_action('login_form', [$this, 'renderLoginWidget']);
        add_action('lostpassword_form', [$this, 'renderLostPasswordWidget']);
        add_action('wp_enqueue_scripts', [$this, 'enqueueFrontendWidgetScript']);
        add_action('wp_footer', [$this, 'renderFrontendFormWidgetTemplates'], 5);
        add_filter('authenticate', [$this, 'enforceLoginChallenge'], 1000, 3);
        add_filter('allow_password_reset', [$this, 'enforcePasswordResetChallenge'], 20, 2);
    }

    public function maybeServeLocalStandaloneEndpoint(): void
    {
        $endpoint = $this->localEndpointFromUri($this->serverText('REQUEST_URI', ''));
        if (empty($endpoint)) {
            return;
        }

        $siteKey = (string) ($endpoint['site_key'] ?? '');
        $action = (string) ($endpoint['action'] ?? '');

        if ($this->settings->provider() !== 'cap' || $siteKey === '' || !hash_equals($this->settings->siteKey(), $siteKey)) {
            $this->serveJson(['success' => false, 'error' => 'Unknown Cap.js Challenge (Captcha) site key.'], 404);
        }

        if ($action === 'challenge') {
            $this->serveLocalChallenge();
        }

        if ($action === 'redeem') {
            $this->serveLocalRedeem();
        }

        if ($action === 'siteverify') {
            $this->serveLocalSiteVerify();
        }

        $this->serveJson(['success' => false, 'error' => 'Unknown Cap.js Challenge (Captcha) endpoint.'], 404);
    }

    public function enqueueLoginWidgetScript(): void
    {
        if (!$this->settings->enabledForLogin() && !$this->settings->enabledForLoginFullscreen() && !$this->settings->enabledForPasswordReset()) {
            return;
        }

        $this->enqueueWidgetScript();
    }

    public function renderLoginWidget(): void
    {
        if (!$this->settings->enabledForLogin() || $this->settings->enabledForLoginFullscreen()) {
            return;
        }

        $this->renderWidgetMarkup('login');
    }

    public function renderLostPasswordWidget(): void
    {
        if (!$this->settings->enabledForPasswordReset()) {
            return;
        }

        $this->renderWidgetMarkup('password_reset');
    }

    /**
     * Loads the widget on front-end pages. The bridge only attaches a widget to
     * recognised WordPress login or password-reset forms, including forms added
     * by themes and account extensions after the page has loaded.
     */
    public function enqueueFrontendWidgetScript(): void
    {
        if (!$this->settings->enabledForLogin() && !$this->settings->enabledForPasswordReset()) {
            return;
        }

        $this->enqueueWidgetScript();
    }

    /**
     * Emits inert widget templates for the generic front-end bridge. Templates
     * are cloned only into recognised forms, avoiding extension-specific hooks.
     */
    public function renderFrontendFormWidgetTemplates(): void
    {
        if (!$this->settings->enabledForLogin() && !$this->settings->enabledForPasswordReset()) {
            return;
        }
        ?>
        <?php if ($this->settings->enabledForLogin()) : ?>
            <template data-shieldx-cap-template="login"><?php $this->renderWidgetMarkup('login'); ?></template>
        <?php endif; ?>
        <?php if ($this->settings->enabledForPasswordReset()) : ?>
            <template data-shieldx-cap-template="password_reset"><?php $this->renderWidgetMarkup('password_reset'); ?></template>
        <?php endif; ?>
        <?php
    }

    /**
     * @param mixed $user
     * @param mixed $username
     * @param mixed $password
     * @return mixed
     */
    public function enforceLoginChallenge($user, $username, $password)
    {
        unset($username, $password);

        if ($this->isWpError($user)) {
            return $user;
        }

        if ($this->settings->enabledForLoginFullscreen() && $this->isNativeWordPressLoginRequest()) {
            return $this->hasPassCookie($this->requestFromServer(), [self::LOGIN_PRELUDE_RULE])
                ? $user
                : $this->genericError(__('Please complete the security challenge and try again.', 'xerotact-shield-x-security'));
        }

        if (!$this->settings->enabledForLogin()) {
            return $user;
        }

        if ($this->verifyPostedToken('login')) {
            return $user;
        }

        return $this->genericError(__('Please complete the security challenge and try again.', 'xerotact-shield-x-security'));
    }

    /**
     * @param mixed $allow
     * @param mixed $userId
     * @return mixed
     */
    public function enforcePasswordResetChallenge($allow, $userId)
    {
        unset($userId);

        if (!$this->settings->enabledForPasswordReset() || !$this->isPasswordResetSubmission() || $this->isWpError($allow) || $allow === false) {
            return $allow;
        }

        if ($this->verifyPostedToken('password_reset')) {
            return $allow;
        }

        return $this->genericError(__('Password reset could not be processed. Complete the security challenge and try again.', 'xerotact-shield-x-security'));
    }

    public function maybeRunLoginPrelude(): void
    {
        if (!$this->settings->enabledForLoginFullscreen() || !$this->isLoginPreludeScreen()) {
            return;
        }

        $request = $this->requestFromServer();
        if ($this->maybeHandleChallengeSubmission($request)) {
            return;
        }

        if ($this->hasPassCookie($request, [self::LOGIN_PRELUDE_RULE])) {
            return;
        }

        $uri = (string) ($request['uri'] ?? '/wp-login.php');
        if (!$this->safeLocalUri($uri)) {
            $uri = '/wp-login.php';
        }

        $context = [
            'uri' => $uri,
            'path' => self::pathFromUri($uri),
            'method' => strtoupper((string) ($request['method'] ?? 'GET')),
            'rules' => [self::LOGIN_PRELUDE_RULE],
            'created_at' => time(),
        ];

        $this->renderChallengePage($request, $context, '', 200);
    }

    /**
     * @param array<string,mixed> $request
     */
    public function maybeHandleChallengeSubmission(array $request): bool
    {
        if (!$this->requestPostText(self::CHALLENGE_FIELD)) {
            return false;
        }

        $context = $this->decodeContext($this->requestPostText(self::CONTEXT_FIELD));
        if (empty($context)) {
            $this->renderChallengePage($request, [], __('The challenge session is invalid. Please retry the request.', 'xerotact-shield-x-security'), 403);
        }

        if (!$this->verifyPostedToken('waf')) {
            $this->renderChallengePage($request, $context, __('Please complete the human challenge and try again.', 'xerotact-shield-x-security'), 403);
        }

        $this->setPassCookie($request, (array) ($context['rules'] ?? []), (string) ($context['path'] ?? ''));
        $method = strtoupper((string) ($context['method'] ?? 'GET'));
        $uri = (string) ($context['uri'] ?? '/');

        if (in_array($method, ['GET', 'HEAD'], true) && $this->safeLocalUri($uri)) {
            if (function_exists('wp_safe_redirect')) {
                wp_safe_redirect($uri);
                exit;
            }
        }

        $this->renderChallengePassedPage($uri);

        return true;
    }

    private function serveLocalChallenge(): void
    {
        if (!$this->consumeLocalChallengeIssueQuota()) {
            $this->serveJson([
                'success' => false,
                'error' => 'Challenge request rate limit exceeded. Please retry shortly.',
            ], 429);
        }

        $token = self::randomHex(25);
        if ($token === '') {
            $this->serveJson([
                'success' => false,
                'error' => 'Challenge generation is temporarily unavailable. Please retry shortly.',
            ], 503);
        }

        $expires = time() + self::LOCAL_CHALLENGE_TTL_SECONDS;
        $challenge = [
            'c' => self::LOCAL_CHALLENGE_COUNT,
            's' => self::LOCAL_CHALLENGE_SIZE,
            'd' => self::LOCAL_CHALLENGE_DIFFICULTY,
        ];

        set_transient($this->localChallengeTransientKey($token), [
            'challenge' => $challenge,
            'expires' => $expires,
        ], self::LOCAL_CHALLENGE_TTL_SECONDS);

        $this->serveJson([
            'challenge' => $challenge,
            'token' => $token,
            'expires' => $expires * 1000,
        ]);
    }

    private function consumeLocalChallengeIssueQuota(): bool
    {
        $client = $this->localChallengeIssueClient();
        if ($client !== '' && !$this->consumeLocalChallengeIssueCounter('client:' . $client, self::LOCAL_CHALLENGE_ISSUE_LIMIT_PER_CLIENT)) {
            return false;
        }

        return $this->consumeLocalChallengeIssueCounter('global', self::LOCAL_CHALLENGE_ISSUE_LIMIT_GLOBAL);
    }

    private function consumeLocalChallengeIssueCounter(string $scope, int $limit): bool
    {
        if (
            !function_exists('get_transient')
            || !function_exists('set_transient')
            || !function_exists('add_option')
            || !function_exists('delete_option')
        ) {
            return false;
        }

        $window = intdiv(time(), self::LOCAL_CHALLENGE_ISSUE_WINDOW_SECONDS);
        $salt = function_exists('wp_salt') ? (string) wp_salt('nonce') : '';
        $salt = $salt !== '' ? $salt : 'shieldx-cap-issue-quota';
        $key = 'shieldx_cap_issue_' . substr(hash_hmac('sha256', $window . '|' . $scope, $salt), 0, 40);
        $lockKey = $key . '_lock';
        if (!$this->acquireLocalChallengeIssueLock($lockKey)) {
            return false;
        }

        try {
            $current = get_transient($key);
            $count = is_numeric($current) ? max(0, (int) $current) : 0;
            if ($count >= $limit) {
                return false;
            }

            return set_transient($key, $count + 1, self::LOCAL_CHALLENGE_ISSUE_WINDOW_SECONDS) !== false;
        } finally {
            delete_option($lockKey);
        }
    }

    private function acquireLocalChallengeIssueLock(string $lockKey): bool
    {
        $lockedAt = time();
        if (add_option($lockKey, $lockedAt, '', 'no')) {
            return true;
        }

        if (!function_exists('get_option')) {
            return false;
        }

        $existing = (int) get_option($lockKey, 0);
        if ($existing <= 0 || $existing > $lockedAt - self::LOCAL_CHALLENGE_ISSUE_LOCK_SECONDS) {
            return false;
        }

        delete_option($lockKey);

        return add_option($lockKey, $lockedAt, '', 'no');
    }

    private function localChallengeIssueClient(): string
    {
        $server = $_SERVER;
        $settings = WafSettings::fromWordPress();
        if (ClientIpResolver::isTrustedProxy($server, $settings)) {
            $client = ClientIpResolver::fromServer($server, $settings);
        } elseif ($this->hasForwardedProxyHeaders($server)) {
            // Do not trust a forwarded identity until the proxy is configured.
            // The global quota still limits allocation in this configuration.
            return '';
        } else {
            $client = ClientIpResolver::remoteAddr($server);
        }

        return filter_var($client, FILTER_VALIDATE_IP) ? $client : '';
    }

    /**
     * @param array<string,mixed> $server
     */
    private function hasForwardedProxyHeaders(array $server): bool
    {
        foreach (['HTTP_CF_CONNECTING_IP', 'HTTP_X_REAL_IP', 'HTTP_X_FORWARDED_FOR'] as $key) {
            if (isset($server[$key]) && trim((string) $server[$key]) !== '') {
                return true;
            }
        }

        return false;
    }

    private function serveLocalRedeem(): void
    {
        $payload = $this->localRequestPayload();
        $token = isset($payload['token']) && is_scalar($payload['token']) ? sanitize_text_field((string) $payload['token']) : '';
        $solutions = isset($payload['solutions']) && is_array($payload['solutions']) ? $payload['solutions'] : [];

        if ($token === '' || empty($solutions)) {
            $this->serveJson(['success' => false, 'error' => 'Invalid body.'], 400);
        }

        $challengeKey = $this->localChallengeTransientKey($token);
        $stored = get_transient($challengeKey);

        if (!is_array($stored) || (int) ($stored['expires'] ?? 0) < time() || !isset($stored['challenge']) || !is_array($stored['challenge'])) {
            $this->serveJson(['success' => false, 'error' => 'Challenge invalid or expired.'], 400);
        }

        $challenge = $stored['challenge'];
        $count = max(1, min(100, (int) ($challenge['c'] ?? self::LOCAL_CHALLENGE_COUNT)));
        $size = max(8, min(128, (int) ($challenge['s'] ?? self::LOCAL_CHALLENGE_SIZE)));
        $difficulty = max(2, min(8, (int) ($challenge['d'] ?? self::LOCAL_CHALLENGE_DIFFICULTY)));

        for ($index = 1; $index <= $count; $index++) {
            $solutionIndex = $index - 1;
            if (!isset($solutions[$solutionIndex]) || !is_numeric($solutions[$solutionIndex])) {
                $this->serveJson(['success' => false, 'error' => 'Invalid solution.'], 400);
            }

            $salt = self::capPrng($token . (string) $index, $size);
            $target = self::capPrng($token . (string) $index . 'd', $difficulty);
            $hash = hash('sha256', $salt . (string) (int) $solutions[$solutionIndex]);

            if (strpos($hash, $target) !== 0) {
                $this->serveJson(['success' => false, 'error' => 'Invalid solution.'], 400);
            }
        }

        delete_transient($challengeKey);

        $verifyToken = self::randomHex(15);
        $id = self::randomHex(8);
        if ($verifyToken === '' || $id === '') {
            $this->serveJson([
                'success' => false,
                'error' => 'Challenge verification is temporarily unavailable. Please retry shortly.',
            ], 503);
        }

        $expires = time() + self::LOCAL_VERIFY_TTL_SECONDS;
        set_transient($this->localVerifyTransientKey($id, $verifyToken), '1', self::LOCAL_VERIFY_TTL_SECONDS);

        $this->serveJson([
            'success' => true,
            'token' => $id . ':' . $verifyToken,
            'expires' => $expires * 1000,
        ]);
    }

    private function serveLocalSiteVerify(): void
    {
        $payload = $this->localRequestPayload();
        $secret = isset($payload['secret']) && is_scalar($payload['secret']) ? sanitize_text_field((string) $payload['secret']) : '';
        $response = isset($payload['response']) && is_scalar($payload['response']) ? sanitize_text_field((string) $payload['response']) : '';
        if ($response === '' && isset($payload['token']) && is_scalar($payload['token'])) {
            $response = sanitize_text_field((string) $payload['token']);
        }

        $configuredSecret = $this->settings->secretKey();
        if ($configuredSecret === '' || !$this->settings->isConfigured()) {
            $this->serveJson(['success' => false, 'error' => 'Cap.js Challenge (Captcha) secret is not configured.'], 503);
        }

        if ($secret === '' || !hash_equals($configuredSecret, $secret) || !$this->localValidateVerifyToken($response)) {
            $this->serveJson(['success' => false]);
        }

        $this->serveJson(['success' => true]);
    }

    /**
     * @param array<string,mixed> $request
     * @param array<string,mixed> $result
     */
    public function shouldChallengeRequest(array $request, array $result): bool
    {
        if (!$this->settings->enabledForWaf() || (string) ($result['action'] ?? '') !== 'challenge') {
            return false;
        }

        $rules = isset($result['challenge_rules']) && is_array($result['challenge_rules'])
            ? $result['challenge_rules']
            : (array) ($result['rules'] ?? []);

        return !$this->hasPassCookie($request, $rules);
    }

    /**
     * @param array<string,mixed> $request
     * @param array<string,mixed> $result
     */
    public function renderWafChallenge(array $request, array $result): void
    {
        $rules = isset($result['challenge_rules']) && is_array($result['challenge_rules'])
            ? array_values(array_map('strval', $result['challenge_rules']))
            : array_values(array_map('strval', (array) ($result['rules'] ?? [])));
        $uri = (string) ($request['uri'] ?? '/');
        $context = [
            'uri' => $uri,
            'path' => self::pathFromUri($uri),
            'method' => strtoupper((string) ($request['method'] ?? 'GET')),
            'rules' => $rules,
            'created_at' => time(),
        ];

        $this->renderChallengePage($request, $context, '', 403);
    }

    /**
     * @param array<string,mixed> $request
     * @param array<int,string>   $rules
     */
    public function hasPassCookie(array $request, array $rules): bool
    {
        $cookie = '';
        if (isset($_COOKIE[self::PASS_COOKIE]) && is_scalar($_COOKIE[self::PASS_COOKIE])) {
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Cookie is unslashed, sanitized, and format-validated immediately below.
            $rawCookie = $_COOKIE[self::PASS_COOKIE];
            $cookie = function_exists('wp_unslash') ? (string) wp_unslash($rawCookie) : (string) $rawCookie;
        }
        $cookie = function_exists('sanitize_text_field') ? sanitize_text_field($cookie) : $cookie;
        $cookie = preg_match('/^[A-Za-z0-9._-]{1,4096}$/', $cookie) === 1 ? $cookie : '';

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

        $payload = $this->decodeSignedPayload($cookie);
        if (empty($payload) || (int) ($payload['exp'] ?? 0) < time()) {
            return false;
        }

        return hash_equals((string) ($payload['ip'] ?? ''), self::fingerprint((string) ($request['ip'] ?? '')))
            && hash_equals((string) ($payload['ua'] ?? ''), self::fingerprint((string) ($request['user_agent'] ?? '')))
            && hash_equals((string) ($payload['path'] ?? ''), self::fingerprint(self::pathFromUri((string) ($request['uri'] ?? '/'))))
            && hash_equals((string) ($payload['rules'] ?? ''), self::fingerprint(implode('|', self::sortedRules($rules))));
    }

    private function verifyPostedToken(string $context): bool
    {
        $token = $this->requestPostText('cap-token');
        if ($token === '') {
            $token = $this->requestPostText('shieldx_cap_token');
        }
        if ($token === '') {
            $token = $this->requestPostText('shieldx_cap_widget_token');
        }

        return $this->verifyToken($token, $context);
    }

    public function verifyToken(string $token, string $context = 'generic'): bool
    {
        $token = trim($token);
        if ($this->settings->provider() !== 'cap' || !$this->settings->isConfigured() || $token === '') {
            return false;
        }

        if ($this->endpointIsLocalStandalone()) {
            if ($this->localValidateVerifyToken($token)) {
                return true;
            }

            return $this->handleVerificationFailure($context, 'Local Cap.js Challenge (Captcha) verification token was not valid.');
        }

        if (!function_exists('wp_remote_post')) {
            return $this->handleVerificationFailure($context, 'WordPress HTTP API is unavailable.');
        }

        $endpoint = rtrim($this->settings->endpointUrl(), '/');
        if (!HumanChallengeSettings::isSafeRemoteEndpointUrl($endpoint)) {
            return $this->handleVerificationFailure($context, 'Cap.js Challenge (Captcha) verification endpoint is not a safe HTTPS URL.');
        }

        try {
            $response = wp_remote_post($endpoint . '/siteverify', [
                'timeout' => $this->settings->timeoutSeconds(),
                'redirection' => 0,
                'reject_unsafe_urls' => true,
                'sslverify' => true,
                'body' => [
                    'secret' => $this->settings->secretKey(),
                    'response' => $token,
                    'token' => $token,
                    'sitekey' => $this->settings->siteKey(),
                ],
            ]);
        } catch (\Throwable $error) {
            return $this->handleVerificationFailure($context, 'Cap.js Challenge (Captcha) verification request failed: ' . $error->getMessage());
        }

        if ($this->isWpError($response)) {
            return $this->handleVerificationFailure($context, 'Cap.js Challenge (Captcha) verification request failed.');
        }

        $code = function_exists('wp_remote_retrieve_response_code') ? (int) wp_remote_retrieve_response_code($response) : 0;
        $body = function_exists('wp_remote_retrieve_body') ? (string) wp_remote_retrieve_body($response) : '';
        $decoded = \ShieldX\Support\Json::decodeArray($body);
        $success = is_array($decoded) && (!empty($decoded['success']) || !empty($decoded['ok']));

        if ($code >= 200 && $code < 300 && $success) {
            return true;
        }

        return $this->handleVerificationFailure($context, 'Cap.js Challenge (Captcha) verification response was not successful.');
    }

    private function handleVerificationFailure(string $context, string $message): bool
    {
        if ($this->logger) {
            $this->logger->warning('human_challenge.verify_failed', [
                'provider' => $this->settings->provider(),
                'context' => $context,
                'fail_behavior' => $this->settings->failBehavior(),
                'message' => $message,
            ]);
        }

        if (in_array($context, ['waf', 'login', 'password_reset', 'comment'], true)) {
            return false;
        }

        return $this->settings->failOpen();
    }

    private function renderWidgetMarkup(string $context): void
    {
        $endpoint = $this->settings->endpointUrl();
        $siteKey = $this->settings->siteKey();

        if ($endpoint === '' || $siteKey === '') {
            return;
        }
        ?>
        <div class="shieldx-cap-challenge shieldx-cap-challenge--<?php echo esc_attr($context); ?>">
            <input type="hidden" name="cap-token" value="" data-shieldx-cap-token>
            <cap-widget
                data-cap-api-endpoint="<?php echo esc_url($endpoint); ?>"
                data-cap-site-key="<?php echo esc_attr($siteKey); ?>"
                data-cap-hidden-field-name="shieldx_cap_widget_token"
                data-cap-i18n-initial-state="<?php echo esc_attr__("I'm a human", 'xerotact-shield-x-security'); ?>"
                data-cap-i18n-verify-aria-label="<?php echo esc_attr__("Click to confirm you're human", 'xerotact-shield-x-security'); ?>"
            >
                <input type="hidden" name="shieldx_cap_widget_token" value="">
            </cap-widget>
            <p class="shieldx-cap-required" data-shieldx-cap-required hidden><?php echo esc_html__('Click the human checkbox first, then continue.', 'xerotact-shield-x-security'); ?></p>
            <noscript><?php echo esc_html__('Enable JavaScript to complete the SHIELD-X human challenge.', 'xerotact-shield-x-security'); ?></noscript>
        </div>
        <?php
    }

    /**
     * @param array<string,mixed> $request
     * @param array<string,mixed> $context
     */
    private function renderChallengePage(array $request, array $context, string $error = '', int $status = 403): void
    {
        if (function_exists('status_header')) {
            status_header($status);
        } else {
            http_response_code($status);
        }

        self::sendNoStoreHeaders();

        $context = !empty($context) ? $context : [
            'uri' => (string) ($request['uri'] ?? '/'),
            'path' => self::pathFromUri((string) ($request['uri'] ?? '/')),
            'method' => strtoupper((string) ($request['method'] ?? 'GET')),
            'rules' => [],
            'created_at' => time(),
        ];
        $safeMethod = in_array(strtoupper((string) ($context['method'] ?? 'GET')), ['GET', 'HEAD'], true);
        $isLoginPrelude = in_array(self::LOGIN_PRELUDE_RULE, array_map('strval', (array) ($context['rules'] ?? [])), true);
        $title = __("I promise I'm a human (mostly).", 'xerotact-shield-x-security');
        $intro = $isLoginPrelude
            ? __('Complete this check to open the WordPress login form.', 'xerotact-shield-x-security')
            : __('This request matched a firewall rule that requires a human challenge before continuing.', 'xerotact-shield-x-security');
        ?>
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title><?php echo esc_html($title); ?></title>
    <?php $this->printStandalonePageStyles(); ?>
    <?php $this->printWidgetScriptForStandalonePage(); ?>
</head>
<body>
    <main class="shieldx-cap-page">
        <h1><?php echo esc_html($title); ?></h1>
        <p><?php echo esc_html($intro); ?></p>
        <?php if ($error !== '') : ?>
            <div class="shieldx-cap-error"><?php echo esc_html($error); ?></div>
        <?php endif; ?>
        <form method="post" action="<?php echo esc_url((string) ($context['uri'] ?? '/')); ?>">
            <input type="hidden" name="<?php echo esc_attr(self::CHALLENGE_FIELD); ?>" value="1">
            <input type="hidden" name="<?php echo esc_attr(self::CONTEXT_FIELD); ?>" value="<?php echo esc_attr($this->encodeContext($context)); ?>">
            <?php $this->renderWidgetMarkup('waf'); ?>
            <button type="submit"><?php echo esc_html($safeMethod ? __('Continue', 'xerotact-shield-x-security') : __('Pass challenge', 'xerotact-shield-x-security')); ?></button>
        </form>
        <?php if (!$safeMethod) : ?>
            <p><?php echo esc_html__('For form submissions or other unsafe methods, pass the challenge first and then retry the original action. SHIELD-X does not replay request bodies.', 'xerotact-shield-x-security'); ?></p>
        <?php endif; ?>
    </main>
</body>
</html>
        <?php
        exit;
    }

    private function renderChallengePassedPage(string $uri): void
    {
        if (function_exists('status_header')) {
            status_header(200);
        }
        self::sendNoStoreHeaders();
        ?>
<!doctype html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title><?php echo esc_html__('Challenge passed', 'xerotact-shield-x-security'); ?></title>
</head>
<body>
    <p><?php echo esc_html__('Challenge passed. Please retry the original request.', 'xerotact-shield-x-security'); ?></p>
    <p><a href="<?php echo esc_url($uri !== '' ? $uri : '/'); ?>"><?php echo esc_html__('Return to requested page', 'xerotact-shield-x-security'); ?></a></p>
</body>
</html>
        <?php
        exit;
    }

    private static function sendNoStoreHeaders(): void
    {
        if (function_exists('nocache_headers')) {
            nocache_headers();
        }

        if (!headers_sent()) {
            header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
            header('Pragma: no-cache');
            header('Expires: Wed, 11 Jan 1984 05:00:00 GMT');
        }
    }

    /**
     * @param array<string,mixed> $context
     */
    private function encodeContext(array $context): string
    {
        $payload = wp_json_encode($context);
        $payload = is_string($payload) ? $payload : '{}';

        return $this->signPayload($payload);
    }

    /**
     * @return array<string,mixed>
     */
    private function decodeContext(string $signed): array
    {
        $payload = $this->decodeSignedPayload($signed);

        if (empty($payload) || (time() - (int) ($payload['created_at'] ?? 0)) > self::PASS_TTL_SECONDS) {
            return [];
        }

        return $payload;
    }

    /**
     * @param array<string,mixed> $request
     * @param array<int,string>   $rules
     */
    private function setPassCookie(array $request, array $rules, string $path): void
    {
        $payload = [
            'exp' => time() + self::PASS_TTL_SECONDS,
            'ip' => self::fingerprint((string) ($request['ip'] ?? '')),
            'ua' => self::fingerprint((string) ($request['user_agent'] ?? '')),
            'path' => self::fingerprint($path !== '' ? $path : self::pathFromUri((string) ($request['uri'] ?? '/'))),
            'rules' => self::fingerprint(implode('|', self::sortedRules($rules))),
        ];
        $json = wp_json_encode($payload);
        $cookie = $this->signPayload(is_string($json) ? $json : '{}');

        if (!headers_sent()) {
            setcookie(self::PASS_COOKIE, $cookie, [
                'expires' => (int) $payload['exp'],
                'path' => '/',
                'secure' => function_exists('is_ssl') ? is_ssl() : false,
                'httponly' => true,
                'samesite' => 'Lax',
            ]);
            $_COOKIE[self::PASS_COOKIE] = $cookie;
        }
    }

    private function signPayload(string $payload): string
    {
        $body = rtrim(strtr(base64_encode($payload), '+/', '-_'), '=');
        $mac = hash_hmac('sha256', $body, self::signingKey());

        return $body . '.' . $mac;
    }

    /**
     * @return array<string,mixed>
     */
    private function decodeSignedPayload(string $signed): array
    {
        $parts = explode('.', $signed, 2);
        if (count($parts) !== 2) {
            return [];
        }

        [$body, $mac] = $parts;
        $expected = hash_hmac('sha256', $body, self::signingKey());
        if (!hash_equals($expected, $mac)) {
            return [];
        }

        $padding = strlen($body) % 4;
        if ($padding > 0) {
            $body .= str_repeat('=', 4 - $padding);
        }

        $json = base64_decode(strtr($body, '-_', '+/'), true);
        $decoded = is_string($json) ? \ShieldX\Support\Json::decodeArray($json) : null;

        return is_array($decoded) ? $decoded : [];
    }

    private static function signingKey(): string
    {
        return hash('sha256', (function_exists('wp_salt') ? (string) wp_salt('secure_auth') : '') . '|shieldx-cap-pass', true);
    }

    private static function fingerprint(string $value): string
    {
        return hash_hmac('sha256', $value, self::signingKey());
    }

    /**
     * @return array<string,mixed>
     */
    private function localRequestPayload(): array
    {
        $payload = $this->sanitizeLocalPayload($this->postedLocalPayload());

        if (!empty($payload)) {
            return $payload;
        }

        $raw = $this->rawLocalRequestBody();
        $decoded = $raw !== '' ? \ShieldX\Support\Json::decodeArray($raw, self::LOCAL_REQUEST_MAX_BYTES) : null;

        return is_array($decoded) ? $this->sanitizeLocalPayload($decoded) : [];
    }

    /**
     * @return array<string|int,mixed>
     */
    private function postedLocalPayload(): array
    {
        $payload = [];

        // phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Cap endpoint requests are authenticated by one-time challenge tokens or the configured Cap secret; values are sanitized by sanitizeLocalPayload().
        foreach ($_POST as $key => $value) {
            $payload[$key] = function_exists('wp_unslash') ? wp_unslash($value) : $value;
        }
        // phpcs:enable WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized

        return $payload;
    }

    /**
     * @param array<string|int,mixed> $input
     * @return array<string,mixed>
     */
    private function sanitizeLocalPayload(array $input): array
    {
        $payload = [];

        foreach ($input as $key => $value) {
            $cleanKey = function_exists('sanitize_key') ? sanitize_key((string) $key) : strtolower(preg_replace('/[^a-z0-9_\-]/', '', (string) $key) ?: '');
            if ($cleanKey === '') {
                continue;
            }

            if ($cleanKey === 'solutions' && is_array($value)) {
                $solutions = [];
                foreach ($value as $solution) {
                    if (is_scalar($solution) && is_numeric($solution)) {
                        $solutions[] = (int) $solution;
                    }
                }

                if ($solutions !== []) {
                    $payload[$cleanKey] = $solutions;
                }
                continue;
            }

            if (!is_scalar($value)) {
                continue;
            }

            $text = function_exists('sanitize_text_field') ? sanitize_text_field((string) $value) : $this->fallbackSanitizeText((string) $value);
            if ($text !== '') {
                $payload[$cleanKey] = $text;
            }
        }

        return $payload;
    }

    private function fallbackSanitizeText(string $value): string
    {
        $plain = function_exists('wp_strip_all_tags')
            ? wp_strip_all_tags($value)
            : (preg_replace('/<[^>]*>/', '', $value) ?: '');

        return trim($plain);
    }

    private function rawLocalRequestBody(): string
    {
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents,WordPress.Security.NonceVerification.Missing -- The local Cap-compatible endpoint accepts JSON bodies; the read is from php://input only, bounded, decoded as JSON, and schema-sanitized before use.
        $raw = file_get_contents('php://input', false, null, 0, self::LOCAL_REQUEST_MAX_BYTES + 1);
        if (!is_string($raw) || $raw === '' || strlen($raw) > self::LOCAL_REQUEST_MAX_BYTES) {
            return '';
        }

        return $raw;
    }

    /**
     * Cap's widget derives salts and targets from this FNV-1a/Xorshift PRNG.
     */
    private static function capPrng(string $seed, int $length): string
    {
        $state = 2166136261;
        $seedLength = strlen($seed);
        for ($index = 0; $index < $seedLength; $index++) {
            $state ^= ord($seed[$index]);
            $state = self::u32($state + ($state << 1) + ($state << 4) + ($state << 7) + ($state << 8) + ($state << 24));
        }

        $result = '';
        while (strlen($result) < $length) {
            $state ^= self::u32($state << 13);
            $state = self::u32($state);
            $state ^= ($state >> 17);
            $state = self::u32($state);
            $state ^= self::u32($state << 5);
            $state = self::u32($state);
            $result .= str_pad(dechex($state), 8, '0', STR_PAD_LEFT);
        }

        return substr($result, 0, $length);
    }

    private static function u32(int $value): int
    {
        return $value & 0xffffffff;
    }

    private static function randomHex(int $bytesCount): string
    {
        try {
            return bin2hex(random_bytes($bytesCount));
        } catch (\Throwable $error) {
            return '';
        }
    }

    private function localChallengeTransientKey(string $token): string
    {
        return 'shieldx_cap_ch_' . hash('sha256', $token);
    }

    private function localVerifyTransientKey(string $id, string $token): string
    {
        return 'shieldx_cap_v_' . hash('sha256', $id . ':' . hash('sha256', $token));
    }

    private function localValidateVerifyToken(string $token): bool
    {
        $parts = explode(':', $token, 2);
        if (count($parts) !== 2 || $parts[0] === '' || $parts[1] === '') {
            return false;
        }

        $transientKey = $this->localVerifyTransientKey($parts[0], $parts[1]);
        if (
            !function_exists('get_transient')
            || !function_exists('delete_transient')
            || !function_exists('add_option')
            || !function_exists('delete_option')
        ) {
            return false;
        }

        $lockKey = $transientKey . '_lock';
        if (!$this->acquireLocalChallengeIssueLock($lockKey)) {
            return false;
        }

        try {
            $valid = get_transient($transientKey) !== false;
            if ($valid) {
                delete_transient($transientKey);
            }

            return $valid;
        } finally {
            delete_option($lockKey);
        }
    }

    /**
     * @param array<string,mixed> $payload
     */
    private function serveJson(array $payload, int $status = 200): void
    {
        if (function_exists('status_header')) {
            status_header($status);
        } else {
            http_response_code($status);
        }

        if (function_exists('nocache_headers')) {
            nocache_headers();
        }

        header('Content-Type: application/json; charset=utf-8');
        echo wp_json_encode($payload);
        exit;
    }

    private static function randomNumber(int $min, int $max): int
    {
        try {
            return random_int($min, $max);
        } catch (\Throwable $error) {
            if (function_exists('wp_rand')) {
                return (int) wp_rand($min, $max);
            }

            throw new \RuntimeException('Secure random generation is unavailable for the human challenge.');
        }
    }

    /**
     * @param array<int,string> $rules
     * @return array<int,string>
     */
    private static function sortedRules(array $rules): array
    {
        $rules = array_values(array_unique(array_filter(array_map('strval', $rules))));
        sort($rules);

        return $rules;
    }

    private static function pathFromUri(string $uri): string
    {
        $parts = self::parseUrl($uri);
        $path = is_array($parts) && isset($parts['path']) ? (string) $parts['path'] : '/';

        return $path !== '' ? $path : '/';
    }

    private function safeLocalUri(string $uri): bool
    {
        if ($uri === '' || strpos($uri, "\n") !== false || strpos($uri, "\r") !== false) {
            return false;
        }

        $parts = self::parseUrl($uri);
        if (!is_array($parts)) {
            return false;
        }

        if (isset($parts['scheme']) || isset($parts['host'])) {
            return false;
        }

        return isset($parts['path']) && strpos((string) $parts['path'], '/') === 0;
    }

    private function enqueueWidgetScript(): void
    {
        $scriptUrl = $this->settings->widgetScriptUrl();
        if ($scriptUrl === '' || !function_exists('wp_register_script') || !function_exists('wp_enqueue_script')) {
            return;
        }

        wp_register_script('shieldx-cap-widget', $scriptUrl, [], defined('SHIELDX_VERSION') ? SHIELDX_VERSION : '0.1.0', true);
        if (function_exists('wp_add_inline_script')) {
            // Static plugin-owned JavaScript; wp_add_inline_script expects executable JS, not escaped display text.
            wp_add_inline_script('shieldx-cap-widget', self::widgetBridgeScript(), 'after');
        }
        wp_enqueue_script('shieldx-cap-widget');
    }

    private function printWidgetScriptForStandalonePage(): void
    {
        $this->enqueueWidgetScript();

        if (function_exists('wp_print_scripts')) {
            wp_print_scripts(['shieldx-cap-widget']);
        }
    }

    private function printStandalonePageStyles(): void
    {
        if (!function_exists('wp_register_style') || !function_exists('wp_enqueue_style') || !function_exists('wp_add_inline_style') || !function_exists('wp_print_styles')) {
            return;
        }

        wp_register_style('shieldx-cap-standalone', false, [], defined('SHIELDX_VERSION') ? SHIELDX_VERSION : '0.1.0');
        // Static plugin-owned CSS; wp_add_inline_style expects raw CSS, not escaped display text.
        wp_add_inline_style('shieldx-cap-standalone', self::standalonePageCss());
        wp_enqueue_style('shieldx-cap-standalone');
        wp_print_styles(['shieldx-cap-standalone']);
    }

    private static function standalonePageCss(): string
    {
        return 'body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f4f6f8;color:#0f172a;font-family:Arial,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.shieldx-cap-page{width:min(680px,calc(100vw - 32px));background:#fff;border:1px solid #dbe4f0;border-radius:10px;padding:32px;box-shadow:0 24px 60px rgba(15,23,42,.12)}.shieldx-cap-page h1{margin:0 0 12px;font-size:28px;font-weight:600}.shieldx-cap-page p{margin:0 0 18px;color:#475569;line-height:1.55}.shieldx-cap-error{margin-bottom:18px;padding:12px 14px;border:1px solid #fecaca;border-radius:8px;background:#fef2f2;color:#991b1b}.shieldx-cap-page button{min-height:44px;padding:0 18px;border:1px solid #05070d;border-radius:8px;background:#05070d;color:#fff;font-weight:500;cursor:pointer}.shieldx-cap-widget,.shieldx-cap-challenge{margin:18px 0}.shieldx-cap-required{margin:10px 0 0;color:#991b1b;font-size:14px}';
    }

    private static function widgetBridgeScript(): string
    {
        return <<<'JS'
(function () {
    function setup(wrapper) {
        var widget = wrapper.querySelector('cap-widget');
        var tokenInput = wrapper.querySelector('input[data-shieldx-cap-token]');
        var requiredMessage = wrapper.querySelector('[data-shieldx-cap-required]');
        if (!widget || !tokenInput || wrapper.getAttribute('data-shieldx-cap-ready') === '1') {
            return;
        }

        wrapper.setAttribute('data-shieldx-cap-ready', '1');

        function readWidgetToken(event) {
            var detailToken = event && event.detail && event.detail.token ? event.detail.token : '';
            var nested = widget.querySelector('input[name="shieldx_cap_widget_token"]');
            var propertyToken = widget.token || widget.tokenValue || '';

            return detailToken || propertyToken || (nested ? nested.value : '');
        }

        function syncToken(event) {
            var token = readWidgetToken(event);
            if (token) {
                tokenInput.value = token;
                if (requiredMessage) {
                    requiredMessage.hidden = true;
                }
            }
        }

        function showRequiredMessage() {
            if (requiredMessage) {
                requiredMessage.hidden = false;
            }
            if (typeof widget.scrollIntoView === 'function') {
                widget.scrollIntoView({ block: 'center' });
            }
            if (typeof widget.focus === 'function') {
                try {
                    widget.focus({ preventScroll: true });
                } catch (error) {
                    widget.focus();
                }
            }
        }

        widget.addEventListener('solve', syncToken);
        widget.addEventListener('progress', syncToken);

        var form = wrapper.closest('form');
        if (!form) {
            return;
        }

        form.addEventListener('submit', function (event) {
            syncToken(event);
            if (tokenInput.value) {
                return;
            }

            event.preventDefault();
            event.stopPropagation();
            showRequiredMessage();
        });
    }

    function namedField(form, names) {
        for (var i = 0; i < names.length; i++) {
            var field = form.querySelector('[name="' + names[i] + '"]');
            if (field) {
                return field;
            }
        }

        return null;
    }

    function formFingerprint(form) {
        var parts = [form.id || '', form.className || '', form.getAttribute('action') || ''];
        var submitControls = form.querySelectorAll('button[type="submit"], button:not([type]), input[type="submit"]');
        for (var i = 0; i < submitControls.length; i++) {
            parts.push(submitControls[i].textContent || '');
            parts.push(submitControls[i].value || '');
            parts.push(submitControls[i].getAttribute('aria-label') || '');
        }

        return parts.join(' ').toLowerCase();
    }

    function isAccountCreationOrPurchaseForm(form, password, fingerprint) {
        var autocomplete = (password.getAttribute('autocomplete') || '').toLowerCase();
        if (autocomplete === 'new-password' || form.querySelector('[autocomplete="new-password"]')) {
            return true;
        }

        return /(?:checkout|payment|billing|shipping|place[-_ ]?order|shopping[-_ ]?cart|register|registration|sign[-_ ]?up|create[-_ ]?account)/.test(fingerprint);
    }

    function formContext(form) {
        if (form.querySelector('.shieldx-cap-challenge')) {
            return '';
        }

        var loginPassword = namedField(form, ['pwd', 'password', 'user_pass', 'login_password']);
        var loginIdentity = namedField(form, ['log', 'username', 'user_login', 'email', 'user_email', 'login_username', 'login_email']);
        var fingerprint = formFingerprint(form);
        var passwordAutocomplete = loginPassword ? (loginPassword.getAttribute('autocomplete') || '').toLowerCase() : '';
        var hasLoginIntent = passwordAutocomplete === 'current-password'
            || /(?:^|[^a-z])(?:login|log[-_ ]?in|sign[-_ ]?in|signin|logon|authenticate)(?:[^a-z]|$)/.test(fingerprint);
        if (
            loginPassword
            && loginIdentity
            && hasLoginIntent
            && !isAccountCreationOrPurchaseForm(form, loginPassword, fingerprint)
        ) {
            return 'login';
        }

        var resetIdentity = namedField(form, ['user_login', 'user_email', 'email', 'username']);
        if (resetIdentity && /(?:lost|forgot|retrieve|recover)[-_ ]?password|password[-_ ]?(?:reset|recovery)/.test(fingerprint)) {
            return 'password_reset';
        }

        return '';
    }

    function attachFrontendWidget(form) {
        var context = formContext(form);
        if (!context) {
            return;
        }

        var template = document.querySelector('template[data-shieldx-cap-template="' + context + '"]');
        if (!template || !template.content) {
            return;
        }

        var fragment = template.content.cloneNode(true);
        form.appendChild(fragment);

        var wrappers = form.querySelectorAll('.shieldx-cap-challenge');
        if (wrappers.length) {
            setup(wrappers[wrappers.length - 1]);
        }
    }

    function attachFrontendWidgets(root) {
        if (!root) {
            return;
        }

        if (root.nodeType === 1 && root.tagName && root.tagName.toLowerCase() === 'form') {
            attachFrontendWidget(root);
        }

        if (!root.querySelectorAll) {
            return;
        }

        var forms = root.querySelectorAll('form');
        for (var i = 0; i < forms.length; i++) {
            attachFrontendWidget(forms[i]);
        }
    }

    function boot() {
        var wrappers = document.querySelectorAll('.shieldx-cap-challenge');
        for (var i = 0; i < wrappers.length; i++) {
            setup(wrappers[i]);
        }

        attachFrontendWidgets(document);

        if (typeof MutationObserver !== 'undefined' && document.body) {
            var queued = false;
            var observer = new MutationObserver(function (mutations) {
                if (queued || !mutations.length) {
                    return;
                }

                queued = true;
                window.setTimeout(function () {
                    queued = false;
                    attachFrontendWidgets(document);
                }, 0);
            });
            observer.observe(document.body, { childList: true, subtree: true });
        }
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', boot);
    } else {
        boot();
    }
})();
JS;
    }

    private function endpointIsLocalStandalone(): bool
    {
        if (!function_exists('home_url')) {
            return false;
        }

        $endpoint = self::parseUrl($this->settings->endpointUrl());
        $home = self::parseUrl((string) home_url('/'));
        if (!is_array($endpoint) || !is_array($home)) {
            return false;
        }

        $endpointHost = strtolower((string) ($endpoint['host'] ?? ''));
        $homeHost = strtolower((string) ($home['host'] ?? ''));
        if ($endpointHost === '' || $homeHost === '') {
            return false;
        }

        if (
            $endpointHost !== $homeHost
            && !in_array($endpointHost, ['localhost', '127.0.0.1', '::1'], true)
            && !in_array($homeHost, ['localhost', '127.0.0.1', '::1'], true)
        ) {
            return false;
        }

        $querySite = self::queryTextFromParts($endpoint, 'shieldx_cap_site');
        if ($querySite !== '') {
            return hash_equals($this->settings->siteKey(), $querySite);
        }

        $path = isset($endpoint['path']) ? (string) $endpoint['path'] : '';
        $homePath = isset($home['path']) ? (string) $home['path'] : '/';
        $path = self::normalizeLocalEndpointPath($path, $homePath);
        $expectedPath = rtrim(self::LOCAL_ENDPOINT_PREFIX . rawurlencode($this->settings->siteKey()), '/') . '/';

        return strpos(rtrim($path, '/') . '/', $expectedPath) === 0;
    }

    /**
     * @return array<string,string>
     */
    private function localEndpointFromUri(string $uri): array
    {
        $parts = self::parseUrl($uri);
        if (is_array($parts)) {
            $siteKey = self::queryTextFromParts($parts, 'shieldx_cap_site');
            if ($siteKey !== '') {
                $action = self::queryTextFromParts($parts, 'shieldx_cap_action');
                $action = function_exists('sanitize_key') ? sanitize_key($action) : strtolower(preg_replace('/[^a-z0-9_\-]/', '', $action) ?: '');

                return [
                    'site_key' => $siteKey,
                    'action' => $action,
                ];
            }
        }

        $path = $this->localEndpointPathFromUri($uri);
        if (strpos($path, self::LOCAL_ENDPOINT_PREFIX) !== 0) {
            return [];
        }

        $segments = explode('/', trim(substr($path, strlen(self::LOCAL_ENDPOINT_PREFIX)), '/'));
        $siteKey = isset($segments[0]) ? sanitize_text_field(rawurldecode((string) $segments[0])) : '';
        $action = isset($segments[1]) ? sanitize_key((string) $segments[1]) : '';

        return [
            'site_key' => $siteKey,
            'action' => $action,
        ];
    }

    private function localEndpointPathFromUri(string $uri): string
    {
        $path = self::pathFromUri($uri);
        $homePath = '/';

        if (function_exists('home_url')) {
            $home = self::parseUrl((string) home_url('/'));
            if (is_array($home) && isset($home['path'])) {
                $homePath = (string) $home['path'];
            }
        }

        return self::normalizeLocalEndpointPath($path, $homePath);
    }

    private static function normalizeLocalEndpointPath(string $path, string $homePath): string
    {
        $path = '/' . ltrim($path !== '' ? $path : '/', '/');
        $homePath = '/' . trim($homePath !== '' ? $homePath : '/', '/');

        if ($homePath !== '/' && strpos($path, rtrim($homePath, '/') . '/') === 0) {
            $path = substr($path, strlen(rtrim($homePath, '/')));
            $path = '/' . ltrim($path, '/');
        }

        if (strpos($path, '/index.php/') === 0) {
            $path = substr($path, strlen('/index.php'));
            $path = '/' . ltrim($path, '/');
        }

        return $path !== '' ? $path : '/';
    }

    /**
     * @param array<string,mixed> $parts
     */
    private static function queryTextFromParts(array $parts, string $key): string
    {
        $queryString = isset($parts['query']) && is_string($parts['query']) ? $parts['query'] : '';
        if ($queryString === '') {
            return '';
        }

        $query = [];
        parse_str($queryString, $query);
        $value = $query[$key] ?? '';
        if (!is_scalar($value)) {
            return '';
        }

        $value = function_exists('wp_unslash') ? (string) wp_unslash($value) : (string) $value;

        return trim(function_exists('sanitize_text_field') ? sanitize_text_field($value) : $value);
    }

    /**
     * @return array<string,mixed>|false
     */
    private static function parseUrl(string $url)
    {
        if (!function_exists('wp_parse_url')) {
            return false;
        }

        $parts = wp_parse_url($url);

        return is_array($parts) ? $parts : false;
    }

    private function requestPostText(string $key): string
    {
        // phpcs:disable WordPress.Security.NonceVerification.Missing -- Login, lost-password, and WAF challenge requests are validated by WordPress auth flow or signed challenge context.
        if (!isset($_POST[$key]) || !is_scalar($_POST[$key])) {
            // phpcs:enable WordPress.Security.NonceVerification.Missing
            return '';
        }

        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.NonceVerification.Missing -- Token-like values are unslashed here and sanitized before use.
        $value = function_exists('wp_unslash') ? wp_unslash($_POST[$key]) : $_POST[$key];
        // phpcs:enable WordPress.Security.NonceVerification.Missing

        $value = is_scalar($value) ? (string) $value : '';

        return trim(function_exists('sanitize_text_field') ? sanitize_text_field($value) : $value);
    }

    private function requestInputText(string $key): string
    {
        // phpcs:disable WordPress.Security.NonceVerification.Recommended,WordPress.Security.NonceVerification.Missing -- Login screen routing is read-only and does not change state.
        if (isset($_POST[$key]) && is_scalar($_POST[$key])) {
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Sanitized below after optional unslash.
            $value = $_POST[$key];
        } elseif (isset($_GET[$key]) && is_scalar($_GET[$key])) {
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Sanitized below after optional unslash.
            $value = $_GET[$key];
        } else {
            $value = '';
        }
        // phpcs:enable WordPress.Security.NonceVerification.Recommended,WordPress.Security.NonceVerification.Missing

        $value = is_scalar($value) ? (string) $value : '';
        $value = function_exists('wp_unslash') ? (string) wp_unslash($value) : $value;

        return trim(function_exists('sanitize_text_field') ? sanitize_text_field($value) : $value);
    }

    /**
     * @return array<string,mixed>
     */
    private function requestFromServer(): array
    {
        $server = $_SERVER;
        $settings = WafSettings::fromWordPress();
        $clientIp = ClientIpResolver::fromServer($server, $settings);

        return [
            'uri' => $this->serverText('REQUEST_URI', '/wp-login.php'),
            'method' => strtoupper($this->serverText('REQUEST_METHOD', 'GET')),
            'ip' => $clientIp !== 'unknown' ? $clientIp : '',
            'user_agent' => $this->serverText('HTTP_USER_AGENT', ''),
        ];
    }

    private function serverText(string $key, string $default = ''): string
    {
        if (isset($_SERVER[$key]) && is_scalar($_SERVER[$key])) {
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Sanitized below after optional unslash.
            $value = $_SERVER[$key];
        } else {
            $value = $default;
        }

        $value = is_scalar($value) ? (string) $value : $default;
        $value = function_exists('wp_unslash') ? (string) wp_unslash($value) : $value;

        return trim(function_exists('sanitize_text_field') ? sanitize_text_field($value) : $value);
    }

    private function isLoginPreludeScreen(): bool
    {
        $action = $this->requestInputText('action');
        $action = function_exists('sanitize_key') ? sanitize_key($action) : strtolower(preg_replace('/[^a-z0-9_\-]/', '', $action) ?: '');

        return in_array($action, ['', 'login'], true);
    }

    private function isNativeWordPressLoginRequest(): bool
    {
        $path = self::pathFromUri($this->serverText('REQUEST_URI', ''));

        return (bool) preg_match('#(?:^|/)wp-login\.php$#i', $path);
    }

    private function isPasswordResetSubmission(): bool
    {
        $action = $this->requestPostText('action');
        $action = function_exists('sanitize_key') ? sanitize_key($action) : strtolower(preg_replace('/[^a-z0-9_\-]/', '', $action) ?: '');

        return in_array($action, ['lostpassword', 'retrievepassword'], true) || $this->requestPostText('user_login') !== '';
    }

    /**
     * @param mixed $value
     */
    private function isWpError($value): bool
    {
        return class_exists('\WP_Error') && $value instanceof \WP_Error;
    }

    private function genericError(string $message)
    {
        if (class_exists('\WP_Error')) {
            return new \WP_Error('shieldx_human_challenge_required', $message);
        }

        return false;
    }
}
