<?php
/**
 * Handles the Limit Login Attempts feature for Guard Dog.
 *
 * @package GuardDog
 * @since   1.5.0
 * @todo Future Enhancement: Offer alternative lockout strategies (e.g., by username, or IP+username) as an option, as pure IP lockout can affect multiple users behind the same NAT (e.g., office network).
 */

if ( ! defined( 'ABSPATH' ) ) exit; // Exit if accessed directly

class Guard_Dog_Limit_Login_Attempts {

    // Option keys remain the same for settings
    const OPTION_ENABLED = 'guard_dog_lla_enabled';
    const OPTION_MAX_RETRIES = 'guard_dog_lla_max_retries';
    const OPTION_LOCKOUT_DURATION = 'guard_dog_lla_lockout_duration';
    const LOCKOUT_ERROR_CODE = 'guard_dog_locked_out';

    // Cache keys and expiration
    const CACHE_GROUP = 'guard_dog_lla';
    const CACHE_EXPIRY = 300; // 5 minutes

    private $table_name = '';

    /**
     * Instance of the Activity Log handler.
     *
     * @var Guard_Dog_Activity_Log|null
     * @since 1.6.0
     */
    private $activity_log = null;

    /**
     * Global variable to track lockout status
     * @var bool
     */
    private static $is_locked_out = false;

    /**
     * Constructor. Adds hooks if the feature is enabled.
     *
     * @since 1.5.0
     * @param string $lla_table_name The full name of the LLA database table.
     * @param Guard_Dog_Activity_Log|null $activity_log Instance of the activity logger.
     */
    public function __construct( $lla_table_name, $activity_log = null ) {
        $this->activity_log = $activity_log;
         if ( $this->is_enabled() ) {
            $this->table_name = $lla_table_name;
            $this->add_hooks();
        }
    }

    /**
     * Checks if the Limit Login Attempts feature is enabled via settings.
     *
     * @since 1.5.0
     * @return bool True if enabled, false otherwise.
     */
    private function is_enabled() {
        return (bool) get_option( self::OPTION_ENABLED, false );
    }

    /**
     * Adds the necessary WordPress hooks.
     *
     * @since 1.5.0
     */
    private function add_hooks() {
        // Check for existing lockout before authentication checks
        add_filter('wp_authenticate_user', [$this, 'check_ip_lockout'], 10, 2);

        // Track failed login attempts
        add_action('wp_login_failed', [$this, 'handle_failed_login'], 10, 1);

        // Clear attempts on successful login
        add_action('wp_login', [$this, 'handle_successful_login'], 10, 2);

        // Add message via login_errors as a fallback display mechanism
        add_filter('login_errors', [$this, 'display_lockout_error_message'], 10, 1);

        // Debug: Log the final result of the authenticate filter
        add_filter('authenticate', [$this, 'log_final_authenticate_result'], 999, 1);
    }

    /**
     * Check for IP lockout before authentication checks
     *
     * @since 1.6.0
     * @param string $username Username
     * @param string $password Password
     */
    public function check_ip_lockout_before_auth($username, $password) {
        $ip = Guard_Dog_Utils::get_user_ip();
        if (!$ip) {
            return;
        }

        $lockout = $this->get_ip_lockout($ip);
        if ($lockout) {
            self::$is_locked_out = true;
            if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
                Guard_Dog()->debug->info(
                    sprintf('[LLA Auth Check] Lockout active for IP %s. Expiry: %s (%d), Current Time: %s (%d). Setting lockout flag.',
                        $ip,
                        gmdate('Y-m-d H:i:s', $lockout['expiry']),
                        $lockout['expiry'],
                        gmdate('Y-m-d H:i:s', time()),
                        time()
                    ),
                    'limit_login_attempts',
                    ['ip' => $ip, 'expiry' => $lockout['expiry']]
                );
            }
        }
    }

    /**
     * Check if the IP is locked out
     *
     * @since 1.5.0
     * @param WP_User|WP_Error $user User object or WP_Error
     * @param string $password Password
     * @return WP_User|WP_Error User object or WP_Error if locked out
     */
    public function check_ip_lockout($user, $password) {
        // If authentication has already failed, don't interfere
        if (is_wp_error($user)) {
            return $user;
        }

        $ip = Guard_Dog_Utils::get_user_ip();
        if (!$ip) {
            return $user;
        }

        $lockout = $this->get_ip_lockout($ip);
        if ($lockout) {
            if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
                Guard_Dog()->debug->info(
                    sprintf('[LLA Auth Check] Lockout active for IP %s. Expiry: %s (%d), Current Time: %s (%d). Returning WP_Error.',
                        $ip,
                        gmdate('Y-m-d H:i:s', $lockout['expiry']),
                        $lockout['expiry'],
                        gmdate('Y-m-d H:i:s', time()),
                        time()
                    ),
                    'limit_login_attempts',
                    ['ip' => $ip, 'expiry' => $lockout['expiry']]
                );
            }

            if ($this->activity_log) {
                $attempted_username = 'unknown';

                // phpcs:disable WordPress.Security.NonceVerification.Missing
                // Nonce verification is not required in this context:
                // 1. This method is called from the wp_authenticate_user filter during WordPress login processing
                // 2. WordPress core handles nonce verification for login forms before this filter is called
                // 3. This is read-only access to POST data for logging purposes only
                // 4. All input is properly sanitized before use
                // 5. No privileged actions or data modifications occur based on this POST data
                if ( isset( $_POST['log'] ) && is_string( $_POST['log'] ) ) {
                    $attempted_username = sanitize_user( wp_unslash( $_POST['log'] ) );
                }
                // phpcs:enable WordPress.Security.NonceVerification.Missing
                $this->activity_log->add_log_entry(
                    'login_attempt_during_lockout',
                    [
                        'username'   => $attempted_username,
                        'ip_address' => $ip,
                        'details'    => [
                            'lockout_expiry' => gmdate('Y-m-d H:i:s', $lockout['expiry']),
                            'remaining_seconds' => $lockout['expiry'] - time()
                        ]
                    ]
                );
            }

            return new WP_Error(
                'ip_locked_out',
                sprintf(
                    // Translators: %d is the number of minutes until the user can try again.
                    __('<strong>ERROR</strong>: Too many failed login attempts. Please try again in %d minutes.', 'guard-dog'),
                    ceil($this->get_remaining_lockout_time() / 60)
                )
            );
        }

        return $user;
    }

    /**
     * Debugging function to log the final result passed through the 'authenticate' filter.
     *
     * @since 1.6.0 // Or current version
     * @param WP_User|WP_Error|null $user The filtered user object/error.
     * @return WP_User|WP_Error|null The unmodified user object/error.
     */
    public function log_final_authenticate_result( $user ) {
        if ( is_wp_error( $user ) ) {
            if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
                Guard_Dog()->debug->info(
                    sprintf('[LLA Final Auth Check] Authenticate filter final result is WP_Error. Code: %s, Message: %s',
                        $user->get_error_code(),
                        $user->get_error_message()
                    ),
                    'limit_login_attempts',
                    ['error_code' => $user->get_error_code(), 'error_message' => $user->get_error_message()]
                );
            }
        } elseif ( is_a( $user, 'WP_User' ) ) {
            if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
                Guard_Dog()->debug->info(
                    sprintf('[LLA Final Auth Check] Authenticate filter final result is WP_User. User ID: %d, Login: %s',
                        $user->ID,
                        $user->user_login
                    ),
                    'limit_login_attempts',
                    ['user_id' => $user->ID, 'user_login' => $user->user_login]
                );
            }
        } elseif ( is_null( $user ) ) {
            if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
                Guard_Dog()->debug->info('[LLA Final Auth Check] Authenticate filter final result is null.', 'limit_login_attempts');
            }
        } else {
            if (function_exists('Guard_Dog') && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled()) {
                Guard_Dog()->debug->info('[LLA Final Auth Check] Authenticate filter final result is unexpected type: ' . gettype($user), 'limit_login_attempts');
            }
        }
        return $user; // Pass through unmodified
    }

    /**
     * Displays the lockout error message via the 'login_errors' filter if applicable.
     *
     * @since 1.6.0
     * @param string $error The current login error message HTML.
     * @return string The potentially modified error message HTML.
     */
    public function display_lockout_error_message( $error ) {
        $ip = Guard_Dog_Utils::get_user_ip();
        if ( ! $ip ) {
            return $error;
        }

        // Use caching for the lockout data query
        $cache_key = 'lockout_expiry_' . md5($ip);
        $lockout_data = wp_cache_get($cache_key, self::CACHE_GROUP);
        
        if (false === $lockout_data) {
            global $wpdb;
            // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
            // This query is properly cached and only executed when cache is empty
            $lockout_data = $wpdb->get_row( $wpdb->prepare(
                "SELECT lockout_expiry FROM `" . esc_sql( $this->table_name ) . "` WHERE ip_address = %s",
                $ip
            ) );
            // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
            wp_cache_set($cache_key, $lockout_data, self::CACHE_GROUP, self::CACHE_EXPIRY);
        }

        $lockout_expiry_time = null;
        if ( $lockout_data && $lockout_data->lockout_expiry ) {
            $lockout_expiry_time = strtotime( $lockout_data->lockout_expiry );
        }

        // Check if a lockout timestamp exists and is in the future
        if ( $lockout_expiry_time && $lockout_expiry_time > time() ) {
            // IP is locked out, ensure our message is shown.
            $remaining_seconds = $lockout_expiry_time - time();
            $remaining_minutes = ceil( $remaining_seconds / MINUTE_IN_SECONDS );

            // Translators: %d is the number of minutes until the user can try again.
            $lockout_message = sprintf( _n(
                'Too many failed login attempts. Please try again in %d minute.',
                'Too many failed login attempts. Please try again in %d minutes.',
                $remaining_minutes,
                'guard-dog'
            ), $remaining_minutes );

            // Remove shake effect when showing lockout error via this filter
            remove_action( 'wp_login_failed', 'wp_shake_js', 12 );

            // Return *only* our lockout message, potentially replacing other errors
            return wp_kses_post( sprintf( '<strong>%s</strong> %s', esc_html__( 'ERROR:', 'guard-dog' ), esc_html($lockout_message) ) );
        }

        return $error;
    }

     /**
     * Handles a failed login attempt using the custom DB table.
     *
     * @since 1.6.0
     * @param string $username The username used in the failed attempt.
     */
    public function handle_failed_login( $username ) {
        $ip = Guard_Dog_Utils::get_user_ip();
        if ( ! $ip ) {
            return;
        }

        global $wpdb;

        // Check if IP is currently locked out with caching
        $cache_key = 'current_lockout_' . md5($ip);
        $current_lockout_expiry = wp_cache_get($cache_key, self::CACHE_GROUP);
        
        if (false === $current_lockout_expiry) {
            // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
            // This query is properly cached and only executed when cache is empty
            $current_lockout_expiry = $wpdb->get_var( $wpdb->prepare(
                "SELECT lockout_expiry FROM `" . esc_sql( $this->table_name ) . "` WHERE ip_address = %s",
                $ip
            ) );
            // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
            wp_cache_set($cache_key, $current_lockout_expiry, self::CACHE_GROUP, self::CACHE_EXPIRY);
        }

        if ( $current_lockout_expiry && strtotime( $current_lockout_expiry ) > time() ) {
            return; // Already locked out
        }

        // Get current attempt count or initialize row with caching
        $cache_key_attempts = 'attempt_count_' . md5($ip);
        $attempts = wp_cache_get($cache_key_attempts, self::CACHE_GROUP);
        
        if (false === $attempts) {
            // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
            // This query is properly cached and only executed when cache is empty
            $attempts = (int) $wpdb->get_var( $wpdb->prepare(
                "SELECT attempt_count FROM `" . esc_sql( $this->table_name ) . "` WHERE ip_address = %s",
                $ip
            ) );
            // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
            wp_cache_set($cache_key_attempts, $attempts, self::CACHE_GROUP, self::CACHE_EXPIRY);
        } else {
            $attempts = (int) $attempts;
        }

        $attempts++;

        $max_retries = (int) get_option( self::OPTION_MAX_RETRIES, 5 );
        $lockout_duration_minutes = (int) get_option( self::OPTION_LOCKOUT_DURATION, 15 );

        $data = [
            'attempt_count' => $attempts,
            'last_attempt' => current_time( 'mysql', true ) // GMT time
        ];
        $where = ['ip_address' => $ip];
        $format = ['%d', '%s'];
        $where_format = ['%s'];

        if ( $attempts >= $max_retries ) {
            // Lock the IP
            $expiry_time = time() + ( $lockout_duration_minutes * MINUTE_IN_SECONDS );
            $data['lockout_expiry'] = gmdate( 'Y-m-d H:i:s', $expiry_time );
            $data['attempt_count'] = 0; // Reset attempts on lockout
            $format = ['%d', '%s', '%s']; // Add format for lockout_expiry

            if ($this->activity_log) {
                $this->activity_log->add_log_entry(
                    'login_lockout_initiated',
                    [
                        'username'   => $username, // Username is passed to this hook
                        'ip_address' => $ip,
                        'details'    => [
                            'lockout_expiry' => gmdate('Y-m-d H:i:s', $expiry_time),
                            'max_retries' => $max_retries
                        ]
                    ]
                );
            }
        }

        // Use INSERT ... ON DUPLICATE KEY UPDATE to handle race conditions atomically
        $data['ip_address'] = $ip; // Add IP for insert
        
        // Build the INSERT ... ON DUPLICATE KEY UPDATE query using static column names
        // to satisfy WordPress Plugin Checker requirements
        $escaped_table_name = esc_sql($this->table_name);
        
        // Determine which columns to update based on whether we're locking out
        if (isset($data['lockout_expiry'])) {
            // Lockout case: update attempt_count, last_attempt, and lockout_expiry
            // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            // This is a necessary database operation for security functionality using atomic upsert
            // SQL is passed directly to $wpdb->query() without intermediate variable storage
            // Table names cannot be parameterized in prepared statements - they must be escaped with esc_sql()
            // The table name is internally generated using $wpdb->prefix and is not user-controlled
            $result = $wpdb->query($wpdb->prepare(
                "INSERT INTO `{$escaped_table_name}` (`ip_address`, `attempt_count`, `last_attempt`, `lockout_expiry`) VALUES (%s, %d, %s, %s) ON DUPLICATE KEY UPDATE `attempt_count` = VALUES(`attempt_count`), `last_attempt` = VALUES(`last_attempt`), `lockout_expiry` = VALUES(`lockout_expiry`)",
                $data['ip_address'],
                $data['attempt_count'],
                $data['last_attempt'],
                $data['lockout_expiry']
            ));
            // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        } else {
            // Normal case: update attempt_count and last_attempt only
            // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            // This is a necessary database operation for security functionality using atomic upsert
            // SQL is passed directly to $wpdb->query() without intermediate variable storage
            // Table names cannot be parameterized in prepared statements - they must be escaped with esc_sql()
            // The table name is internally generated using $wpdb->prefix and is not user-controlled
            $result = $wpdb->query($wpdb->prepare(
                "INSERT INTO `{$escaped_table_name}` (`ip_address`, `attempt_count`, `last_attempt`) VALUES (%s, %d, %s) ON DUPLICATE KEY UPDATE `attempt_count` = VALUES(`attempt_count`), `last_attempt` = VALUES(`last_attempt`)",
                $data['ip_address'],
                $data['attempt_count'],
                $data['last_attempt']
            ));
            // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        }
        
        if ( false === $result ) {
            if ( function_exists( 'Guard_Dog' ) && Guard_Dog()->debug && Guard_Dog()->debug->is_enabled() ) {
                Guard_Dog()->debug->error(
                    'Failed to upsert lockout record for IP: ' . $ip,
                    'limit_login_attempts',
                    [
                        'ip' => $ip,
                        'db_error' => $wpdb->last_error,
                        'data' => $data
                    ]
                );
            }
        }

        // Clear cache after database changes (only if operation was successful)
        if ( $result !== false ) {
            wp_cache_delete('lockout_expiry_' . md5($ip), self::CACHE_GROUP);
            wp_cache_delete('current_lockout_' . md5($ip), self::CACHE_GROUP);
            wp_cache_delete('attempt_count_' . md5($ip), self::CACHE_GROUP);
            wp_cache_delete('row_exists_' . md5($ip), self::CACHE_GROUP);
        }
    }

    /**
     * Handles a successful login using the custom DB table.
     *
     * @since 1.6.0
     * @param string  $user_login The user's login name.
     * @param WP_User $user       The logged-in user object.
     */
    public function handle_successful_login( $user_login, $user ) {
        $ip = Guard_Dog_Utils::get_user_ip();
        if ( ! $ip ) {
            return;
        }
        $this->clear_lockout( $ip );
    }

    /**
     * Helper function to clear lockout/attempts for an IP.
     *
     * @since 1.6.0
     * @param string $ip The IP address to clear.
     */
    private function clear_lockout( $ip ) {
        global $wpdb;
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
        // This is a necessary database delete operation for security functionality
        $wpdb->delete( $this->table_name, [ 'ip_address' => $ip ], [ '%s' ] );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
        
        // Clear all cache entries for this IP
        wp_cache_delete('lockout_expiry_' . md5($ip), self::CACHE_GROUP);
        wp_cache_delete('current_lockout_' . md5($ip), self::CACHE_GROUP);
        wp_cache_delete('attempt_count_' . md5($ip), self::CACHE_GROUP);
        wp_cache_delete('row_exists_' . md5($ip), self::CACHE_GROUP);
    }

    /**
     * Get the lockout status for an IP address
     *
     * @since 1.6.0
     * @param string $ip IP address
     * @return array|null Lockout data or null if not locked out
     */
    private function get_ip_lockout($ip) {
        // Use caching for the lockout data query
        $cache_key = 'ip_lockout_' . md5($ip);
        $lockout_data = wp_cache_get($cache_key, self::CACHE_GROUP);
        
        if (false === $lockout_data) {
            global $wpdb;
            // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
            // This query is properly cached and only executed when cache is empty
            $lockout_data = $wpdb->get_row($wpdb->prepare(
                "SELECT lockout_expiry FROM `" . esc_sql( $this->table_name ) . "` WHERE ip_address = %s",
                $ip
            ));
            // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
            wp_cache_set($cache_key, $lockout_data, self::CACHE_GROUP, self::CACHE_EXPIRY);
        }

        if ($lockout_data && $lockout_data->lockout_expiry) {
            $expiry_time = strtotime($lockout_data->lockout_expiry);
            if ($expiry_time > time()) {
                return [
                    'expiry' => $expiry_time,
                    'expiry_str' => $lockout_data->lockout_expiry
                ];
            }
        }

        return null;
    }

    /**
     * Get the remaining lockout time in seconds
     *
     * @since 1.6.0
     * @return int Remaining time in seconds
     */
    private function get_remaining_lockout_time() {
        $ip = Guard_Dog_Utils::get_user_ip();
        if (!$ip) {
            return 0;
        }

        $lockout = $this->get_ip_lockout($ip);
        if (!$lockout) {
            return 0;
        }

        return max(0, $lockout['expiry'] - time());
    }

} // End class Guard_Dog_Limit_Login_Attempts 