<?php
/**
 * Brute-force protection: rate-limits failed logins per IP, blocks abusers.
 *
 * @package Brainwerk Security Suite
 */

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

class Shieldforge_Bruteforce {

    public static function register( Shieldforge_Loader $loader ) {
        if ( ! Shieldforge_Settings::get( 'bruteforce_enabled', true ) ) {
            // Still log login events even when blocking is off — handled below.
        }
        $loader->add_filter( 'authenticate', __CLASS__, 'pre_auth_check', 30, 3 );
        $loader->add_action( 'wp_login_failed', __CLASS__, 'on_login_failed', 10, 1 );
        $loader->add_action( 'wp_login', __CLASS__, 'on_login_success', 10, 2 );
    }

    /**
     * Runs before authentication. Short-circuits with WP_Error if IP is blocked.
     */
    public static function pre_auth_check( $user, $username, $password ) {
        if ( '' === (string) $username && '' === (string) $password ) {
            return $user; // empty form load
        }
        $ip       = Shieldforge_Utils::get_real_ip();
        $store_ip = Shieldforge_Utils::store_ip( $ip );
        if ( self::is_whitelisted( $ip ) ) {
            return $user;
        }
        if ( self::is_blocked( $store_ip ) ) {
            Shieldforge_Logger::log( Shieldforge_Logger::EVT_LOGIN_BLOCKED, array(
                'severity' => Shieldforge_Logger::SEV_HIGH,
                'username' => $username,
                'context'  => array( 'reason' => 'ip_locked_out' ),
            ) );
            return new WP_Error(
                'shieldforge_blocked',
                __( '<strong>Access denied.</strong> Too many failed login attempts. Try again later.', 'brainwerk-security-suite' )
            );
        }
        // User-account-based lockout: even if the IP is fresh, if this account
        // has been hammered across many IPs (botnet rotation), reject.
        if ( self::is_user_locked( $username ) ) {
            Shieldforge_Logger::log( Shieldforge_Logger::EVT_LOGIN_BLOCKED, array(
                'severity' => Shieldforge_Logger::SEV_HIGH,
                'username' => $username,
                'context'  => array( 'reason' => 'user_locked_out' ),
            ) );
            return new WP_Error(
                'shieldforge_user_blocked',
                __( '<strong>Access denied.</strong> This account has been temporarily locked due to repeated failed sign-ins. Try again later.', 'brainwerk-security-suite' )
            );
        }
        return $user;
    }

    public static function on_login_failed( $username ) {
        $ip       = Shieldforge_Utils::get_real_ip();
        $store_ip = Shieldforge_Utils::store_ip( $ip );

        Shieldforge_Logger::log( Shieldforge_Logger::EVT_LOGIN_FAIL, array(
            'severity' => Shieldforge_Logger::SEV_WARN,
            'username' => $username,
        ) );

        // Master switch — turning brute-force off disables BOTH defenses.
        if ( ! Shieldforge_Settings::get( 'bruteforce_enabled', true ) ) {
            return;
        }

        // IP-based lockout: skipped for whitelisted source IPs (e.g. office
        // range). Account-based lockout below runs regardless — whitelist
        // is about source IP, not about which account a botnet hammers.
        if ( ! self::is_whitelisted( $ip ) ) {
            $window    = (int) Shieldforge_Settings::get( 'bruteforce_window_min', 5 );
            $threshold = (int) Shieldforge_Settings::get( 'bruteforce_threshold', 5 );
            $since     = gmdate( 'Y-m-d H:i:s', time() - MINUTE_IN_SECONDS * $window );

            $fails = Shieldforge_Logger::count_since( $since, array(
                'event_type' => Shieldforge_Logger::EVT_LOGIN_FAIL,
                'ip'         => $store_ip,
            ) );

            if ( $fails >= $threshold ) {
                self::block_ip( $store_ip, (int) Shieldforge_Settings::get( 'bruteforce_lockout_min', 30 ), 'brute_force_threshold' );
                Shieldforge_Logger::log( Shieldforge_Logger::EVT_BF_LOCKOUT, array(
                    'severity' => Shieldforge_Logger::SEV_HIGH,
                    'username' => $username,
                    'context'  => array( 'failed_count' => $fails, 'threshold' => $threshold ),
                ) );
                // Phase 7 stage 2: emit a typed event in parallel with the
                // legacy log row so downstream AI / threat-feed consumers
                // can rely on a stable, salted-hash-only shape.
                if ( class_exists( 'Shieldforge_Event' ) ) {
                    Shieldforge_Event::emit( array(
                        'event_type' => 'auth.bf_lockout',
                        'severity'   => 'high',
                        'actor'      => array(
                            // $ip is the raw client IP from get_real_ip();
                            // hash_value() salts+sha256s it before storage.
                            'ip'         => $ip,
                            'username'   => $username,
                            'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) ) : '',
                        ),
                        'context'    => array(
                            'fail_count'     => (int) $fails,
                            'threshold'      => (int) $threshold,
                            'window_seconds' => (int) Shieldforge_Settings::get( 'bruteforce_window_min', 5 ) * 60,
                            'lockout_min'    => (int) Shieldforge_Settings::get( 'bruteforce_lockout_min', 30 ),
                        ),
                    ) );
                }
                do_action( 'shieldforge_bf_lockout', $store_ip, $fails, $username );
            }
        }

        // Per-account brute-force tracker. Always runs (when master enabled).
        self::maybe_lock_user_account( $username );
    }

    /**
     * Count failed-login events for this username across all IPs in the
     * configured user-window, lock the account if threshold reached.
     */
    public static function maybe_lock_user_account( $username ) {
        if ( ! Shieldforge_Settings::get( 'bf_user_lockout_enabled', true ) ) {
            return;
        }
        $store_username = Shieldforge_Utils::store_username( $username );
        if ( '' === $store_username ) {
            return;
        }
        $window    = (int) Shieldforge_Settings::get( 'bf_user_lockout_window_min', 15 );
        $threshold = (int) Shieldforge_Settings::get( 'bf_user_lockout_threshold', 8 );
        $minutes   = (int) Shieldforge_Settings::get( 'bf_user_lockout_minutes', 15 );
        $since     = gmdate( 'Y-m-d H:i:s', time() - MINUTE_IN_SECONDS * max( 1, $window ) );

        $fails = Shieldforge_Logger::count_since( $since, array(
            'event_type' => Shieldforge_Logger::EVT_LOGIN_FAIL,
            'username'   => $store_username,
        ) );

        if ( $fails >= $threshold ) {
            self::lock_user( $username, $minutes, $fails, $threshold );
        }
    }

    /**
     * Lock a single user account for $minutes via WP transient. Stable cache
     * key uses store_username so the lookup matches the storage form.
     */
    public static function lock_user( $username, $minutes, $fails = 0, $threshold = 0 ) {
        $store_username = Shieldforge_Utils::store_username( $username );
        if ( '' === $store_username ) {
            return false;
        }
        $key     = 'shieldforge_user_lock_' . md5( $store_username );
        $minutes = max( 1, (int) $minutes );
        set_transient( $key, time() + MINUTE_IN_SECONDS * $minutes, MINUTE_IN_SECONDS * $minutes );

        Shieldforge_Logger::log( Shieldforge_Logger::EVT_BF_LOCKOUT, array(
            'severity' => Shieldforge_Logger::SEV_HIGH,
            'username' => $username,
            'context'  => array(
                'mode'         => 'user_account',
                'failed_count' => (int) $fails,
                'threshold'    => (int) $threshold,
                'minutes'      => $minutes,
            ),
        ) );

        do_action( 'shieldforge_user_lockout', $username, $fails );
        return true;
    }

    public static function is_user_locked( $username ) {
        if ( ! Shieldforge_Settings::get( 'bf_user_lockout_enabled', true ) ) {
            return false;
        }
        $store_username = Shieldforge_Utils::store_username( $username );
        if ( '' === $store_username ) {
            return false;
        }
        $key   = 'shieldforge_user_lock_' . md5( $store_username );
        $until = get_transient( $key );
        return $until && (int) $until > time();
    }

    public static function unlock_user( $username ) {
        $store_username = Shieldforge_Utils::store_username( $username );
        if ( '' === $store_username ) {
            return false;
        }
        return delete_transient( 'shieldforge_user_lock_' . md5( $store_username ) );
    }

    public static function on_login_success( $user_login, $user ) {
        Shieldforge_Logger::log( Shieldforge_Logger::EVT_LOGIN_OK, array(
            'severity' => Shieldforge_Logger::SEV_INFO,
            'user_id'  => $user ? (int) $user->ID : 0,
            'username' => $user_login,
        ) );
    }

    public static function block_ip( $ip, $minutes, $reason = '' ) {
        global $wpdb;
        if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
            return false;
        }
        $until = gmdate( 'Y-m-d H:i:s', time() + MINUTE_IN_SECONDS * max( 1, (int) $minutes ) );
        $now   = current_time( 'mysql', 1 );
        $table = Shieldforge_Utils::table( 'blocks' );

        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom security table (not cacheable, no WP object-cache backing); direct live read by design; user values use $wpdb->prepare().
        $existing = $wpdb->get_row(
            $wpdb->prepare( "SELECT id FROM {$table} WHERE ip = %s LIMIT 1", $ip )
        );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        if ( $existing ) {
            // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom security table (not cacheable, no WP object-cache backing); direct live write by design.
            $wpdb->update(
                $table,
                array( 'blocked_until' => $until, 'reason' => $reason ),
                array( 'id' => (int) $existing->id ),
                array( '%s', '%s' ),
                array( '%d' )
            );
            // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            return (int) $existing->id;
        }
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery -- custom security table (not cacheable, no WP object-cache backing); direct live write by design.
        $wpdb->insert(
            $table,
            array(
                'ip'            => $ip,
                'reason'        => $reason,
                'blocked_until' => $until,
                'created_at'    => $now,
                'blog_id'       => Shieldforge_Utils::current_blog_id(),
            ),
            array( '%s', '%s', '%s', '%s', '%d' )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
        return (int) $wpdb->insert_id;
    }

    public static function is_blocked( $ip ) {
        global $wpdb;
        if ( ! $ip ) {
            return false;
        }
        $table = Shieldforge_Utils::table( 'blocks' );
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom security table (not cacheable, no WP object-cache backing); direct live read by design; user values use $wpdb->prepare().
        $row = $wpdb->get_row(
            $wpdb->prepare( "SELECT blocked_until FROM {$table} WHERE ip = %s LIMIT 1", $ip )
        );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        if ( ! $row ) {
            return false;
        }
        return strtotime( $row->blocked_until . ' UTC' ) > time();
    }

    public static function is_whitelisted( $ip ) {
        $list = (array) Shieldforge_Settings::get( 'bruteforce_whitelist', array() );
        if ( empty( $list ) ) {
            return false;
        }
        return Shieldforge_Utils::ip_in_list( $ip, $list );
    }

    public static function active_blocks() {
        global $wpdb;
        $table = Shieldforge_Utils::table( 'blocks' );
        $now   = current_time( 'mysql', 1 );
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom security table (not cacheable, no WP object-cache backing); direct live read by design; user values use $wpdb->prepare().
        return $wpdb->get_results(
            $wpdb->prepare( "SELECT * FROM {$table} WHERE blocked_until > %s ORDER BY blocked_until DESC", $now )
        );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
    }
}
