<?php

declare(strict_types=1);

namespace SecurWP\Modules\Firewall;

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

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

/**
 * Firewall Event Logger
 *
 * Persists every firewall block to the wp_securwp_firewall_events table.
 * Hooked to securwp_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 {

    /**
     * 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',
        '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' || str_ends_with( $attack_type, 'Rate Limit' );
    }

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

        return $wpdb->prefix . 'securwp_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_UNSAFE_RAW );
        if ( ! is_string( $value ) || $value === '' ) {
            return $default;
        }

        return wp_unslash( $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 ) {
            securwp_log( 'SecurWP 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 ) {
            securwp_log( 'SecurWP 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: securwp_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 ) ) {
            securwp_log( 'SecurWP Firewall: failed to write firewall event - ' . $wpdb->last_error );
        }
    }

    /**
     * Delete firewall events older than the configured retention period.
     *
     * Hooked to: securwp_firewall_log_cleanup (daily cron)
     * Uses the same `data_retention_days` global setting as the audit log
     * so both tables age out together under a single admin control.
     * Falls back to 90 days if the setting is not configured.
     * Uses a LIMIT to avoid long table locks on high-volume sites.
     */
    public static function cleanup(): void {
        if ( ! self::is_schema_current() ) {
            return;
        }

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

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

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

        // Delete in batches to avoid long table locks on high-volume sites.
        // Loop until fewer than 5 000 rows are removed (meaning we have caught up).
        do {
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Cleans up rows from a plugin-owned event table in bounded batches.
            $deleted = (int) $wpdb->query( $wpdb->prepare( "DELETE FROM $table WHERE event_time < %d LIMIT 5000", $cutoff ) );
        } while ( $deleted >= 5000 );
    }

    /**
     * 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( 'securwp_waf_log_ingested' ) ) {
            return;
        }

        $data_dir        = WP_CONTENT_DIR . '/securwp-data';
        $log_file        = $data_dir . '/waf-blocks.php';
        $legacy_log_file = $data_dir . '/waf-blocks.log';
        $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,
                ];
            }
        }

        if ( is_file( $legacy_log_file ) ) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Legacy migration drains the previous plaintext bootstrap log atomically once.
            $fp = @fopen( $legacy_log_file, 'c+' );
            if ( $fp ) {
                if ( flock( $fp, LOCK_EX | LOCK_NB ) ) {
                    $legacy_raw = stream_get_contents( $fp );
                    if ( is_string( $legacy_raw ) && $legacy_raw !== '' ) {
                        $sources[] = [
                            'path'    => $legacy_log_file,
                            'raw'     => $legacy_raw,
                            'guarded' => false,
                            'drained' => true,
                        ];
                    }
                    ftruncate( $fp, 0 );
                    flock( $fp, LOCK_UN );
                }
                // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the legacy WAF log handle after migration read.
                fclose( $fp );
            }
        }

        // Also ingest any rotated guarded files and legacy .bak files.
        $bak_files = array_merge(
            glob( $data_dir . '/waf-blocks.*.php' ) ?: [],
            glob( $legacy_log_file . '.*.bak' ) ?: []
        );

        if ( is_array( $bak_files ) ) {
            foreach ( $bak_files as $bak ) {
                $guarded = str_ends_with( $bak, '.php' );
                $bak_raw = $guarded
                    ? GuardedDataFile::read_payload( $bak )
                    : @file_get_contents( $bak );

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

                $sources[] = [
                    'path'    => $bak,
                    'raw'     => $bak_raw,
                    'guarded' => $guarded,
                    '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( 'securwp_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;
            }

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

            $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 ) {
            securwp_log( 'SecurWP 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";
    }

    /**
     * Parse one WAF log line in either JSON-lines or legacy pipe-delimited form.
     *
     * @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 );

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

        $parts = array_map( 'trim', explode( '|', $line ) );
        if ( count( $parts ) < 3 ) {
            return null;
        }

        $ts_str = preg_replace( '/\s+UTC$/', '', $parts[0] );
        $ts_str = is_string( $ts_str ) ? trim( $ts_str ) : trim( $parts[0] );
        $ts     = strtotime( $ts_str . ' UTC' );
        if ( ! $ts ) {
            return null;
        }

        $request_uri = isset( $parts[3] ) && $parts[3] !== '' ? $parts[3] : '/';
        $attack_type = self::map_waf_reason( $parts[2] );

        return [
            'event_time'  => (int) $ts,
            'ip_address'  => (string) $parts[1],
            'request_uri' => $request_uri,
            'http_method' => 'GET',
            'http_status' => self::is_rate_limit_attack_type( $attack_type ) ? 429 : 403,
            'attack_type' => $attack_type,
            'blog_id'     => self::resolve_waf_blog_id( '', $request_uri ),
        ];
    }

    /**
     * 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 ): bool {
        $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 false;
            }

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

            // 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' => (string) $entry['attack_type'],
                    'user_agent'  => 'WAF Bootstrap',
                    'user_id'     => 0,
                    'blog_id'     => $target_blog_id,
                ],
                [ '%d', '%s', '%s', '%s', '%d', '%s', '%s', '%d', '%d' ]
            );

            if ( $result === false && ! empty( $wpdb->last_error ) ) {
                securwp_log( 'SecurWP Firewall: failed to import WAF event - ' . $wpdb->last_error );
            }

            return $result !== false;
        } finally {
            if ( $switched_blog ) {
                restore_current_blog();
                self::sync_table_context();
            }
        }
    }

    /**
     * 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 );
    }
}
