<?php
/**
 * Password Validator Utility
 *
 * Validates passwords against configurable policy requirements.
 *
 * @package GuardDog
 * @since 1.9.0
 */

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

class Guard_Dog_Password_Validator {

    /**
     * Validate password against policy.
     *
     * @since 1.9.0
     * @param string $password The password to validate.
     * @param string $username Optional username for checking.
     * @param string $email Optional email for checking.
     * @param int $user_id Optional user ID for password history checking.
     * @return WP_Error|true WP_Error on failure, true on success.
     */
    public static function validate_password($password, $username = '', $email = '', $user_id = 0) {
        $errors = new WP_Error();

        // Get policy settings
        $min_length = get_option('guard_dog_password_min_length', 12);
        $require_uppercase = get_option('guard_dog_password_require_uppercase', true);
        $require_lowercase = get_option('guard_dog_password_require_lowercase', true);
        $require_numbers = get_option('guard_dog_password_require_numbers', true);
        $require_special = get_option('guard_dog_password_require_special', true);
        $block_common = get_option('guard_dog_password_block_common', false);
        $block_username = get_option('guard_dog_password_block_username', true);
        $block_email = get_option('guard_dog_password_block_email', true);
        $reuse_limit = get_option('guard_dog_password_reuse_limit', 5);

        // Check minimum length
        if (strlen($password) < $min_length) {
            $errors->add(
                'password_too_short',
                sprintf(
                    // Translators: %d is the minimum password length.
                    __('Password must be at least %d characters long.', 'guard-dog'),
                    $min_length
                )
            );
        }

        // Check uppercase requirement
        if ($require_uppercase && !preg_match('/[A-Z]/', $password)) {
            $errors->add(
                'password_no_uppercase',
                __('Password must contain at least one uppercase letter.', 'guard-dog')
            );
        }

        // Check lowercase requirement
        if ($require_lowercase && !preg_match('/[a-z]/', $password)) {
            $errors->add(
                'password_no_lowercase',
                __('Password must contain at least one lowercase letter.', 'guard-dog')
            );
        }

        // Check numbers requirement
        if ($require_numbers && !preg_match('/[0-9]/', $password)) {
            $errors->add(
                'password_no_numbers',
                __('Password must contain at least one number.', 'guard-dog')
            );
        }

        // Check special characters requirement
        if ($require_special && !preg_match('/[^A-Za-z0-9]/', $password)) {
            $errors->add(
                'password_no_special',
                __('Password must contain at least one special character.', 'guard-dog')
            );
        }

        // Check username in password
        if ($block_username && !empty($username) && stripos($password, $username) !== false) {
            $errors->add(
                'password_contains_username',
                __('Password cannot contain your username.', 'guard-dog')
            );
        }

        // Check email in password
        if ($block_email && !empty($email)) {
            $email_local = explode('@', $email)[0];
            if (stripos($password, $email_local) !== false) {
                $errors->add(
                    'password_contains_email',
                    __('Password cannot contain your email address.', 'guard-dog')
                );
            }
        }

        // Check common passwords
        if ($block_common && self::is_common_password($password)) {
            $errors->add(
                'password_common',
                __('This password is too common. Please choose a different password.', 'guard-dog')
            );
        }

        // Check password history (reuse prevention)
        if ($reuse_limit > 0 && $user_id > 0) {
            if (self::is_password_in_history($password, $user_id, $reuse_limit)) {
                $errors->add(
                    'password_reused',
                    sprintf(
                        // Translators: %d is the number of previous passwords to avoid.
                        __('You cannot reuse any of your last %d passwords.', 'guard-dog'),
                        $reuse_limit
                    )
                );
            }
        }

        // Return errors if any, otherwise true
        if ($errors->has_errors()) {
            return $errors;
        }

        return true;
    }

    /**
     * Check if password meets minimum length requirement.
     *
     * @since 1.9.0
     * @param string $password The password to check.
     * @return bool True if meets requirement, false otherwise.
     */
    public static function check_min_length($password) {
        $min_length = get_option('guard_dog_password_min_length', 12);
        return strlen($password) >= $min_length;
    }

    /**
     * Check if password contains uppercase letters.
     *
     * @since 1.9.0
     * @param string $password The password to check.
     * @return bool True if contains uppercase, false otherwise.
     */
    public static function check_uppercase($password) {
        return preg_match('/[A-Z]/', $password) === 1;
    }

    /**
     * Check if password contains lowercase letters.
     *
     * @since 1.9.0
     * @param string $password The password to check.
     * @return bool True if contains lowercase, false otherwise.
     */
    public static function check_lowercase($password) {
        return preg_match('/[a-z]/', $password) === 1;
    }

    /**
     * Check if password contains numbers.
     *
     * @since 1.9.0
     * @param string $password The password to check.
     * @return bool True if contains numbers, false otherwise.
     */
    public static function check_numbers($password) {
        return preg_match('/[0-9]/', $password) === 1;
    }

    /**
     * Check if password contains special characters.
     *
     * @since 1.9.0
     * @param string $password The password to check.
     * @return bool True if contains special characters, false otherwise.
     */
    public static function check_special_chars($password) {
        return preg_match('/[^A-Za-z0-9]/', $password) === 1;
    }

    /**
     * Check if password is in common passwords list.
     *
     * @since 1.9.0
     * @param string $password The password to check.
     * @return bool True if common, false otherwise.
     */
    public static function check_common_passwords($password) {
        return self::is_common_password($password);
    }

    /**
     * Check if password contains username.
     *
     * @since 1.9.0
     * @param string $password The password to check.
     * @param string $username The username to check against.
     * @return bool True if contains username, false otherwise.
     */
    public static function check_username_in_password($password, $username) {
        if (empty($username)) {
            return false;
        }
        return stripos($password, $username) !== false;
    }

    /**
     * Check if password contains email.
     *
     * @since 1.9.0
     * @param string $password The password to check.
     * @param string $email The email to check against.
     * @return bool True if contains email, false otherwise.
     */
    public static function check_email_in_password($password, $email) {
        if (empty($email)) {
            return false;
        }
        $email_local = explode('@', $email)[0];
        return stripos($password, $email_local) !== false;
    }

    /**
     * Check if password is in user's password history.
     *
     * @since 1.9.0
     * @param string $password The password to check.
     * @param int $user_id The user ID.
     * @param int $limit Number of recent passwords to check.
     * @return bool True if password is in history, false otherwise.
     */
    public static function check_password_history($password, $user_id, $limit = 5) {
        return self::is_password_in_history($password, $user_id, $limit);
    }

    /**
     * Store password hash in history.
     *
     * @since 1.9.0
     * @param int $user_id The user ID.
     * @param string $password_hash The password hash to store.
     * @return bool Success status.
     */
    public static function store_password_history($user_id, $password_hash) {
        global $wpdb;

        if (empty($password_hash) || !$user_id) {
            return false;
        }

        $table_name = Guard_Dog()->db->get_password_history_table_name();
        $reuse_limit = get_option('guard_dog_password_reuse_limit', 5);

        // Insert new password hash
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery
        $result = $wpdb->insert(
            $table_name,
            [
                'user_id' => $user_id,
                'password_hash' => $password_hash,
                'created_at' => current_time('mysql'),
            ],
            ['%d', '%s', '%s']
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery

        if ($result === false) {
            return false;
        }

        // Clean up old password history entries (keep only last N+1 passwords to allow checking against N)
        if ($reuse_limit > 0) {
            // Get IDs to keep
            // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.NoCaching
            // Table names are validated and sanitized, then escaped with esc_sql() for Plugin Check compliance.
            // Caching is not appropriate for password history cleanup operations as they need to be real-time and accurate.
            // This is a necessary database query for security audit functionality.
            $escaped_table_name = esc_sql( $table_name );
            $keep_ids = $wpdb->get_col(
                $wpdb->prepare(
                    "SELECT id FROM $escaped_table_name 
                    WHERE user_id = %d 
                    ORDER BY created_at DESC 
                    LIMIT %d",
                    $user_id,
                    $reuse_limit + 1
                )
            );
            // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.NoCaching

            if (!empty($keep_ids)) {
                // Delete old entries by comparing timestamps instead of using IN clause
                // Get the oldest timestamp we want to keep
                // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.NoCaching
                // Table names are validated and sanitized, then escaped with esc_sql() for Plugin Check compliance.
                // Caching is not appropriate for password history cleanup operations as they need to be real-time and accurate.
                // This is a necessary database query for security audit functionality.
                $escaped_table_name = esc_sql( $table_name );
                $oldest_keep_id = min(array_map('intval', $keep_ids));
                $oldest_entry = $wpdb->get_row(
                    $wpdb->prepare("SELECT created_at FROM $escaped_table_name WHERE id = %d", $oldest_keep_id)
                );
                // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.NoCaching
                
                if ($oldest_entry) {
                    // Delete entries older than the oldest one we're keeping
                    // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.NoCaching
                    // Table names are validated and sanitized, then escaped with esc_sql() for Plugin Check compliance.
                    // Caching is not appropriate for password history cleanup operations as they need to be real-time and accurate.
                    // This is a necessary database delete operation for security audit functionality.
                    $escaped_table_name = esc_sql( $table_name );
                    $wpdb->query(
                        $wpdb->prepare(
                            "DELETE FROM $escaped_table_name 
                            WHERE user_id = %d 
                            AND created_at < %s",
                            $user_id,
                            $oldest_entry->created_at
                        )
                    );
                    // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.NoCaching
                }
            }
        }

        return true;
    }

    /**
     * Check if password is in common passwords list.
     *
     * @since 1.9.0
     * @param string $password The password to check.
     * @return bool True if common, false otherwise.
     */
    private static function is_common_password($password) {
        // List of common passwords (top 100 most common)
        $common_passwords = [
            'password', 'password123', '123456', '12345678', '123456789', '1234567890',
            'qwerty', 'abc123', 'monkey', '1234567', 'letmein', 'trustno1', 'dragon',
            'baseball', 'iloveyou', 'master', 'sunshine', 'ashley', 'bailey', 'passw0rd',
            'shadow', '123123', '654321', 'superman', 'qazwsx', 'michael', 'football',
            'welcome', 'jesus', 'ninja', 'mustang', 'password1', '123qwe', 'admin',
            'princess', 'qwerty123', 'solo', 'starwars', 'donald', 'access', 'flower',
            'hello', 'freedom', 'whatever', 'qwertyuiop', '123mudar', 'charlie', 'aa123456',
            'donald', 'password123', 'qwerty123', 'welcome123', 'admin123', 'letmein123',
            'trustno1', '123456789', 'sunshine', 'football', 'monkey123', 'dragon123',
        ];

        return in_array(strtolower($password), array_map('strtolower', $common_passwords), true);
    }

    /**
     * Check if password is in user's password history.
     *
     * @since 1.9.0
     * @param string $password The password to check.
     * @param int $user_id The user ID.
     * @param int $limit Number of recent passwords to check.
     * @return bool True if password is in history, false otherwise.
     */
    private static function is_password_in_history($password, $user_id, $limit = 5) {
        global $wpdb;

        $table_name = Guard_Dog()->db->get_password_history_table_name();

        // Get recent password hashes
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.NoCaching
        // Table names are validated and sanitized, then escaped with esc_sql() for Plugin Check compliance.
        // Caching is not appropriate for password history validation as it needs to be real-time and accurate.
        // This is a necessary database query for security validation functionality.
        $escaped_table_name = esc_sql( $table_name );
        $history = $wpdb->get_col(
            $wpdb->prepare(
                "SELECT password_hash FROM $escaped_table_name 
                WHERE user_id = %d 
                ORDER BY created_at DESC 
                LIMIT %d",
                $user_id,
                $limit
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.NoCaching

        // Check if password matches any historical hash
        foreach ($history as $old_hash) {
            if (wp_check_password($password, $old_hash)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Get the date when the user's password was last set.
     *
     * @since 1.9.1
     * @param int $user_id The user ID.
     * @return string|null MySQL datetime string or null if not found.
     */
    public static function get_password_set_date($user_id) {
        global $wpdb;

        if (!$user_id) {
            return null;
        }

        $table_name = Guard_Dog()->db->get_password_history_table_name();

        // Get most recent password entry
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.NoCaching
        // Table names are validated and sanitized, then escaped with esc_sql() for Plugin Check compliance.
        // Caching is not appropriate for password expiration checks as they need to be real-time and accurate.
        // This is a necessary database query for security functionality.
        $escaped_table_name = esc_sql( $table_name );
        $result = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT created_at FROM $escaped_table_name 
                WHERE user_id = %d 
                ORDER BY created_at DESC 
                LIMIT 1",
                $user_id
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.NoCaching

        // If no password history, fall back to user registration date
        if (empty($result)) {
            $user = get_userdata($user_id);
            if ($user && !empty($user->user_registered)) {
                return $user->user_registered;
            }
            return null;
        }

        return $result;
    }

    /**
     * Calculate expiration date based on password set date and expiration period.
     *
     * @since 1.9.1
     * @param string $set_date MySQL datetime string when password was set.
     * @param int $expiration_days Number of days until expiration.
     * @return string|null MySQL datetime string or null if invalid input.
     */
    public static function calculate_expiration_date($set_date, $expiration_days) {
        if (empty($set_date) || $expiration_days <= 0) {
            return null;
        }

        $set_timestamp = strtotime($set_date);
        if ($set_timestamp === false) {
            return null;
        }

        $expiration_timestamp = $set_timestamp + ($expiration_days * DAY_IN_SECONDS);
        return date('Y-m-d H:i:s', $expiration_timestamp);
    }

    /**
     * Check if a user's password is expired.
     *
     * @since 1.9.1
     * @param int $user_id The user ID.
     * @return bool True if expired, false otherwise.
     */
    public static function is_password_expired($user_id) {
        // Check if expiration is enabled
        $expiration_enabled = get_option('guard_dog_password_expiration_enabled', false);
        if (!$expiration_enabled) {
            return false;
        }

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

        $set_date = self::get_password_set_date($user_id);
        if (empty($set_date)) {
            return false; // Can't determine expiration without set date
        }

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

        $current_time = current_time('mysql');
        return strtotime($current_time) >= strtotime($expiration_date);
    }

} // End class Guard_Dog_Password_Validator

