<?php

declare(strict_types=1);

namespace SecurWP\Modules\Scanner;

use SecurWP\Modules\Cloud\Services\CloudScanner;
use SecurWP\Modules\Scanner\Repository\IssueRepository;

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

class UserScanner  {
    private const CURSOR_OPTION_PREFIX = 'securwp_user_scan_cursor_';
    private const DEFAULT_TIME_LIMIT = 20;
    private const MAX_STALL_RUNS = 3;
    private const SCAN_STANDARD = 'standard';
    private const SCAN_DEEP = 'deep';
    private const USER_BATCH_SIZE = 120;

    /**
     * @var CloudScanner
     */
    private $service;
    private string $scan_id = '';
    private ?string $scan_type = null;

    public function __construct() {
        $this->service = new CloudScanner();
    }

    /**
     * Inject current scan context from controller.
     *
     * @param string|null $scan_id
     * @return void
     */
    public function set_scan_context( $scan_id = null ): void {
        $id = sanitize_key( (string) $scan_id );
        $this->scan_id = $id !== '' ? $id : '';
    }

    public function get_title() {
        return 'User & Accounts Audit';
    }

    /**
     * Resolve active scan id for cursor persistence.
     *
     * @return string
     */
    private function resolve_scan_id(): string {
        if ( $this->scan_id !== '' ) {
            return $this->scan_id;
        }

        if ( class_exists( '\SecurWP\Modules\Scanner\Queue\ScanQueue' ) ) {
            $queue = \SecurWP\Modules\Scanner\Queue\ScanQueue::get();
            if ( is_array( $queue ) && ! empty( $queue['id'] ) ) {
                $resolved = sanitize_key( (string) $queue['id'] );
                if ( $resolved !== '' ) {
                    $this->scan_id = $resolved;
                    return $this->scan_id;
                }
            }
        }

        return 'adhoc';
    }

    /**
     * Resolve the active scan type for this run.
     *
     * User audits need this to keep costlier checks, such as password
     * verification, confined to deep scans only.
     *
     * @return string
     */
    private function resolve_scan_type(): string {
        if ( $this->scan_type !== null ) {
            return $this->scan_type;
        }

        $resolved_type = self::SCAN_STANDARD;

        if ( class_exists( '\SecurWP\Modules\Scanner\Queue\ScanQueue' ) ) {
            $queue = null;
            $scan_id = $this->resolve_scan_id();

            if ( $scan_id !== '' && $scan_id !== 'adhoc' ) {
                $queue = \SecurWP\Modules\Scanner\Queue\ScanQueue::get_job( $scan_id );
            }

            if ( ! is_array( $queue ) ) {
                $queue = \SecurWP\Modules\Scanner\Queue\ScanQueue::get();
            }

            $candidate = is_array( $queue ) && isset( $queue['scan_type'] )
                ? sanitize_key( (string) $queue['scan_type'] )
                : '';

            if ( $candidate === self::SCAN_DEEP ) {
                $resolved_type = self::SCAN_DEEP;
            }
        }

        $this->scan_type = $resolved_type;
        return $this->scan_type;
    }

    /**
     * @param string $scan_id
     * @return string
     */
    private function get_cursor_option_key( string $scan_id ): string {
        return self::CURSOR_OPTION_PREFIX . sanitize_key( $scan_id );
    }

    private function load_cursor( string $scan_id ): array {
        $raw = get_option( $this->get_cursor_option_key( $scan_id ), [] );
        if ( ! is_array( $raw ) ) {
            $raw = [];
        }

        // Backward-compatible migration from page-based cursor.
        $after_user_id = isset( $raw['after_user_id'] ) ? (int) $raw['after_user_id'] : 0;
        if ( $after_user_id <= 0 && isset( $raw['last_user'] ) ) {
            $after_user_id = (int) $raw['last_user'];
        }

        $active_user_id = isset( $raw['active_user_id'] ) ? (int) $raw['active_user_id'] : 0;
        $dict_offset = isset( $raw['dict_offset'] ) ? (int) $raw['dict_offset'] : 0;
        $active_user_emitted = ! empty( $raw['active_user_emitted'] );
        $privileged_checked = ! empty( $raw['privileged_checked'] );
        $privileged_state = isset( $raw['privileged_state'] ) && is_array( $raw['privileged_state'] )
            ? $raw['privileged_state']
            : [
                'network_checked'    => false,
                'site_offset'        => 0,
                'site_id'            => 0,
                'last_umeta_id'      => 0,
                'hidden_checked'     => false,
                'hidden_before_umeta_id' => 0,
                'hidden_batch'       => [],
            ];
        $stall_count = isset( $raw['stall_count'] ) ? (int) $raw['stall_count'] : 0;

        $after_user_id = max( 0, $after_user_id );
        $active_user_id = max( 0, $active_user_id );
        $dict_offset = max( 0, $dict_offset );
        $stall_count = max( 0, $stall_count );

        if ( $active_user_id > 0 && $active_user_id <= $after_user_id ) {
            $active_user_id = 0;
            $dict_offset = 0;
            $active_user_emitted = false;
        }

        return [
            'after_user_id'       => $after_user_id,
            'active_user_id'      => $active_user_id,
            'dict_offset'         => $dict_offset,
            'active_user_emitted' => $active_user_emitted,
            'privileged_checked'  => $privileged_checked,
            'privileged_state'    => [
                'network_checked'    => ! empty( $privileged_state['network_checked'] ),
                'site_offset'        => max( 0, (int) ( $privileged_state['site_offset'] ?? 0 ) ),
                'site_id'            => max( 0, (int) ( $privileged_state['site_id'] ?? 0 ) ),
                'last_umeta_id'      => max( 0, (int) ( $privileged_state['last_umeta_id'] ?? 0 ) ),
                'hidden_checked'     => ! empty( $privileged_state['hidden_checked'] ),
                'hidden_before_umeta_id' => max( 0, (int) ( $privileged_state['hidden_before_umeta_id'] ?? 0 ) ),
                'hidden_batch'       => isset( $privileged_state['hidden_batch'] ) && is_array( $privileged_state['hidden_batch'] )
                    ? $privileged_state['hidden_batch']
                    : [],
            ],
            'stall_count'         => $stall_count,
        ];
    }

    private function save_cursor(
        string $scan_id,
        int $after_user_id,
        int $active_user_id,
        int $dict_offset,
        bool $active_user_emitted,
        bool $privileged_checked,
        array $privileged_state,
        int $stall_count
    ): void {
        $payload = [
            'after_user_id'       => max( 0, $after_user_id ),
            'active_user_id'      => max( 0, $active_user_id ),
            'dict_offset'         => max( 0, $dict_offset ),
            'active_user_emitted' => (bool) $active_user_emitted,
            'privileged_checked'  => (bool) $privileged_checked,
            'privileged_state'    => [
                'network_checked'    => ! empty( $privileged_state['network_checked'] ),
                'site_offset'        => max( 0, (int) ( $privileged_state['site_offset'] ?? 0 ) ),
                'site_id'            => max( 0, (int) ( $privileged_state['site_id'] ?? 0 ) ),
                'last_umeta_id'      => max( 0, (int) ( $privileged_state['last_umeta_id'] ?? 0 ) ),
                'hidden_checked'     => ! empty( $privileged_state['hidden_checked'] ),
                'hidden_before_umeta_id' => max( 0, (int) ( $privileged_state['hidden_before_umeta_id'] ?? 0 ) ),
                'hidden_batch'       => isset( $privileged_state['hidden_batch'] ) && is_array( $privileged_state['hidden_batch'] )
                    ? $privileged_state['hidden_batch']
                    : [],
            ],
            'stall_count'         => max( 0, $stall_count ),
            'updated_at'          => time(),
        ];

        update_option( $this->get_cursor_option_key( $scan_id ), $payload, false );
    }

    private function clear_cursor( string $scan_id ): void {
        delete_option( $this->get_cursor_option_key( $scan_id ) );
    }

    private function get_time_limit_seconds(): int {
        $max_exec = (int) ini_get( 'max_execution_time' );
        if ( $max_exec > 0 ) {
            $budget = $max_exec - 3;
            return max( 2, min( 30, $budget ) );
        }

        return self::DEFAULT_TIME_LIMIT;
    }

    private function build_user_audit_failure_issue( int $after_user_id, int $active_user_id, string $reason = 'scan_timeout', string $message = '' ): array {
        $reason = sanitize_key( $reason );
        if ( $reason === '' ) {
            $reason = 'scan_failed';
        }

        $stable_key = 'users_' . $reason;
        $data = [
            'reason'    => $reason,
            'component' => 'users',
            'last_user' => max( 0, $after_user_id ),
        ];
        if ( $active_user_id > 0 ) {
            $data['active_user'] = $active_user_id;
        }

        if ( $message === '' ) {
            $message = $reason === 'scan_timeout'
                ? 'User audit could not complete due to restrictive server execution limits. Increase PHP max_execution_time and run scan again.'
                : 'User audit failed before all account checks completed. Rerun the scan after the underlying service issue is resolved.';
        }

        return [
            'id'       => $stable_key,
            'hash'     => IssueRepository::generateHash( 'user', $stable_key ),
            'type'     => 'user',
            'status'   => $reason === 'scan_timeout' ? 'scan_timeout' : 'failed',
            'severity' => $reason === 'scan_timeout' ? 'low' : 'high',
            'message'  => $message,
            'file'     => 'System Users',
            'data'     => $data,
        ];
    }

    /**
     * Compute safe per-run password-check budget from execution window.
     *
     * @param int $time_limit_seconds
     * @return int
     */
    private function get_password_checks_budget( int $time_limit_seconds ): int {
        $derived = $time_limit_seconds * 120;
        return max( 250, min( 4000, $derived ) );
    }

    /**
     * Normalize cloud dictionary entries once per run.
     *
     * @param mixed $dictionary
     * @return array<int,string>
     */
    private function normalize_dictionary( $dictionary ): array {
        if ( ! is_array( $dictionary ) ) {
            return [];
        }

        $normalized = [];
        $seen = [];
        foreach ( $dictionary as $entry ) {
            if ( ! is_string( $entry ) ) {
                continue;
            }
            $candidate = trim( $entry );
            if ( $candidate === '' ) {
                continue;
            }
            if ( strlen( $candidate ) > 128 ) {
                continue;
            }
            if ( isset( $seen[ $candidate ] ) ) {
                continue;
            }
            $seen[ $candidate ] = true;
            $normalized[] = $candidate;
        }

        return $normalized;
    }

    /**
     * Resolve the current site context for per-user findings.
     *
     * @return int
     */
    private function get_user_issue_blog_id(): int {
        if ( function_exists( 'get_current_blog_id' ) ) {
            return max( 0, (int) get_current_blog_id() );
        }

        return 0;
    }

    /**
     * Build the canonical hash key used to collapse multiple findings for one account.
     *
     * @param int $blog_id
     * @param int $user_id
     * @return string
     */
    private function get_user_issue_hash( int $blog_id, int $user_id ): string {
        return IssueRepository::generateHash(
            'user',
            'user_account_risk_' . max( 0, $blog_id ) . '_' . max( 0, $user_id )
        );
    }

    /**
     * Expected WordPress profile metadata often missing from injected accounts.
     *
     * @param int $user_id
     * @return array<int,string>
     */
    private function get_missing_profile_meta( int $user_id ): array {
        $missing = [];
        foreach ( [ 'nickname', 'rich_editing', 'admin_color', 'show_admin_bar_front' ] as $key ) {
            $value = get_user_meta( $user_id, $key, true );
            if ( $value === '' || $value === null ) {
                $missing[] = $key;
            }
        }

        return $missing;
    }

    /**
     * Add canonical account context to user findings so the card can expand consistently.
     *
     * @param \WP_User $user
     * @param array    $roles
     * @param int      $blog_id
     * @param array    $missing_profile_meta
     * @return array<string,mixed>
     */
    private function build_user_issue_context( \WP_User $user, array $roles, int $blog_id, array $missing_profile_meta ): array {
        $registered = '';
        if ( isset( $user->user_registered ) && is_string( $user->user_registered ) ) {
            $registered = trim( $user->user_registered );
        }

        return [
            'user_id'              => (int) $user->ID,
            'user_login'           => (string) $user->user_login,
            'user_email'           => (string) $user->user_email,
            'roles'                => array_values( array_filter( $roles, 'is_string' ) ),
            'blog_id'              => max( 0, $blog_id ),
            'registered'           => $registered,
            'missing_profile_meta' => $missing_profile_meta,
            'evidence'             => [
                'missing_profile_meta' => $missing_profile_meta,
            ],
        ];
    }

    /**
     * Collapse repeated same-account findings emitted within one run call.
     *
     * @param array $issues
     * @return array
     */
    private function consolidate_user_issues( array $issues ): array {
        $rank = [
            'low'      => 0,
            'medium'   => 1,
            'high'     => 2,
            'critical' => 3,
        ];
        $reason_priority = [
            'privileged_creation_event_anomaly'     => 1000,
            'hidden_privileged_user'                => 900,
            'shadow_manage_capability'              => 850,
            'pattern_and_meta'                      => 800,
            'missing_meta'                          => 780,
            'malformed_privileged_capability_meta'  => 750,
            'inconsistent_privileged_user_level'    => 730,
            'weak_password'                         => 720,
            'pattern_match'                         => 700,
        ];

        $singletons = [];
        $grouped = [];

        foreach ( $issues as $issue ) {
            if ( ! is_array( $issue ) ) {
                continue;
            }

            $data = isset( $issue['data'] ) && is_array( $issue['data'] ) ? $issue['data'] : [];
            $user_id = isset( $data['user_id'] ) ? (int) $data['user_id'] : 0;
            $hash = isset( $issue['hash'] ) ? (string) $issue['hash'] : '';
            if ( $user_id <= 0 || $hash === '' ) {
                $singletons[] = $issue;
                continue;
            }

            $severity = strtolower( (string) ( $issue['severity'] ?? 'low' ) );
            $reason = sanitize_key( (string) ( $data['reason'] ?? '' ) );
            $score = ( $rank[ $severity ] ?? -1 ) * 10000 + ( $reason_priority[ $reason ] ?? 0 );

            if ( ! isset( $grouped[ $hash ] ) ) {
                $grouped[ $hash ] = [
                    'score'   => $score,
                    'issue'   => $issue,
                    'related' => $reason !== '' ? [ $reason ] : [],
                ];
                continue;
            }

            if ( $reason !== '' && ! in_array( $reason, $grouped[ $hash ]['related'], true ) ) {
                $grouped[ $hash ]['related'][] = $reason;
            }

            if ( $score > $grouped[ $hash ]['score'] ) {
                $grouped[ $hash ]['score'] = $score;
                $grouped[ $hash ]['issue'] = $issue;
            }
        }

        $collapsed = $singletons;
        foreach ( $grouped as $item ) {
            $issue = $item['issue'];
            $data = isset( $issue['data'] ) && is_array( $issue['data'] ) ? $issue['data'] : [];
            if ( ! empty( $item['related'] ) ) {
                $data['related_reasons'] = array_values( array_unique( $item['related'] ) );
                $issue['data'] = $data;
            }
            $collapsed[] = $issue;
        }

        return $collapsed;
    }

    /**
     * Prepare cloud patterns into regex and substring buckets.
     *
     * @param mixed $patterns
     * @return array{regex: array<int,string>, plain: array<int,string>}
     */
    private function prepare_patterns( $patterns ): array {
        if ( ! is_array( $patterns ) ) {
            return [ 'regex' => [], 'plain' => [] ];
        }

        $regex = [];
        $plain = [];
        foreach ( $patterns as $pattern ) {
            if ( ! is_string( $pattern ) ) {
                continue;
            }
            $value = trim( $pattern );
            if ( $value === '' ) {
                continue;
            }

            if ( strpos( $value, '/' ) === 0 ) {
                if ( @preg_match( $value, '' ) !== false ) {
                    $regex[] = $value;
                }
                continue;
            }

            $plain[] = strtolower( $value );
        }

        return [ 'regex' => $regex, 'plain' => array_values( array_unique( $plain ) ) ];
    }

    /**
     * Evaluate username against prepared pattern buckets.
     *
     * @param string $user_login
     * @param array{regex: array<int,string>, plain: array<int,string>} $prepared_patterns
     * @return bool
     */
    private function is_login_suspicious( string $user_login, array $prepared_patterns ): bool {
        foreach ( $prepared_patterns['regex'] as $regex ) {
            if ( @preg_match( $regex, $user_login ) ) {
                return true;
            }
        }

        $login_lower = strtolower( $user_login );
        foreach ( $prepared_patterns['plain'] as $needle ) {
            if ( $needle !== '' && $login_lower === $needle ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Build user-derived weak-password candidates (fast path).
     *
     * @param \WP_User $user
     * @return array<int,string>
     */
    private function get_derived_weak_candidates( \WP_User $user ): array {
        $values = [];
        $login = trim( (string) ( $user->user_login ?? '' ) );
        if ( $login !== '' ) {
            $values[] = $login;
            $values[] = strtolower( $login );
        }

        $email = trim( (string) ( $user->user_email ?? '' ) );
        if ( $email !== '' && strpos( $email, '@' ) !== false ) {
            $parts = explode( '@', $email, 2 );
            if ( ! empty( $parts[0] ) ) {
                $values[] = trim( (string) $parts[0] );
                $values[] = strtolower( trim( (string) $parts[0] ) );
            }
        }

        $filtered = [];
        $seen = [];
        foreach ( $values as $value ) {
            if ( $value === '' || strlen( $value ) > 128 ) {
                continue;
            }
            if ( isset( $seen[ $value ] ) ) {
                continue;
            }
            $seen[ $value ] = true;
            $filtered[] = $value;
        }

        return $filtered;
    }

    /**
     * Whether the user's stored hash uses bcrypt.
     *
     * Bcrypt verification costs ~100ms per call by design. Running a 500-entry
     * dictionary against a bcrypt hash takes ~50 seconds per user, which always
     * exceeds the time budget and triggers stall detection in deep scans.
     *
     * @param \WP_User $user
     * @return bool
     */
    private function uses_bcrypt_hash( \WP_User $user ): bool {
        $hash = (string) $user->user_pass;
        return strncmp( $hash, '$2y$', 4 ) === 0 || strncmp( $hash, '$2b$', 4 ) === 0;
    }

    /**
     * Determine whether a user should receive full cloud dictionary checks.
     *
     * Privileged roles (administrator, editor, super admin) get the full dictionary
     * pass during deep scans unless their password is bcrypt-hashed. Bcrypt makes
     * dictionary scanning impractical within any safe execution budget.
     *
     * @param \WP_User $user
     * @return bool
     */
    private function should_use_full_dictionary( \WP_User $user ): bool {
        $roles = is_array( $user->roles ) ? $user->roles : [];
        $is_privileged = in_array( 'administrator', $roles, true )
            || in_array( 'editor', $roles, true )
            || ( function_exists( 'is_super_admin' ) && is_super_admin( (int) $user->ID ) );

        if ( ! $is_privileged ) {
            return false;
        }

        // Skip dictionary for bcrypt-hashed privileged users: bcrypt cost makes
        // a full dictionary scan exceed any safe per-request time budget.
        return ! $this->uses_bcrypt_hash( $user );
    }

    /**
     * Fetch user IDs in ascending order using ID cursor.
     *
     * @param int $after_user_id
     * @param int $limit
     * @return array<int,int>
     */
    private function fetch_user_ids_after( int $after_user_id, int $limit ): array {
        global $wpdb;
        $users_table = self::table_identifier( $wpdb->users );
        $safe_limit = max( 1, $limit );
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
        $sql = $wpdb->prepare(
            "SELECT ID FROM {$users_table} WHERE ID > %d ORDER BY ID ASC LIMIT %d",
            max( 0, $after_user_id ),
            $safe_limit
        );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
        $ids = [];
        if ( is_string( $sql ) && '' !== $sql ) {
            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Cursor-based scanning reads user IDs directly from the users table in primary-key order.
            $ids = $wpdb->get_col( $sql );
        }
        if ( ! is_array( $ids ) ) {
            return [];
        }

        return array_values(
            array_filter(
                array_map( 'intval', $ids ),
                static fn( int $id ): bool => $id > 0
            )
        );
    }

    public function run() {
        $issues = [];
        $scan_id = $this->resolve_scan_id();
        $scan_type = $this->resolve_scan_type();
        $run_weak_password_checks = ( $scan_type === self::SCAN_DEEP );
        $cursor = $this->load_cursor( $scan_id );
        
        if ( ! class_exists( 'SecurWP\\Modules\\Scanner\\Repository\\IssueRepository' ) ) {
            require_once __DIR__ . '/Repository/IssueRepository.php';
        }
        
        $after_user_id = max( 0, (int) $cursor['after_user_id'] );
        $active_user_id = max( 0, (int) $cursor['active_user_id'] );
        $dict_offset = max( 0, (int) $cursor['dict_offset'] );
        $active_user_emitted = (bool) $cursor['active_user_emitted'];
        $stall_count = max( 0, (int) $cursor['stall_count'] );
        $privileged_checked = (bool) $cursor['privileged_checked'];
        $privileged_state = is_array( $cursor['privileged_state'] ) ? $cursor['privileged_state'] : [];
        $time_limit = $this->get_time_limit_seconds();

        if ( ! $privileged_checked ) {
            if ( ! class_exists( __NAMESPACE__ . '\\PrivilegedAccountInspector' ) ) {
                require_once __DIR__ . '/PrivilegedAccountInspector.php';
            }

            try {
                $privileged_start = time();
                $inspector = new PrivilegedAccountInspector();
                $privileged_result = $inspector->inspect( $privileged_state, $privileged_start, $time_limit );
            } catch ( \Throwable $e ) {
                securwp_log( 'SecurWP: Privileged account inspection failed: ' . sanitize_text_field( $e->getMessage() ) );
                $issues[] = $this->build_user_audit_failure_issue(
                    $after_user_id,
                    $active_user_id,
                    'privileged_account_scan_failed',
                    'Privileged account inspection failed before all account metadata could be checked. Rerun the scan after reviewing server error logs.'
                );
                $this->clear_cursor( $scan_id );
                $issues = $this->consolidate_user_issues( $issues );

                return [
                    'issues'   => $issues,
                    'count'    => count( $issues ),
                    'complete' => false,
                    'failed'   => true,
                ];
            }

            if ( ! empty( $privileged_result['issues'] ) && is_array( $privileged_result['issues'] ) ) {
                $issues = array_merge( $issues, $privileged_result['issues'] );
            }

            $privileged_state = isset( $privileged_result['state'] ) && is_array( $privileged_result['state'] )
                ? $privileged_result['state']
                : $privileged_state;

            if ( empty( $privileged_result['complete'] ) ) {
                $this->save_cursor(
                    $scan_id,
                    $after_user_id,
                    $active_user_id,
                    $dict_offset,
                    $active_user_emitted,
                    false,
                    $privileged_state,
                    $stall_count
                );
                $issues = $this->consolidate_user_issues( $issues );

                return [
                    'issues'   => $issues,
                    'count'    => count( $issues ),
                    'complete' => false,
                ];
            }

            $privileged_checked = true;
        }

        $patterns = $this->service->get_user_patterns();
        if ( is_wp_error( $patterns ) ) {
            securwp_log( 'SecurWP: User intelligence sync failed: ' . $patterns->get_error_message() );
            $issues[] = $this->build_user_audit_failure_issue(
                $after_user_id,
                $active_user_id,
                'user_intelligence_sync_failed',
                'User intelligence patterns could not be synchronized, so the user audit did not complete.'
            );
            $this->clear_cursor( $scan_id );
            $issues = $this->consolidate_user_issues( $issues );
            return [ 'issues' => $issues, 'count' => count( $issues ), 'complete' => false, 'failed' => true ];
        }

        $prepared_patterns = $this->prepare_patterns( $patterns );
        $weak_dictionary = [];
        $dictionary_count = 0;
        if ( $run_weak_password_checks ) {
            $dictionary = $this->service->get_password_dictionary();
            if ( is_wp_error( $dictionary ) ) {
                securwp_log( 'SecurWP: Password dictionary sync failed: ' . $dictionary->get_error_message() );
                $issues[] = $this->build_user_audit_failure_issue(
                    $after_user_id,
                    $active_user_id,
                    'password_dictionary_sync_failed',
                    'Password dictionary data could not be synchronized, so the user audit did not complete.'
                );
                $this->clear_cursor( $scan_id );
                $issues = $this->consolidate_user_issues( $issues );
                return [ 'issues' => $issues, 'count' => count( $issues ), 'complete' => false, 'failed' => true ];
            }

            $weak_dictionary = $this->normalize_dictionary( $dictionary );
            $dictionary_count = count( $weak_dictionary );
        }

        $start_time = time();
        $password_check_budget = $this->get_password_checks_budget( $time_limit );
        $password_checks_done = 0;
        $timed_out = false;
        $progress_units = 0;

        do {
            if ( time() - $start_time >= $time_limit ) {
                $timed_out = true;
                break;
            }

            $user_ids = $this->fetch_user_ids_after( $after_user_id, self::USER_BATCH_SIZE );
            if ( empty( $user_ids ) ) {
                $this->clear_cursor( $scan_id );
                break;
            }

            if ( function_exists( 'update_meta_cache' ) ) {
                update_meta_cache( 'user', $user_ids );
            }

            // Pre-warm is done; block new cache additions for the loop so user
            // objects loaded by get_userdata() don't accumulate in the runtime cache.
            $prev_suspended = function_exists( 'wp_suspend_cache_addition' ) ? wp_suspend_cache_addition() : false;
            if ( function_exists( 'wp_suspend_cache_addition' ) ) {
                wp_suspend_cache_addition( true );
            }

            foreach ( $user_ids as $user_id ) {
                if ( time() - $start_time >= $time_limit ) {
                    $timed_out = true;
                    break;
                }

                if ( $active_user_id > 0 && $user_id > $active_user_id && $dict_offset > 0 ) {
                    $active_user_id = 0;
                    $dict_offset = 0;
                    $active_user_emitted = false;
                }

                $is_resuming_active_user = ( $active_user_id > 0 && $active_user_id === $user_id );
                if ( ! $is_resuming_active_user ) {
                    $active_user_id = $user_id;
                    $dict_offset = 0;
                    $active_user_emitted = false;
                }

                $user_obj = get_userdata( $user_id );
                if ( ! ( $user_obj instanceof \WP_User ) ) {
                    $after_user_id = $user_id;
                    $active_user_id = 0;
                    $dict_offset = 0;
                    $active_user_emitted = false;
                    $progress_units++;
                    continue;
                }

                $is_admin = in_array( 'administrator', (array) $user_obj->roles, true );
                $use_full_dictionary = $this->should_use_full_dictionary( $user_obj );

                $user_roles = ( $user_obj && is_array( $user_obj->roles ) ) ? array_values( $user_obj->roles ) : [];
                $user_issue_blog_id = $this->get_user_issue_blog_id();
                $missing_profile_meta = $this->get_missing_profile_meta( $user_id );
                $user_meta = $this->build_user_issue_context(
                    $user_obj,
                    $user_roles,
                    $user_issue_blog_id,
                    $missing_profile_meta
                );

                $rule1_match = $this->is_login_suspicious( (string) $user_obj->user_login, $prepared_patterns );
                $rule2_match = false;
                $role_label  = $is_admin ? 'Administrator' : 'User';

                if ( $is_admin ) {
                    $missing_artifacts = count( $missing_profile_meta );

                    $has_name = get_user_meta( $user_id, 'first_name', true ) || get_user_meta( $user_id, 'last_name', true );
                    if ( ! $has_name ) {
                        $missing_artifacts++;
                    }

                    if ( $missing_artifacts >= 2 ) {
                        $rule2_match = true;
                    }
                }

                $user_issue_hash = $this->get_user_issue_hash( $user_issue_blog_id, $user_id );
                if ( ! $active_user_emitted ) {
                    if ( $rule1_match && $rule2_match ) {
                        $issues[] = [
                            'id'       => 'malicious_admin_' . $user_id,
                            'hash'     => $user_issue_hash,
                            'type'     => 'user',
                            'status'   => 'critical',
                            'severity' => 'critical',
                            'message'  => "Confirmed Malicious Identity: {$role_label} '{$user_obj->user_login}' matches suspicious patterns AND was likely created outside of WordPress.",
                            'file'     => 'User: ' . $user_obj->user_login,
                            'data'     => array_merge( $user_meta, [ 'reason' => 'pattern_and_meta' ] ),
                        ];
                    } elseif ( $rule2_match ) {
                        $issues[] = [
                            'id'       => 'script_admin_' . $user_id,
                            'hash'     => $user_issue_hash,
                            'type'     => 'user',
                            'status'   => 'critical',
                            'severity' => 'critical',
                            'message'  => "High Probability Script-Created User: {$role_label} '{$user_obj->user_login}' is missing standard WordPress metadata, indicating it was likely injected via script or database access.",
                            'file'     => 'User: ' . $user_obj->user_login,
                            'data'     => array_merge( $user_meta, [ 'reason' => 'missing_meta' ] ),
                        ];
                    } elseif ( $rule1_match ) {
                        $issues[] = [
                            'id'       => 'suspicious_user_' . $user_id,
                            'hash'     => $user_issue_hash,
                            'type'     => 'user',
                            'status'   => 'critical',
                            'severity' => 'high',
                            'message'  => "Suspicious Username: '{$user_obj->user_login}' matches known malicious username patterns.",
                            'file'     => 'User: ' . $user_obj->user_login,
                            'data'     => array_merge( $user_meta, [ 'reason' => 'pattern_match' ] ),
                        ];
                    }

                    $active_user_emitted = true;
                }

                $skip_weak_password = ( $is_admin && $rule1_match && $rule2_match );
                if ( $run_weak_password_checks && ! $skip_weak_password ) {
                    $weak_password_found = false;

                    if ( ! $use_full_dictionary && $dict_offset > 0 ) {
                        $dict_offset = 0;
                    }

                    if ( $dict_offset === 0 ) {
                        $derived_candidates = $this->get_derived_weak_candidates( $user_obj );
                        foreach ( $derived_candidates as $candidate ) {
                            if ( time() - $start_time >= $time_limit || $password_checks_done >= $password_check_budget ) {
                                $timed_out = true;
                                break;
                            }
                            if ( wp_check_password( $candidate, (string) $user_obj->user_pass ) ) {
                                $weak_password_found = true;
                                $issues[] = [
                                    'id'       => 'weak_password_' . $user_id,
                                    'hash'     => $user_issue_hash,
                                    'type'     => 'user',
                                    'status'   => 'critical',
                                    'severity' => 'critical',
                                    'message'  => "Weak Password: User '{$user_obj->user_login}' matched a common weak password pattern.",
                                    'file'     => 'User: ' . $user_obj->user_login,
                                    'data'     => array_merge( $user_meta, [ 'reason' => 'weak_password' ] ),
                                ];
                                break;
                            }
                            $password_checks_done++;
                            $progress_units++;
                        }
                    }

                    if ( $use_full_dictionary && ! $weak_password_found && ! $timed_out && $dictionary_count > 0 ) {
                        for ( $idx = $dict_offset; $idx < $dictionary_count; $idx++ ) {
                            if ( time() - $start_time >= $time_limit || $password_checks_done >= $password_check_budget ) {
                                $dict_offset = $idx;
                                $timed_out = true;
                                break;
                            }

                            if ( wp_check_password( $weak_dictionary[ $idx ], (string) $user_obj->user_pass ) ) {
                                $weak_password_found = true;
                                $dict_offset = 0;
                                $issues[] = [
                                    'id'       => 'weak_password_' . $user_id,
                                    'hash'     => $user_issue_hash,
                                    'type'     => 'user',
                                    'status'   => 'critical',
                                    'severity' => 'critical',
                                    'message'  => "Weak Password: User '{$user_obj->user_login}' matched a common weak password pattern.",
                                    'file'     => 'User: ' . $user_obj->user_login,
                                    'data'     => array_merge( $user_meta, [ 'reason' => 'weak_password' ] ),
                                ];
                                break;
                            }

                            $password_checks_done++;
                            $progress_units++;
                        }
                    }

                    if ( ! $timed_out ) {
                        $dict_offset = 0;
                    }
                }

                if ( $timed_out ) {
                    break;
                }

                $after_user_id = $user_id;
                $active_user_id = 0;
                $dict_offset = 0;
                $active_user_emitted = false;
                $progress_units++;
            }

            if ( function_exists( 'wp_suspend_cache_addition' ) ) {
                wp_suspend_cache_addition( $prev_suspended );
            }

            if ( $timed_out ) {
                break;
            }

        } while ( true );

        if ( $timed_out ) {
            $stall_count = $progress_units > 0 ? 0 : ( $stall_count + 1 );

            if ( $stall_count >= self::MAX_STALL_RUNS ) {
                $issues[] = $this->build_user_audit_failure_issue( $after_user_id, $active_user_id );
                $this->clear_cursor( $scan_id );
                $issues = $this->consolidate_user_issues( $issues );
                return [
                    'issues'    => $issues,
                    'count'     => count( $issues ),
                    'complete'  => true,
                    'failed'    => true,
                ];
            }

            $this->save_cursor(
                $scan_id,
                $after_user_id,
                $active_user_id,
                $dict_offset,
                $active_user_emitted,
                $privileged_checked,
                $privileged_state,
                $stall_count
            );
            $issues = $this->consolidate_user_issues( $issues );
            return [
                'issues'   => $issues,
                'count'    => count( $issues ),
                'complete' => false,
            ];
        }

        $this->clear_cursor( $scan_id );
        $issues = $this->consolidate_user_issues( $issues );

        return [
            'issues' => $issues,
            'count'  => count( $issues ),
            'complete' => true,
        ];
    }

    private static function table_identifier( string $table_name ): string {
        if ( $table_name === '' || strlen( $table_name ) > 64 || ! preg_match( '/^[A-Za-z0-9_]+$/', $table_name ) ) {
            throw new \InvalidArgumentException( 'Invalid table identifier.' );
        }

        return '`' . $table_name . '`';
    }
}
