<?php

declare(strict_types=1);

namespace SiteFort\Modules\Firewall;

use SiteFort\Core\Database\MigrationManager;
use SiteFort\Core\Database\SchemaInspector;
use SiteFort\Includes\GuardedDataFile;

// If this file is called directly, abort.
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * Firewall Event Logger
 *
 * Persists every firewall block to the wp_sitefort_firewall_events table.
 * Hooked to sitefort_firewall_block at priority 5. Notification digest delivery
 * reads this table on its own schedule instead of sending per-event alerts.
 * Provides cleanup for the daily retention cron and severity mapping for the
 * dashboard security feed.
 *
 * Firewall events are the source of truth for analytics and digest summaries.
 *
 */
class FirewallLogger {

    private const WAF_INSERT_OK    = 1;
    private const WAF_INSERT_SKIP  = 0;
    private const WAF_INSERT_DEFER = -1;

    // Daily counts of deleted rows: [ utc_day_index => [ blog_id => count ] ].
    private const PURGED_COUNTS_OPTION = 'sitefort_firewall_purged_counts';

    // Covers the 90-day dashboard window plus timezone slack.
    private const PURGED_BUCKET_DAYS = 91;

    private const DELETE_BATCH_SIZE = 5000;

    // Serializes cleanup and manual clear so a batch is never counted twice.
    private const MAINTENANCE_LOCK_OPTION = 'sitefort_firewall_log_maintenance_lock';

    private const MAINTENANCE_LOCK_TTL = 300;

    private const DEFAULT_MAX_ROWS = 100000;

    /**
     * Guards the table-existence check so it only runs once per request.
     */
    private static bool $table_runtime_checked = false;

    private static bool $schema_checked = false;

    private static bool $table_ready = false;

    private static bool $schema_ready = false;

    private static bool $table_missing_logged = false;

    private static ?string $table_context_prefix = null;

    /**
     * Severity levels by attack type prefix.
     * Country Block entries arrive as "Country Block: XX" so prefix matching is used.
     */
    private const SEVERITY_MAP = [
        'IP Ban'              => 'warning',
        'Community Blocklist' => 'warning',
        'Bot Ban'             => 'warning',
        'Sensitive Path'            => 'warning',
        'Sensitive Path Escalation' => 'warning',
        'Scanner Probe'             => 'warning',
        'Login Lockout'             => 'warning',
        'XML-RPC'             => 'warning',
        'REST API Block'      => 'info',
        '404 Flood'           => 'info',
        'Rate Limit'          => 'info',
        'Country Block'       => 'info',
        'UA Ban'              => 'info',
    ];

    /**
     * Determine whether an attack type should be recorded as an HTTP 429 event.
     */
    private static function is_rate_limit_attack_type( string $attack_type ): bool {
        return $attack_type === '404 Flood'
            || $attack_type === 'Rate Limit'
            || str_starts_with( $attack_type, 'Rate Limit ' );
    }

    private static function is_rate_limit_reason( string $reason ): bool {
        $reason = strtolower( trim( str_replace( '_', ' ', $reason ) ) );

        return str_starts_with( $reason, 'rate limit' );
    }

    private static function table_name(): string {
        global $wpdb;

        return $wpdb->prefix . 'sitefort_firewall_events';
    }

    /**
     * Read a server value safely for request metadata capture.
     */
    private static function server_value( string $key, string $default = '' ): string {
        $value = filter_input(
            INPUT_SERVER,
            $key,
            FILTER_CALLBACK,
            [ 'options' => [ self::class, 'sanitize_server_scalar' ] ]
        );
        if ( ! is_string( $value ) || $value === '' ) {
            return $default;
        }

        return $value;
    }

    /**
     * @param mixed $value
     */
    public static function sanitize_server_scalar( $value ): string {
        if ( ! is_scalar( $value ) ) {
            return '';
        }

        $value = str_replace( "\0", '', (string) $value );
        $value = preg_replace( '/[\x00-\x1F\x7F]/', '', $value );

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

    /**
     * Check whether the firewall events table is ready for runtime use.
     *
     * Runtime firewall requests trust the migration registry rather than
     * probing information_schema on the hot path. Deep schema verification is
     * reserved for migrations and admin-style reads via is_schema_current().
     */
    public static function is_table_ready(): bool {
        self::sync_table_context();

        if ( self::$table_runtime_checked ) {
            return self::$table_ready;
        }

        self::$table_runtime_checked = true;
        self::$table_ready           = MigrationManager::is_module_current( 'firewall' );

        if ( ! self::$table_ready && ! self::$table_missing_logged ) {
            sitefort_log( 'SiteFort Firewall: firewall events table is unavailable. Traffic log writes are paused until schema migration completes.' );
            self::$table_missing_logged = true;
        }

        return self::$table_ready;
    }

    /**
     * Reset memoized table state after schema migrations.
     */
    public static function reset_table_state(): void {
        self::$table_runtime_checked = false;
        self::$schema_checked        = false;
        self::$table_ready           = false;
        self::$schema_ready          = false;
        self::$table_missing_logged  = false;
    }

    /**
     * Reset memoized table state when execution switches to a different blog.
     */
    private static function sync_table_context(): void {
        global $wpdb;

        $prefix = isset( $wpdb->prefix ) ? (string) $wpdb->prefix : '';
        if ( self::$table_context_prefix === $prefix ) {
            return;
        }

        self::$table_context_prefix = $prefix;
        self::reset_table_state();
    }

    /**
     * Check whether the firewall events schema is fully available.
     */
    public static function is_schema_current(): bool {
        self::sync_table_context();

        if ( self::$schema_checked ) {
            return self::$schema_ready;
        }

        $table = self::table_name();
        $ready = SchemaInspector::table_exists( $table )
            && SchemaInspector::has_columns(
                $table,
                [
                    'id',
                    'event_time',
                    'ip_address',
                    'request_uri',
                    'http_method',
                    'http_status',
                    'attack_type',
                    'user_agent',
                    'user_id',
                    'blog_id',
                ]
            )
            && SchemaInspector::has_indexes(
                $table,
                [
                    'PRIMARY',
                    'idx_event_time',
                    'idx_ip_address',
                    'idx_attack_type',
                    'idx_ip_time',
                    'idx_blog_time',
                ]
            );

        self::$schema_checked        = true;
        self::$schema_ready          = $ready;
        self::$table_runtime_checked = true;
        self::$table_ready           = $ready;

        if ( ! $ready && ! self::$table_missing_logged ) {
            sitefort_log( 'SiteFort Firewall: firewall events table is unavailable. Traffic log writes are paused until schema migration completes.' );
            self::$table_missing_logged = true;
        }

        return $ready;
    }

    /**
     * Log a firewall block event to the database.
     *
     * Hooked to: sitefort_firewall_block (priority 5, 1 arg)
     *
     * @param array $data {
     *     @type string $ip          Blocked IP address.
     *     @type string $attack_type Block reason (e.g. 'IP Ban', 'Rate Limit').
     *     @type string $request_uri Requested URI.
     *     @type string $site_url    Site URL (unused here, kept for hook compat).
     * }
     */
    public static function log( array $data ): void {
        if ( ! self::is_table_ready() ) {
            return;
        }

        global $wpdb;
        $table = self::table_name();

        $attack_type = (string) ( $data['attack_type'] ?? 'Firewall Block' );
        $http_status = isset( $data['http_status'] ) ? (int) $data['http_status'] : 0;
        if ( $http_status < 100 ) {
            $http_status = self::is_rate_limit_attack_type( $attack_type ) ? 429 : 403;
        }

        // Store raw request_uri and user_agent; mb_substr preserves multibyte sequences.
        $request_uri = mb_substr( (string) ( $data['request_uri'] ?? '' ), 0, 500, 'UTF-8' );
        $user_agent  = mb_substr( self::server_value( 'HTTP_USER_AGENT' ), 0, 500, 'UTF-8' );
        $http_method = mb_substr( self::server_value( 'REQUEST_METHOD', 'GET' ), 0, 10, 'UTF-8' );

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Inserts a single event row into a plugin-owned table.
        $result = $wpdb->insert(
            $table,
            [
                'event_time'  => time(),
                'ip_address'  => (string) ( $data['ip'] ?? '' ),
                'request_uri' => $request_uri,
                'http_method' => $http_method,
                'http_status' => $http_status,
                'attack_type' => $attack_type,
                'user_agent'  => $user_agent,
                'user_id'     => get_current_user_id(),
                'blog_id'     => get_current_blog_id(),
            ],
            [ '%d', '%s', '%s', '%s', '%d', '%s', '%s', '%d', '%d' ]
        );

        if ( $result === false && ! empty( $wpdb->last_error ) ) {
            sitefort_log( 'SiteFort Firewall: failed to write firewall event - ' . $wpdb->last_error );
        }
    }

    /**
     * Delete events past retention and enforce the row cap.
     *
     * Hooked to: sitefort_firewall_log_cleanup (daily cron)
     * Shares the `data_retention_days` global setting with the audit log.
     */
    public static function cleanup(): void {
        if ( ! self::is_schema_current() ) {
            return;
        }

        global $wpdb;
        $table = self::table_name();

        $global_settings = \SiteFort\Includes\SettingsStore::get( \SiteFort\Includes\SettingsStore::COL_GLOBAL );
        $days = isset( $global_settings['data_retention_days'] ) ? (int) $global_settings['data_retention_days'] : 90;
        $days = (int) apply_filters( 'sitefort_firewall_log_retention_days', $days );
        if ( $days < 1 ) {
            $days = 90;
        }

        $cutoff  = time() - ( $days * DAY_IN_SECONDS );

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

        // Budgeted so a huge backlog exits cleanly instead of hitting PHP limits;
        // the next daily run continues.
        $deadline = time() + 45;

        try {
            do {
                $deleted = self::delete_event_batch( 'WHERE event_time < %d', [ $cutoff ] );
            } while ( $deleted >= self::DELETE_BATCH_SIZE && time() < $deadline );

            // Trim oldest-first past the row cap.
            $max_rows = (int) apply_filters( 'sitefort_firewall_log_max_rows', self::DEFAULT_MAX_ROWS );
            if ( $max_rows > 0 ) {
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Row count of a plugin-owned event table.
                $excess = (int) $wpdb->get_var( "SELECT COUNT(*) FROM $table" ) - $max_rows;
                while ( $excess > 0 && time() < $deadline ) {
                    $deleted = self::delete_event_batch( '', [], min( self::DELETE_BATCH_SIZE, $excess ), 'ORDER BY event_time ASC' );
                    if ( $deleted < 1 ) {
                        break;
                    }
                    $excess -= $deleted;
                }
            }
        } finally {
            self::release_maintenance_lock();
        }
    }

    /**
     * Clear logged events for one blog in time-budgeted batches.
     *
     * @return array{deleted:int,remaining:int}|null Null when the lock is held.
     */
    public static function clear_events( int $blog_id, int $time_budget_seconds = 15 ): ?array {
        if ( ! self::is_schema_current() ) {
            return [
                'deleted'   => 0,
                'remaining' => 0,
            ];
        }

        if ( ! self::acquire_maintenance_lock() ) {
            return null;
        }

        global $wpdb;
        $table    = self::table_name();
        $deadline = time() + max( 1, $time_budget_seconds );
        $total    = 0;

        try {
            do {
                $deleted = self::delete_event_batch( 'WHERE blog_id = %d', [ $blog_id ] );
                $total  += $deleted;
            } while ( $deleted >= self::DELETE_BATCH_SIZE && time() < $deadline );

            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Row count of a plugin-owned event table.
            $remaining = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM $table WHERE blog_id = %d", $blog_id ) );
        } finally {
            self::release_maintenance_lock();
        }

        return [
            'deleted'   => $total,
            'remaining' => $remaining,
        ];
    }

    private static function acquire_maintenance_lock(): bool {
        $now = time();
        if ( add_option( self::MAINTENANCE_LOCK_OPTION, (string) $now, '', false ) ) {
            return true;
        }

        $held = (int) get_option( self::MAINTENANCE_LOCK_OPTION );
        if ( $held >= $now - self::MAINTENANCE_LOCK_TTL ) {
            return false;
        }

        // Steal locks left behind by crashed runs.
        update_option( self::MAINTENANCE_LOCK_OPTION, (string) $now, false );
        return true;
    }

    private static function release_maintenance_lock(): void {
        delete_option( self::MAINTENANCE_LOCK_OPTION );
    }

    /**
     * Fetch a batch, fold its counts into the purged buckets, delete by id.
     * Only the row cap needs $order_sql; the other callers eventually delete
     * everything they match, so they skip the sort.
     */
    private static function delete_event_batch( string $where_sql, array $params, int $limit = self::DELETE_BATCH_SIZE, string $order_sql = '' ): int {
        global $wpdb;
        $table = self::table_name();

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Bounded maintenance of a plugin-owned event table.
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT id, blog_id, event_time FROM $table $where_sql $order_sql LIMIT %d",
                ...array_merge( $params, [ $limit ] )
            ),
            ARRAY_A
        );

        if ( empty( $rows ) ) {
            return 0;
        }

        // Past every stats window; not worth counting.
        $stats_floor = time() - ( self::PURGED_BUCKET_DAYS * DAY_IN_SECONDS );

        $ids     = [];
        $buckets = [];
        foreach ( $rows as $row ) {
            $ids[] = (int) $row['id'];

            $event_time = (int) $row['event_time'];
            if ( $event_time < $stats_floor ) {
                continue;
            }

            $day  = intdiv( $event_time, DAY_IN_SECONDS );
            $blog = (int) $row['blog_id'];

            $buckets[ $day ][ $blog ] = ( $buckets[ $day ][ $blog ] ?? 0 ) + 1;
        }

        $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
        $deleted = (int) $wpdb->query(
            $wpdb->prepare( "DELETE FROM $table WHERE id IN ($placeholders)", ...$ids )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,PluginCheck.Security.DirectDB.UnescapedDBParameter

        if ( $deleted > 0 ) {
            self::merge_purged_buckets( $buckets );
        }

        return $deleted;
    }

    /**
     * @param array<int,array<int,int>> $buckets utc_day_index => blog_id => count
     */
    private static function merge_purged_buckets( array $buckets ): void {
        if ( empty( $buckets ) ) {
            return;
        }

        $stored = get_option( self::PURGED_COUNTS_OPTION, [] );
        if ( ! is_array( $stored ) ) {
            $stored = [];
        }

        foreach ( $buckets as $day => $blogs ) {
            foreach ( $blogs as $blog => $count ) {
                $stored[ $day ][ $blog ] = (int) ( $stored[ $day ][ $blog ] ?? 0 ) + $count;
            }
        }

        $min_day = intdiv( time(), DAY_IN_SECONDS ) - self::PURGED_BUCKET_DAYS;
        foreach ( array_keys( $stored ) as $day ) {
            if ( (int) $day < $min_day ) {
                unset( $stored[ $day ] );
            }
        }

        update_option( self::PURGED_COUNTS_OPTION, $stored, false );
    }

    /**
     * Purged blocks inside [$start, $end); a bucket counts toward the window
     * holding its UTC-day midpoint.
     */
    public static function count_purged_between( int $start, int $end, ?int $blog_id = null ): int {
        $stored = get_option( self::PURGED_COUNTS_OPTION, [] );
        if ( ! is_array( $stored ) || empty( $stored ) ) {
            return 0;
        }

        $total = 0;
        foreach ( $stored as $day => $blogs ) {
            $midpoint = (int) $day * DAY_IN_SECONDS + ( DAY_IN_SECONDS / 2 );
            if ( $midpoint < $start || $midpoint >= $end || ! is_array( $blogs ) ) {
                continue;
            }

            if ( $blog_id === null ) {
                $total += (int) array_sum( array_map( 'intval', $blogs ) );
            } else {
                $total += (int) ( $blogs[ $blog_id ] ?? 0 );
            }
        }

        return $total;
    }

    public static function count_purged_since( int $since, ?int $blog_id = null ): int {
        return self::count_purged_between( $since, PHP_INT_MAX, $blog_id );
    }

    /**
     * Map an attack_type string to a dashboard severity level.
     *
     * Uses prefix matching to handle compound values like "Country Block: DE".
     *
     * @param string $attack_type Value stored in the attack_type column.
     * @return string 'info' | 'warning' | 'error' | 'critical'
     */
    public static function get_severity( string $attack_type ): string {
        foreach ( self::SEVERITY_MAP as $prefix => $severity ) {
            if ( str_starts_with( $attack_type, $prefix ) ) {
                return $severity;
            }
        }
        return 'warning';
    }

    /**
     * Ingest WAF bootstrap block log entries into the firewall_events table.
     *
     * The WAF bootstrap (auto_prepend_file) runs before WordPress loads and
     * writes blocks to a guarded flat file. This method imports those entries
     * into the per-site firewall event tables during admin and scheduled
     * reconciliation passes, while throttling work to at most once per minute.
     */
    public static function ingest_waf_log(): void {
        if ( ! self::is_schema_current() ) {
            return;
        }

        // Throttle: import at most once per minute.
        if ( get_transient( 'sitefort_waf_log_ingested' ) ) {
            return;
        }

        $data_dir        = WP_CONTENT_DIR . '/sitefort-data';
        $log_file        = $data_dir . '/waf-blocks.php';
        $sources         = [];

        if ( is_file( $log_file ) ) {
            $drained = GuardedDataFile::drain_payload( $log_file );
            if ( is_string( $drained ) && $drained !== '' ) {
                $sources[] = [
                    'path'    => $log_file,
                    'raw'     => $drained,
                    'guarded' => true,
                    'drained' => true,
                ];
            }
        }

        $bak_files = glob( $data_dir . '/waf-blocks.*.php' ) ?: [];

        if ( is_array( $bak_files ) ) {
            foreach ( $bak_files as $bak ) {
                $bak_raw = GuardedDataFile::read_payload( $bak );

                if ( ! is_string( $bak_raw ) || $bak_raw === '' ) {
                    wp_delete_file( $bak );
                    continue;
                }

                $sources[] = [
                    'path'    => $bak,
                    'raw'     => $bak_raw,
                    'guarded' => true,
                    'drained' => false,
                ];
            }
        }

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

        $remaining_capacity = 500;

        foreach ( $sources as $source ) {
            [ $processed, $remaining ] = self::import_waf_source_payload(
                (string) $source['raw'],
                $remaining_capacity
            );

            $remaining_capacity -= $processed;
            self::finalize_waf_source( $source, $remaining );

            if ( $remaining_capacity <= 0 ) {
                break;
            }
        }

        // Set throttle transient (60 seconds).
        set_transient( 'sitefort_waf_log_ingested', 1, 60 );
    }

    /**
     * Import WAF payload lines until the caller's capacity is exhausted.
     *
     * @return array{0:int,1:string} Inserted rows and unprocessed payload.
     */
    private static function import_waf_source_payload( string $raw, int $capacity ): array {
        if ( $capacity < 1 ) {
            return [ 0, $raw ];
        }

        $lines = preg_split( "/\r\n|\r|\n/", $raw );
        if ( ! is_array( $lines ) ) {
            return [ 0, '' ];
        }

        $inserted        = 0;
        $remaining_lines = [];

        foreach ( $lines as $index => $line ) {
            if ( $inserted >= $capacity ) {
                $remaining_lines = array_slice( $lines, $index );
                break;
            }

            $entry = self::parse_waf_log_line( $line );
            if ( null === $entry ) {
                continue;
            }

            $status = self::insert_waf_entry( $entry );
            if ( self::WAF_INSERT_DEFER === $status ) {
                $remaining_lines = array_slice( $lines, $index );
                break;
            }

            if ( self::WAF_INSERT_OK === $status ) {
                $inserted++;
            }
        }

        return [ $inserted, self::join_waf_lines( $remaining_lines ) ];
    }

    /**
     * Finalize a WAF source after import, preserving any unprocessed rows.
     *
     * @param array{path:string,raw:string,guarded:bool,drained:bool} $source
     */
    private static function finalize_waf_source( array $source, string $remaining ): void {
        if ( ! empty( $source['drained'] ) ) {
            if ( $remaining !== '' ) {
                self::write_waf_source_payload( $source['path'], $remaining, ! empty( $source['guarded'] ) );
            }

            return;
        }

        if ( $remaining === $source['raw'] ) {
            return;
        }

        if ( $remaining === '' ) {
            wp_delete_file( $source['path'] );
            return;
        }

        self::write_waf_source_payload( $source['path'], $remaining, ! empty( $source['guarded'] ) );
    }

    /**
     * Persist the remainder of a WAF source for a later import pass.
     */
    private static function write_waf_source_payload( string $path, string $payload, bool $guarded ): void {
        $ok = $guarded
            ? GuardedDataFile::write_payload_atomic( $path, $payload )
            : ( false !== @file_put_contents( $path, $payload, LOCK_EX ) );

        if ( ! $ok ) {
            sitefort_log( 'SiteFort Firewall: failed to persist deferred WAF log entries for retry.' );
        }
    }

    /**
     * Join deferred WAF log lines back into a payload.
     *
     * @param array<int,string> $lines
     */
    private static function join_waf_lines( array $lines ): string {
        if ( empty( $lines ) ) {
            return '';
        }

        return implode( "\n", $lines ) . "\n";
    }

    /** @return array<string,int|string>|null */
    private static function parse_waf_log_line( string $line ): ?array {
        $line = trim( $line );
        if ( $line === '' ) {
            return null;
        }

        if ( str_starts_with( $line, '{' ) ) {
            $decoded = json_decode( $line, true );
            if ( is_array( $decoded ) ) {
                $reason = trim( (string) ( $decoded['reason'] ?? '' ) );
                if ( $reason === '' ) {
                    return null;
                }

                $timestamp = $decoded['timestamp'] ?? 0;
                $event_time = is_numeric( $timestamp )
                    ? (int) $timestamp
                    : (int) strtotime( trim( (string) $timestamp ) . ' UTC' );

                if ( $event_time <= 0 ) {
                    return null;
                }

                $request_uri = (string) ( $decoded['request_uri'] ?? '/' );
                if ( $request_uri === '' ) {
                    $request_uri = '/';
                }

                $http_method = strtoupper( trim( (string) ( $decoded['method'] ?? '' ) ) );
                if ( $http_method === '' ) {
                    $http_method = 'GET';
                }

                $attack_type = self::map_waf_reason( $reason );
                $http_status = isset( $decoded['http_status'] ) && is_numeric( $decoded['http_status'] )
                    ? (int) $decoded['http_status']
                    : 0;
                if ( $http_status < 100 || $http_status > 599 ) {
                    $http_status = (
                        self::is_rate_limit_reason( $reason )
                        || self::is_rate_limit_attack_type( $attack_type )
                    ) ? 429 : 403;
                }

                return [
                    'event_time'  => $event_time,
                    'ip_address'  => (string) ( $decoded['ip'] ?? '' ),
                    'request_uri' => $request_uri,
                    'http_method' => $http_method,
                    'http_status' => $http_status,
                    'attack_type' => $attack_type,
                    'user_agent'  => (string) ( $decoded['user_agent'] ?? '' ),
                    'blog_id'     => self::resolve_waf_blog_id(
                        (string) ( $decoded['host'] ?? '' ),
                        $request_uri
                    ),
                ];
            }
        }

        return null;
    }

    /**
     * Insert a WAF bootstrap event into the appropriate site-local event table.
     *
     * @param array<string,int|string> $entry
     */
    private static function insert_waf_entry( array $entry ): int {
        $target_blog_id  = max( 1, (int) ( $entry['blog_id'] ?? get_current_blog_id() ) );
        $current_blog_id = get_current_blog_id();
        $switched_blog   = false;

        if ( is_multisite() && $target_blog_id !== $current_blog_id ) {
            switch_to_blog( $target_blog_id );
            $switched_blog = true;
        }

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

            global $wpdb;
            $http_method = strtoupper( trim( (string) $entry['http_method'] ) );
            if ( $http_method === '' ) {
                $http_method = 'GET';
            }

            $user_agent = trim( (string) ( $entry['user_agent'] ?? '' ) );
            if ( $user_agent === '' ) {
                $user_agent = 'WAF Bootstrap';
            }

            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Inserts imported WAF log rows into a plugin-owned table.
            $result = $wpdb->insert(
                self::table_name(),
                [
                    'event_time'  => (int) $entry['event_time'],
                    'ip_address'  => (string) $entry['ip_address'],
                    'request_uri' => mb_substr( (string) $entry['request_uri'], 0, 500, 'UTF-8' ),
                    'http_method' => mb_substr( $http_method, 0, 10, 'UTF-8' ),
                    'http_status' => (int) $entry['http_status'],
                    'attack_type' => mb_substr( (string) $entry['attack_type'], 0, 100, 'UTF-8' ),
                    'user_agent'  => mb_substr( $user_agent, 0, 500, 'UTF-8' ),
                    'user_id'     => 0,
                    'blog_id'     => $target_blog_id,
                ],
                [ '%d', '%s', '%s', '%s', '%d', '%s', '%s', '%d', '%d' ]
            );

            if ( $result !== false ) {
                return self::WAF_INSERT_OK;
            }

            $error = (string) $wpdb->last_error;
            if ( $error !== '' ) {
                sitefort_log( 'SiteFort Firewall: failed to import WAF event - ' . $error );
            }

            return self::waf_insert_error_is_transient( $error )
                ? self::WAF_INSERT_DEFER
                : self::WAF_INSERT_SKIP;
        } finally {
            if ( $switched_blog ) {
                restore_current_blog();
                self::sync_table_context();
            }
        }
    }

    // Connection-class errors retry; a data error means the row is bad and gets skipped.
    private static function waf_insert_error_is_transient( string $error ): bool {
        if ( $error === '' ) {
            return true;
        }

        foreach ( [ 'gone away', 'Lost connection', 'Deadlock', 'Lock wait', 'too many connections' ] as $needle ) {
            if ( stripos( $error, $needle ) !== false ) {
                return true;
            }
        }

        return false;
    }

    /**
     * Resolve the destination site for a pre-WordPress WAF event.
     */
    private static function resolve_waf_blog_id( string $host, string $request_uri ): int {
        $current_blog_id = max( 1, get_current_blog_id() );
        if ( ! is_multisite() ) {
            return $current_blog_id;
        }

        $host = self::normalize_waf_host( $host );
        if ( $host === '' ) {
            $uri_host = wp_parse_url( $request_uri, PHP_URL_HOST );
            $host     = is_string( $uri_host ) ? self::normalize_waf_host( $uri_host ) : '';
        }

        if ( $host === '' && function_exists( 'get_network' ) ) {
            $network = get_network();
            if ( $network instanceof \WP_Network && ! empty( $network->domain ) ) {
                $host = self::normalize_waf_host( (string) $network->domain );
            }
        }

        if ( $host === '' || ! function_exists( 'get_site_by_path' ) ) {
            return $current_blog_id;
        }

        $path = wp_parse_url( $request_uri, PHP_URL_PATH );
        $path = is_string( $path ) && $path !== '' ? $path : '/';

        $site = get_site_by_path( $host, $path );
        if ( $site instanceof \WP_Site && ! empty( $site->blog_id ) ) {
            return (int) $site->blog_id;
        }

        return $current_blog_id;
    }

    /**
     * Normalize a host header for multisite lookups.
     */
    private static function normalize_waf_host( string $host ): string {
        $host = trim( strtolower( $host ) );
        if ( $host === '' ) {
            return '';
        }

        $parsed = wp_parse_url(
            str_contains( $host, '://' ) ? $host : 'http://' . $host,
            PHP_URL_HOST
        );

        if ( is_string( $parsed ) && $parsed !== '' ) {
            return strtolower( $parsed );
        }

        return preg_replace( '/:\d+$/', '', $host ) ?: '';
    }

    /**
     * Map a WAF log reason string to a standardized attack_type.
     *
     * @param string $reason Raw reason from the WAF log line.
     * @return string Normalized attack_type for the firewall_events table.
     */
    private static function map_waf_reason( string $reason ): string {
        $reason = trim( $reason );

        $map = [
            'Community blocklist' => 'Community Blocklist',
            'IP blocklist'        => 'Community Blocklist',
            'Rate limit'          => 'Rate Limit',
            'rate_limit'          => 'Rate Limit',
            'Country block'       => 'Country Block',
        ];

        // Case-insensitive prefix match.
        $lower = strtolower( $reason );
        foreach ( $map as $prefix => $type ) {
            if ( str_starts_with( $lower, strtolower( $prefix ) ) ) {
                // Preserve any suffix (e.g. "Country block: DE" -> "Country Block: DE").
                $suffix = substr( $reason, strlen( $prefix ) );
                return $type . $suffix;
            }
        }

        // Fallback: use as-is with first letter capitalized.
        return ucfirst( $reason );
    }
}
