<?php
/**
 * Password Policy Feature
 *
 * Enforces configurable password strength requirements.
 *
 * @package GuardDog
 * @since 1.9.0
 */

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

class Guard_Dog_Password_Policy {

    /**
     * Option key for password policy enabled setting.
     * @var string
     */
    const OPTION_ENABLED = 'guard_dog_password_policy_enabled';

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

    /**
     * Constructor.
     *
     * @since 1.9.0
     * @param Guard_Dog_Activity_Log|null $activity_log Instance of the activity logger.
     */
    public function __construct($activity_log = null) {
        $this->activity_log = $activity_log;
        $this->add_hooks();
    }

    /**
     * Adds the necessary WordPress hooks.
     *
     * @since 1.9.0
     */
    private function add_hooks() {
        // Only add hooks if feature is enabled
        if (!get_option(self::OPTION_ENABLED, false)) {
            return;
        }

        // Registration validation - priority 20 to run after email verification
        add_filter('registration_errors', [$this, 'validate_registration_password'], 20, 3);

        // Password reset validation
        add_filter('validate_password_reset', [$this, 'validate_password_reset'], 10, 2);

        // Profile update validation
        add_filter('user_profile_update_errors', [$this, 'validate_profile_password'], 10, 3);

        // Enqueue scripts for frontend and admin
        add_action('login_enqueue_scripts', [$this, 'enqueue_password_policy_scripts']);
        add_action('admin_enqueue_scripts', [$this, 'enqueue_password_policy_scripts']);

        // Customize password hint
        add_filter('password_hint', [$this, 'customize_password_hint']);

        // AJAX handlers for real-time validation
        add_action('wp_ajax_guard_dog_validate_password', [$this, 'ajax_validate_password']);
        add_action('wp_ajax_nopriv_guard_dog_validate_password', [$this, 'ajax_validate_password']);

        // Watch for password expiration being switched on. These must be registered
        // even while expiration is OFF - that is precisely the state it is turned on
        // from - so baselines can be stamped before the first expiry check runs.
        add_action('add_option_guard_dog_password_expiration_enabled', [$this, 'on_expiration_option_added'], 10, 2);
        add_action('update_option_guard_dog_password_expiration_enabled', [$this, 'on_expiration_option_updated'], 10, 3);

        // Password expiration hooks (only if expiration is enabled)
        if (get_option('guard_dog_password_expiration_enabled', false)) {
            // Track password changes
            add_action('wp_set_password', [$this, 'track_password_change'], 10, 3);

            // Give newly registered users a full expiration window; they have no
            // password history row yet.
            add_action('user_register', [$this, 'set_baseline_for_new_user']);

            // Seed baselines for pre-existing users, in batches. The scheduling call
            // also covers sites upgrading from before 1.9.50 that already had
            // expiration enabled, where no off -> on transition will ever fire.
            add_action('guard_dog_seed_password_baselines', [$this, 'run_baseline_seeding']);
            add_action('admin_init', [$this, 'continue_baseline_seeding_in_admin']);
            $this->maybe_schedule_baseline_seeding();

            // Check expiration on login
            add_filter('wp_authenticate_user', [$this, 'check_password_expiration_on_login'], 10, 2);

            // Handle inline password reset form
            add_action('login_init', [$this, 'handle_expired_password_reset_submission'], 5);
            add_action('login_init', [$this, 'maybe_render_expired_password_reset_form']);

            // Show warnings on login
            add_action('wp_login', [$this, 'check_password_expiration_warning'], 10, 2);

            // Schedule daily cron for expiration checks
            if (!wp_next_scheduled('guard_dog_check_password_expirations')) {
                wp_schedule_event(time(), 'daily', 'guard_dog_check_password_expirations');
            }
            add_action('guard_dog_check_password_expirations', [$this, 'check_all_password_expirations']);

            // Display session error messages on login page
            add_filter('login_message', [$this, 'display_session_error_message']);
            
            // Display password reset success messages
            add_action('admin_notices', [$this, 'display_password_reset_success_notice']);
        }
    }

    /**
     * Validate password during registration.
     *
     * @since 1.9.0
     * @param WP_Error $errors Registration errors.
     * @param string $sanitized_user_login Sanitized username.
     * @param string $user_email User email.
     * @return WP_Error Modified errors.
     */
    public function validate_registration_password($errors, $sanitized_user_login, $user_email) {
        // phpcs:disable WordPress.Security.NonceVerification.Missing
        // Nonce verification is not required in this context:
        // 1. This method is called from the registration_errors filter during WordPress registration processing
        // 2. WordPress core handles nonce verification for registration forms before this filter is called
        // 3. This is read-only access to POST data for validation purposes only
        // 4. All input is properly sanitized before use
        // 5. No privileged actions or data modifications occur based on this POST data
        
        // Get password from POST
        if (!isset($_POST['pass1']) || empty($_POST['pass1'])) {
            // phpcs:enable WordPress.Security.NonceVerification.Missing
            return $errors; // WordPress will handle empty password
        }

        $password = isset($_POST['pass1']) && is_string($_POST['pass1']) ? wp_unslash($_POST['pass1']) : '';
        if (!is_string($password)) {
            return $errors;
        }

        // Check if user role is exempt
        $role = isset($_POST['role']) ? sanitize_text_field(wp_unslash($_POST['role'])) : get_option('default_role');
        // phpcs:enable WordPress.Security.NonceVerification.Missing
        if ($this->is_role_exempt($role)) {
            return $errors;
        }

        // Validate password
        $validation_result = Guard_Dog_Password_Validator::validate_password(
            $password,
            $sanitized_user_login,
            $user_email
        );

        if (is_wp_error($validation_result)) {
            // Add all errors from validation
            foreach ($validation_result->get_error_codes() as $code) {
                $errors->add($code, $validation_result->get_error_message($code));
            }

            // Log violation
            if ($this->activity_log) {
                $this->activity_log->add_log_entry('password_policy_violation', [
                    'username' => $sanitized_user_login,
                    'ip_address' => Guard_Dog_Utils::get_client_ip(),
                    'details' => 'Registration attempt with weak password',
                ]);
            }
        }

        return $errors;
    }

    /**
     * Validate password during password reset.
     *
     * @since 1.9.0
     * @param WP_Error $errors Validation errors.
     * @param WP_User|WP_Error $user User object or error.
     * @return WP_Error Modified errors.
     */
    public function validate_password_reset($errors, $user) {
        // If user is invalid, don't interfere
        if (is_wp_error($user)) {
            return $errors;
        }

        // phpcs:disable WordPress.Security.NonceVerification.Missing
        // Nonce verification is not required in this context:
        // 1. This method is called from the validate_password_reset filter during WordPress password reset processing
        // 2. WordPress core handles nonce verification for password reset forms before this filter is called
        // 3. This is read-only access to POST data for validation purposes only
        // 4. All input is properly sanitized before use
        // 5. No privileged actions or data modifications occur based on this POST data
        
        // Get password from POST
        if (!isset($_POST['pass1']) || empty($_POST['pass1'])) {
            // phpcs:enable WordPress.Security.NonceVerification.Missing
            return $errors; // WordPress will handle empty password
        }

        $password = isset($_POST['pass1']) && is_string($_POST['pass1']) ? wp_unslash($_POST['pass1']) : '';
        if (!is_string($password)) {
            return $errors;
        }
        // phpcs:enable WordPress.Security.NonceVerification.Missing

        // Check if user role is exempt
        if ($this->is_role_exempt($user->roles[0] ?? '')) {
            return $errors;
        }

        // Validate password
        $validation_result = Guard_Dog_Password_Validator::validate_password(
            $password,
            $user->user_login,
            $user->user_email,
            $user->ID
        );

        if (is_wp_error($validation_result)) {
            // Add all errors from validation
            foreach ($validation_result->get_error_codes() as $code) {
                $errors->add($code, $validation_result->get_error_message($code));
            }

            // Log violation
            if ($this->activity_log) {
                $this->activity_log->add_log_entry('password_policy_violation', [
                    'user_id' => $user->ID,
                    'username' => $user->user_login,
                    'ip_address' => Guard_Dog_Utils::get_client_ip(),
                    'details' => 'Password reset attempt with weak password',
                ]);
            }
        } else {
            // Password is valid - it will be stored after successful reset
            add_action('after_password_reset', [$this, 'store_password_after_reset'], 10, 2);
        }

        return $errors;
    }

    /**
     * Store password in history after reset.
     *
     * @since 1.9.0
     * @param WP_User $user The user object.
     * @param string $new_pass The new password.
     * @return void
     */
    public function store_password_after_reset($user, $new_pass) {
        if (empty($new_pass) || !$user || !$user->ID) {
            return;
        }

        // Get the hashed password from user data
        $user_data = get_userdata($user->ID);
        if (!$user_data || empty($user_data->user_pass)) {
            return;
        }

        // Check if password was expired before storing new one
        $was_expired = Guard_Dog_Password_Validator::is_password_expired($user->ID);

        // Store the actual password hash used by WordPress
        Guard_Dog_Password_Validator::store_password_history($user->ID, $user_data->user_pass);

        if ($this->activity_log) {
            if ($was_expired) {
                $this->activity_log->add_log_entry('password_expiration_reset', [
                    'user_id' => $user->ID,
                    'username' => $user->user_login,
                ]);
            } else {
                $this->activity_log->add_log_entry('password_policy_enforced', [
                    'user_id' => $user->ID,
                    'username' => $user->user_login,
                ]);
            }
        }
    }

    /**
     * Validate password during profile update.
     *
     * @since 1.9.0
     * @param WP_Error $errors Update errors.
     * @param bool $update Whether this is an update operation.
     * @param WP_User $user User object.
     * @return WP_Error Modified errors.
     */
    public function validate_profile_password($errors, $update, $user) {
        // phpcs:disable WordPress.Security.NonceVerification.Missing
        // Nonce verification is not required in this context:
        // 1. This method is called from the user_profile_update_errors filter during WordPress profile update processing
        // 2. WordPress core handles nonce verification for profile update forms before this filter is called
        // 3. This is read-only access to POST data for validation purposes only
        // 4. All input is properly sanitized before use
        // 5. No privileged actions or data modifications occur based on this POST data
        
        // Only validate if password is being changed
        if (!isset($_POST['pass1']) || empty($_POST['pass1'])) {
            // phpcs:enable WordPress.Security.NonceVerification.Missing
            return $errors; // No password change
        }

        $password = isset($_POST['pass1']) && is_string($_POST['pass1']) ? wp_unslash($_POST['pass1']) : '';
        if (!is_string($password)) {
            return $errors;
        }

        // Check if user role is exempt
        if ($update && isset($user->roles) && is_array($user->roles) && !empty($user->roles)) {
            if ($this->is_role_exempt($user->roles[0])) {
                // phpcs:enable WordPress.Security.NonceVerification.Missing
                return $errors;
            }
        }

        // Get username and email from user object or POST
        $username = $update ? $user->user_login : (isset($_POST['user_login']) ? sanitize_text_field(wp_unslash($_POST['user_login'])) : '');
        $email = $update ? $user->user_email : (isset($_POST['email']) ? sanitize_email(wp_unslash($_POST['email'])) : '');
        // phpcs:enable WordPress.Security.NonceVerification.Missing
        $user_id = $update ? $user->ID : 0;

        // Validate password
        $validation_result = Guard_Dog_Password_Validator::validate_password(
            $password,
            $username,
            $email,
            $user_id
        );

        if (is_wp_error($validation_result)) {
            // Add all errors from validation
            foreach ($validation_result->get_error_codes() as $code) {
                $errors->add($code, $validation_result->get_error_message($code));
            }

            // Log violation
            if ($this->activity_log) {
                $this->activity_log->add_log_entry('password_policy_violation', [
                    'user_id' => $user_id,
                    'username' => $username,
                    'ip_address' => Guard_Dog_Utils::get_client_ip(),
                    'details' => 'Profile update attempt with weak password',
                ]);
            }
        } else {
            // Password is valid - store in history after password is actually changed
            if ($update && $user_id > 0) {
                add_action('profile_update', [$this, 'store_password_after_profile_update'], 20, 2);
            }
        }

        return $errors;
    }

    /**
     * Check if role is exempt from password policy.
     *
     * @since 1.9.0
     * @param string $role User role.
     * @return bool True if exempt, false otherwise.
     */
    private function is_role_exempt($role) {
        $exempt_roles = get_option('guard_dog_password_policy_exempt_roles', []);
        if (!is_array($exempt_roles)) {
            return false;
        }
        return in_array($role, $exempt_roles, true);
    }

    /**
     * Enqueue password policy scripts.
     *
     * @since 1.9.0
     * @param string|null $hook Current admin page hook (null when called from login_enqueue_scripts).
     * @return void
     */
    public function enqueue_password_policy_scripts($hook = null) {
        // Only enqueue on relevant pages
        $allowed_hooks = ['profile.php', 'user-edit.php', 'user-new.php'];
        
        // If hook is provided and it's an allowed admin page, enqueue
        if (!empty($hook) && in_array($hook, $allowed_hooks, true)) {
            $this->enqueue_password_policy_assets();
            return;
        }
        
        // If hook is null/empty, we're on the login page (login_enqueue_scripts doesn't pass a hook)
        // Enqueue assets on all login pages - JavaScript will determine which forms to enhance
        if (empty($hook)) {
            $this->enqueue_password_policy_assets();
        }
    }

    /**
     * Enqueue password policy CSS and JavaScript assets.
     *
     * @since 1.9.0
     * @return void
     */
    private function enqueue_password_policy_assets() {

        wp_enqueue_script(
            'guard-dog-password-policy',
            GUARD_DOG_URL . 'assets/js/guard-dog-password-policy.js',
            ['jquery', 'password-strength-meter'],
            GUARD_DOG_VERSION,
            true
        );

        wp_enqueue_style(
            'guard-dog-password-policy',
            GUARD_DOG_URL . 'assets/css/guard-dog-user-registration.css',
            [],
            GUARD_DOG_VERSION
        );

        // Pass policy settings to JavaScript
        wp_localize_script('guard-dog-password-policy', 'guardDogPasswordPolicy', [
            'minLength' => get_option('guard_dog_password_min_length', 12),
            'requireUppercase' => get_option('guard_dog_password_require_uppercase', true),
            'requireLowercase' => get_option('guard_dog_password_require_lowercase', true),
            'requireNumbers' => get_option('guard_dog_password_require_numbers', true),
            'requireSpecial' => get_option('guard_dog_password_require_special', true),
            'blockUsername' => get_option('guard_dog_password_block_username', true),
            'blockEmail' => get_option('guard_dog_password_block_email', true),
            'ajaxUrl' => admin_url('admin-ajax.php'),
            'nonce' => wp_create_nonce('guard_dog_validate_password'),
        ]);
    }

    /**
     * Customize password hint.
     *
     * @since 1.9.0
     * @param string $hint Default password hint.
     * @return string Modified password hint.
     */
    public function customize_password_hint($hint) {
        $requirements = [];

        $min_length = get_option('guard_dog_password_min_length', 12);
        // Translators: %d is the minimum password length.
        $requirements[] = sprintf(__('At least %d characters', 'guard-dog'), $min_length);

        if (get_option('guard_dog_password_require_uppercase', true)) {
            $requirements[] = __('One uppercase letter', 'guard-dog');
        }

        if (get_option('guard_dog_password_require_lowercase', true)) {
            $requirements[] = __('One lowercase letter', 'guard-dog');
        }

        if (get_option('guard_dog_password_require_numbers', true)) {
            $requirements[] = __('One number', 'guard-dog');
        }

        if (get_option('guard_dog_password_require_special', true)) {
            $requirements[] = __('One special character', 'guard-dog');
        }

        if (!empty($requirements)) {
            return __('Hint: The password should be ', 'guard-dog') . implode(', ', $requirements) . '.';
        }

        return $hint;
    }

    /**
     * AJAX handler for real-time password validation.
     *
     * @since 1.9.0
     * @return void
     */
    public function ajax_validate_password() {
        // Verify nonce
        if (!isset($_POST['nonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'])), 'guard_dog_validate_password')) {
            wp_send_json_error(['message' => __('Security check failed.', 'guard-dog')]);
        }

        // Rate limiting (per-IP) to prevent policy probing and user enumeration
        $ip = class_exists('Guard_Dog_Utils') ? Guard_Dog_Utils::get_client_ip() : ($_SERVER['REMOTE_ADDR'] ?? '');
        $rate_key = 'guard_dog_validate_password_attempts_' . md5($ip);
        $attempts = (int) get_transient($rate_key);
        if ($attempts >= 20) {
            wp_send_json_error(['message' => __('Too many requests. Please try again later.', 'guard-dog')]);
        }
        set_transient($rate_key, $attempts + 1, 15 * MINUTE_IN_SECONDS);

        $password = isset($_POST['password']) && is_string($_POST['password']) ? wp_unslash($_POST['password']) : '';
        $username = isset($_POST['username']) ? sanitize_text_field(wp_unslash($_POST['username'])) : '';
        $email = isset($_POST['email']) ? sanitize_email(wp_unslash($_POST['email'])) : '';
        $user_id = isset($_POST['user_id']) ? absint($_POST['user_id']) : 0;

        if (empty($password)) {
            wp_send_json_error(['message' => __('Password is required.', 'guard-dog')]);
        }

        $validation_result = Guard_Dog_Password_Validator::validate_password(
            $password,
            $username,
            $email,
            $user_id
        );

        if (is_wp_error($validation_result)) {
            // For unauthenticated or user_id=0, return generic message to avoid enumeration
            if (!is_user_logged_in() || $user_id === 0) {
                wp_send_json_error(['message' => __('Password does not meet requirements.', 'guard-dog')]);
            }
            $errors = [];
            foreach ($validation_result->get_error_codes() as $code) {
                $errors[$code] = $validation_result->get_error_message($code);
            }
            wp_send_json_error(['errors' => $errors]);
        } else {
            wp_send_json_success(['message' => __('Password meets all requirements.', 'guard-dog')]);
        }
    }

    /**
     * Store password in history after profile update.
     *
     * @since 1.9.0
     * @param int $user_id The user ID.
     * @param WP_User $old_user_data The old user data.
     * @return void
     */
    public function store_password_after_profile_update($user_id, $old_user_data) {
        // phpcs:disable WordPress.Security.NonceVerification.Missing
        // Nonce verification is not required in this context:
        // 1. This method is called from the profile_update action hook after WordPress has processed the form
        // 2. WordPress core handles nonce verification for profile update forms before this action is called
        // 3. This is read-only access to POST data to check if password was changed
        // 4. All input is properly sanitized before use
        // 5. No privileged actions or data modifications occur based on this POST data check
        
        // Check if password was actually changed
        if (!isset($_POST['pass1']) || empty($_POST['pass1'])) {
            // phpcs:enable WordPress.Security.NonceVerification.Missing
            return;
        }
        // phpcs:enable WordPress.Security.NonceVerification.Missing

        // Get the updated user data
        $user = get_userdata($user_id);
        if (!$user || empty($user->user_pass)) {
            return;
        }

        // Only store if password hash changed
        if ($old_user_data->user_pass === $user->user_pass) {
            return;
        }

        // Check if password was expired before storing new one
        // We check before storing because is_password_expired() uses the most recent entry in history
        $was_expired = Guard_Dog_Password_Validator::is_password_expired($user_id);

        // Store the actual password hash used by WordPress
        Guard_Dog_Password_Validator::store_password_history($user_id, $user->user_pass);

        if ($this->activity_log) {
            if ($was_expired) {
                $this->activity_log->add_log_entry('password_expiration_reset', [
                    'user_id' => $user_id,
                    'username' => $user->user_login,
                ]);
            } else {
                $this->activity_log->add_log_entry('password_policy_enforced', [
                    'user_id' => $user_id,
                    'username' => $user->user_login,
                ]);
            }
        }
    }

    /**
     * Track password change for expiration purposes.
     *
     * @since 1.9.1
     * @param string $password The plaintext password (not used, but required by hook).
     * @param int $user_id The user ID.
     * @param WP_User $old_user_data The old user data.
     * @return void
     */
    public function track_password_change($password, $user_id, $old_user_data) {
        // Password history is already stored by store_password_after_reset/store_password_after_profile_update
        // This hook ensures we track password changes from all sources (wp_set_password)
        $user = get_userdata($user_id);
        if ($user && !empty($user->user_pass)) {
            // Only store if password actually changed
            if ($old_user_data && $old_user_data->user_pass !== $user->user_pass) {
                Guard_Dog_Password_Validator::store_password_history($user_id, $user->user_pass);
            }
        }
    }

    /**
     * Handle password expiration being created in the "on" state.
     *
     * @since 1.9.50
     * @param string $option Option name.
     * @param mixed  $value  New value.
     * @return void
     */
    public function on_expiration_option_added($option, $value) {
        if (!empty($value)) {
            $this->start_baseline_seeding();
        }
    }

    /**
     * Handle the off -> on transition of password expiration.
     *
     * @since 1.9.50
     * @param mixed  $old_value Previous value.
     * @param mixed  $value     New value.
     * @param string $option    Option name.
     * @return void
     */
    public function on_expiration_option_updated($old_value, $value, $option = '') {
        if (empty($old_value) && !empty($value)) {
            $this->start_baseline_seeding();
        }
    }

    /**
     * Begin seeding password-age baselines.
     *
     * @since 1.9.50
     * @return void
     */
    private function start_baseline_seeding() {
        delete_option(Guard_Dog_Password_Validator::BASELINES_SEEDED_OPTION);
        $this->maybe_schedule_baseline_seeding();
    }

    /**
     * Schedule baseline seeding if it has not completed and is not already queued.
     *
     * @since 1.9.50
     * @return void
     */
    private function maybe_schedule_baseline_seeding() {
        if (get_option(Guard_Dog_Password_Validator::BASELINES_SEEDED_OPTION)) {
            return;
        }

        if (wp_next_scheduled('guard_dog_seed_password_baselines')) {
            return;
        }

        wp_schedule_single_event(time(), 'guard_dog_seed_password_baselines');
    }

    /**
     * Cron callback: seed a batch of password-age baselines.
     *
     * @since 1.9.50
     * @return void
     */
    public function run_baseline_seeding() {
        $this->seed_baseline_batch(500);
    }

    /**
     * Continue seeding from admin requests.
     *
     * Fallback for sites running with DISABLE_WP_CRON where the scheduled event
     * may never fire. Deliberately a small batch so admin requests stay fast.
     *
     * @since 1.9.50
     * @return void
     */
    public function continue_baseline_seeding_in_admin() {
        if (get_option(Guard_Dog_Password_Validator::BASELINES_SEEDED_OPTION)) {
            return;
        }

        $this->seed_baseline_batch(100);
    }

    /**
     * Seed one batch of baselines and queue a follow-up if more remain.
     *
     * @since 1.9.50
     * @param int $batch_size Users to process in this pass.
     * @return void
     */
    private function seed_baseline_batch($batch_size) {
        $result = Guard_Dog_Password_Validator::seed_password_baselines($batch_size);

        if (!empty($result['remaining'])) {
            if (!wp_next_scheduled('guard_dog_seed_password_baselines')) {
                wp_schedule_single_event(time() + MINUTE_IN_SECONDS, 'guard_dog_seed_password_baselines');
            }
            return;
        }

        update_option(Guard_Dog_Password_Validator::BASELINES_SEEDED_OPTION, '1');
    }

    /**
     * Stamp a baseline for a newly registered user.
     *
     * @since 1.9.50
     * @param int $user_id The new user ID.
     * @return void
     */
    public function set_baseline_for_new_user($user_id) {
        Guard_Dog_Password_Validator::set_password_baseline($user_id);
    }

    /**
     * Check if password is expired.
     *
     * @since 1.9.1
     * @param int $user_id The user ID.
     * @return bool True if expired, false otherwise.
     */
    public function check_password_expiration($user_id) {
        // Check if user role is exempt from expiration
        $user = get_userdata($user_id);
        if ($user && !empty($user->roles)) {
            $exempt_roles = get_option('guard_dog_password_expiration_exempt_roles', []);
            if (is_array($exempt_roles) && !empty($exempt_roles)) {
                foreach ($user->roles as $role) {
                    if (in_array($role, $exempt_roles, true)) {
                        return false; // User is exempt
                    }
                }
            }
        }

        return Guard_Dog_Password_Validator::is_password_expired($user_id);
    }

    /**
     * Get password age in days.
     *
     * @since 1.9.1
     * @param int $user_id The user ID.
     * @return int|null Number of days since password was set, or null if not found.
     */
    public function get_password_age($user_id) {
        $set_date = Guard_Dog_Password_Validator::get_password_set_date($user_id);
        if (empty($set_date)) {
            return null;
        }

        $set_timestamp = strtotime($set_date);
        $current_timestamp = current_time('timestamp');
        
        if ($set_timestamp === false) {
            return null;
        }

        $diff = $current_timestamp - $set_timestamp;
        return floor($diff / DAY_IN_SECONDS);
    }

    /**
     * Get days until password expiration.
     *
     * @since 1.9.1
     * @param int $user_id The user ID.
     * @return int|null Number of days until expiration, or null if not found. Negative if expired.
     */
    public function get_days_until_expiration($user_id) {
        $expiration_enabled = get_option('guard_dog_password_expiration_enabled', false);
        if (!$expiration_enabled) {
            return null;
        }

        $expiration_days = get_option('guard_dog_password_expiration_days', 90);
        if ($expiration_days <= 0) {
            return null;
        }

        $set_date = Guard_Dog_Password_Validator::get_password_set_date($user_id);
        if (empty($set_date)) {
            return null;
        }

        $expiration_date = Guard_Dog_Password_Validator::calculate_expiration_date($set_date, $expiration_days);
        if (empty($expiration_date)) {
            return null;
        }

        $expiration_timestamp = strtotime($expiration_date);
        $current_timestamp = current_time('timestamp');

        if ($expiration_timestamp === false) {
            return null;
        }

        $diff = $expiration_timestamp - $current_timestamp;
        return floor($diff / DAY_IN_SECONDS);
    }

    /**
     * Check if password is expiring soon (within warning period).
     *
     * @since 1.9.1
     * @param int $user_id The user ID.
     * @return bool True if expiring soon, false otherwise.
     */
    public function is_password_expiring_soon($user_id) {
        $days_until = $this->get_days_until_expiration($user_id);
        if ($days_until === null || $days_until < 0) {
            return false; // Already expired or can't determine
        }

        $warning_days = get_option('guard_dog_password_expiration_warning_days', 7);
        return $days_until <= $warning_days;
    }

    /**
     * Check if password is expired on login and redirect to inline reset form.
     *
     * Instead of returning an error, this method stores user data in a transient
     * and redirects to the login page where an inline password reset form is shown.
     *
     * @since 1.9.1
     * @param WP_User|WP_Error $user User object or error.
     * @param string $password User password (not used, but required by filter).
     * @return WP_User|WP_Error User object or error.
     */
    public function check_password_expiration_on_login($user, $password) {
        // If user is invalid, don't interfere
        if (is_wp_error($user)) {
            return $user;
        }

        if ($this->check_password_expiration($user->ID)) {
            // Password is expired - redirect to inline reset form instead of returning error

            // Log expiration event
            if ($this->activity_log) {
                $this->activity_log->add_log_entry('password_expired', [
                    'user_id' => $user->ID,
                    'username' => $user->user_login,
                    'ip_address' => Guard_Dog_Utils::get_client_ip(),
                ]);
            }

            // Store user data in transient for the reset form
            $transient_key = 'guard_dog_password_expired_user_' . hash( 'sha256', $user->user_login );

            // phpcs:disable WordPress.Security.NonceVerification.Missing
            // Nonce verification is not required in this context:
            // 1. This runs during WordPress authentication before user is logged in
            // 2. We are only reading POST data to preserve user preferences (remember me, redirect)
            // 3. All input is properly sanitized before use
            // 4. No privileged actions occur - we're just storing data for the reset form
            $remember_me = filter_input(INPUT_POST, 'rememberme', FILTER_SANITIZE_FULL_SPECIAL_CHARS);
            $redirect_to_param = filter_input(INPUT_POST, 'redirect_to', FILTER_SANITIZE_URL);
            // phpcs:enable WordPress.Security.NonceVerification.Missing

            $redirect_to = wp_validate_redirect($redirect_to_param ?: admin_url(), admin_url());

            $transient_data = [
                'user_id' => $user->ID,
                'username' => $user->user_login,
                'remember' => ($remember_me !== null),
                'redirect_to' => $redirect_to,
            ];

            // Store for 10 minutes
            set_transient($transient_key, $transient_data, 600);

            // Redirect to login page with state token (the login_init hook will render the reset form)
            $redirect_url = Guard_Dog_Utils::create_login_state_url(
                Guard_Dog_Utils::get_login_base_url(),
                array(
                    'type'     => 'password_expired',
                    'username' => $user->user_login,
                )
            );

            wp_safe_redirect($redirect_url);
            exit;
        }

        return $user;
    }

    /**
     * Render the inline password reset form if session flag is set.
     *
     * @since 1.9.1
     * @return void
     */
    public function maybe_render_expired_password_reset_form() {
        // Check for state-token-based password reset flow
        $login_state = Guard_Dog_Utils::consume_login_state();
        $state_username = null;
        $state_error    = null;

        if ( is_array( $login_state ) && isset( $login_state['type'] ) && $login_state['type'] === 'password_expired' ) {
            $state_username = isset( $login_state['username'] ) ? sanitize_user( $login_state['username'] ) : null;
            $state_error    = isset( $login_state['error'] ) ? sanitize_text_field( $login_state['error'] ) : null;
        }

        if (empty($state_username)) {
            return; // Not in password reset flow
        }

        // Get transient data
        $transient_key = 'guard_dog_password_expired_user_' . hash( 'sha256', $state_username );
        $transient_data = get_transient($transient_key);

        if (false === $transient_data) {
            // Transient expired - redirect with error via state token
            $login_url = Guard_Dog_Utils::create_login_state_url(
                Guard_Dog_Utils::get_login_base_url(),
                array( 'type' => 'error', 'error' => 'session_expired' ),
                60
            );
            wp_safe_redirect($login_url);
            exit;
        }

        // Set up WordPress login page environment
        if (!function_exists('login_header')) {
            require_once ABSPATH . 'wp-login.php';
        }

        // Set up global variables that login_header() expects
        global $error, $interim_login, $action;
        $error = null;
        $interim_login = false;
        $action = 'guard_dog_password_reset';

        // Check for error from state token
        $error_message = $state_error ? $state_error : '';

        $wp_error = null;
        if (!empty($error_message)) {
            $wp_error = new WP_Error('password_reset_error', $error_message);
        }

        // Enqueue password policy scripts
        $this->enqueue_password_policy_scripts();

        $GLOBALS['guard_dog_login_screen_state'] = 'password_expired';

        // Render the login header
        login_header(__('Password Expired', 'guard-dog'), '', $wp_error);

        // Render the password reset form
        Guard_Dog_View_Loader::render_partial('login-password-reset-form', [
            'user_id' => $transient_data['user_id'],
            'username' => $transient_data['username'],
            'redirect_to' => $transient_data['redirect_to'],
            'rememberme' => $transient_data['remember'],
        ]);

        login_footer();
        exit;
    }

    /**
     * Handle the expired password reset form submission.
     *
     * @since 1.9.1
     * @return void
     */
    public function handle_expired_password_reset_submission() {
        // phpcs:disable WordPress.Security.NonceVerification.Missing
        // 1. Context: Checking for password reset form submission on login page
        // 2. Security: Nonce is verified immediately after reading form data (line below)
        // 3. Sanitization: All input sanitized with sanitize_text_field/wp_unslash
        // 4. Justification: Must read POST fields to identify form submission before nonce check
        // 5. Nonce limitation: Cannot verify nonce without first reading the identifying field
        // 6. Risk assessment: Minimal - early return if not a password reset submission

        // Check if this is a password reset form submission
        if ( ! isset( $_POST['guard_dog_expired_password_reset'] ) || sanitize_text_field( wp_unslash( $_POST['guard_dog_expired_password_reset'] ) ) !== '1' ) {
            // phpcs:enable WordPress.Security.NonceVerification.Missing
            return; // Not a password reset form submission
        }

        // Get form data
        $username = isset( $_POST['log'] ) ? sanitize_text_field( wp_unslash( $_POST['log'] ) ) : '';
        $nonce    = isset( $_POST['guard_dog_password_reset_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['guard_dog_password_reset_nonce'] ) ) : '';
        $pass1    = isset( $_POST['pass1'] ) ? $_POST['pass1'] : ''; // Don't sanitize password
        $pass2    = isset( $_POST['pass2'] ) ? $_POST['pass2'] : ''; // Don't sanitize password
        // phpcs:enable WordPress.Security.NonceVerification.Missing

        if (empty($username)) {
            $this->redirect_with_reset_error(__('Invalid request.', 'guard-dog'));
            return;
        }

        // Get user
        $user = get_user_by('login', $username);
        if (!$user) {
            $this->redirect_with_reset_error(__('Invalid request.', 'guard-dog'));
            return;
        }

        // Verify nonce
        if (!wp_verify_nonce($nonce, 'guard_dog_password_reset_' . $user->ID)) {
            $this->redirect_with_reset_error(__('Security check failed. Please try again.', 'guard-dog'));
            return;
        }

        // Get transient data
        $transient_key = 'guard_dog_password_expired_user_' . hash( 'sha256', $username );
        $transient_data = get_transient($transient_key);

        if (false === $transient_data) {
            $this->redirect_with_reset_error(__('Your session has expired. Please log in again.', 'guard-dog'));
            return;
        }

        // Ensure transient belongs to the user from the form
        if ((int) ($transient_data['user_id'] ?? 0) !== (int) $user->ID) {
            $this->redirect_with_reset_error(__('Invalid request.', 'guard-dog'));
            return;
        }

        // Validate passwords match
        if ($pass1 !== $pass2) {
            $this->redirect_with_password_error($username, __('Passwords do not match.', 'guard-dog'));
        }

        // Validate password is not empty
        if (empty($pass1)) {
            $this->redirect_with_password_error($username, __('Please enter a new password.', 'guard-dog'));
        }

        // Validate password against policy
        $validation_result = Guard_Dog_Password_Validator::validate_password(
            $pass1,
            $user->user_login,
            $user->user_email,
            $user->ID
        );

        if (is_wp_error($validation_result)) {
            // Get the first error message
            $error_messages = $validation_result->get_error_messages();
            $error_message = !empty($error_messages) ? $error_messages[0] : __('Password does not meet requirements.', 'guard-dog');

            $this->redirect_with_password_error($username, $error_message);
        }

        // Password is valid - update it
        wp_set_password($pass1, $user->ID);

        // Store password in history
        $user_data = get_userdata($user->ID);
        if ($user_data && !empty($user_data->user_pass)) {
            Guard_Dog_Password_Validator::store_password_history($user->ID, $user_data->user_pass);
        }

        // Log the password reset
        if ($this->activity_log) {
            $this->activity_log->add_log_entry('password_expiration_reset', [
                'user_id' => $user->ID,
                'username' => $user->user_login,
                'ip_address' => Guard_Dog_Utils::get_client_ip(),
            ]);
        }

        // Clean up transient
        delete_transient($transient_key);

        // Check if user has 2FA enabled before attempting login
        $two_factor_auth = Guard_Dog()->features['two_factor_auth'] ?? null;
        $has_2fa = false;
        if ($two_factor_auth && method_exists($two_factor_auth, 'user_has_any_2fa_enabled')) {
            $has_2fa = $two_factor_auth->user_has_any_2fa_enabled($user);
        }

        // If 2FA is enabled, set a transient flag so we can show success message after 2FA completes
        if ($has_2fa) {
            set_transient('guard_dog_password_reset_pending_' . $user->ID, true, 60);
        }

        // Re-authenticate with new password to trigger 2FA if enabled
        $credentials = [
            'user_login' => $user->user_login,
            'user_password' => $pass1,
            'remember' => $transient_data['remember'],
        ];

        // Use wp_signon to go through full authentication chain (including 2FA)
        $signon_result = wp_signon($credentials, is_ssl());

        if (is_wp_error($signon_result)) {
            // If error (shouldn't happen with valid new password), redirect to login with success message
            // This handles edge cases where wp_signon fails but password was reset
            $redirect_url = Guard_Dog_Utils::create_login_state_url(
                Guard_Dog_Utils::get_login_base_url(),
                array( 'type' => 'password_reset_success' ),
                60
            );
            wp_safe_redirect($redirect_url);
            exit;
        }

        // If we get here, 2FA wasn't triggered, so complete the login automatically
        wp_set_current_user($user->ID);
        wp_set_auth_cookie($user->ID, $transient_data['remember']);

        // Fire WordPress login action for compatibility
        do_action('wp_login', $user->user_login, $user);

        // Set transient to show success message after redirect
        set_transient('guard_dog_password_reset_success_' . $user->ID, true, 30);

        // Redirect to original destination (validate to allow only safe redirects)
        $redirect_to = wp_validate_redirect($transient_data['redirect_to'] ?? admin_url(), admin_url());
        wp_safe_redirect($redirect_to);
        exit;
    }

    /**
     * Redirect to login page with password reset error message.
     *
     * @since 1.9.1
     * @param string $message Error message to display.
     * @return void
     */
    private function redirect_with_reset_error($message) {
        $redirect_url = Guard_Dog_Utils::create_login_state_url(
            Guard_Dog_Utils::get_login_base_url(),
            array( 'type' => 'error', 'error' => sanitize_text_field($message) ),
            60
        );
        wp_safe_redirect($redirect_url);
        exit;
    }

    /**
     * Redirect to login page with password expired error message.
     *
     * Redirects back to the inline password reset form with an error
     * message passed via a state token.
     *
     * @since 1.9.20
     * @param string $username The username for the password expired flow.
     * @param string $error_message Error message to display.
     * @return void
     */
    private function redirect_with_password_error($username, $error_message) {
        $redirect_url = Guard_Dog_Utils::create_login_state_url(
            Guard_Dog_Utils::get_login_base_url(),
            array(
                'type'     => 'password_expired',
                'username' => $username,
                'error'    => $error_message,
            ),
            60
        );
        wp_safe_redirect($redirect_url);
        exit;
    }

    /**
     * Display session-based error messages on the login page.
     *
     * @since 1.9.1
     * @param string $message Existing login message.
     * @return string Modified login message.
     */
    public function display_session_error_message($message) {
        $login_state = Guard_Dog_Utils::consume_login_state();

        if ( is_array( $login_state ) && isset( $login_state['type'] ) ) {
            if ( $login_state['type'] === 'error' && ! empty( $login_state['error'] ) ) {
                $error_message = sanitize_text_field( $login_state['error'] );
                $message .= '<div id="login_error">' . esc_html( $error_message ) . '</div>';
            }

            if ( $login_state['type'] === 'password_reset_success' ) {
                $success_message = __( 'Password reset successfully. Please log in with your new password.', 'guard-dog' );
                $message .= '<div id="login_error" class="message">' . esc_html( $success_message ) . '</div>';
            }
        }

        return $message;
    }

    /**
     * Display password reset success notice in admin area.
     *
     * @since 1.9.1
     * @return void
     */
    public function display_password_reset_success_notice() {
        if (!is_user_logged_in()) {
            return;
        }

        $user_id = get_current_user_id();
        $transient_key = 'guard_dog_password_reset_success_' . $user_id;
        
        if (get_transient($transient_key)) {
            delete_transient($transient_key);
            ?>
            <div class="notice notice-success is-dismissible">
                <p><?php esc_html_e('Password reset successfully. Your password has been updated.', 'guard-dog'); ?></p>
            </div>
            <?php
        }
    }

    /**
     * Check and show password expiration warning on login.
     *
     * @since 1.9.1
     * @param string $user_login User login name.
     * @param WP_User $user User object.
     * @return void
     */
    public function check_password_expiration_warning($user_login, $user) {
        // Check if password is expiring soon
        if (!$this->is_password_expiring_soon($user->ID)) {
            return;
        }

        // Use transient to prevent showing warning on every page load
        $transient_key = 'guard_dog_password_expiration_warning_' . $user->ID;
        if (get_transient($transient_key)) {
            return; // Already shown recently
        }

        // Set transient for 24 hours
        set_transient($transient_key, true, DAY_IN_SECONDS);

        $days_until = $this->get_days_until_expiration($user->ID);
        if ($days_until === null) {
            return;
        }

        // Show admin notice for admins, user notice for others
        if (current_user_can('manage_options')) {
            add_action('admin_notices', function() use ($days_until) {
                echo '<div class="notice notice-warning is-dismissible">';
                echo '<p>';
                // Translators: %d is the number of days until expiration.
                echo esc_html(sprintf(__('Your password will expire in %d day(s). Please change your password soon.', 'guard-dog'), $days_until));
                echo '</p>';
                echo '</div>';
            });
        } else {
            // For non-admin users, we'll show a notice on the dashboard
            add_action('admin_notices', function() use ($days_until) {
                echo '<div class="notice notice-warning is-dismissible">';
                echo '<p>';
                // Translators: %d is the number of days until expiration.
                echo esc_html(sprintf(__('Your password will expire in %d day(s). Please change your password soon.', 'guard-dog'), $days_until));
                echo ' <a href="' . esc_url(admin_url('profile.php')) . '">' . esc_html__('Change Password', 'guard-dog') . '</a>';
                echo '</p>';
                echo '</div>';
            });
        }
    }

    /**
     * Check all users for password expiration (cron job).
     *
     * @since 1.9.1
     * @return void
     */
    public function check_all_password_expirations() {
        $expiration_enabled = get_option('guard_dog_password_expiration_enabled', false);
        if (!$expiration_enabled) {
            return;
        }

        $email_enabled = get_option('guard_dog_password_expiration_email_enabled', false);
        if (!$email_enabled) {
            return; // Don't check if emails aren't enabled
        }

        // Get all users
        $users = get_users(['fields' => 'ID']);
        
        foreach ($users as $user_id) {
            // Check if expired
            if ($this->check_password_expiration($user_id)) {
                $this->send_expiration_notice_email($user_id);
            } elseif ($this->is_password_expiring_soon($user_id)) {
                // Check if we should send warning (only once per day)
                $warning_sent_key = 'guard_dog_password_expiration_warning_sent_' . $user_id;
                if (!get_transient($warning_sent_key)) {
                    $this->send_expiration_warning_email($user_id);
                    // Set transient for 24 hours to prevent duplicate emails
                    set_transient($warning_sent_key, true, DAY_IN_SECONDS);
                }
            }
        }
    }

    /**
     * Send password expiration warning email.
     *
     * @since 1.9.1
     * @param int $user_id The user ID.
     * @return bool True if sent successfully, false otherwise.
     */
    public function send_expiration_warning_email($user_id) {
        $user = get_userdata($user_id);
        if (!$user || empty($user->user_email)) {
            return false;
        }

        // Check if email provider is configured
        $email_provider = Guard_Dog()->features['email_provider'] ?? null;
        if (!$email_provider || !$email_provider->is_configured()) {
            return false; // Can't send without email provider
        }

        $days_until = $this->get_days_until_expiration($user_id);
        if ($days_until === null || $days_until < 0) {
            return false;
        }

        $expiration_date = Guard_Dog_Password_Validator::get_password_set_date($user_id);
        $expiration_days = get_option('guard_dog_password_expiration_days', 90);
        $expiration_timestamp = strtotime($expiration_date) + ($expiration_days * DAY_IN_SECONDS);
        $expiration_date_formatted = date_i18n(get_option('date_format'), $expiration_timestamp);

        $reset_link = wp_lostpassword_url();

        // Use email template customizer
        $customizer = new Guard_Dog_Email_Template_Customizer( array( 'template_type' => 'password-expiration' ) );

        // Generate email HTML using customizer
        $email_html = $customizer->generate_output([
            'user_name' => $user->display_name,
            'reset_link' => $reset_link,
            'days_until' => $days_until,
            'expiration_date' => $expiration_date_formatted,
            'is_expired' => false,
        ]);

        // Generate plain text version
        $plain_text = $customizer->generate_plain_text_output([
            'user_name' => $user->display_name,
            'reset_link' => $reset_link,
            'days_until' => $days_until,
            'expiration_date' => $expiration_date_formatted,
            'is_expired' => false,
        ]);

        $subject = $customizer->get_email_subject( array( 'user' => $user, 'site_name' => get_bloginfo( 'name' ), 'name' => $user->display_name, 'is_expired' => false ) );
        if ( $subject === null || $subject === '' ) {
            // Translators: %s is the site name.
            $subject = sprintf( __( '[%s] Password Expiring Soon', 'guard-dog' ), get_bloginfo( 'name' ) );
        }

        $result = $email_provider->send_email(
            $user->user_email,
            $subject,
            $email_html,
            $plain_text
        );

        if ($result['success'] && $this->activity_log) {
            $this->activity_log->add_log_entry('password_expiration_warning_sent', [
                'user_id' => $user_id,
                'username' => $user->user_login,
                'days_until' => $days_until,
            ]);
        }

        return $result['success'];
    }

    /**
     * Send password expiration notice email.
     *
     * @since 1.9.1
     * @param int $user_id The user ID.
     * @return bool True if sent successfully, false otherwise.
     */
    public function send_expiration_notice_email($user_id) {
        $user = get_userdata($user_id);
        if (!$user || empty($user->user_email)) {
            return false;
        }

        // Check if email provider is configured
        $email_provider = Guard_Dog()->features['email_provider'] ?? null;
        if (!$email_provider || !$email_provider->is_configured()) {
            return false; // Can't send without email provider
        }

        $reset_link = wp_lostpassword_url();

        // Use email template customizer
        $customizer = new Guard_Dog_Email_Template_Customizer( array( 'template_type' => 'password-expiration' ) );

        // Generate email HTML using customizer
        $email_html = $customizer->generate_output([
            'user_name' => $user->display_name,
            'reset_link' => $reset_link,
            'days_until' => 0,
            'expiration_date' => date_i18n(get_option('date_format')),
            'is_expired' => true,
        ]);

        // Generate plain text version
        $plain_text = $customizer->generate_plain_text_output([
            'user_name' => $user->display_name,
            'reset_link' => $reset_link,
            'days_until' => 0,
            'expiration_date' => date_i18n(get_option('date_format')),
            'is_expired' => true,
        ]);

        $subject = $customizer->get_email_subject( array( 'user' => $user, 'site_name' => get_bloginfo( 'name' ), 'name' => $user->display_name, 'is_expired' => true ) );
        if ( $subject === null || $subject === '' ) {
            // Translators: %s is the site name.
            $subject = sprintf( __( '[%s] Password Expired', 'guard-dog' ), get_bloginfo( 'name' ) );
        }

        $result = $email_provider->send_email(
            $user->user_email,
            $subject,
            $email_html,
            $plain_text
        );

        if ($result['success'] && $this->activity_log) {
            $this->activity_log->add_log_entry('password_expired_notice_sent', [
                'user_id' => $user_id,
                'username' => $user->user_login,
            ]);
        }

        return $result['success'];
    }

    /**
     * Manually trigger the password expiration check.
     *
     * Useful for testing without waiting for cron.
     *
     * @since 1.9.1
     * @return array Results of the manual trigger.
     */
    public function manual_trigger_expiration_check() {
        $results = [
            'triggered' => false,
            'users_checked' => 0,
            'warnings_sent' => 0,
            'notices_sent' => 0,
            'errors' => [],
        ];

        // Check prerequisites
        $expiration_enabled = get_option('guard_dog_password_expiration_enabled', false);
        if (!$expiration_enabled) {
            $results['errors'][] = __('Password expiration is not enabled', 'guard-dog');
            return $results;
        }

        $email_enabled = get_option('guard_dog_password_expiration_email_enabled', false);
        if (!$email_enabled) {
            $results['errors'][] = __('Expiration emails are not enabled', 'guard-dog');
            return $results;
        }

        $email_provider = Guard_Dog()->features['email_provider'] ?? null;
        if (!$email_provider || !$email_provider->is_configured()) {
            $results['errors'][] = __('Email provider is not configured', 'guard-dog');
            return $results;
        }

        $results['triggered'] = true;

        // Get all users
        $users = get_users(['fields' => 'ID']);
        $results['users_checked'] = count($users);

        foreach ($users as $user_id) {
            // Check if expired
            if ($this->check_password_expiration($user_id)) {
                if ($this->send_expiration_notice_email($user_id)) {
                    $results['notices_sent']++;
                }
            } elseif ($this->is_password_expiring_soon($user_id)) {
                // Check if we should send warning (only once per day)
                $warning_sent_key = 'guard_dog_password_expiration_warning_sent_' . $user_id;
                if (!get_transient($warning_sent_key)) {
                    if ($this->send_expiration_warning_email($user_id)) {
                        set_transient($warning_sent_key, true, DAY_IN_SECONDS);
                        $results['warnings_sent']++;
                    }
                }
            }
        }

        return $results;
    }

} // End class Guard_Dog_Password_Policy
