<?php
/**
 * Custom login URL — rewrite wp-login.php to a custom slug.
 *
 * Cuts out 90% of bot traffic against /wp-login.php in one move. Modeled
 * after WPS Hide Login but kept in-house so we can wire it cleanly with
 * our 2FA interstitial (which posts back to wp-login.php?action=sf_2fa
 * and MUST keep working).
 *
 * Lockout-safety:
 *   - Default OFF; admin must opt in + set a slug.
 *   - Emergency rescue via wp-config: define('SHIELDFORGE_LOGIN_RESCUE', 'some-secret');
 *     then visit /wp-login.php?shieldforge_rescue=some-secret to bypass the 404.
 *   - WP-CLI escape hatch: `wp option update / wp site option update`
 *     to flip the toggle off if the admin truly locked themselves out.
 *
 * @package Brainwerk Security Suite
 */

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

class Shieldforge_Login_Url {

    /** Actions that must remain reachable via the original /wp-login.php URL. */
    private static $allowed_actions = array(
        'logout',     // user-initiated logout from anywhere
        'postpass',   // password-protected-post submit
        'sf_2fa',     // our 2FA interstitial (Phase 5b)
        'rp',         // reset-password landing
        'resetpass',  // reset-password completion
    );

    public static function register( Shieldforge_Loader $loader ) {
        if ( ! self::is_enabled() ) {
            return;
        }
        // Run as early as possible so we can short-circuit before the
        // request hits wp-login.php's own logic.
        $loader->add_action( 'plugins_loaded', __CLASS__, 'maybe_intercept', 0 );
        $loader->add_action( 'wp_loaded',      __CLASS__, 'maybe_intercept', 0 );

        // Hide /wp-admin from logged-out visitors so the auth redirect can't
        // leak the custom login slug. Runs once login state is known.
        $loader->add_action( 'wp_loaded',      __CLASS__, 'block_admin_anon', 1 );

        // Rewrite generated wp-login.php URLs everywhere WP emits them.
        $loader->add_filter( 'site_url',         __CLASS__, 'filter_site_url', 10, 4 );
        $loader->add_filter( 'network_site_url', __CLASS__, 'filter_site_url', 10, 3 );
        $loader->add_filter( 'wp_redirect',      __CLASS__, 'filter_redirect', 10, 2 );
        $loader->add_filter( 'login_url',        __CLASS__, 'filter_simple_url', 10, 1 );
        $loader->add_filter( 'register_url',     __CLASS__, 'filter_simple_url', 10, 1 );
        $loader->add_filter( 'lostpassword_url', __CLASS__, 'filter_simple_url', 10, 1 );
    }

    public static function is_enabled() {
        return (bool) Shieldforge_Settings::get( 'login_url_enabled', false ) && '' !== self::custom_slug();
    }

    public static function custom_slug() {
        $slug = (string) Shieldforge_Settings::get( 'login_url_slug', '' );
        return trim( preg_replace( '/[^a-z0-9_-]/i', '', $slug ), '/' );
    }

    /**
     * The path-segment of the current request, normalized (no leading/trailing
     * slash, no query string).
     */
    private static function current_path() {
        $req = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
        $p   = (string) wp_parse_url( $req, PHP_URL_PATH );
        return trim( $p, '/' );
    }

    /**
     * Heart of the feature. Two paths:
     *   1. Visitor hits /CUSTOMSLUG/  → load wp-login.php in-place.
     *   2. Visitor hits /wp-login.php → 404 (with allowed-action exceptions).
     */
    public static function maybe_intercept() {
        static $done = false;
        if ( $done ) {
            return;
        }
        $done = true;

        $path = self::current_path();
        $slug = self::custom_slug();

        // ---- 1. Custom slug → present the login form ---------------------
        if ( $path === $slug ) {
            // Some plugins (and WP itself in re-auth flows) check
            // SCRIPT_NAME. Pretend we are wp-login.php.
            $_SERVER['SCRIPT_NAME'] = '/wp-login.php';
            $_SERVER['PHP_SELF']    = '/wp-login.php';
            require_once ABSPATH . 'wp-login.php';
            exit;
        }

        // ---- 2. Direct wp-login.php access → 404 -------------------------
        if ( 'wp-login.php' !== $path && 'wp-login' !== $path ) {
            return;
        }
        // Allow specific actions to pass (logout, password reset, 2FA).
        $action = isset( $_GET['action'] ) ? sanitize_key( wp_unslash( $_GET['action'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing check, pre-auth (runs before wp-login.php), no state change.
        if ( in_array( $action, self::$allowed_actions, true ) ) {
            return;
        }
        // Emergency rescue (lockout-recovery for the admin). The rescue
        // token MUST be defined in wp-config.php — never via the DB — so
        // an attacker who pwns the admin DB still can't bypass.
        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only routing check, pre-auth (runs before wp-login.php); the rescue "credential" itself is a wp-config.php-defined secret compared via hash_equals(), a nonce is not applicable here.
        if ( defined( 'SHIELDFORGE_LOGIN_RESCUE' ) && SHIELDFORGE_LOGIN_RESCUE
             && isset( $_GET['shieldforge_rescue'] )
             && hash_equals( (string) SHIELDFORGE_LOGIN_RESCUE, (string) wp_unslash( $_GET['shieldforge_rescue'] ) ) ) {
            return;
        }
        // phpcs:enable WordPress.Security.NonceVerification.Recommended

        // Log the probe — it's a useful signal that someone is scanning.
        Shieldforge_Logger::log( Shieldforge_Logger::EVT_HARDENING_BLOCK, array(
            'severity' => Shieldforge_Logger::SEV_WARN,
            'context'  => array(
                'reason' => 'wp_login_direct_access_blocked',
                'path'   => $path,
            ),
        ) );

        self::serve_404();
    }

    private static function serve_404() {
        // Avoid emitting any of the wp-login.php headers/HTML. Render the
        // theme's own 404 template so the response looks like a vanilla
        // not-found URL — exactly what we want bots to see.
        global $wp_query;
        status_header( 404 );
        nocache_headers();
        if ( $wp_query instanceof WP_Query ) {
            $wp_query->set_404();
        }
        // Send a minimal, self-contained 404 body. We intentionally do NOT load
        // wp-includes/template-loader.php: the WordPress.org guidelines forbid a
        // plugin from requiring core files directly, and this interceptor runs
        // on wp_loaded — before the main query is parsed — so the active theme's
        // 404 template (get_header()/the loop) has no query context to render
        // against anyway. A bare 404 is all a login-URL scanner ever needs.
        header( 'Content-Type: text/html; charset=' . get_option( 'blog_charset', 'UTF-8' ) );
        echo '<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="robots" content="noindex"><title>404 Not Found</title></head><body><h1>404 Not Found</h1><p>'
            . esc_html__( 'The requested page could not be found.', 'brainwerk-security-suite' )
            . '</p></body></html>';
        exit;
    }

    /* ==================================================================
     *  URL filters — replace wp-login.php with the custom slug everywhere
     * ================================================================== */

    /**
     * Hide /wp-admin from logged-out visitors. Without this, WP's auth
     * redirect bounces anon /wp-admin requests to the login URL and leaks
     * the custom slug in the Location header — defeating the whole point.
     * Logged-in users pass through; static admin assets + ajax/post stay
     * reachable so login-page styling and front-end AJAX keep working.
     */
    public static function block_admin_anon() {
        if ( is_user_logged_in() ) {
            return;
        }
        $path = self::current_path();
        if ( 0 !== strpos( $path, 'wp-admin' ) ) {
            return;
        }
        // Only gate PHP entry points; let CSS/JS/images through.
        $is_php = ( 'wp-admin' === $path ) || ( '.php' === substr( $path, -4 ) );
        if ( ! $is_php ) {
            return;
        }
        // Endpoints that legitimately serve unauthenticated requests.
        $allow = array( 'admin-ajax.php', 'admin-post.php', 'load-scripts.php', 'load-styles.php' );
        if ( in_array( basename( $path ), $allow, true ) ) {
            return;
        }
        Shieldforge_Logger::log( Shieldforge_Logger::EVT_HARDENING_BLOCK, array(
            'severity' => Shieldforge_Logger::SEV_WARN,
            'context'  => array(
                'reason' => 'wp_admin_anon_blocked',
                'path'   => $path,
            ),
        ) );
        self::serve_404();
    }

    public static function filter_simple_url( $url ) {
        return self::rewrite( $url );
    }

    public static function filter_site_url( $url, $path = '', $scheme = null, $blog_id = null ) {
        return self::rewrite( $url );
    }

    public static function filter_redirect( $location, $status = 0 ) {
        return self::rewrite( $location );
    }

    private static function rewrite( $url ) {
        if ( ! is_string( $url ) || false === strpos( $url, 'wp-login.php' ) ) {
            return $url;
        }
        // Don't rewrite URLs carrying our allowed-action query — they need
        // to keep going to wp-login.php (logout, sf_2fa, password reset).
        $query = (string) wp_parse_url( $url, PHP_URL_QUERY );
        if ( $query ) {
            parse_str( $query, $qa );
            if ( isset( $qa['action'] ) && in_array( $qa['action'], self::$allowed_actions, true ) ) {
                return $url;
            }
        }
        $slug = self::custom_slug();
        return str_replace( 'wp-login.php', $slug, $url );
    }
}
