<?php

declare(strict_types=1);

namespace SiteFort\Modules\Login;

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

use SiteFort\Includes\Interfaces\Module;
use SiteFort\Includes\IpDetector;
use SiteFort\Includes\Module_Loader;

/**
 * Login Security Module.
 *
 * Token-based login URL protection: hides wp-login.php behind a custom slug,
 * blocks unauthenticated wp-admin access, and prevents URL leakage.
 *
 * Uses init immediately after core/theme render dependencies registered at the
 * default priority. This keeps blocking early enough to minimize third-party
 * bootstrap on denied requests while still allowing the module to render the
 * site's real 404 template for blocked admin/login requests.
 *
 */
class Login implements Module {

    private string $plugin_name;
    private string $version;
    private string $token_var = 'sitefort_login_token';

    /** Domain-separates the known-admin MAC from any other use of the secret. */
    private const KNOWN_ADMIN_MAC_PREFIX = 'sitefort-known-admin.v1.';

    /** @var bool Guard flag to prevent infinite loops in URL filters. */
    private bool $disable_filters = false;

    /** @var array{raw: string, decoded: string, script: string} Normalized request path variants. */
    private array $path_variants = [];

    /** @var array|null Cached settings. */
    private ?array $settings_cache = null;

    public function __construct( string $plugin_name, string $version ) {
        $this->plugin_name = $plugin_name;
        $this->version     = $version;
    }

    public function initialize( Module_Loader $loader ): void {
        // Early request interception - runs immediately after core/theme init work.
        $loader->add_action( 'init', $this, 'handle_request_interception', 11, 0 );

        // wp-login.php token validation - fires only when wp-login.php loads.
        $loader->add_action( 'login_init', $this, 'validate_login_access' );

        // Remove WordPress's built-in /login and /admin redirects.
        $loader->add_action( 'init', $this, 'remove_admin_redirect_locations' );

        // Known-admin browsers: marked on login, refreshed in wp-admin, so an
        // expired session lands on the login page instead of the block page.
        $loader->add_action( 'set_logged_in_cookie', $this, 'mark_known_admin_browser', 10, 4 );
        $loader->add_action( 'admin_init', $this, 'refresh_known_admin_cookie', 10, 0 );

        // URL rewriting - priority 100 so we run after other plugins generate URLs.
        $loader->add_filter( 'site_url', $this, 'filter_generated_url', 100, 4 );
        $loader->add_filter( 'network_site_url', $this, 'filter_generated_url', 100, 4 );
        $loader->add_filter( 'wp_redirect', $this, 'filter_redirect', 10, 1 );
        $loader->add_filter( 'admin_url', $this, 'filter_admin_url', 100, 2 );

        // Comment/email URL leakage prevention.
        $loader->add_filter( 'comment_form_defaults', $this, 'filter_comment_form_defaults', 20, 1 );
        $loader->add_filter( 'comment_reply_link', $this, 'filter_comment_reply_link', 20, 1 );
        $loader->add_filter( 'post_comments_link', $this, 'filter_post_comments_link', 20, 1 );
        $loader->add_filter( 'comment_moderation_text', $this, 'filter_comment_moderation_email', 10, 2 );

        // Multisite: inject token into signup form hidden fields.
        $loader->add_action( 'signup_hidden_fields', $this, 'add_signup_hidden_token', 10, 1 );

        // Hide registration page nav links so they do not expose blocked login flows.
        $loader->add_action( 'login_enqueue_scripts', $this, 'maybe_hide_login_nav', 10, 0 );

        // Prevent token leakage to third-party services via Referer header.
        // WordPress core emits this header via wp_admin_headers() on login_init.
        $loader->add_filter( 'admin_referrer_policy', $this, 'filter_admin_referrer_policy', 999, 1 );
        $loader->add_action( 'login_init', $this, 'send_referrer_policy', 999, 0 );
    }

    // -------------------------------------------------------------------------
    // Hook callbacks - request interception
    // -------------------------------------------------------------------------

    /**
     * Intercept requests at init to handle custom slug, wp-admin
     * blocking, and wp-signup.php blocking.
     */
    public function handle_request_interception(): void {
        // Emergency bypass: define('SITEFORT_DISABLE_MODULES', true) in wp-config.php.
        if ( defined( 'SITEFORT_DISABLE_MODULES' ) && SITEFORT_DISABLE_MODULES ) {
            return;
        }

        $settings = $this->get_settings();

        if ( empty( $settings['rename_login_url_enabled'] ) || empty( $settings['rename_login_url_slug'] ) ) {
            return;
        }

        // Feature requires pretty permalinks - without them the web server
        // may not route custom slug requests to WordPress at all.
        if ( '' === get_option( 'permalink_structure' ) ) {
            return;
        }

        $this->get_normalized_request_path();

        // Skip API/system requests that must never be intercepted.
        if ( $this->is_api_request() ) {
            return;
        }

        $custom_slug             = $settings['rename_login_url_slug'];
        $register_slug           = $this->normalize_register_slug_setting( (string) ( $settings['rename_login_url_register_slug'] ?? '' ) );
        $custom_register_enabled = ( $register_slug !== 'wp-signup.php' );

        if ( $this->matches_public_route_slug( $custom_slug, [ 'index.php', 'wp-login.php' ] ) ) {
            $query_args = $this->get_safe_query_args();
            if ( $custom_register_enabled && ( ( $query_args['action'] ?? '' ) === 'register' ) ) {
                $this->handle_blocked_redirect();
                return;
            }
            $this->do_redirect_with_token( $query_args );
            return;
        }

        $register_route_scripts = is_multisite()
            ? [ 'index.php', 'wp-signup.php', 'wp-login.php' ]
            : [ 'index.php', 'wp-login.php' ];

        if ( $custom_register_enabled && $this->matches_public_route_slug( $register_slug, $register_route_scripts ) ) {
            if ( ! get_option( 'users_can_register' ) && ! is_multisite() ) {
                // Registration disabled - block access rather than showing a broken form.
                $this->handle_blocked_redirect();
                return;
            }
            if ( is_multisite() ) {
                // Multisite uses wp-signup.php for registration.
                $this->do_redirect_to_signup_with_token();
            } else {
                $query_args           = $this->get_safe_query_args();
                $query_args['action'] = 'register';
                $this->do_redirect_with_token( $query_args );
            }
            return;
        }

        // register slug is configured.
        if ( $custom_register_enabled && $this->is_wp_signup_request() ) {
            if ( ! is_user_logged_in() && ! $this->is_validated() ) {
                $this->handle_blocked_redirect();
                return;
            }
        }

        if ( $this->is_wp_register_request() ) {
            if ( ! is_user_logged_in() && ! $this->is_validated() ) {
                $this->handle_blocked_redirect();
                return;
            }
        }

        // WordPress activation keys are 20-character alphanumeric strings generated
        // by wp_generate_password(20, false, false). Requiring this format prevents
        // trivial bypasses like ?key=x from reaching wp-activate.php without a
        // valid SiteFort token.
        if ( $this->is_wp_activate_request() ) {
            $key = $this->get_request_value( 'key' );
            if ( '' !== $key && (bool) preg_match( '/^[a-zA-Z0-9]{20,}$/', $key ) ) {
                return; // Looks like a genuine activation link from email.
            }
            if ( ! is_user_logged_in() && ! $this->is_validated() ) {
                $this->handle_blocked_redirect();
                return;
            }
        }

        // Security policy: default admin endpoints must never disclose or
        // bootstrap access to the hidden login URL. Known-admin browsers are
        // the exception; they fall through to core auth_redirect().
        if ( $this->is_wp_admin_request() ) {
            // Allow database repair if explicitly enabled.
            if ( $this->path_matches( 'wp-admin/maint/repair.php' ) && defined( 'WP_ALLOW_REPAIR' ) ) {
                return;
            }
            if ( ! is_user_logged_in() && ! $this->is_validated() ) {
                if ( $this->is_known_admin_browser() ) {
                    return;
                }
                $this->handle_blocked_redirect();
                return;
            }
        }
    }

    /**
     * Validate access to wp-login.php. Fires on login_init (only when
     * wp-login.php is actually being loaded).
     */
    public function validate_login_access(): void {
        if ( defined( 'SITEFORT_DISABLE_MODULES' ) && SITEFORT_DISABLE_MODULES ) {
            return;
        }

        $settings = $this->get_settings();

        if ( empty( $settings['rename_login_url_enabled'] ) ) {
            return;
        }

        if ( '' === get_option( 'permalink_structure' ) ) {
            return;
        }

        if ( is_user_logged_in() ) {
            return;
        }

        // Allow passthrough actions that must work without a token.
        // Intentionally minimal - logout, lostpassword, etc. require a token
        // (WordPress generates tokenised URLs via our site_url filter).
        $action = $this->get_request_action();

        $register_slug           = $this->normalize_register_slug_setting( (string) ( $settings['rename_login_url_register_slug'] ?? '' ) );
        $custom_register_enabled = ( $register_slug !== 'wp-signup.php' );

        // Some hosts internally rewrite the public login slug to wp-login.php
        // before PHP executes. Allow that handoff only when we can recover the
        // original public route from trusted server variables.
        if ( $this->matches_public_route_slug( (string) $settings['rename_login_url_slug'], [ 'index.php', 'wp-login.php' ] ) ) {
            if ( $custom_register_enabled && 'register' === $action ) {
                $this->handle_blocked_redirect();
                return;
            }

            return;
        }

        $allowed_actions = [
            'postpass',            // Password-protected post form.
        ];

        // Jetpack compatibility - allow SSO and JSON API authorization actions
        // only when Jetpack is actually handling them.
        if ( 'jetpack_json_api_authorization' === $action && has_filter( 'login_form_jetpack_json_api_authorization' ) ) {
            return;
        }
        if ( 'jetpack-sso' === $action && has_filter( 'login_form_jetpack-sso' ) ) {
            // Jetpack SSO redirects from wordpress.com - allow if its handler is present,
            // but block if authentication fails via login_form action.
            add_action( 'login_form_jetpack-sso', [ $this, 'block_access_public' ] );
            return;
        }

        // SiteFort's own password change interstitial.
        if ( 'sitefort_password_change' === $action ) {
            $allowed_actions[] = 'sitefort_password_change';
        }

        if ( $register_slug === 'wp-signup.php' ) {
            $allowed_actions[] = 'register';
        }

        /**
         * Filter the list of allowed wp-login.php actions when login URL is renamed.
         *
         * @param string[] $allowed_actions Action names that bypass login rename protection.
         */
        $allowed_actions = apply_filters( 'sitefort_login_rename_allowed_actions', $allowed_actions );

        if ( ! empty( $action ) && in_array( $action, $allowed_actions, true ) ) {
            return;
        }

        // Custom login_form actions are not public by default. They continue to
        // work through the renamed login URL because validated requests fall
        // through to the token/cookie check below. Public passthrough requires
        // an explicit allowlist decision via the filter.
        if ( ! empty( $action ) && has_action( 'login_form_' . $action ) ) {
            /**
             * Filter whether to publicly allow a third-party login_form action
             * without a SiteFort login token.
             *
             * @param bool   $allow  Whether to allow the action. Default false.
             * @param string $action The login action name.
             */
            if ( apply_filters( 'sitefort_login_rename_auto_allow_action', false, $action ) ) {
                return;
            }
        }

        // Validate token (cookie or GET param).
        if ( $this->is_validated() ) {
            return;
        }

        // Known-admin browsers get routed into the token flow instead of
        // blocked, so a bookmarked wp-login.php keeps working for staff.
        if ( $this->is_known_admin_browser() ) {
            $this->do_redirect_with_token( $this->get_safe_query_args() );
            return;
        }

        $this->handle_blocked_redirect();
    }

    /**
     * Remove WordPress's built-in /login, /admin, /dashboard redirects
     * that would leak the real wp-login.php and wp-admin URLs.
     */
    public function remove_admin_redirect_locations(): void {
        $settings = $this->get_settings();
        if ( ! empty( $settings['rename_login_url_enabled'] ) ) {
            remove_action( 'template_redirect', 'wp_redirect_admin_locations', 1000 );
        }
    }

    // -------------------------------------------------------------------------
    // Hook callbacks - URL filtering
    // -------------------------------------------------------------------------

    /**
     * Filter generated site/network URLs to rewrite wp-login.php references.
     *
     * @param string      $url     The full URL.
     * @param string      $path    The path (e.g. 'wp-login.php').
     * @param string|null $scheme  URL scheme.
     * @param int|null    $blog_id Blog ID.
     * @return string
     */
    public function filter_generated_url( $url, $path, $scheme = null, $blog_id = null ) {
        if ( $this->disable_filters ) {
            return $url;
        }

        $settings = $this->get_settings();
        if ( empty( $settings['rename_login_url_enabled'] ) || empty( $settings['rename_login_url_slug'] ) ) {
            return $url;
        }

        $url_str = (string) $url;
        $has_login  = strpos( $url_str, 'wp-login.php' ) !== false;
        $has_signup = strpos( $url_str, 'wp-signup.php' ) !== false;

        if ( ! $has_login && ! $has_signup ) {
            return $url;
        }

        // Parse path to detect action.
        $clean_path = explode( '?', (string) $path )[0];

        $register_slug           = $this->normalize_register_slug_setting( (string) ( $settings['rename_login_url_register_slug'] ?? '' ) );
        $custom_register_enabled = ( $register_slug !== 'wp-signup.php' );

        // wp-signup.php: tokenise or rewrite to custom register slug.
        if ( 'wp-signup.php' === $clean_path ) {
            if ( $custom_register_enabled ) {
                return home_url( '/' . $register_slug );
            }
            // Default register route: keep wp-signup.php but add token so
            // redirects to wp-login.php?action=register are validated.
            return add_query_arg( $this->token_var, $this->get_access_token(), $url );
        }

        if ( 'wp-login.php' !== $clean_path ) {
            return $url;
        }

        // Extract action from URL query string.
        $parsed = wp_parse_url( $url );
        $query  = [];
        if ( ! empty( $parsed['query'] ) ) {
            parse_str( $parsed['query'], $query );
        }
        $action = $query['action'] ?? '';

        // Postpass: password-protected posts - pass through unmodified.
        if ( 'postpass' === $action ) {
            return $url;
        }

        // Registration: use custom register slug when configured.
        if ( 'register' === $action && $custom_register_enabled ) {
            unset( $query['action'] );
            $register_url = home_url( '/' . $register_slug );
            if ( ! empty( $query ) ) {
                $register_url = add_query_arg( $query, $register_url );
            }
            return $register_url;
        }
        if ( 'register' === $action ) {
            return add_query_arg( $this->token_var, $this->get_access_token(), $url );
        }

        // Plain login URL (no action or action=login): rewrite to custom slug.
        if ( empty( $action ) || 'login' === $action ) {
            unset( $query['action'] );
            $custom_url = home_url( '/' . $settings['rename_login_url_slug'] );
            if ( ! empty( $query ) ) {
                $custom_url = add_query_arg( $query, $custom_url );
            }
            return $custom_url;
        }

        // All other actions (lostpassword, rp, resetpass, etc.): route through
        // the secret slug so tokens are only minted from the secret entrypoint.
        unset( $query[ $this->token_var ] );
        $custom_url = home_url( '/' . $settings['rename_login_url_slug'] );
        if ( ! empty( $query ) ) {
            $custom_url = add_query_arg( $query, $custom_url );
        }
        return $custom_url;
    }

    /**
     * Filter wp_redirect to add token to login-page redirects.
     *
     * @param string $location Redirect URL.
     * @return string
     */
    public function filter_redirect( $location ) {
        if ( $this->disable_filters ) {
            return $location;
        }

        $settings = $this->get_settings();
        if ( empty( $settings['rename_login_url_enabled'] ) ) {
            return $location;
        }

        $loc = (string) $location;
        if ( ! $this->is_internal_url_target( $loc ) ) {
            return $location;
        }

        if ( $this->is_login_endpoint_url( $loc ) ) {
            $parsed = wp_parse_url( $loc );
            if ( ! is_array( $parsed ) ) {
                return $location;
            }

            $path = isset( $parsed['path'] ) ? trim( (string) $parsed['path'], '/' ) : '';
            if ( '' === $path ) {
                return $location;
            }

            $base = basename( trim( urldecode( $path ), '/' ) );
            if ( 'wp-login.php' !== $base ) {
                // Keep wp-signup.php behavior unchanged.
                return $location;
            }

            $query = [];
            if ( ! empty( $parsed['query'] ) ) {
                parse_str( (string) $parsed['query'], $query );
            }

            // Postpass must remain on wp-login.php.
            if ( ( $query['action'] ?? '' ) === 'postpass' ) {
                return $location;
            }

            $register_slug           = $this->normalize_register_slug_setting( (string) ( $settings['rename_login_url_register_slug'] ?? '' ) );
            $custom_register_enabled = ( $register_slug !== 'wp-signup.php' );
            if ( ( $query['action'] ?? '' ) === 'register' && $custom_register_enabled ) {
                unset( $query['action'], $query[ $this->token_var ] );
                $register_url = home_url( '/' . $register_slug );
                if ( ! empty( $query ) ) {
                    $register_url = add_query_arg( $query, $register_url );
                }
                return $register_url;
            }
            if ( ( $query['action'] ?? '' ) === 'register' ) {
                return add_query_arg( $this->token_var, $this->get_access_token(), $location );
            }

            unset( $query[ $this->token_var ] );
            $custom_url = home_url( '/' . $settings['rename_login_url_slug'] );
            if ( ! empty( $query ) ) {
                $custom_url = add_query_arg( $query, $custom_url );
            }
            return $custom_url;
        }

        return $location;
    }

    /**
     * Filter admin_url to add token for paths that redirect unauthenticated
     * users to wp-login.php (e.g. email verification links).
     *
     * @param string $url  Admin URL.
     * @param string $path Path appended.
     * @return string
     */
    public function filter_admin_url( $url, $path ) {
        if ( $this->disable_filters ) {
            return $url;
        }

        $settings = $this->get_settings();
        if ( empty( $settings['rename_login_url_enabled'] ) ) {
            return $url;
        }

        // Email verification and similar paths that require login.
        // Route through the secret slug, not tokenized wp-admin URLs.
        if ( strpos( (string) $path, 'profile.php?newuseremail=' ) === 0
             || strpos( (string) $path, 'admin.php?action=confirm_admin_email' ) === 0 ) {
            $login_url = home_url( '/' . (string) $settings['rename_login_url_slug'] );
            return add_query_arg( 'redirect_to', (string) $url, $login_url );
        }

        return $url;
    }

    // -------------------------------------------------------------------------
    // Hook callbacks - comment/email leak prevention
    // -------------------------------------------------------------------------

    /**
     * Replace the "must be logged in" comment form link so it doesn't
     * expose wp-login.php when login URL hiding is active.
     *
     * @param array $defaults Comment form defaults.
     * @return array
     */
    public function filter_comment_form_defaults( $defaults ) {
        $settings = $this->get_settings();
        if ( empty( $settings['rename_login_url_enabled'] ) || is_user_logged_in() ) {
            return $defaults;
        }

        $login_url = home_url( '/' . $settings['rename_login_url_slug'] );
        $defaults['must_log_in'] = sprintf(
            '<p class="must-log-in">%s</p>',
            sprintf(
                /* translators: %s: login URL */
                __( 'You must be <a href="%s">logged in</a> to post a comment.', 'sitefort' ),
                esc_url( add_query_arg( 'redirect_to', (string) ( get_permalink() ?? '' ), $login_url ) )
            )
        );

        return $defaults;
    }

    /**
     * Hide the comment reply link when login URL hiding is active and
     * comment registration is required but the user is logged out.
     * Prevents the link from exposing wp-login.php.
     *
     * @param string $html Reply link HTML.
     * @return string
     */
    public function filter_comment_reply_link( $html ) {
        $settings = $this->get_settings();
        if ( empty( $settings['rename_login_url_enabled'] ) ) {
            return $html;
        }

        if ( get_option( 'comment_registration' ) && ! is_user_logged_in() ) {
            return '';
        }

        return $html;
    }

    /**
     * Hide the post comments link when login URL hiding is active and
     * comment registration is required but the user is logged out.
     *
     * @param string $html Post comments link HTML.
     * @return string
     */
    public function filter_post_comments_link( $html ) {
        $settings = $this->get_settings();
        if ( empty( $settings['rename_login_url_enabled'] ) ) {
            return $html;
        }

        if ( get_option( 'comment_registration' ) && ! is_user_logged_in() ) {
            return '';
        }

        return $html;
    }

    /**
     * Rewrite wp-login.php and wp-admin URLs in comment moderation emails
     * so they include the access token.
     *
     * @param string $text       Email body.
     * @param int    $comment_id Comment ID.
     * @return string
     */
    public function filter_comment_moderation_email( $text, $comment_id ) {
        $settings = $this->get_settings();
        if ( empty( $settings['rename_login_url_enabled'] ) ) {
            return $text;
        }

        $token = $this->get_access_token();
        $var   = $this->token_var;

        // Add token to any wp-login.php, wp-signup.php, or wp-admin URL in the email body.
        $text = preg_replace_callback(
            '#(https?://[^\s]*(?:wp-login\.php|wp-signup\.php|wp-admin)[^\s]*)#',
            function ( $matches ) use ( $token, $var ) {
                $full = $matches[1];
                $url  = rtrim( $full, '.,;:)>' );
                $tail = substr( $full, strlen( $url ) );
                if ( ! $this->is_internal_url_target( $url ) ) {
                    return $full;
                }
                return add_query_arg( $var, $token, $url ) . $tail;
            },
            $text
        );

        return $text;
    }

    // -------------------------------------------------------------------------
    // Hook callbacks - multisite & login page
    // -------------------------------------------------------------------------

    /**
     * Inject a hidden token input into the multisite signup form.
     *
     * wp-signup.php's form does not use site_url() for its action attribute,
     * so our URL filters can't add the token. We must inject it manually.
     *
     * @param string $context The signup form context ('validate-user' etc.).
     */
    public function add_signup_hidden_token( $context ): void {
        $settings = $this->get_settings();
        if ( empty( $settings['rename_login_url_enabled'] ) ) {
            return;
        }

        // Inject for all signup contexts: validate-user, validate-site, create-another-site.
        echo '<input type="hidden" name="' . esc_attr( $this->token_var )
             . '" value="' . esc_attr( $this->get_access_token() ) . '" />' . "\n";
    }

    /**
     * On the registration form, hide the nav links (Log In, Lost Password)
     * that would point to blocked login flows without a valid token.
     */
    public function maybe_hide_login_nav(): void {
        $settings = $this->get_settings();
        if ( empty( $settings['rename_login_url_enabled'] ) ) {
            return;
        }

        $action = $this->get_request_action();
        if ( 'register' === $action ) {
            wp_register_style( $this->plugin_name . '-login-nav', false, [], $this->version );
            wp_enqueue_style( $this->plugin_name . '-login-nav' );
            wp_add_inline_style( $this->plugin_name . '-login-nav', '#nav,#backtoblog{display:none!important}' );
        }
    }

    /**
     * Send Referrer-Policy header on login pages to prevent the token
     * (in the URL query string) from leaking to external services
     * (fonts, analytics, CAPTCHA, etc.) via the Referer header.
     */
    public function send_referrer_policy(): void {
        $settings = $this->get_settings();
        if ( ! empty( $settings['rename_login_url_enabled'] ) && ! headers_sent() ) {
            header( 'Referrer-Policy: no-referrer' );
        }
    }

    /**
     * Force no-referrer on login/signup requests to avoid query-token leakage.
     *
     * This filter is consumed by WordPress core's wp_admin_headers() on login_init.
     */
    public function filter_admin_referrer_policy( string $policy ): string {
        $settings = $this->get_settings();
        if ( empty( $settings['rename_login_url_enabled'] ) ) {
            return $policy;
        }

        if ( empty( $this->path_variants ) ) {
            $this->get_normalized_request_path();
        }

        foreach ( $this->path_variants as $variant ) {
            if ( $variant === 'wp-login.php' || $variant === 'wp-signup.php' ) {
                return 'no-referrer';
            }
        }

        return $policy;
    }

    /**
     * Public wrapper for block_access used as a hook callback (Jetpack SSO).
     */
    public function block_access_public(): void {
        if ( ! is_user_logged_in() && ! $this->is_validated() ) {
            $this->handle_blocked_redirect();
        }
    }

    // -------------------------------------------------------------------------
    // Token & cookie management
    // -------------------------------------------------------------------------

    /**
     * Check if the request has a valid token (GET param or cookie).
     */
    private function is_validated(): bool {
        $token = $this->get_access_token();

        // Check GET or POST param (timing-safe comparison).
        // POST is needed for multisite signup form hidden field submissions.
        $request_token = $this->get_request_token();
        if ( $request_token !== '' && hash_equals( $token, $request_token ) ) {
            // Rotate token on successful token-based validation to reduce replay risk.
            // This keeps cookie-based continuity while invalidating the URL token used.
            $new_token = self::regenerate_access_token();
            $this->set_cookie( $new_token );
            return true;
        }

        // Check cookie (timing-safe comparison).
        $cookie_name  = 'sitefort_login_auth_' . COOKIEHASH;
        $cookie_token = self::read_cookie_text_scalar( $cookie_name );
        if ( $cookie_token !== '' && hash_equals( $token, $cookie_token ) ) {
            return true;
        }

        return false;
    }

    /**
     * Set the authorization cookie with SameSite=Lax (PHP 7.3+).
     * Uses a broad path so it's readable on both the custom slug URL and
     * wp-login.php.
     *
     * On subdomain multisite, uses the network-level domain (e.g. .example.com)
     * so the cookie persists across subsites (site1.example.com, site2.example.com).
     */
    private function set_cookie( ?string $token = null ): void {
        if ( ! is_string( $token ) || '' === $token ) {
            $token = $this->get_access_token();
        }

        $cookie_name = 'sitefort_login_auth_' . COOKIEHASH;
        $cookie_path = COOKIEPATH ?: '/';

        // Determine cookie domain. On subdomain multisite, use the network
        // domain with a leading dot for cross-subdomain persistence.
        $cookie_domain = (string) COOKIE_DOMAIN;
        if ( is_multisite() && defined( 'SUBDOMAIN_INSTALL' ) && SUBDOMAIN_INSTALL ) {
            $network = get_network();
            if ( $network ) {
                $cookie_domain = '.' . ltrim( $network->domain, '.' );
            }
        }

        setcookie( $cookie_name, $token, [
            'expires'  => time() + 3600,
            'path'     => $cookie_path,
            'domain'   => $cookie_domain,
            'secure'   => is_ssl(),
            'httponly'  => true,
            'samesite' => 'Lax',
        ] );
    }

    /**
     * Get the secret access token, generating one on first use.
     */
    private function get_access_token(): string {
        $token_data = \SiteFort\Includes\SettingsStore::get_field( \SiteFort\Includes\SettingsStore::COL_STATE, 'login_access_token', [] );
        if ( ! is_array( $token_data ) ) { $token_data = []; }

        if ( ! empty( $token_data['token'] ) && is_string( $token_data['token'] ) ) {
            $stored = $token_data['token'];
            if ( (bool) preg_match( '/^[a-f0-9]{64}$/', $stored ) ) {
                return $stored;
            }
        }

        return self::regenerate_access_token();
    }

    /**
     * Regenerate the login access token.
     *
     * Static so it can be called from other modules (e.g. Hardening on slug change)
     * without instantiating a Login object.
     *
     * @return string The new token.
     */
    public static function regenerate_access_token(): string {
        // Use a CSPRNG (/dev/urandom on Linux/macOS, CryptGenRandom on Windows)
        // rather than wp_generate_password() which on WordPress < 6.0 fell back
        // to the seeded wp_rand() and is NOT cryptographically secure.
        $token = bin2hex( random_bytes( 32 ) ); // 256-bit entropy
        \SiteFort\Includes\SettingsStore::set_field( \SiteFort\Includes\SettingsStore::COL_STATE, 'login_access_token', [
            'token'        => $token,
            'generated_at' => time(),
        ] );
        return $token;
    }

    // -------------------------------------------------------------------------
    // Known-admin browser cookie
    // -------------------------------------------------------------------------

    /**
     * Mark the browser as known when WordPress issues a session cookie.
     *
     * Hooked to set_logged_in_cookie, not wp_login: it carries the Remember
     * Me signal via $expire and also fires for SSO plugins that call
     * wp_set_auth_cookie() directly.
     *
     * @param string $logged_in_cookie Cookie value (unused).
     * @param int    $expire           Browser cookie expiry; 0 when Remember Me was unchecked.
     * @param int    $expiration       Session expiration (unused).
     * @param int    $user_id          User the session belongs to.
     */
    public function mark_known_admin_browser( $logged_in_cookie = '', $expire = 0, $expiration = 0, $user_id = 0 ): void {
        unset( $logged_in_cookie, $expiration );

        if ( headers_sent() || ! $this->known_admin_feature_active() ) {
            return;
        }

        $user = is_numeric( $user_id ) ? get_userdata( (int) $user_id ) : false;
        if ( ! ( $user instanceof \WP_User ) || ! $this->user_is_known_admin_candidate( $user ) ) {
            return;
        }

        $this->set_known_admin_cookie( ! empty( $expire ) );
    }

    /**
     * Keep the known-admin cookie fresh while a wp-admin user is working.
     * Re-issues at most once a day to avoid a Set-Cookie header on every
     * admin request.
     */
    public function refresh_known_admin_cookie(): void {
        if ( wp_doing_ajax() || headers_sent() ) {
            return;
        }

        if ( ! $this->known_admin_feature_active() || ! is_user_logged_in() ) {
            return;
        }

        if ( ! $this->user_is_known_admin_candidate( wp_get_current_user() ) ) {
            return;
        }

        $state = $this->parse_known_admin_cookie();
        if ( $state ) {
            $remembered = $state['remembered'];
            if ( $state['expires'] > time() + $this->get_known_admin_ttl( $remembered ) - DAY_IN_SECONDS ) {
                return; // Issued within the last day.
            }
        } else {
            // Session predates the cookie (e.g. feature just enabled).
            $remembered = $this->current_session_is_remembered();
        }

        $this->set_known_admin_cookie( $remembered );
    }

    /**
     * Whether this browser previously held a wp-admin session.
     *
     * Intentionally survives logout; all it grants is a route to the login
     * page instead of the block page. Rotating the secret revokes every
     * device at once.
     */
    private function is_known_admin_browser(): bool {
        if ( ! $this->known_admin_feature_active() ) {
            return false;
        }

        return null !== $this->parse_known_admin_cookie();
    }

    /**
     * Parse and verify the known-admin cookie.
     *
     * Format: v1.<expires>.<r|s>.<hmac>. The r/s tier records whether the
     * login used Remember Me so refreshes keep the same lifetime.
     *
     * @return array{expires: int, remembered: bool}|null Null when missing, invalid, or expired.
     */
    private function parse_known_admin_cookie(): ?array {
        $raw = self::read_cookie_text_scalar( 'sitefort_known_admin_' . COOKIEHASH );
        if ( '' === $raw || 3 !== substr_count( $raw, '.' ) ) {
            return null;
        }

        list( $version, $expires, $tier, $mac ) = explode( '.', $raw, 4 );
        if ( 'v1' !== $version || '' === $mac || ! ctype_digit( $expires ) || ! in_array( $tier, [ 'r', 's' ], true ) ) {
            return null;
        }

        $expected = hash_hmac( 'sha256', self::KNOWN_ADMIN_MAC_PREFIX . $expires . '.' . $tier, $this->get_known_admin_secret() );
        if ( ! hash_equals( $expected, $mac ) ) {
            return null;
        }

        if ( (int) $expires <= time() ) {
            return null;
        }

        return [
            'expires'    => (int) $expires,
            'remembered' => ( 'r' === $tier ),
        ];
    }

    private function set_known_admin_cookie( bool $remembered ): void {
        $expires = time() + $this->get_known_admin_ttl( $remembered );
        $tier    = $remembered ? 'r' : 's';
        $mac     = hash_hmac( 'sha256', self::KNOWN_ADMIN_MAC_PREFIX . $expires . '.' . $tier, $this->get_known_admin_secret() );
        $value   = 'v1.' . $expires . '.' . $tier . '.' . $mac;

        $cookie_domain = (string) COOKIE_DOMAIN;
        if ( is_multisite() && defined( 'SUBDOMAIN_INSTALL' ) && SUBDOMAIN_INSTALL ) {
            $network = get_network();
            if ( $network ) {
                $cookie_domain = '.' . ltrim( $network->domain, '.' );
            }
        }

        // wp-admin lives under the site path, which can diverge from the home
        // path (WordPress in its own directory). Set on both, like core does.
        $paths = [ COOKIEPATH ?: '/' ];
        if ( defined( 'SITECOOKIEPATH' ) && SITECOOKIEPATH && SITECOOKIEPATH !== COOKIEPATH ) {
            $paths[] = SITECOOKIEPATH;
        }

        foreach ( $paths as $path ) {
            setcookie( 'sitefort_known_admin_' . COOKIEHASH, $value, [
                'expires'  => $expires,
                'path'     => $path,
                'domain'   => $cookie_domain,
                'secure'   => is_ssl(),
                'httponly' => true,
                'samesite' => 'Lax',
            ] );
        }
    }

    /**
     * Remember Me detection for sessions that predate the known-admin cookie:
     * a logged_in cookie with over three days left can only come from a
     * Remember Me login (14-day vs 2-day lifetime).
     */
    private function current_session_is_remembered(): bool {
        $parsed = wp_parse_auth_cookie( '', 'logged_in' );
        if ( ! is_array( $parsed ) || empty( $parsed['expiration'] ) || ! is_numeric( $parsed['expiration'] ) ) {
            return false;
        }

        return (int) $parsed['expiration'] > time() + 3 * DAY_IN_SECONDS;
    }

    private function known_admin_feature_active(): bool {
        $settings = $this->get_settings();
        if ( empty( $settings['rename_login_url_enabled'] ) || empty( $settings['rename_login_url_slug'] ) ) {
            return false;
        }

        /**
         * Filter whether known-admin browsers bypass login rename blocking.
         *
         * @param bool $enabled Default true.
         */
        return (bool) apply_filters( 'sitefort_login_rename_known_admin_enabled', true );
    }

    private function user_is_known_admin_candidate( \WP_User $user ): bool {
        /**
         * Filter the capability that marks a browser as a known wp-admin user.
         *
         * @param string $cap Capability name. Default 'edit_posts'.
         */
        $cap = (string) apply_filters( 'sitefort_login_rename_known_admin_cap', 'edit_posts' );

        return $user->exists() && user_can( $user, $cap );
    }

    private function get_known_admin_ttl( bool $remembered ): int {
        $default = $remembered ? 30 * DAY_IN_SECONDS : 7 * DAY_IN_SECONDS;

        /**
         * Filter how long a browser stays known after an admin login.
         *
         * @param int  $ttl        Lifetime in seconds. Default 30 days with
         *                         Remember Me, 7 days without.
         * @param bool $remembered Whether the login used Remember Me.
         */
        $ttl = (int) apply_filters( 'sitefort_login_rename_known_admin_ttl', $default, $remembered );

        return max( HOUR_IN_SECONDS, $ttl );
    }

    private function get_known_admin_secret(): string {
        $data = \SiteFort\Includes\SettingsStore::get_field( \SiteFort\Includes\SettingsStore::COL_STATE, 'known_admin_secret', [] );
        if ( is_array( $data ) && ! empty( $data['secret'] ) && is_string( $data['secret'] ) && (bool) preg_match( '/^[a-f0-9]{64}$/', $data['secret'] ) ) {
            return $data['secret'];
        }

        return self::regenerate_known_admin_secret();
    }

    /**
     * Rotate the known-admin secret, revoking every known-admin cookie.
     *
     * Static so other modules ("log out everywhere", password resets) can
     * revoke devices without a Login instance.
     *
     * @return string The new secret.
     */
    public static function regenerate_known_admin_secret(): string {
        $secret = bin2hex( random_bytes( 32 ) );
        \SiteFort\Includes\SettingsStore::set_field( \SiteFort\Includes\SettingsStore::COL_STATE, 'known_admin_secret', [
            'secret'       => $secret,
            'generated_at' => time(),
        ] );
        return $secret;
    }

    // -------------------------------------------------------------------------
    // Request handling helpers
    // -------------------------------------------------------------------------

    /**
     * Redirect to wp-login.php with a valid token, preserving query args.
     *
     * Uses HTTP 307 for POST requests so the browser re-sends the form
     * body (credentials, registration data, etc.) to wp-login.php instead
     * of downgrading to GET which would lose all POST data.
     *
     * @param array $query_args Extra query args to forward.
     */
    private function do_redirect_with_token( array $query_args = [] ): void {
        $this->set_cookie();

        // Disable URL filters to prevent infinite redirect loop.
        // Flag stays true through wp_redirect() since exit follows immediately.
        $this->disable_filters = true;

        $query_args[ $this->token_var ] = $this->get_access_token();
        $url = add_query_arg( $query_args, site_url( 'wp-login.php' ) );

        // 307 preserves POST method+body; 302 converts to GET (safe for navigational GETs).
        $status = $this->get_redirect_status();
        wp_redirect( $url, $status ); // phpcs:ignore
        exit;
    }

    /**
     * Redirect to wp-signup.php with token (multisite registration).
     */
    private function do_redirect_to_signup_with_token(): void {
        $this->set_cookie();
        $this->disable_filters = true;

        $url = add_query_arg(
            $this->token_var,
            $this->get_access_token(),
            network_site_url( 'wp-signup.php' )
        );

        $status = $this->get_redirect_status();
        wp_redirect( $url, $status ); // phpcs:ignore
        exit;
    }

    /**
     * Return the appropriate HTTP redirect status code.
     * 307 for POST (preserves method + body), 302 for everything else.
     */
    private function get_redirect_status(): int {
        $request_method = strtoupper( self::read_server_route_scalar( 'REQUEST_METHOD' ) );
        return ( 'POST' === $request_method ) ? 307 : 302;
    }

    /**
     * Handle a blocked access attempt using the configured behavior.
     * Modes:
     * - 403: deny access with the SiteFort restricted page.
     * - 404: return not found.
     * - redirect: send visitor to configured local URL/path (defaults to `/`).
     */
    private function handle_blocked_redirect(): void {
        $settings = $this->get_settings();
        $behavior = $this->normalize_block_behavior_setting( (string) ( $settings['rename_login_url_block_behavior'] ?? '' ) );

        if ( $behavior === 'redirect' ) {
            $redirect_url = $this->normalize_redirect_url_setting( (string) ( $settings['rename_login_url_redirect_url'] ?? '' ) );
            if ( $redirect_url === '' ) {
                $redirect_url = '/';
            }
            if ( $redirect_url !== '' ) {
                $target = home_url( $redirect_url );
                $slug   = $this->extract_first_slug( (string) wp_parse_url( $target, PHP_URL_PATH ) );

                $protected_slugs = [
                    sanitize_title( (string) ( $settings['rename_login_url_slug'] ?? '' ) ),
                    sanitize_title( $this->normalize_register_slug_setting( (string) ( $settings['rename_login_url_register_slug'] ?? '' ) ) ),
                    sanitize_title( 'wp-login.php' ),
                    sanitize_title( 'wp-signup.php' ),
                    sanitize_title( 'wp-register.php' ),
                    sanitize_title( 'wp-activate.php' ),
                    sanitize_title( 'wp-admin' ),
                    sanitize_title( 'xmlrpc.php' ),
                    sanitize_title( 'wp-cron.php' ),
                ];

                $rest_prefix = function_exists( 'rest_get_url_prefix' ) ? rest_get_url_prefix() : 'wp-json';
                if ( is_string( $rest_prefix ) && $rest_prefix !== '' ) {
                    $protected_slugs[] = sanitize_title( $rest_prefix );
                }
                $protected_slugs = array_values( array_unique( array_filter( $protected_slugs ) ) );

                if ( $slug === '' || ! in_array( $slug, $protected_slugs, true ) ) {
                    wp_safe_redirect( $target, 302 );
                    exit;
                }
            }
        } elseif ( $behavior === '404' ) {
            $this->block_access_not_found();
            return;
        }

        $this->block_access();
    }

    private function normalize_block_behavior_setting( string $value ): string {
        $mode = strtolower( trim( $value ) );
        if ( in_array( $mode, [ '403', '404', 'redirect' ], true ) ) {
            return $mode;
        }

        return '404';
    }

    private function normalize_redirect_url_setting( string $value ): string {
        $raw = trim( $value );
        if ( $raw === '' ) {
            return '';
        }

        if ( preg_match( '/^[a-z][a-z0-9+\-.]*:\/\//i', $raw ) ) {
            $safe_url = esc_url_raw( $raw );
            if ( $safe_url === '' ) {
                return '';
            }
            $home_host   = wp_parse_url( home_url( '/' ), PHP_URL_HOST );
            $target_host = wp_parse_url( $safe_url, PHP_URL_HOST );
            if ( ! is_string( $home_host ) || ! is_string( $target_host ) || strtolower( $home_host ) !== strtolower( $target_host ) ) {
                return '';
            }
            $path  = (string) wp_parse_url( $safe_url, PHP_URL_PATH );
            $query = (string) wp_parse_url( $safe_url, PHP_URL_QUERY );
            $path  = '/' . ltrim( $path, '/' );
            if ( $path === '' ) {
                $path = '/';
            }
            return $query !== '' ? $path . '?' . $query : $path;
        }

        if ( 0 === strpos( $raw, '/' ) ) {
            $path  = (string) wp_parse_url( $raw, PHP_URL_PATH );
            $query = (string) wp_parse_url( $raw, PHP_URL_QUERY );
            $path  = '/' . ltrim( $path, '/' );
            if ( $path === '' ) {
                $path = '/';
            }
            return $query !== '' ? $path . '?' . $query : $path;
        }

        $slug = sanitize_title( $raw );
        if ( $slug === '' ) {
            return '';
        }

        return '/' . $slug;
    }

    private function extract_first_slug( string $path ): string {
        $trimmed = trim( $path, '/' );
        if ( $trimmed === '' ) {
            return '';
        }

        $parts = explode( '/', $trimmed );
        return sanitize_title( (string) ( $parts[0] ?? '' ) );
    }

    /**
     * Hard-block with a 404 Not Found response.
     *
     * Renders the self-contained block page, not the theme's 404 template:
     * this fires on `init`, before themes and front-end plugins (e.g.
     * WooCommerce) finish booting, so a theme template would fatal here.
     */
    private function block_access_not_found(): void {
        global $wp_query, $wp_the_query;

        if ( ! ( $wp_query instanceof \WP_Query ) ) {
            $wp_query = new \WP_Query();
        }

        $wp_query->set_404();
        $wp_query->query_vars['error'] = '404';
        $wp_the_query = $wp_query;

        status_header( 404 );
        nocache_headers();

        $this->render_block_page(
            404,
            __( 'The requested resource could not be found on this server.', 'sitefort' ),
            __( 'Not Found', 'sitefort' )
        );
    }

    /**
     * Hard-block with a 403 Forbidden error.
     */
    private function block_access(): void {
        $this->render_block_page(
            403,
            __( 'This login page is protected by this site\'s SiteFort access controls.', 'sitefort' ),
            __( 'Forbidden', 'sitefort' )
        );
    }

    private function render_block_page( int $code, string $message, string $die_title ): void {
        if ( ! function_exists( 'sitefort_render_block_page' ) ) {
            $block_page = dirname( __DIR__ ) . '/Firewall/block-page.php';
            if ( is_readable( $block_page ) ) {
                require_once $block_page;
            }
        }

        if ( function_exists( 'sitefort_render_block_page' ) ) {
            sitefort_render_block_page( $code, $message, IpDetector::get_ip() );
            exit;
        }

        wp_die( esc_html( $message ), esc_html( $die_title ), absint( $code ) );
    }

    /**
     * Check if the current request is an API/system request that must
     * bypass login URL protection entirely.
     */
    private function is_api_request(): bool {
        if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
            return true;
        }
        if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
            return true;
        }
        if ( defined( 'XMLRPC_REQUEST' ) && XMLRPC_REQUEST ) {
            return true;
        }
        if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
            return true;
        }
        if ( defined( 'WP_CLI' ) && WP_CLI ) {
            return true;
        }

        // Some request-type constants are not reliable across all server handoffs.
        // Fall back to normalized path matching for rewrite compatibility.
        if ( empty( $this->path_variants ) ) {
            $this->get_normalized_request_path();
        }

        // Use WordPress's own REST prefix (defaults to 'wp-json' but can be filtered).
        $rest_prefix = function_exists( 'rest_get_url_prefix' ) ? rest_get_url_prefix() : 'wp-json';
        $rest_prefix = is_string( $rest_prefix ) ? trim( $rest_prefix, '/' ) : 'wp-json';
        $api_paths   = [
            'wp-admin/admin-ajax.php',
            'wp-admin/admin-post.php',
            'xmlrpc.php',
            'wp-cron.php',
        ];

        foreach ( $this->path_variants as $variant ) {
            foreach ( $api_paths as $path ) {
                if ( $variant === $path ) {
                    return true;
                }
            }

            if ( $rest_prefix !== '' && ( $variant === $rest_prefix || strpos( $variant, $rest_prefix . '/' ) === 0 ) ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Return true only for same-site (or network-site) URLs.
     *
     * Prevents token exfiltration to external domains or alternate services on
     * the same hostname when rewriting redirects/emails.
     */
    private function is_internal_url_target( string $url ): bool {
        $parsed = wp_parse_url( $url );
        if ( ! is_array( $parsed ) ) {
            return false;
        }

        // Relative URLs are always internal.
        if ( empty( $parsed['host'] ) ) {
            return true;
        }

        $target_host = strtolower( ltrim( (string) $parsed['host'], '.' ) );
        if ( $target_host === '' ) {
            return false;
        }

        $allowed_hosts = $this->get_internal_target_hosts();
        if ( ! in_array( $target_host, $allowed_hosts, true ) ) {
            return false;
        }

        if ( ! isset( $parsed['port'] ) ) {
            return true;
        }

        $target_port = (int) $parsed['port'];
        if ( $target_port <= 0 ) {
            return false;
        }

        foreach ( $this->get_internal_target_host_ports() as $allowed ) {
            if ( $allowed['host'] === $target_host && in_array( $target_port, $allowed['ports'], true ) ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Return the normalized set of internal hosts that may receive rewritten URLs.
     *
     * @return string[]
     */
    private function get_internal_target_hosts(): array {
        $hosts = [];

        foreach ( $this->get_internal_target_host_ports() as $allowed ) {
            $hosts[] = $allowed['host'];
        }

        return array_values( array_unique( array_filter( $hosts ) ) );
    }

    /**
     * Return the normalized set of internal hosts and their allowed ports.
     *
     * @return array<int,array{host:string,ports:int[]}>
     */
    private function get_internal_target_host_ports(): array {
        $ports_by_host = [];
        $candidates    = [
            home_url( '/' ),
            site_url( '/' ),
        ];

        if ( function_exists( 'network_home_url' ) ) {
            $candidates[] = network_home_url( '/' );
        }
        if ( function_exists( 'network_site_url' ) ) {
            $candidates[] = network_site_url( '/' );
        }

        foreach ( $candidates as $candidate ) {
            $parsed = wp_parse_url( (string) $candidate );
            if ( ! is_array( $parsed ) || empty( $parsed['host'] ) ) {
                continue;
            }

            $host = strtolower( ltrim( (string) $parsed['host'], '.' ) );
            if ( $host === '' ) {
                continue;
            }

            $port = self::infer_url_port( $parsed );
            if ( $port === null ) {
                continue;
            }

            if ( ! isset( $ports_by_host[ $host ] ) ) {
                $ports_by_host[ $host ] = [];
            }
            $ports_by_host[ $host ][] = $port;
        }

        $normalized = [];
        foreach ( $ports_by_host as $host => $ports ) {
            $normalized[] = [
                'host'  => $host,
                'ports' => array_values( array_unique( array_filter( array_map( 'intval', $ports ) ) ) ),
            ];
        }

        return $normalized;
    }

    /**
     * Infer the effective port for a parsed absolute URL.
     *
     * @param array<string,mixed> $parsed
     */
    private static function infer_url_port( array $parsed ): ?int {
        if ( isset( $parsed['port'] ) ) {
            $port = (int) $parsed['port'];
            return $port > 0 ? $port : null;
        }

        $scheme = isset( $parsed['scheme'] ) ? strtolower( (string) $parsed['scheme'] ) : '';
        if ( $scheme === 'https' ) {
            return 443;
        }
        if ( $scheme === 'http' ) {
            return 80;
        }

        return null;
    }

    /**
     * Return true when URL path points to a WordPress login endpoint.
     */
    private function is_login_endpoint_url( string $url ): bool {
        $parsed = wp_parse_url( $url );
        if ( ! is_array( $parsed ) ) {
            return false;
        }

        $path = isset( $parsed['path'] ) ? trim( (string) $parsed['path'], '/' ) : '';
        if ( $path === '' ) {
            return false;
        }

        $candidates = [ $path, trim( urldecode( $path ), '/' ) ];
        foreach ( $candidates as $candidate ) {
            $base = basename( $candidate );
            if ( $base === 'wp-login.php' || $base === 'wp-signup.php' ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Forward compatible query args from the current request.
     *
     * Text-field/key sanitizers are intentionally avoided here because:
     * 1. wp-login.php performs its own validation/sanitization on every
     *    parameter it consumes.
     * 2. sanitize_key() lowercases keys and strips special chars; this breaks
     *    plugins using camelCase keys (e.g. redirectTo) or bracket notation
     *    (e.g. data[name]).
     * 3. sanitize_text_field() strips HTML tags; this can corrupt encoded URLs
     *    or special values that plugins pass through the login flow.
     *
     * Values are parsed from the request query string and stripped of control
     * bytes before forwarding. Only our own token is excluded to avoid
     * duplication.
     *
     * @return array<string,mixed>
     */
    private function get_safe_query_args(): array {
        $safe = self::parse_forwarded_query_args( self::read_server_route_scalar( 'QUERY_STRING' ) );
        unset( $safe[ $this->token_var ] );

        return $safe;
    }

    /**
     * Read an expected request value from POST first, then GET.
     */
    private function get_request_value( string $key ): string {
        $value = self::read_input_text_scalar( INPUT_POST, $key );
        if ( $value === '' ) {
            $value = self::read_input_text_scalar( INPUT_GET, $key );
        }

        return $value;
    }

    /**
     * Resolve the current login action without relying on PHP's request_order.
     */
    private function get_request_action(): string {
        return $this->get_request_value( 'action' );
    }

    /**
     * Resolve the access token from POST or GET without relying on the combined request bag.
     */
    private function get_request_token(): string {
        return $this->get_request_value( $this->token_var );
    }

    // -------------------------------------------------------------------------
    // Path normalization & matching
    // -------------------------------------------------------------------------

    /**
     * Build normalized request path variants (raw, URL-decoded, SCRIPT_FILENAME)
     * to prevent URL-encoding bypasses like /%77p-login.php.
     *
     * Handles subdirectory installs by stripping the site's base path prefix.
     *
     * @return string The URL-decoded path (primary variant).
     */
    private function get_normalized_request_path(): string {
        $request_uri = self::read_server_route_scalar( 'REQUEST_URI' );

        // Normalise multiple slashes to single slashes.
        $request_uri = (string) preg_replace( '|//+|', '/', $request_uri );

        // Variant 1: raw parsed path.
        $raw = trim( (string) wp_parse_url( $request_uri, PHP_URL_PATH ), '/' );

        // Strip the site's base path for subdirectory installs (e.g. /blog/).
        $site_base = trim( (string) wp_parse_url( home_url(), PHP_URL_PATH ), '/' );
        if ( '' !== $site_base ) {
            $prefix = $site_base . '/';
            if ( strpos( $raw, $prefix ) === 0 ) {
                $raw = substr( $raw, strlen( $prefix ) );
            } elseif ( $raw === $site_base ) {
                $raw = '';
            }
        }

        // Variant 2: URL-decoded (catches e.g. /%77p-login.php → wp-login.php).
        $decoded = trim( self::sanitize_route_scalar( urldecode( $raw ) ), '/' );

        // Variant 3: SCRIPT_FILENAME relative to ABSPATH.
        $script = '';
        $script_filename = self::read_server_route_scalar( 'SCRIPT_FILENAME' );
        if ( $script_filename !== '' ) {
            $abspath  = rtrim( ABSPATH, '/' );
            $realpath = realpath( $script_filename );
            if ( $realpath && strpos( $realpath, $abspath ) === 0 ) {
                $script = trim( substr( $realpath, strlen( $abspath ) ), '/' );
            }
        }

        $this->path_variants = [
            'raw'     => $raw,
            'decoded' => $decoded,
            'script'  => $script,
        ];

        return $decoded;
    }

    /**
     * Check if any path variant matches the given target.
     *
     * @param string $target Path to match (e.g. 'my-login', 'wp-admin').
     * @return bool
     */
    private function path_matches( string $target ): bool {
        foreach ( $this->path_variants as $variant ) {
            if ( $variant === $target ) {
                return true;
            }
        }
        return false;
    }

    /**
     * Match the configured public slug across trusted server-route sources.
     *
     * PATH_INFO/ORIG_PATH_INFO are only trusted when the outer request path is
     * already a canonical internal handoff like wp-login.php or index.php.
     * This preserves compatibility with rewritten hosts without treating
     * wp-login.php/<slug> as a valid public route.
     *
     * @param string   $target               Public slug to match.
     * @param string[] $allowed_script_paths Canonical script paths that may
     *                                       legally carry the slug via PATH_INFO.
     */
    private function matches_public_route_slug( string $target, array $allowed_script_paths ): bool {
        $target = trim( $target, '/' );
        if ( $target === '' ) {
            return false;
        }

        foreach ( $this->get_public_route_candidates( $allowed_script_paths ) as $candidate ) {
            if ( $candidate === $target ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Collect normalized public-route candidates from rewrite-aware server vars.
     *
     * @param string[] $allowed_script_paths Canonical script paths that may
     *                                       safely pair with PATH_INFO.
     * @return string[]
     */
    private function get_public_route_candidates( array $allowed_script_paths ): array {
        $candidates = [];

        foreach ( [ 'REQUEST_URI', 'REDIRECT_URL', 'SCRIPT_URL', 'UNENCODED_URL' ] as $key ) {
            $candidate = $this->normalize_public_route_candidate( self::read_server_route_scalar( $key ) );
            if ( $candidate !== '' ) {
                $candidates[] = $candidate;
            }
        }

        $request_path = $this->normalize_request_path_value( self::read_server_route_scalar( 'REQUEST_URI' ) );
        if ( $request_path !== '' && in_array( $request_path, $allowed_script_paths, true ) ) {
            foreach ( [ 'PATH_INFO', 'ORIG_PATH_INFO' ] as $key ) {
                $candidate = $this->normalize_request_path_value( self::read_server_route_scalar( $key ) );
                if ( $candidate !== '' ) {
                    $candidates[] = $candidate;
                }
            }
        }

        return array_values( array_unique( $candidates ) );
    }

    /**
     * Normalize a server path value for public-route matching.
     *
     * index.php/<slug> is accepted as a front-controller equivalent of /<slug>.
     */
    private function normalize_public_route_candidate( string $value ): string {
        $path = $this->normalize_request_path_value( $value );
        if ( $path === '' ) {
            return '';
        }

        if ( str_starts_with( $path, 'index.php/' ) ) {
            return trim( substr( $path, strlen( 'index.php/' ) ), '/' );
        }

        if ( $path === 'index.php' ) {
            return '';
        }

        return $path;
    }

    /**
     * Normalize a raw path/URL value relative to the site base path.
     */
    private function normalize_request_path_value( string $value ): string {
        if ( $value === '' ) {
            return '';
        }

        $value = (string) preg_replace( '|//+|', '/', $value );
        $path  = wp_parse_url( $value, PHP_URL_PATH );
        if ( ! is_string( $path ) || $path === '' ) {
            return '';
        }

        $normalized = trim( self::sanitize_route_scalar( rawurldecode( $path ) ), '/' );
        if ( $normalized === '' ) {
            return '';
        }

        $site_base = trim( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ), '/' );
        if ( $site_base !== '' ) {
            if ( $normalized === $site_base ) {
                return '';
            }

            $prefix = $site_base . '/';
            if ( str_starts_with( $normalized, $prefix ) ) {
                $normalized = substr( $normalized, strlen( $prefix ) );
            }
        }

        return trim( $normalized, '/' );
    }

    /**
     * Check if the request targets wp-admin/ (excluding AJAX/admin-post).
     */
    private function is_wp_admin_request(): bool {
        $excluded = [
            'wp-admin/admin-ajax.php',
            'wp-admin/admin-post.php',
            'wp-admin/load-scripts.php',  // Concatenated JS for login page.
            'wp-admin/load-styles.php',   // Concatenated CSS for login page.
        ];

        foreach ( $this->path_variants as $variant ) {
            if ( in_array( $variant, $excluded, true ) ) {
                return false;
            }
        }

        foreach ( $this->path_variants as $variant ) {
            if ( $variant === 'wp-admin' || strpos( $variant, 'wp-admin/' ) === 0 ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Check if the request targets wp-signup.php.
     */
    private function is_wp_signup_request(): bool {
        foreach ( $this->path_variants as $variant ) {
            if ( $variant === 'wp-signup.php' ) {
                return true;
            }
        }
        return false;
    }

    /**
     * Check if the request targets the legacy wp-register.php.
     */
    private function is_wp_register_request(): bool {
        foreach ( $this->path_variants as $variant ) {
            if ( $variant === 'wp-register.php' ) {
                return true;
            }
        }
        return false;
    }

    /**
     * Check if the request targets wp-activate.php.
     */
    private function is_wp_activate_request(): bool {
        foreach ( $this->path_variants as $variant ) {
            if ( $variant === 'wp-activate.php' ) {
                return true;
            }
        }
        return false;
    }

    // -------------------------------------------------------------------------
    // Settings
    // -------------------------------------------------------------------------

    /**
     * Read rename-login settings from the Hardening settings option.
     *
     * @return array
     */
    private function get_settings(): array {
        if ( null !== $this->settings_cache ) {
            return $this->settings_cache;
        }

        $defaults = [
            'rename_login_url_enabled'          => false,
            'rename_login_url_slug'             => '',
            'rename_login_url_register_slug'    => 'wp-signup.php',
            'rename_login_url_block_behavior'   => '404',
            'rename_login_url_redirect_url'     => '/',
        ];

        $settings = \SiteFort\Includes\SettingsStore::get( \SiteFort\Includes\SettingsStore::COL_HARDENING, $defaults );
        $settings = is_array( $settings ) ? wp_parse_args( $settings, $defaults ) : $defaults;

        $raw_redirect = (string) ( $settings['rename_login_url_redirect_url'] ?? $defaults['rename_login_url_redirect_url'] );
        $normalized_redirect = $this->normalize_redirect_url_setting( $raw_redirect );
        if ( $normalized_redirect === '' && trim( $raw_redirect ) === '' ) {
            $normalized_redirect = '/';
        }

        $settings['rename_login_url_register_slug'] = $this->normalize_register_slug_setting(
            (string) ( $settings['rename_login_url_register_slug'] ?? $defaults['rename_login_url_register_slug'] )
        );
        $settings['rename_login_url_block_behavior'] = $this->normalize_block_behavior_setting(
            (string) ( $settings['rename_login_url_block_behavior'] ?? $defaults['rename_login_url_block_behavior'] )
        );
        $settings['rename_login_url_redirect_url'] = $normalized_redirect;

        $this->settings_cache = $settings;

        return $this->settings_cache;
    }

    private function normalize_register_slug_setting( string $value ): string {
        $slug = trim( $value );
        if ( $slug === '' || strtolower( $slug ) === 'wp-signup.php' ) {
            return 'wp-signup.php';
        }

        return sanitize_title( $slug );
    }

    /**
     * Read a scalar request value and sanitize it for downstream checks.
     */
    private static function read_input_text_scalar( int $type, string $key ): string {
        $value = filter_input(
            $type,
            $key,
            FILTER_CALLBACK,
            [
                'flags'   => FILTER_REQUIRE_SCALAR,
                'options' => [ self::class, 'sanitize_text_request_scalar' ],
            ]
        );

        return is_string( $value ) ? $value : '';
    }

    /**
     * Read a scalar cookie value and sanitize it for token comparison.
     */
    private static function read_cookie_text_scalar( string $key ): string {
        $value = filter_input(
            INPUT_COOKIE,
            $key,
            FILTER_CALLBACK,
            [
                'flags'   => FILTER_REQUIRE_SCALAR,
                'options' => [ self::class, 'sanitize_text_request_scalar' ],
            ]
        );

        return is_string( $value ) ? $value : '';
    }

    /**
     * Callback used by filter_input() for text request values.
     */
    public static function sanitize_text_request_scalar( $value ): string {
        return is_scalar( $value ) ? sanitize_text_field( (string) $value ) : '';
    }

    /**
     * Read a scalar server value while preserving route semantics.
     */
    private static function read_server_route_scalar( string $key ): string {
        if ( ! isset( $_SERVER[ $key ] ) || ! is_scalar( $_SERVER[ $key ] ) ) {
            return '';
        }

        $value = filter_var(
            wp_unslash( (string) $_SERVER[ $key ] ),
            FILTER_CALLBACK,
            [
                'options' => [ self::class, 'sanitize_route_scalar' ],
            ]
        );

        return is_string( $value ) ? $value : '';
    }

    /**
     * Callback used by filter_input() for route and query-forwarding values.
     */
    public static function sanitize_route_scalar( $value ): string {
        if ( ! is_scalar( $value ) ) {
            return '';
        }

        $value = str_replace( "\0", '', (string) $value );
        $value = (string) preg_replace( '/[\x00-\x1F\x7F]/', '', $value );

        return $value;
    }

    /**
     * Parse query-string values that may be forwarded to wp-login.php.
     *
     * @return array<string,mixed>
     */
    private static function parse_forwarded_query_args( string $query_string ): array {
        if ( '' === $query_string ) {
            return [];
        }

        $query_args = [];
        wp_parse_str( $query_string, $query_args );

        return self::sanitize_forwarded_query_args( $query_args );
    }

    /**
     * Strip control bytes from parsed query keys and values without changing
     * plugin-defined key casing or value encoding semantics.
     *
     * @param array<mixed> $source
     * @return array<string,mixed>
     */
    private static function sanitize_forwarded_query_args( array $source ): array {
        $safe = [];

        foreach ( $source as $key => $value ) {
            $clean_key = self::sanitize_route_scalar( $key );
            if ( '' === $clean_key ) {
                continue;
            }

            if ( is_array( $value ) ) {
                $safe[ $clean_key ] = self::sanitize_forwarded_query_args( $value );
                continue;
            }

            if ( is_scalar( $value ) ) {
                $safe[ $clean_key ] = self::sanitize_route_scalar( $value );
            }
        }

        return $safe;
    }
}
