<?php

declare(strict_types=1);

namespace SecurWP\Modules\AuditLog;

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

use SecurWP\Includes\Interfaces\Module;
use SecurWP\Includes\Module_Loader;
use SecurWP\Includes\SettingsStore;
use SecurWP\Core\Database\SchemaInspector;
use SecurWP\Modules\Hardening\Hardening;

class AuditLog implements Module {

    const TABLE_NAME = 'securwp_audit_log';
    private const PRIVILEGED_EVENTS_OPTION = 'securwp_privileged_account_events';
    private const PRIVILEGED_EVENTS_LIMIT = 300;
    private $plugin_name;
    private static bool $schema_repair_attempted = false;

    public function __construct( string $plugin_name, string $version ) {
        $this->plugin_name = $plugin_name;
    }

    public function initialize( \SecurWP\Includes\Module_Loader $loader ) {
        // Authentication
        $loader->add_action( 'wp_login', $this, 'log_login', 10, 2 );
        $loader->add_action( 'wp_login_failed', $this, 'log_login_failed', 10, 2 );
        $loader->add_action( 'wp_logout', $this, 'log_user_logout' );
        $loader->add_action( 'securwp_2fa_enrolled', $this, 'log_2fa_enrolled', 10, 2 );
        $loader->add_action( 'securwp_2fa_disabled', $this, 'log_2fa_disabled', 10, 2 );
        $loader->add_action( 'securwp_2fa_recovery_codes_regenerated', $this, 'log_2fa_recovery_codes_regenerated', 10, 2 );
        $loader->add_action( 'securwp_2fa_verified', $this, 'log_2fa_verified', 10, 2 );
        $loader->add_action( 'securwp_2fa_verification_failed', $this, 'log_2fa_verification_failed', 10, 2 );
        $loader->add_action( 'securwp_2fa_challenge_locked', $this, 'log_2fa_challenge_locked', 10, 2 );
        $loader->add_action( 'securwp_2fa_fallback_activated', $this, 'log_2fa_fallback_activated', 10, 2 );
        $loader->add_action( 'securwp_2fa_unavailable', $this, 'log_2fa_unavailable', 10, 2 );
        $loader->add_action( 'securwp_2fa_remembered_device', $this, 'log_2fa_remembered_device', 10, 2 );

        // Plugins / Themes / Core
        $loader->add_action( 'activated_plugin', $this, 'log_plugin_activation' );
        $loader->add_action( 'deactivated_plugin', $this, 'log_plugin_deactivation' );
        $loader->add_action( 'switch_theme', $this, 'log_theme_switch' );
        $loader->add_action( 'upgrader_process_complete', $this, 'log_update_complete', 10, 2 );

        // User lifecycle
        $loader->add_action( 'user_register', $this, 'log_user_registration' );
        $loader->add_action( 'delete_user', $this, 'log_user_deletion' );
        $loader->add_action( 'wp_update_user', $this, 'log_user_update', 10, 2 );
        $loader->add_action( 'set_user_role', $this, 'log_role_set', 10, 3 );
        $loader->add_action( 'add_user_role', $this, 'record_privileged_role_added', 10, 2 );
        $loader->add_action( 'added_user_meta', $this, 'record_privileged_user_meta_added', 10, 4 );
        $loader->add_action( 'updated_user_meta', $this, 'record_privileged_user_meta_updated', 10, 4 );
        $loader->add_action( 'granted_super_admin', $this, 'record_privileged_super_admin_granted', 10, 1 );

        // Password events
        $loader->add_action( 'retrieve_password', $this, 'log_password_reset_requested', 10, 1 );
        $loader->add_action( 'password_reset', $this, 'log_password_reset', 10, 2 );

        // Application passwords
        $loader->add_action( 'wp_create_application_password', $this, 'log_app_password_created', 10, 4 );
        $loader->add_action( 'wp_delete_application_password', $this, 'log_app_password_deleted', 10, 2 );

        // Settings
        $loader->add_action( 'updated_option', $this, 'log_option_update', 10, 3 );

        // API
        $loader->add_action( 'rest_api_init', $this, 'register_routes' );

        // Retention policy: schedule daily cleanup of old audit log entries.
        $loader->add_action( 'securwp_audit_log_cleanup', $this, 'cleanup_old_entries' );
        if ( ! wp_next_scheduled( 'securwp_audit_log_cleanup' ) ) {
            wp_schedule_event( time(), 'daily', 'securwp_audit_log_cleanup' );
        }
    }

    /**
     * Delete old audit log entries (database + file).
     *
     * Database retention uses `data_retention_days` from global settings.
     * File retention uses `audit_file_retention_days` (separate setting).
     */
    public function cleanup_old_entries(): void {
        global $wpdb;
        $table = self::table_identifier( $wpdb->prefix . self::TABLE_NAME );

        $global_settings = SettingsStore::get( SettingsStore::COL_GLOBAL );

        // --- Database cleanup ---
        $db_days = isset( $global_settings['data_retention_days'] ) ? (int) $global_settings['data_retention_days'] : 90;
        $db_days = (int) apply_filters( 'securwp_audit_log_retention_days', $db_days );
        if ( $db_days < 1 ) {
            $db_days = 90;
        }

        $cutoff = time() - ( $db_days * DAY_IN_SECONDS );
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
        $sql = $wpdb->prepare( "DELETE FROM {$table} WHERE created_at < %d", $cutoff );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
        if ( is_string( $sql ) && '' !== $sql ) {
            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Applies retention cleanup to the plugin-owned audit log table.
            $wpdb->query( $sql );
        }

        // --- File cleanup ---
        $storage = $global_settings['audit_log_storage'] ?? 'database';
        if ( 'file' === $storage || 'both' === $storage ) {
            $file_days = isset( $global_settings['audit_file_retention_days'] ) ? (int) $global_settings['audit_file_retention_days'] : 90;
            if ( $file_days < 1 ) {
                $file_days = 90;
            }

            require_once __DIR__ . '/AuditFileLogger.php';
            AuditFileLogger::cleanup_old_files( $file_days );
        }
    }

    public function register_routes() {
        register_rest_route( $this->plugin_name . '/v1', '/audit/log', array(
            'methods' => 'GET',
            'callback' => array( $this, 'get_log_items' ),
            'permission_callback' => function () {
                return \SecurWP\Includes\Capabilities::can_manage_plugin();
            },
        ) );

        register_rest_route( $this->plugin_name . '/v1', '/audit/log', array(
            'methods' => 'DELETE',
            'callback' => array( $this, 'clear_all_logs' ),
            'permission_callback' => function () {
                return \SecurWP\Includes\Capabilities::can_manage_plugin();
            },
        ) );
    }

    /**
     * Delete all audit log entries (database + file).
     */
    public function clear_all_logs( \WP_REST_Request $request ) {
        global $wpdb;
        $table = self::table_identifier( $wpdb->prefix . self::TABLE_NAME );

        // TRUNCATE is faster but may be blocked; fall back to DELETE if so.
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- TRUNCATE targets a validated plugin-owned audit log table.
        $db_result = $wpdb->query( "TRUNCATE TABLE {$table}" );
        if ( false === $db_result ) {
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- DELETE fallback targets a validated plugin-owned audit log table.
            $db_result = $wpdb->query( "DELETE FROM {$table}" );
        }

        if ( false === $db_result ) {
            return rest_ensure_response( [ 'success' => false, 'message' => 'Failed to clear database logs.' ] );
        }

        // Also clear file logs.
        require_once __DIR__ . '/AuditFileLogger.php';
        AuditFileLogger::purge_all();

        // Keep a tamper-evident marker and avoid suppression by info-level filters.
        self::add_log( 'Audit Log Cleared', [], 'warning', 'settings' );

        return rest_ensure_response( [ 'success' => true, 'message' => 'All log entries have been cleared.' ] );
    }

    public function get_log_items( \WP_REST_Request $request ) {
        return rest_ensure_response( self::get_formatted_entries( [ 'limit' => 100 ] ) );
    }

    /**
     * Read and format audit log rows for admin surfaces.
     *
     * @param array{
     *     limit?: int,
     *     category?: string,
     *     events?: array<int,string>,
     *     summary_context?: string
     * } $args Query options.
     * @return array<int,array<string,mixed>>
     */
    public static function get_formatted_entries( array $args = [] ): array {
        global $wpdb;

        $limit           = isset( $args['limit'] ) ? (int) $args['limit'] : 100;
        $limit           = max( 1, min( 200, $limit ) );
        $category        = isset( $args['category'] ) ? sanitize_key( (string) $args['category'] ) : '';
        $summary_context = isset( $args['summary_context'] ) ? sanitize_key( (string) $args['summary_context'] ) : 'default';
        $events          = isset( $args['events'] ) && is_array( $args['events'] ) ? $args['events'] : [];
        $events          = array_values(
            array_filter(
                array_map(
                    static fn( $event ): string => sanitize_text_field( (string) $event ),
                    $events
                ),
                static fn( string $event ): bool => '' !== $event
            )
        );

        $table = self::table_identifier( $wpdb->prefix . self::TABLE_NAME );
        $prepared = '';

        if ( '' !== $category && ! empty( $events ) ) {
            $placeholders = implode( ', ', array_fill( 0, count( $events ), '%s' ) );
            $params       = array_merge( [ $category ], $events, [ $limit ] );
            $prepared     = self::prepare_dynamic_statement(
                $wpdb,
                "SELECT * FROM {$table} WHERE category = %s AND event IN ({$placeholders}) ORDER BY created_at DESC LIMIT %d",
                $params
            );
        } elseif ( '' !== $category ) {
            $prepared = self::prepare_dynamic_statement(
                $wpdb,
                "SELECT * FROM {$table} WHERE category = %s ORDER BY created_at DESC LIMIT %d",
                [ $category, $limit ]
            );
        } elseif ( ! empty( $events ) ) {
            $placeholders = implode( ', ', array_fill( 0, count( $events ), '%s' ) );
            $params       = array_merge( $events, [ $limit ] );
            $prepared     = self::prepare_dynamic_statement(
                $wpdb,
                "SELECT * FROM {$table} WHERE event IN ({$placeholders}) ORDER BY created_at DESC LIMIT %d",
                $params
            );
        } else {
            $prepared = self::prepare_dynamic_statement(
                $wpdb,
                "SELECT * FROM {$table} ORDER BY created_at DESC LIMIT %d",
                [ $limit ]
            );
        }

        $logs = [];
        if ( is_string( $prepared ) && '' !== $prepared ) {
            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Prepared audit query.
            $logs = $wpdb->get_results( $prepared, ARRAY_A );
        }
        if ( ! is_array( $logs ) ) {
            $logs = [];
        }

        $user_ids = array_unique(
            array_filter(
                array_map(
                    static function ( $log ): int {
                        return (int) ( $log['user_id'] ?? 0 );
                    },
                    $logs
                )
            )
        );

        $user_map = [];
        if ( ! empty( $user_ids ) ) {
            $users = get_users( [ 'include' => $user_ids ] );
            foreach ( $users as $user ) {
                $user_map[ $user->ID ] = $user;
            }
        }

        $formatted = [];
        foreach ( $logs as $log ) {
            $meta = json_decode( (string) ( $log['meta'] ?? '' ), true );
            if ( ! is_array( $meta ) ) {
                $meta = [];
            }
            $event = (string) ( $log['event'] ?? '' );
            $meta  = self::normalize_details_for_response( $event, $meta );
            $user  = $user_map[ (int) ( $log['user_id'] ?? 0 ) ] ?? null;

            $formatted[] = [
                'id'         => (int) ( $log['id'] ?? 0 ),
                'timestamp'  => gmdate( 'Y-m-d H:i:s', (int) ( $log['created_at'] ?? 0 ) ),
                'event_time' => (int) ( $log['created_at'] ?? 0 ),
                'event'      => self::display_event_name( $event ),
                'event_key'  => $event,
                'user'       => $user ? $user->user_login : 'System',
                'ip'         => isset( $log['ip_address'] ) ? (string) $log['ip_address'] : '',
                'role'       => $user instanceof \WP_User ? self::get_wordpress_role_display_for_user( $user ) : '',
                'category'   => isset( $log['category'] ) ? (string) $log['category'] : 'general',
                'details'    => $meta,
                'severity'   => isset( $log['level'] ) ? (string) $log['level'] : 'info',
                'summary'    => self::build_message( $event, $meta, $summary_context ),
            ];
        }

        return $formatted;
    }

    /**
     * Normalize response details so the frontend receives stable values.
     *
     * @param string $event Event name.
     * @param array  $meta  Raw stored metadata.
     * @return array
     */
    private static function normalize_details_for_response( string $event, array $meta ): array {
        if ( 'Login Failed' === $event ) {
            $normalized_username = self::normalize_login_identifier( $meta['username'] ?? '' );
            $meta['username'] = '' !== $normalized_username ? $normalized_username : 'unknown';
        }

        if ( 'User Registered' === $event && isset( $meta['role'] ) && is_string( $meta['role'] ) ) {
            $meta['role'] = self::normalize_logged_role_string( $meta['role'] );
        }

        if ( 'User Role Changed' === $event ) {
            if ( isset( $meta['from'] ) && is_string( $meta['from'] ) ) {
                $meta['from'] = self::normalize_logged_role_string( $meta['from'], 'none' );
            }
            if ( isset( $meta['to'] ) && is_string( $meta['to'] ) ) {
                $meta['to'] = self::normalize_logged_role_string( $meta['to'], 'none' );
            }
        }

        return $meta;
    }

    /** Prefer core role labels and fall back to custom roles for audit display. */
    private static function get_wordpress_role_display_for_user( \WP_User $user, string $empty_display = '' ): string {
        $roles = is_array( $user->roles ) ? $user->roles : [];
        return self::normalize_logged_role_string( implode( ', ', $roles ), $empty_display );
    }

    /** Normalize a comma-delimited role string for audit display. */
    private static function normalize_logged_role_string( string $role_string, string $empty_display = '' ): string {
        $raw_roles = array_filter(
            array_map(
                'trim',
                explode( ',', $role_string )
            ),
            static function ( $role ): bool {
                return is_string( $role ) && '' !== $role;
            }
        );

        if ( empty( $raw_roles ) ) {
            return $empty_display;
        }

        $core_role_slugs = [
            'administrator',
            'editor',
            'author',
            'contributor',
            'subscriber',
        ];

        $preferred_roles = array_values(
            array_filter(
                $raw_roles,
                static function ( string $role ) use ( $core_role_slugs ): bool {
                    return in_array( $role, $core_role_slugs, true );
                }
            )
        );

        $roles_for_display = ! empty( $preferred_roles ) ? $preferred_roles : $raw_roles;
        $display_roles     = self::map_role_slugs_to_labels( $roles_for_display );

        if ( empty( $display_roles ) ) {
            return $empty_display;
        }

        return implode( ', ', $display_roles );
    }

    /** Map role slugs to unique display labels. */
    private static function map_role_slugs_to_labels( array $roles ): array {
        $wp_roles      = function_exists( 'wp_roles' ) ? wp_roles() : null;
        $display_roles = [];

        foreach ( $roles as $role ) {
            $label = $role;
            if ( $wp_roles && isset( $wp_roles->roles[ $role ]['name'] ) && is_string( $wp_roles->roles[ $role ]['name'] ) ) {
                $label = $wp_roles->roles[ $role ]['name'];
            } else {
                $label = ucwords( str_replace( '_', ' ', $role ) );
            }

            if ( ! in_array( $label, $display_roles, true ) ) {
                $display_roles[] = $label;
            }
        }

        return $display_roles;
    }

    /**
     * Convert stored audit event keys into user-facing labels.
     *
     * Storage keeps stable internal event identifiers; admin surfaces should
     * render normalized, human-readable titles.
     */
    private static function display_event_name( string $event ): string {
        switch ( $event ) {
            case 'scan_action_delete':
                return 'File Deleted';
            case 'scan_action_quarantine':
                return 'File Quarantined';
            case 'scan_action_repair':
                return 'File Repaired';
            case 'scan_action_quarantine_restore':
                return 'Quarantine Restored';
            case 'scan_action_quarantine_snapshot_restore':
                return 'Pre-Repair Backup Restored';
            case 'scan_action_quarantine_purge':
                return 'Quarantine Purged';
            default:
                return $event;
        }
    }

    /**
     * Add an entry to the audit log.
     *
     * Dispatches to database, file, or both based on the `audit_log_storage`
     * setting. Falls back to database if file write fails, and to error_log
     * if both fail.
     *
     * @param string $event    Event name.
     * @param array  $details  Additional details.
     * @param string $severity Log level (info, warning, error).
     * @param string $category Event category.
     */
    public static function add_log( $event, $details = [], $severity = 'info', $category = 'general' ) {
        // Skip logging when the audit tables are not available in isolated analysis environments.
        global $wpdb;
        if ( str_starts_with( $wpdb->prefix, 'wp_pc_' ) ) {
            return;
        }

        // Reentrancy guard: get_option() can trigger updated_option → add_log().
        static $logging_in_progress = false;
        if ( $logging_in_progress ) {
            return;
        }
        $logging_in_progress = true;

        try {
            $details = is_array( $details ) ? $details : [];
            $actor_user_id = isset( $details['_actor_user_id'] ) ? (int) $details['_actor_user_id'] : 0;
            unset( $details['_actor_user_id'], $details['_actor_username'] );

            if ( $actor_user_id > 0 ) {
                $user     = get_userdata( $actor_user_id );
                $user_id  = $user instanceof \WP_User ? $user->ID : 0;
                $username = $user instanceof \WP_User ? $user->user_login : 'System';
            } else {
                $user     = wp_get_current_user();
                $user_id  = $user && $user->exists() ? $user->ID : 0;
                $username = $user && $user->exists() ? $user->user_login : 'System';
            }

	            // Get IP via central detector (per-request cached, handles proxies).
	            $ip = class_exists( 'SecurWP\\Includes\\IpDetector' )
	                ? \SecurWP\Includes\IpDetector::get_ip()
	                : sanitize_text_field( (string) wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) );

            // Determine storage mode.
            $global_settings = SettingsStore::get( SettingsStore::COL_GLOBAL );

            // Apply log level filter before writing.
            $log_level = $global_settings['audit_log_level'] ?? 'standard';
            if ( 'warning' === $log_level ) {
                if ( 'info' === $severity ) {
                    $logging_in_progress = false;
                    return;
                }
            } elseif ( 'standard' === $log_level ) {
                // Drop info-severity events from low-signal categories: routine config
                // saves, scanner admin actions, and software update completions.
                if ( 'info' === $severity && in_array( $category, [ 'settings', 'scanner', 'updates' ], true ) ) {
                    $logging_in_progress = false;
                    return;
                }
            }
            // 'all': no filtering.

            if ( isset( $global_settings['audit_log_enabled'] ) && ! $global_settings['audit_log_enabled'] ) {
                $logging_in_progress = false;
                return;
            }

            $storage = $global_settings['audit_log_storage'] ?? 'database';
            if ( ! in_array( $storage, [ 'database', 'file', 'both' ], true ) ) {
                $storage = 'database';
            }

            $db_success   = false;
            $file_success = false;

            // --- Database logging ---
            if ( 'database' === $storage || 'both' === $storage ) {
                $db_success = self::write_to_database( $event, $details, $severity, $category, $user_id, $ip );
            }

            // --- File logging ---
            if ( 'file' === $storage || 'both' === $storage ) {
                require_once __DIR__ . '/AuditFileLogger.php';
                $file_success = AuditFileLogger::write( $event, $details, $severity, $category, $user_id, $username, $ip );
            }

            // --- Graceful degradation: file-only mode but file failed → fall back to DB ---
            if ( 'file' === $storage && ! $file_success ) {
                $db_success = self::write_to_database( $event, $details, $severity, $category, $user_id, $ip );
            }

            // --- Last resort ---
            if ( ! $db_success && ! $file_success ) {
                @securwp_log( 'SecurWP AuditLog: logging failed for event: ' . $event );
            }

            if ( $db_success || $file_success ) {
                // Fire hook for Cloud StatePush batching.
                do_action( 'securwp_audit_log_entry', $event, [
                    'message'  => self::build_message( $event, $details ),
                    'details'  => $details,
                    'username' => $username,
                    'user_id'  => $user_id,
                    'ip'       => $ip,
                    'level'    => $severity,
                    'category' => $category,
                ] );

                // Backward-compatible hook used by Reporting cache busting.
                do_action( 'securwp_audit_log_created' );
            }
        } finally {
            $logging_in_progress = false;
        }
    }

    /**
     * Build a human-readable message for an audit event.
     *
     * Mirrors the formatEventMessage() logic in the plugin admin React component
     * so the same rich descriptions are available in both the plugin UI and the
     * Console central audit view.
     *
     * @param string $event   Event name.
     * @param array  $details Event metadata.
     * @param string $context Consumer context for summary formatting.
     * @return string
     */
    public static function build_message( string $event, array $details, string $context = 'default' ): string {
        $raw_username = isset( $details['username'] ) && is_string( $details['username'] ) ? trim( $details['username'] ) : '';
        $u            = $raw_username !== '' ? '"' . $raw_username . '"' : 'A user';
        $context      = sanitize_key( $context );
        $include_ip   = 'login_protection' !== $context;

        switch ( $event ) {
            // Authentication
            case 'User Login':
                return sprintf( '%s logged in successfully.', $u );
            case 'User Logout':
                return sprintf( '%s logged out.', $u );
            case 'Login Failed':
                $id = $raw_username !== '' ? $raw_username : 'unknown';
                if ( 'login_protection' === $context ) {
                    return $id !== 'unknown'
                        ? sprintf( 'Failed login for "%s".', $id )
                        : 'Failed login attempt.';
                }
                return $id !== 'unknown'
                    ? sprintf( 'Failed login attempt for "%s".', $id )
                    : 'Failed login attempt with unknown username.';
            case 'Two-Factor Enabled': {
                $method_label = self::format_2fa_method_label( (string) ( $details['method'] ?? '' ) );
                return '' !== $method_label
                    ? sprintf( '%s enabled two-factor authentication with %s.', $u, $method_label )
                    : sprintf( '%s enabled two-factor authentication.', $u );
            }
            case 'Two-Factor Disabled': {
                $method_label = self::format_2fa_method_label( (string) ( $details['previous_method'] ?? '' ) );
                return '' !== $method_label
                    ? sprintf( '%s disabled two-factor authentication (%s).', $u, $method_label )
                    : sprintf( '%s disabled two-factor authentication.', $u );
            }
            case 'Recovery Codes Regenerated': {
                $remaining = isset( $details['recovery_codes_remaining'] ) ? (int) $details['recovery_codes_remaining'] : 0;
                return $remaining > 0
                    ? sprintf( '%s generated a new set of recovery codes (%d available).', $u, $remaining )
                    : sprintf( '%s generated a new set of recovery codes.', $u );
            }
            case 'Two-Factor Verified': {
                $method_label = self::format_2fa_method_label( (string) ( $details['method_used'] ?? $details['configured_method'] ?? '' ) );
                $remembered   = ! empty( $details['remember_device'] );

                if ( '' === $method_label ) {
                    return sprintf( '%s completed two-factor verification.', $u );
                }

                if ( $remembered && 'Recovery Code' !== $method_label ) {
                    return sprintf( '%s completed two-factor verification with %s and trusted this device for future sign-ins.', $u, $method_label );
                }

                return sprintf( '%s completed two-factor verification with %s.', $u, $method_label );
            }
            case 'Two-Factor Verification Failed': {
                $method_label = self::format_2fa_method_label( (string) ( $details['submitted_method'] ?? $details['configured_method'] ?? '' ) );
                $fail_count   = isset( $details['fail_count'] ) ? (int) $details['fail_count'] : 0;
                $max_failures = isset( $details['max_failures'] ) ? (int) $details['max_failures'] : 0;
                $base         = '' !== $method_label
                    ? sprintf( '%s submitted an invalid %s code.', $u, $method_label )
                    : sprintf( '%s submitted an invalid two-factor verification code.', $u );

                if ( $fail_count > 0 && $max_failures > 0 ) {
                    return sprintf( '%s Attempt %d of %d.', $base, $fail_count, $max_failures );
                }

                return $base;
            }
            case 'Two-Factor Challenge Locked': {
                $fail_count = isset( $details['fail_count'] ) ? (int) $details['fail_count'] : 0;
                return $fail_count > 0
                    ? sprintf( '%s\'s two-factor challenge was locked after %d failed verification attempts.', $u, $fail_count )
                    : sprintf( '%s\'s two-factor challenge was locked after repeated failed verification attempts.', $u );
            }
            case 'Two-Factor Fallback Activated': {
                $from_label   = self::format_2fa_method_label( (string) ( $details['configured_method'] ?? '' ) );
                $to_label     = self::format_2fa_method_label( (string) ( $details['effective_method'] ?? '' ) );
                $reason_label = self::format_2fa_reason_label( (string) ( $details['reason'] ?? '' ) );

                if ( '' !== $from_label && '' !== $to_label && '' !== $reason_label ) {
                    return sprintf( '%s\'s two-factor challenge switched from %s to %s because %s.', $u, $from_label, $to_label, $reason_label );
                }

                if ( '' !== $from_label && '' !== $to_label ) {
                    return sprintf( '%s\'s two-factor challenge switched from %s to %s.', $u, $from_label, $to_label );
                }

                return sprintf( '%s\'s two-factor challenge switched to an alternate verification method.', $u );
            }
            case 'Two-Factor Unavailable': {
                $reason_label = self::format_2fa_reason_label( (string) ( $details['reason'] ?? '' ) );
                return '' !== $reason_label
                    ? sprintf( '%s could not continue two-factor verification because %s.', $u, $reason_label )
                    : sprintf( '%s could not continue two-factor verification because no available method could be used.', $u );
            }
            case 'Two-Factor Remembered Device Accepted':
                return sprintf( '%s bypassed the interactive two-factor challenge using a remembered device.', $u );

            // Password
            case 'Password Changed': {
                $via    = isset( $details['via'] ) && is_string( $details['via'] ) ? $details['via'] : '';
                $by     = isset( $details['changed_by'] ) && is_string( $details['changed_by'] ) ? $details['changed_by'] : '';
                $reason = isset( $details['reason'] ) && is_string( $details['reason'] ) ? $details['reason'] : '';
                if ( $reason !== '' ) {
                    $reason_labels = [
                        'expired'      => 'their password had expired',
                        'admin_forced' => 'an administrator required it',
                        'breached'     => 'their password appeared in a data breach',
                        'role_upgrade' => 'their role was upgraded and a stronger password is required',
                    ];
                    $label = $reason_labels[ $reason ] ?? $reason;
                    return sprintf( '%s was required to set a new password (%s).', $u, $label );
                }
                if ( $via === 'admin' && $by !== '' ) return sprintf( '"%s" changed the password for %s.', $by, $u );
                if ( $via === 'profile' )             return sprintf( '%s changed their own password.', $u );
                if ( $via === 'system' )              return sprintf( 'Password for %s was changed by the system.', $u );
                return sprintf( 'Password for %s was changed.', $u );
            }

            case 'Password Reset Requested':
                return sprintf( '%s requested a password reset link.', $u );
            case 'Password Reset Completed':
                return sprintf( '%s set a new password using the reset link.', $u );

            // User management
            case 'User Registered': {
                $email      = isset( $details['email'] ) && is_string( $details['email'] ) ? $details['email'] : '';
                $role       = isset( $details['role'] ) && is_string( $details['role'] ) ? $details['role'] : '';
                $created_by = isset( $details['created_by'] ) && is_string( $details['created_by'] ) ? $details['created_by'] : '';
                $base       = $email !== ''
                    ? sprintf( 'New user account created for "%s" (%s)', $raw_username, $email )
                    : sprintf( 'New user account created for %s', $u );
                $role_part  = $role !== '' ? sprintf( ' with role %s', $role ) : '';
                $by_part    = $created_by !== '' ? sprintf( ' by "%s"', $created_by ) : '';
                return $base . $role_part . $by_part . '.';
            }

            case 'Application Password Created': {
                $app = isset( $details['app_name'] ) && is_string( $details['app_name'] ) ? $details['app_name'] : '';
                return $app !== ''
                    ? sprintf( '%s created an application password named "%s".', $u, $app )
                    : sprintf( '%s created an application password.', $u );
            }
            case 'Application Password Deleted': {
                $app = isset( $details['app_name'] ) && is_string( $details['app_name'] ) ? $details['app_name'] : '';
                return $app !== ''
                    ? sprintf( '%s deleted the application password named "%s".', $u, $app )
                    : sprintf( '%s deleted an application password.', $u );
            }

            case 'User Deleted':
                return sprintf( 'User account %s was permanently deleted.', $u );
            case 'User Profile Updated':
                return isset( $details['change'] ) && $details['change'] === 'email'
                    ? sprintf( '%s updated their email address.', $u )
                    : sprintf( '%s updated their profile.', $u );
            case 'User Role Changed': {
                $from = isset( $details['from'] ) && is_string( $details['from'] ) ? $details['from'] : '';
                $to   = isset( $details['to'] ) && is_string( $details['to'] ) ? $details['to'] : '';
                return ( $from !== '' && $to !== '' )
                    ? sprintf( '%s\'s role was changed from "%s" to "%s".', $u, $from, $to )
                    : sprintf( '%s\'s role was changed.', $u );
            }

            // Plugins / Themes / Core
            case 'Plugin Activated': {
                $plugin = isset( $details['plugin'] ) && is_string( $details['plugin'] ) ? $details['plugin'] : '';
                return $plugin !== '' ? sprintf( 'Plugin "%s" was activated.', $plugin ) : 'A plugin was activated.';
            }
            case 'Plugin Deactivated': {
                $plugin = isset( $details['plugin'] ) && is_string( $details['plugin'] ) ? $details['plugin'] : '';
                return $plugin !== '' ? sprintf( 'Plugin "%s" was deactivated.', $plugin ) : 'A plugin was deactivated.';
            }
            case 'Plugin Updated': {
                $plugin = isset( $details['plugin'] ) && is_string( $details['plugin'] ) ? $details['plugin'] : '';
                return $plugin !== '' ? sprintf( 'Plugin "%s" was updated.', $plugin ) : 'A plugin was updated.';
            }
            case 'Theme Switched': {
                $theme = isset( $details['theme'] ) && is_string( $details['theme'] ) ? $details['theme'] : '';
                return $theme !== '' ? sprintf( 'Active theme changed to "%s".', $theme ) : 'The active theme was changed.';
            }
            case 'Theme Updated': {
                $theme = isset( $details['theme'] ) && is_string( $details['theme'] ) ? $details['theme'] : '';
                return $theme !== '' ? sprintf( 'Theme "%s" was updated.', $theme ) : 'A theme was updated.';
            }
            case 'WordPress Core Updated':
                return 'WordPress core was updated.';

            // Settings
            case 'Hardening Settings Changed': {
                $changes = isset( $details['changes'] ) && is_array( $details['changes'] ) ? $details['changes'] : [];
                if ( empty( $changes ) ) return 'Hardening settings were updated.';
                $labels = [
                    'disable_xmlrpc'             => 'XML-RPC',
                    'disable_directory_browsing' => 'Directory browsing protection',
                    'block_php_uploads'          => 'PHP upload blocking',
                    'block_php_plugins'          => 'Plugin PHP blocking',
                    'block_php_themes'           => 'Theme PHP blocking',
                    'block_sensitive_files'      => 'Sensitive file protection',
                    'rename_login_url_enabled'   => 'Login URL renaming',
                    'enable_strong_passwords'    => 'Strong password enforcement',
                    'password_expiration_days'   => 'Password expiration',
                    'max_login_attempts_ip'      => 'IP lockout threshold',
                    'max_login_attempts_user'    => 'User lockout threshold',
                    'check_breached_passwords'   => 'Breached password checking',
                    'captcha_enabled'            => 'CAPTCHA protection',
                    'require_2fa_for_admins'     => 'Two-factor authentication requirement',
                    'restrict_rest_api'          => 'REST API restriction',
                    'hide_wp_version'            => 'WordPress version concealment',
                    'prevent_password_reuse'     => 'Password reuse prevention',
                ];
                $parts = [];
                foreach ( $changes as $key => $val ) {
                    $label  = $labels[ $key ] ?? str_replace( '_', ' ', $key );
                    $to_val = is_array( $val ) ? ( $val['to'] ?? null ) : $val;
                    if ( is_bool( $to_val ) ) {
                        $parts[] = $label . ' ' . ( $to_val ? 'enabled' : 'disabled' );
                    } else {
                        $parts[] = sprintf( '%s set to "%s"', $label, self::stringify_message_value( $to_val ) );
                    }
                }
                return 'Hardening settings updated: ' . implode( ', ', $parts ) . '.';
            }

            case 'Site Setting Changed': {
                $option = isset( $details['option'] ) && is_string( $details['option'] ) ? $details['option'] : '';
                $from   = $details['from'] ?? null;
                $to     = $details['to'] ?? null;
                $option_labels = [
                    'siteurl'            => 'Site URL',
                    'home'               => 'Home URL',
                    'admin_email'        => 'Admin email address',
                    'users_can_register' => 'User registration',
                    'default_role'       => 'Default user role',
                    'active_plugins'     => 'Active plugins list',
                    'start_of_week'      => 'Week start day',
                ];
                $label = $option_labels[ $option ] ?? ( $option !== '' ? '"' . $option . '"' : 'A site setting' );
                if ( $from !== null && $to !== null && $option !== 'active_plugins' ) {
                    return sprintf(
                        '%s was changed from "%s" to "%s".',
                        $label,
                        self::stringify_message_value( $from ),
                        self::stringify_message_value( $to )
                    );
                }
                return sprintf( '%s was changed.', $label );
            }

            // Lockouts
            case 'IP Lockout': {
                $ip = isset( $details['ip'] ) && is_string( $details['ip'] ) ? $details['ip'] : 'unknown';
                $reason       = isset( $details['reason'] ) && is_string( $details['reason'] ) ? trim( $details['reason'] ) : '';
                $attempts     = isset( $details['triggered_after'] ) ? (int) $details['triggered_after'] : 0;
                $window_label = self::format_duration_label( isset( $details['window_seconds'] ) ? (int) $details['window_seconds'] : 0 );

                if ( 'admin_username_attempt' === $reason ) {
                    return $include_ip
                        ? sprintf( 'IP %s was locked immediately after a blocked "admin" username attempt.', $ip )
                        : 'Immediate lockout after a blocked "admin" username attempt.';
                }

                if ( $attempts > 0 && '' !== $window_label ) {
                    return $include_ip
                        ? sprintf(
                            'IP %1$s was locked after %2$d failed login attempt%3$s within %4$s.',
                            $ip,
                            $attempts,
                            1 === $attempts ? '' : 's',
                            $window_label
                        )
                        : sprintf(
                            'Locked after %1$d failed login attempt%2$s in %3$s.',
                            $attempts,
                            1 === $attempts ? '' : 's',
                            $window_label
                        );
                }

                return $include_ip
                    ? sprintf( 'IP %s was locked out after repeated failed login attempts.', $ip )
                    : 'Locked out after repeated failed login attempts.';
            }
            case 'User Lockout': {
                $attempts     = isset( $details['triggered_after'] ) ? (int) $details['triggered_after'] : 0;
                $window_label = self::format_duration_label( isset( $details['window_seconds'] ) ? (int) $details['window_seconds'] : 0 );

                if ( $attempts > 0 && '' !== $window_label ) {
                    if ( 'login_protection' === $context ) {
                        return sprintf(
                            '%1$s locked after %2$d failed login attempt%3$s in %4$s.',
                            $u,
                            $attempts,
                            1 === $attempts ? '' : 's',
                            $window_label
                        );
                    }

                    return sprintf(
                        '%1$s was locked after %2$d failed login attempt%3$s within %4$s.',
                        $u,
                        $attempts,
                        1 === $attempts ? '' : 's',
                        $window_label
                    );
                }

                return sprintf( '%s was locked out after repeated failed login attempts.', $u );
            }
            case 'IP Lockout Cleared': {
                $ip = isset( $details['ip'] ) && is_string( $details['ip'] ) ? $details['ip'] : 'unknown';
                if ( ! empty( $details['attempts_reset'] ) ) {
                    return $include_ip
                        ? sprintf( 'The login lockout for IP %s was lifted by an administrator and the failed-attempt counter was reset.', $ip )
                        : 'Lockout cleared and counter reset.';
                }
                return $include_ip
                    ? sprintf( 'The login lockout for IP %s was lifted by an administrator.', $ip )
                    : 'Lockout cleared by an administrator.';
            }
            case 'User Lockout Cleared':
                if ( 'login_protection' === $context ) {
                    return ! empty( $details['attempts_reset'] )
                        ? sprintf( '%s lockout cleared and counter reset.', $u )
                        : sprintf( '%s lockout cleared by an administrator.', $u );
                }
                if ( ! empty( $details['attempts_reset'] ) ) {
                    return sprintf( 'The login lockout for %s was lifted by an administrator and the failed-attempt counter was reset.', $u );
                }
                return sprintf( 'The login lockout for %s was lifted by an administrator.', $u );

            // Audit
            case 'Audit Log Cleared':
                return 'The audit log was cleared.';

            // Scanner remediation
            case 'scan_action_delete': {
                $file = isset( $details['file'] ) && is_string( $details['file'] ) ? $details['file'] : 'the selected file';
                $result = isset( $details['result'] ) && is_string( $details['result'] ) ? $details['result'] : '';
                if ( $result === 'missing' ) {
                    return sprintf( 'Scanner dismissed the finding for %s because the file was already missing.', $file );
                }
                return sprintf( 'Scanner permanently deleted %s.', $file );
            }
            case 'scan_action_quarantine': {
                $file = isset( $details['file'] ) && is_string( $details['file'] ) ? $details['file'] : 'the selected file';
                return sprintf( 'Scanner quarantined and deleted %s.', $file );
            }
            case 'scan_action_repair': {
                $file = isset( $details['file'] ) && is_string( $details['file'] ) ? $details['file'] : 'the selected file';
                if ( ! empty( $details['repair_snapshot_id'] ) ) {
                    return sprintf( 'Scanner repaired %s after saving a quarantine snapshot.', $file );
                }
                return sprintf( 'Scanner repaired %s using the clean repository version.', $file );
            }
            case 'scan_action_quarantine_restore': {
                $file = isset( $details['file'] ) && is_string( $details['file'] ) ? $details['file'] : 'the selected file';
                return sprintf( 'Scanner restored %s from quarantine.', $file );
            }
            case 'scan_action_quarantine_snapshot_restore': {
                $file = isset( $details['file'] ) && is_string( $details['file'] ) ? $details['file'] : 'the selected file';
                return sprintf( 'Scanner restored %s from the pre-repair backup.', $file );
            }
            case 'scan_action_quarantine_purge': {
                $file = isset( $details['file'] ) && is_string( $details['file'] ) ? $details['file'] : 'the selected file';
                if ( ! empty( $details['expired'] ) ) {
                    return sprintf( 'Scanner purged the expired quarantine record for %s.', $file );
                }
                return sprintf( 'Scanner permanently purged the quarantine record for %s.', $file );
            }

            default:
                if ( empty( $details ) ) return $event . '.';
                $parts = [];
                foreach ( $details as $k => $v ) {
                    $key     = ucfirst( str_replace( '_', ' ', $k ) );
                    $val_str = self::stringify_message_value( $v );
                    $parts[] = $key . ': ' . $val_str;
                }
                return implode( ' · ', $parts );
        }
    }

    /**
     * Format a short human-readable duration label.
     */
    private static function format_duration_label( int $seconds ): string {
        if ( $seconds <= 0 ) {
            return '';
        }

        if ( 0 === $seconds % DAY_IN_SECONDS ) {
            $days = (int) round( $seconds / DAY_IN_SECONDS );
            return 1 === $days ? '1 day' : sprintf( '%d days', $days );
        }

        if ( 0 === $seconds % HOUR_IN_SECONDS ) {
            $hours = (int) round( $seconds / HOUR_IN_SECONDS );
            return 1 === $hours ? '1 hour' : sprintf( '%d hours', $hours );
        }

        if ( 0 === $seconds % MINUTE_IN_SECONDS ) {
            $minutes = (int) round( $seconds / MINUTE_IN_SECONDS );
            return 1 === $minutes ? '1 minute' : sprintf( '%d minutes', $minutes );
        }

        return 1 === $seconds ? '1 second' : sprintf( '%d seconds', $seconds );
    }

    /**
     * Convert mixed audit metadata into stable human-readable strings.
     *
     * Prevents PHP from coercing arrays into "Array" during message building.
     *
     * @param mixed $value Raw detail value.
     * @return string
     */
    private static function stringify_message_value( $value ): string {
        if ( is_bool( $value ) ) {
            return $value ? 'true' : 'false';
        }

        if ( null === $value ) {
            return 'null';
        }

        if ( is_scalar( $value ) ) {
            return (string) $value;
        }

        $encoded = wp_json_encode( $value );
        return is_string( $encoded ) && '' !== $encoded ? $encoded : '[complex value]';
    }

    /**
     * Convert a stored 2FA method code into a human-readable label.
     */
    private static function format_2fa_method_label( string $method ): string {
        switch ( sanitize_key( $method ) ) {
            case 'totp':
                return 'Authenticator App';
            case 'email':
                return 'Email Verification';
            case 'recovery':
                return 'Recovery Code';
            default:
                return '';
        }
    }

    /**
     * Convert a 2FA fallback or availability reason code into readable text.
     */
    private static function format_2fa_reason_label( string $reason ): string {
        switch ( sanitize_key( $reason ) ) {
            case 'totp_unavailable_email':
            case 'totp_unavailable_recovery':
            case 'totp_unavailable_no_recovery':
                return 'authenticator app data was unavailable';
            case 'totp_disabled_email':
            case 'totp_disabled_recovery':
            case 'totp_disabled_no_recovery':
                return 'authenticator app verification is not allowed for this site';
            case 'email_disabled_recovery':
                return 'email verification is not allowed for this site';
            case 'email_unavailable_recovery':
            case 'email_unavailable_no_recovery':
                return 'email verification was unavailable';
            case 'email_delivery_failed_no_recovery':
                return 'email delivery failed and no recovery method was available';
            case 'session_storage_unavailable':
                return 'challenge storage was unavailable';
            default:
                return '';
        }
    }

    /**
     * Write a single entry to the database table.
     *
     * @return bool True on success.
     */
    private static function write_to_database( string $event, array $details, string $severity, string $category, int $user_id, string $ip ): bool {
        global $wpdb;
        $table = $wpdb->prefix . self::TABLE_NAME;
        $row = [
            'event'      => $event,
            'message'    => self::build_message( $event, $details ),
            'user_id'    => $user_id,
            'ip_address' => $ip,
            'level'      => $severity,
            'category'   => $category,
            'meta'       => wp_json_encode( $details ),
            'created_at' => time(),
        ];

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Inserts a new row into the plugin-owned audit log table.
        $result = $wpdb->insert( $table, $row );

        if ( false === $result && self::maybe_repair_schema_for_write_error( (string) $wpdb->last_error ) ) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Retries the insert once after repairing an outdated plugin-owned schema.
            $result = $wpdb->insert( $table, $row );
        }

        return false !== $result;
    }

    public static function is_schema_current(): bool {
        global $wpdb;

        $table = $wpdb->prefix . self::TABLE_NAME;

        if ( ! SchemaInspector::table_exists( $table ) ) {
            return false;
        }

        return SchemaInspector::has_columns(
            $table,
            [ 'event', 'message', 'user_id', 'ip_address', 'level', 'category', 'meta', 'created_at' ]
        );
    }

    public static function backfill_missing_categories(): void {
        global $wpdb;

        if ( ! self::is_schema_current() ) {
            return;
        }

        $table = self::table_identifier( $wpdb->prefix . self::TABLE_NAME );

        self::backfill_category_group(
            $table,
            'authentication',
            [
                'User Login',
                'User Logout',
                'Login Failed',
                'Two-Factor Enabled',
                'Two-Factor Disabled',
                'Recovery Codes Regenerated',
                'Two-Factor Verified',
                'Two-Factor Verification Failed',
                'Two-Factor Challenge Locked',
                'Two-Factor Fallback Activated',
                'Two-Factor Unavailable',
                'Two-Factor Remembered Device Accepted',
                'Password Changed',
                'Password Reset Requested',
                'Password Reset Completed',
                'Application Password Created',
                'Application Password Deleted',
                'IP Lockout',
                'User Lockout',
                'IP Lockout Cleared',
                'User Lockout Cleared',
            ]
        );

        self::backfill_category_group(
            $table,
            'updates',
            [
                'Plugin Activated',
                'Plugin Deactivated',
                'Plugin Updated',
                'Theme Switched',
                'Theme Updated',
                'WordPress Core Updated',
            ]
        );

        self::backfill_category_group(
            $table,
            'settings',
            [
                'Hardening Settings Changed',
                'Site Setting Changed',
                'Audit Log Cleared',
            ]
        );
    }

    private static function backfill_category_group( string $table, string $category, array $events ): void {
        global $wpdb;

        if ( empty( $events ) ) {
            return;
        }

        $placeholders = implode( ', ', array_fill( 0, count( $events ), '%s' ) );
        $params = array_merge( [ $category ], $events );
        $sql = self::prepare_dynamic_statement(
            $wpdb,
            "UPDATE {$table}
             SET category = %s
             WHERE (category = '' OR category = 'general' OR category IS NULL)
               AND event IN ({$placeholders})",
            $params
        );

        if ( is_string( $sql ) && $sql !== '' ) {
            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Backfills categories on the plugin-owned audit table after schema upgrade.
            $wpdb->query( $sql );
        }
    }

    private static function maybe_repair_schema_for_write_error( string $db_error ): bool {
        if ( self::$schema_repair_attempted ) {
            return false;
        }

        $error = strtolower( trim( $db_error ) );
        if ( $error === '' || ! str_contains( $error, 'unknown column' ) || ! str_contains( $error, 'category' ) ) {
            return false;
        }

        self::$schema_repair_attempted = true;

        \SecurWP\Core\Installer::ensure_audit_table_schema();
        self::backfill_missing_categories();

        return self::is_schema_current();
    }

    /**
     * Prepare a SQL statement with a dynamic placeholder count.
     *
     * @param \wpdb            $wpdb   WordPress database connection.
     * @param string           $sql    SQL template.
     * @param array<int,mixed> $params Positional prepare() arguments.
     * @return string
     */
    private static function prepare_dynamic_statement( \wpdb $wpdb, string $sql, array $params ): string {
        if ( empty( $params ) ) {
            return $sql;
        }

        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
        $prepared = call_user_func_array( [ $wpdb, 'prepare' ], array_merge( [ $sql ], $params ) );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter

        return is_string( $prepared ) ? $prepared : '';
    }

    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 . '`';
    }

    // --- Event Handlers ---

    public function log_login( $user_login, $user ) {
        $this->add_log( 'User Login', [ 'username' => $user_login ], 'info', 'authentication' );
    }

    public function log_login_failed( $username, $error = null ) {
        $identifier = $this->resolve_failed_login_identifier( $username, $error );

        // Guard against false positives from non-credential auth hooks firing
        // during redirected/tokenized login-page GET requests.
        if ( ! $this->should_log_login_failed( $identifier ) ) {
            return;
        }

        $this->add_log( 'Login Failed', [ 'username' => $identifier ], 'warning', 'authentication' );
    }

    /**
     * Resolve a stable login identifier for failed login events.
     *
     * `wp_login_failed` can receive empty usernames in some auth paths.
     * Fall back to request payload where possible, then to "unknown".
     */
    private function resolve_failed_login_identifier( $username, $error = null ): string {
        $candidates = [ $username ];

	        // phpcs:disable WordPress.Security.NonceVerification.Missing
	        if ( isset( $_POST['log'] ) ) {
	            $candidates[] = sanitize_text_field( wp_unslash( $_POST['log'] ) );
	        }
	        if ( isset( $_POST['username'] ) ) {
	            $candidates[] = sanitize_text_field( wp_unslash( $_POST['username'] ) );
	        }
	        if ( isset( $_POST['user_login'] ) ) {
	            $candidates[] = sanitize_text_field( wp_unslash( $_POST['user_login'] ) );
	        }
	        // phpcs:enable WordPress.Security.NonceVerification.Missing

        // Some auth handlers attach user input to WP_Error data.
        if ( $error instanceof \WP_Error ) {
            foreach ( $error->get_error_codes() as $code ) {
                $error_data = $error->get_error_data( $code );
                if ( is_array( $error_data ) ) {
                    if ( isset( $error_data['username'] ) ) {
                        $candidates[] = $error_data['username'];
                    }
                    if ( isset( $error_data['user_login'] ) ) {
                        $candidates[] = $error_data['user_login'];
                    }
                }
            }
        }

        foreach ( $candidates as $candidate ) {
            $normalized = self::normalize_login_identifier( $candidate );
            if ( '' !== $normalized ) {
                return $normalized;
            }
        }

        return 'unknown';
    }

    /**
     * Merge a target user into audit details and force row ownership to that user.
     *
     * @param int   $user_id Subject user ID.
     * @param array $details Event details.
     * @return array
     */
    private static function with_subject_details( int $user_id, array $details = [] ): array {
        $details['_actor_user_id'] = $user_id;

        if ( empty( $details['username'] ) ) {
            $user = get_userdata( $user_id );
            if ( $user instanceof \WP_User ) {
                $details['username'] = $user->user_login;
            }
        }

        return $details;
    }

    /**
     * Decide whether a failed-login event should be persisted.
     *
     * Guards (applied in order):
     * 1. Trust only real credential submissions as defined by the Hardening
     *    module's failed-login signal policy.
     * 2. Deduplication: identical IP+identifier combos within 5 minutes are
     *    collapsed to one entry. The Hardening module handles lockouts.
     */
    private function should_log_login_failed( string $identifier ): bool {
        if ( ! Hardening::should_process_failed_login_signal( $identifier ) ) {
            return false;
        }

        $remote_ip = sanitize_text_field( (string) wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) );

        // Guard 2: deduplication within 5-minute window per IP+identifier.
        $ip  = class_exists( 'SecurWP\\Includes\\IpDetector' )
            ? \SecurWP\Includes\IpDetector::get_ip()
            : $remote_ip;
        $key = 'swp_alf_' . substr( md5( $identifier . '|' . $ip ), 0, 16 );

        if ( get_transient( $key ) !== false ) {
            return false;
        }

        set_transient( $key, 1, 5 * MINUTE_IN_SECONDS );
        return true;
    }

    /**
     * Normalize and sanitize attempted login identifiers.
     */
    private static function normalize_login_identifier( $candidate ): string {
        if ( ! is_scalar( $candidate ) ) {
            return '';
        }

        $raw = trim( wp_unslash( (string) $candidate ) );
        if ( '' === $raw ) {
            return '';
        }

        $sanitized = sanitize_user( $raw, false );
        if ( '' !== $sanitized ) {
            return $sanitized;
        }

        return sanitize_text_field( $raw );
    }

    private function get_plugin_display_name( string $plugin ): string {
        if ( ! function_exists( 'get_plugin_data' ) ) {
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
        }
        $data = get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin, false, false );
        return ! empty( $data['Name'] ) ? $data['Name'] : $plugin;
    }

    public function log_plugin_activation( $plugin ) {
        $this->add_log( 'Plugin Activated', [ 'plugin' => $this->get_plugin_display_name( $plugin ) ], 'info', 'plugins' );
    }

    public function log_plugin_deactivation( $plugin ) {
        $this->add_log( 'Plugin Deactivated', [ 'plugin' => $this->get_plugin_display_name( $plugin ) ], 'warning', 'plugins' );
    }

    public function log_theme_switch( $new_name, $new_theme = null ) {
        // WP 5.8+ sends WP_Theme object as second arg
        $theme_name = is_object( $new_theme ) ? $new_theme->get( 'Name' ) : $new_name;
        $this->add_log( 'Theme Switched', [ 'theme' => $theme_name ], 'info', 'themes' );
    }

    public function log_user_registration( $user_id ) {
        $current_user = wp_get_current_user();
        $user = get_userdata( $user_id );
        if ( ! $user ) {
            return;
        }
        $role     = self::get_wordpress_role_display_for_user( $user, 'none' );
        $severity = in_array( 'administrator', $user->roles, true ) ? 'warning' : 'info';
        $created_by = ( $current_user && $current_user->exists() ) ? $current_user->user_login : 'self_registration';
        $this->add_log( 'User Registered', [
            'username'   => $user->user_login,
            'email'      => $user->user_email,
            'role'       => $role,
            'created_by' => $created_by,
        ], $severity, 'user' );

        if ( self::user_has_privileged_role( $user ) ) {
            self::record_privileged_account_event(
                'user_registered',
                (int) $user_id,
                [
                    'role'  => $role,
                    'roles' => is_array( $user->roles ) ? array_values( $user->roles ) : [],
                ]
            );
        }
    }

    public function log_user_deletion( $user_id ) {
        $user = get_userdata( $user_id );
        if ( $user ) {
            $this->add_log( 'User Deleted', [ 'username' => $user->user_login ], 'warning', 'user' );
        }
    }

    public function log_user_logout() {
        $user = wp_get_current_user();
        $username = $user && $user->exists() ? $user->user_login : 'unknown';
        $this->add_log( 'User Logout', [ 'username' => $username ], 'info', 'authentication' );
    }

    public function log_2fa_enrolled( $user_id, $details = [] ) {
        $this->add_log( 'Two-Factor Enabled', self::with_subject_details( (int) $user_id, is_array( $details ) ? $details : [] ), 'info', 'authentication' );
    }

    public function log_2fa_disabled( $user_id, $details = [] ) {
        $this->add_log( 'Two-Factor Disabled', self::with_subject_details( (int) $user_id, is_array( $details ) ? $details : [] ), 'warning', 'authentication' );
    }

    public function log_2fa_recovery_codes_regenerated( $user_id, $details = [] ) {
        $this->add_log( 'Recovery Codes Regenerated', self::with_subject_details( (int) $user_id, is_array( $details ) ? $details : [] ), 'warning', 'authentication' );
    }

    public function log_2fa_verified( $user_id, $details = [] ) {
        $this->add_log( 'Two-Factor Verified', self::with_subject_details( (int) $user_id, is_array( $details ) ? $details : [] ), 'info', 'authentication' );
    }

    public function log_2fa_verification_failed( $user_id, $details = [] ) {
        $this->add_log( 'Two-Factor Verification Failed', self::with_subject_details( (int) $user_id, is_array( $details ) ? $details : [] ), 'warning', 'authentication' );
    }

    public function log_2fa_challenge_locked( $user_id, $details = [] ) {
        $this->add_log( 'Two-Factor Challenge Locked', self::with_subject_details( (int) $user_id, is_array( $details ) ? $details : [] ), 'warning', 'authentication' );
    }

    public function log_2fa_fallback_activated( $user_id, $details = [] ) {
        $this->add_log( 'Two-Factor Fallback Activated', self::with_subject_details( (int) $user_id, is_array( $details ) ? $details : [] ), 'warning', 'authentication' );
    }

    public function log_2fa_unavailable( $user_id, $details = [] ) {
        $this->add_log( 'Two-Factor Unavailable', self::with_subject_details( (int) $user_id, is_array( $details ) ? $details : [] ), 'error', 'authentication' );
    }

    public function log_2fa_remembered_device( $user_id, $details = [] ) {
        $this->add_log( 'Two-Factor Remembered Device Accepted', self::with_subject_details( (int) $user_id, is_array( $details ) ? $details : [] ), 'info', 'authentication' );
    }

    public function log_user_update( $user_id, $old_user_data ) {
        $user = get_userdata( $user_id );
        if ( ! $user ) {
            return;
        }

        // $old_user_data is a WP_User object per the wp_update_user hook contract,
        // but some code paths (third-party plugins calling wp_update_user programmatically)
        // can deliver an array instead. Normalise defensively.
        if ( is_array( $old_user_data ) ) {
            $old_user_data = (object) $old_user_data;
        }
        if ( ! is_object( $old_user_data ) ) {
            return;
        }

        if ( $user->user_email !== ( $old_user_data->user_email ?? null ) ) {
            $this->add_log( 'User Profile Updated', [ 'username' => $user->user_login, 'change' => 'email' ], 'info', 'user' );
        }

        if ( $user->user_pass !== ( $old_user_data->user_pass ?? null ) ) {
            $current_user    = wp_get_current_user();
            $current_user_id = $current_user && $current_user->exists() ? $current_user->ID : 0;

            if ( 0 === $current_user_id ) {
                $details = [ 'username' => $user->user_login, 'via' => 'system' ];
            } elseif ( $current_user_id === $user_id ) {
                $details = [ 'username' => $user->user_login, 'via' => 'profile' ];
            } else {
                $details = [ 'username' => $user->user_login, 'via' => 'admin', 'changed_by' => $current_user->user_login ];
            }

            $this->add_log( 'Password Changed', $details, 'warning', 'user' );
        }
        // Role changes are handled by the set_user_role hook to avoid double-logging.
    }

    /**
     * Fires when a user's role is changed via WP_User::set_role() or wp_update_user().
     * Skips initial role assignment during user registration (old_roles is empty).
     */
    public function log_role_set( $user_id, $role, $old_roles ) {
        if ( empty( $old_roles ) ) {
            return;
        }
        $user = get_userdata( $user_id );
        if ( ! $user ) {
            return;
        }
        $this->add_log( 'User Role Changed', [
            'username' => $user->user_login,
            'from'     => self::normalize_logged_role_string( implode( ', ', (array) $old_roles ), 'none' ),
            'to'       => self::normalize_logged_role_string( (string) $role, 'none' ),
        ], 'warning', 'user' );

        if ( self::role_is_privileged( (string) $role ) ) {
            self::record_privileged_account_event(
                'role_set',
                (int) $user_id,
                [
                    'new_role'  => sanitize_key( (string) $role ),
                    'old_roles' => array_values( array_map( 'sanitize_key', (array) $old_roles ) ),
                ]
            );
        }
    }

    public function record_privileged_role_added( $user_id, $role ) {
        if ( ! self::role_is_privileged( (string) $role ) ) {
            return;
        }

        self::record_privileged_account_event(
            'role_added',
            (int) $user_id,
            [ 'role' => sanitize_key( (string) $role ) ]
        );
    }

    public function record_privileged_user_meta_added( $meta_id, $user_id, $meta_key, $meta_value ) {
        $this->record_privileged_user_meta_change( 'capability_meta_added', $meta_id, $user_id, $meta_key, $meta_value );
    }

    public function record_privileged_user_meta_updated( $meta_id, $user_id, $meta_key, $meta_value ) {
        $this->record_privileged_user_meta_change( 'capability_meta_updated', $meta_id, $user_id, $meta_key, $meta_value );
    }

    public function record_privileged_super_admin_granted( $user_id ) {
        self::record_privileged_account_event(
            'super_admin_granted',
            (int) $user_id,
            [
                'blog_id' => 0,
                'role'    => 'super_admin',
            ]
        );
    }

    private function record_privileged_user_meta_change( string $event_type, $meta_id, $user_id, $meta_key, $meta_value ): void {
        $key = (string) $meta_key;
        if ( ! self::is_capability_meta_key( $key ) || ! self::capability_payload_is_privileged( $meta_value ) ) {
            return;
        }

        self::record_privileged_account_event(
            $event_type,
            (int) $user_id,
            [
                'meta_id'  => max( 0, (int) $meta_id ),
                // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Audit event payload key, not a WP meta query argument.
                'meta_key' => sanitize_key( $key ),
                'blog_id'  => self::blog_id_from_capability_meta_key( $key ),
            ]
        );
    }

    private static function record_privileged_account_event( string $event_type, int $target_user_id, array $details ): void {
        if ( $target_user_id <= 0 ) {
            return;
        }

        $context = self::build_privileged_event_context( $target_user_id );
        if ( isset( $details['blog_id'] ) ) {
            $context['blog_id'] = max( 0, (int) $details['blog_id'] );
            unset( $details['blog_id'] );
        }

        $event = array_merge(
            $context,
            [
                'id'             => hash( 'sha256', microtime( true ) . '|' . wp_rand() . '|' . $event_type . '|' . $target_user_id ),
                'event_type'     => sanitize_key( $event_type ),
                'target_user_id' => $target_user_id,
                'captured_at'    => time(),
                'details'        => self::sanitize_privileged_event_details( $details ),
            ]
        );

        self::store_privileged_account_event( $event );
    }

    private static function build_privileged_event_context( int $target_user_id ): array {
        $actor = wp_get_current_user();
        $actor_id = $actor && $actor->exists() ? (int) $actor->ID : 0;
        $channel = self::detect_request_channel();
        $request_uri = sanitize_text_field( (string) wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ) );
        $path = '';
        $query_keys = [];

        if ( $request_uri !== '' ) {
            $parsed_path = wp_parse_url( $request_uri, PHP_URL_PATH );
            $path = is_string( $parsed_path ) ? sanitize_text_field( substr( $parsed_path, 0, 160 ) ) : '';
            $query = wp_parse_url( $request_uri, PHP_URL_QUERY );
            if ( is_string( $query ) && $query !== '' ) {
                parse_str( $query, $query_args );
                if ( is_array( $query_args ) ) {
                    $query_keys = array_slice(
                        array_values(
                            array_filter(
                                array_map( 'sanitize_key', array_keys( $query_args ) ),
                                static fn( string $key ): bool => $key !== ''
                            )
                        ),
                        0,
                        20
                    );
                }
            }
        }

        $method = sanitize_key( (string) wp_unslash( $_SERVER['REQUEST_METHOD'] ?? '' ) );
        $flags = [];
        if ( $actor_id <= 0 ) {
            $flags[] = 'unauthenticated_actor';
        }
        if ( $channel === 'frontend' ) {
            $flags[] = 'frontend_request';
        }
        if ( $method === 'get' ) {
            $flags[] = 'get_request';
        }
        if ( ! empty( $query_keys ) ) {
            $flags[] = 'query_trigger';
        }

        return [
            'actor_user_id' => $actor_id,
            'blog_id'       => (int) get_current_blog_id(),
            'channel'       => $channel,
            'method'        => $method,
            'request_path'  => $path,
            'query_keys'    => $query_keys,
            'context_flags' => array_values( array_unique( $flags ) ),
            'target_exists' => get_userdata( $target_user_id ) instanceof \WP_User,
        ];
    }

    private static function detect_request_channel(): string {
        if ( defined( 'WP_CLI' ) && WP_CLI ) {
            return 'cli';
        }
        if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) {
            return 'cron';
        }
        if ( function_exists( 'wp_doing_ajax' ) && wp_doing_ajax() ) {
            return 'ajax';
        }
        if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
            return 'rest';
        }
        if ( is_admin() ) {
            return 'wp_admin';
        }

        return 'frontend';
    }

    private static function store_privileged_account_event( array $event ): void {
        $events = is_multisite()
            ? get_site_option( self::PRIVILEGED_EVENTS_OPTION, [] )
            : get_option( self::PRIVILEGED_EVENTS_OPTION, [] );

        if ( ! is_array( $events ) ) {
            $events = [];
        }

        $dedupe_key = self::privileged_event_dedupe_key( $event );
        $now = time();
        foreach ( array_slice( $events, 0, 12 ) as $existing ) {
            if ( ! is_array( $existing ) ) {
                continue;
            }
            $existing_time = max( 0, (int) ( $existing['captured_at'] ?? 0 ) );
            if ( $existing_time >= ( $now - 2 ) && self::privileged_event_dedupe_key( $existing ) === $dedupe_key ) {
                return;
            }
        }

        array_unshift( $events, $event );
        $events = array_slice( $events, 0, self::PRIVILEGED_EVENTS_LIMIT );

        if ( is_multisite() ) {
            update_site_option( self::PRIVILEGED_EVENTS_OPTION, $events );
        } else {
            update_option( self::PRIVILEGED_EVENTS_OPTION, $events, false );
        }
    }

    private static function privileged_event_dedupe_key( array $event ): string {
        $details = isset( $event['details'] ) && is_array( $event['details'] ) ? $event['details'] : [];

        return implode(
            '|',
            [
                sanitize_key( (string) ( $event['event_type'] ?? '' ) ),
                max( 0, (int) ( $event['target_user_id'] ?? 0 ) ),
                max( 0, (int) ( $event['blog_id'] ?? 0 ) ),
                sanitize_key( (string) ( $details['role'] ?? $details['new_role'] ?? $details['meta_key'] ?? '' ) ),
            ]
        );
    }

    private static function sanitize_privileged_event_details( array $details ): array {
        $allowed = [];
        foreach ( [ 'role', 'new_role', 'meta_key' ] as $key ) {
            if ( isset( $details[ $key ] ) ) {
                $allowed[ $key ] = sanitize_key( (string) $details[ $key ] );
            }
        }
        foreach ( [ 'meta_id' ] as $key ) {
            if ( isset( $details[ $key ] ) ) {
                $allowed[ $key ] = max( 0, (int) $details[ $key ] );
            }
        }
        foreach ( [ 'roles', 'old_roles' ] as $key ) {
            if ( isset( $details[ $key ] ) && is_array( $details[ $key ] ) ) {
                $allowed[ $key ] = array_values(
                    array_filter(
                        array_map( 'sanitize_key', $details[ $key ] ),
                        static fn( string $value ): bool => $value !== ''
                    )
                );
            }
        }

        return $allowed;
    }

    private static function user_has_privileged_role( \WP_User $user ): bool {
        foreach ( (array) $user->roles as $role ) {
            if ( self::role_is_privileged( (string) $role ) ) {
                return true;
            }
        }

        return user_can( $user, 'manage_options' );
    }

    private static function role_is_privileged( string $role ): bool {
        $role = sanitize_key( $role );
        if ( $role === 'administrator' || $role === 'super_admin' ) {
            return true;
        }
        if ( $role === '' || ! function_exists( 'wp_roles' ) ) {
            return false;
        }

        $roles = wp_roles();
        $definition = is_object( $roles ) && isset( $roles->roles[ $role ] ) && is_array( $roles->roles[ $role ] )
            ? $roles->roles[ $role ]
            : [];
        $caps = isset( $definition['capabilities'] ) && is_array( $definition['capabilities'] )
            ? $definition['capabilities']
            : [];

        foreach ( self::privileged_capability_names() as $cap ) {
            if ( ! empty( $caps[ $cap ] ) ) {
                return true;
            }
        }

        return false;
    }

    private static function capability_payload_is_privileged( $value ): bool {
        $payload = maybe_unserialize( $value );
        if ( is_array( $payload ) ) {
            foreach ( $payload as $key => $enabled ) {
                if ( ! $enabled ) {
                    continue;
                }
                $cap_or_role = sanitize_key( (string) $key );
                if ( $cap_or_role === 'administrator' || self::role_is_privileged( $cap_or_role ) || in_array( $cap_or_role, self::privileged_capability_names(), true ) ) {
                    return true;
                }
            }
        }

        $raw_payload = is_scalar( $value ) ? (string) $value : (string) wp_json_encode( $value );
        $raw = strtolower( $raw_payload );
        if ( strpos( $raw, 'administrator' ) !== false ) {
            return true;
        }
        foreach ( self::privileged_capability_names() as $cap ) {
            if ( strpos( $raw, $cap ) !== false ) {
                return true;
            }
        }

        return false;
    }

    private static function is_capability_meta_key( string $meta_key ): bool {
        global $wpdb;

        if ( ! is_multisite() ) {
            return $meta_key === $wpdb->prefix . 'capabilities';
        }

        $base = preg_quote( $wpdb->base_prefix, '/' );
        return preg_match( '/^' . $base . '(?:[0-9]+_)?capabilities$/', $meta_key ) === 1;
    }

    private static function blog_id_from_capability_meta_key( string $meta_key ): int {
        global $wpdb;

        if ( ! is_multisite() ) {
            return (int) get_current_blog_id();
        }

        $base = preg_quote( $wpdb->base_prefix, '/' );
        if ( preg_match( '/^' . $base . 'capabilities$/', $meta_key ) ) {
            return function_exists( 'get_main_site_id' ) ? (int) get_main_site_id() : 1;
        }
        if ( preg_match( '/^' . $base . '([0-9]+)_capabilities$/', $meta_key, $matches ) ) {
            return max( 1, (int) $matches[1] );
        }

        return (int) get_current_blog_id();
    }

    private static function privileged_capability_names(): array {
        return [
            'manage_options',
        ];
    }

    public function log_password_reset_requested( $user_login ) {
        // retrieve_password can fire with an email address if user entered email.
        if ( is_email( $user_login ) ) {
            $found = get_user_by( 'email', $user_login );
            if ( $found ) {
                $user_login = $found->user_login;
            }
        }
        $this->add_log( 'Password Reset Requested', [ 'username' => $user_login ], 'warning', 'user' );
    }

    public function log_password_reset( $user, $new_password ) {
        $this->add_log( 'Password Reset Completed', [ 'username' => $user->user_login ], 'warning', 'user' );
    }

    public function log_app_password_created( $user_id, $item, $new_password, $args ) {
        $user = get_userdata( $user_id );
        $this->add_log( 'Application Password Created', [
            'username' => $user ? $user->user_login : 'unknown',
            'app_name' => $item['name'] ?? 'unnamed',
        ], 'warning', 'user' );
    }

    public function log_app_password_deleted( $user_id, $item ) {
        $user = get_userdata( $user_id );
        $this->add_log( 'Application Password Deleted', [
            'username' => $user ? $user->user_login : 'unknown',
            'app_name' => $item['name'] ?? 'unnamed',
        ], 'warning', 'user' );
    }

    public function log_option_update( $option, $old_value, $value ) {
        // List of sensitive options to track
        $sensitive_options = [
            'siteurl', 'home', 'admin_email', 'users_can_register', 
            'default_role', 'active_plugins', 'start_of_week'
        ];

        if ( in_array( $option, $sensitive_options, true ) ) {
             $this->add_log( 'Site Setting Changed', [ 'option' => $option, 'from' => $old_value, 'to' => $value ], 'warning', 'settings' );
        }
    }

    public function log_update_complete( $upgrader_object, $options ) {
        if ( $options['action'] === 'update' && $options['type'] === 'plugin' ) {
            if ( isset( $options['plugins'] ) ) {
                foreach ( $options['plugins'] as $plugin ) {
                     $this->add_log( 'Plugin Updated', [ 'plugin' => $this->get_plugin_display_name( $plugin ) ], 'info', 'updates' );
                }
            }
        }
        if ( $options['action'] === 'update' && $options['type'] === 'theme' ) {
             if ( isset( $options['themes'] ) ) {
                foreach ( $options['themes'] as $theme ) {
                     $this->add_log( 'Theme Updated', [ 'theme' => $theme ], 'info', 'updates' );
                }
            }
        }
        if ( $options['action'] === 'update' && $options['type'] === 'core' ) {
            $this->add_log( 'WordPress Core Updated', [], 'info', 'updates' );
        }
    }

}
