<?php
/**
 * FirewallSeeder — Populates attack_patterns and firewall_rules tables
 * from bundled JSON files and/or the UltraGuard intelligence server.
 *
 * Uses DatabaseCrud::insert_raw() ($internal=true) to bypass sanitize_text_field()
 * (which destroys regex patterns) and the capability check (so seeding works in
 * all execution contexts: activation, cron, public init hook).
 * load_existing() uses $wpdb->get_col() directly because during first-time
 * activation the DatabaseSchema registry is not yet populated.
 *
 * @package UltraguardPro\modules\firewall
 * @since   6.5.1
 */

namespace UltraguardPro\modules\firewall;

use UltraguardPro\support\RemoteIntelligence;

use UltraguardPro\core\CacheManager;
use UltraguardPro\core\DatabaseCrud;

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

// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Firewall seeding intentionally syncs raw rule and pattern datasets against live tables.

class FirewallSeeder {

    const PATTERNS_VERSION_OPTION = 'ultraguard_firewall_patterns_db_version';
    const PATTERNS_SEEDED_OPTION  = 'ultraguard_firewall_patterns_seeded';
    const RULES_VERSION_OPTION    = 'ultraguard_firewall_rules_db_version';
    const RULES_HASH_OPTION       = 'ultraguard_firewall_rules_db_hash';
    const RULES_SEEDED_OPTION     = 'ultraguard_firewall_rules_seeded';
    const TYPE_PATTERNS           = 'attack-patterns';
    const TYPE_RULES              = 'firewall-rules';

    private RemoteIntelligence $remote;

    public function __construct() {
        $this->remote = new RemoteIntelligence();
    }

    public function seed( bool $force = false ): array {
        return [
            'patterns' => $this->seed_patterns( $force ),
            'rules'    => $this->seed_rules( $force ),
        ];
    }

    public function force_reseed(): array {
        return $this->seed( true );
    }

    public function needs_seeding(): bool {
        if ( ! get_option( self::PATTERNS_SEEDED_OPTION ) ) { return true; }
        if ( ! get_option( self::RULES_SEEDED_OPTION ) )    { return true; }
        $patterns_version = (string) get_option( self::PATTERNS_VERSION_OPTION, '' );
        $rules_version    = (string) get_option( self::RULES_VERSION_OPTION, '' );
        $rules_hash       = (string) get_option( self::RULES_HASH_OPTION, '' );

        if ( $this->remote->remote_update_available( self::TYPE_PATTERNS, $patterns_version ) ) { return true; }
        if ( $this->remote->remote_update_available( self::TYPE_RULES, $rules_hash ) ) { return true; }

        $bundled_patterns = $this->remote->local_version( self::TYPE_PATTERNS );
        if ( '' !== $bundled_patterns && version_compare( $bundled_patterns, $patterns_version, '>' ) ) { return true; }

        $bundled_rules = $this->remote->local_version( self::TYPE_RULES );
        return '' !== $bundled_rules && version_compare( $bundled_rules, $rules_version, '>' );
    }

    // ── Patterns ───────────────────────────────────────────────────────────────

    public function seed_patterns( bool $force = false ): array {
        global $wpdb;
        $result = $this->blank_result();

        $fetched = $this->remote->fetch( self::TYPE_PATTERNS,
            $force ? '' : (string) get_option( self::PATTERNS_VERSION_OPTION, '' ), $force );

        if ( $fetched['error'] ) {
            return $this->set_error( $result, 'Fetch failed: ' . $fetched['error'] );
        }

        $data    = $fetched['data'];
        $version = $fetched['version'];
        $result['source']  = $fetched['source'];
        $result['version'] = $version;

        if ( ! empty( $fetched['up_to_date'] ) ) {
            return $result;
        }

        if ( empty( $data['patterns'] ) || ! is_array( $data['patterns'] ) ) {
            return $this->set_error( $result, 'No patterns array found in payload.' );
        }

        $result['total'] = count( $data['patterns'] );

        if ( ! $force && $fetched['source'] !== 'remote'
             && get_option( self::PATTERNS_VERSION_OPTION ) === $version ) {
            $result['skipped'] = $result['total'];
            return $result;
        }

        $table = $wpdb->prefix . 'ultraguard_attack_patterns';
        if ( ! $this->table_exists( $table ) ) {
            return $this->set_error( $result, "Table {$table} does not exist — run plugin activation first." );
        }

        $existing = $this->load_existing( $table, 'pattern_name' );
        $now      = current_time( 'mysql', true );

        foreach ( $data['patterns'] as $p ) {
            if ( empty( $p['pattern_name'] ) || empty( $p['pattern_value'] ) ) {
                $result['skipped']++;
                continue;
            }

            $name  = trim( (string) $p['pattern_name'] );
            $value = $this->sanitize_regex_pattern( (string) $p['pattern_value'] );

            if ( $name === '' || $value === '' )          { $result['skipped']++; continue; }
            if ( isset( $existing[ $name ] ) )             { $result['skipped']++; continue; }

            // insert_raw() bypasses sanitize_text_field() + $internal=true bypasses
            // the capability check so seeding works in activation/cron/public contexts.
            $insert_id = DatabaseCrud::insert_raw( 'ultraguard_attack_patterns', [
                'pattern_name'    => $name,
                'pattern_type'    => $this->sanitize_pattern_type( $p['pattern_type'] ?? 'regex' ),
                'pattern_value'   => $value,
                'attack_category' => sanitize_text_field( (string) ( $p['attack_category'] ?? 'generic' ) ),
                'severity'        => $this->sanitize_severity( $p['severity'] ?? 'medium' ),
                'is_active'       => 1,
                'match_count'     => 0,
                'created_at'      => $now,
                'updated_at'      => $now,
            ], [ '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s' ], true );
            $ok = ( $insert_id !== false );

            if ( ! $ok ) {
                \UltraguardPro\core\Logger::error( "[UltraGuard FirewallSeeder] patterns insert_raw failed for '{$name}'" );
                $result['skipped']++;
            } else {
                $result['inserted']++;
                $existing[ $name ] = true;
            }
        }

        update_option( self::PATTERNS_VERSION_OPTION, $version, false );
        update_option( self::PATTERNS_SEEDED_OPTION,  time(),   false );
        CacheManager::get_instance()->invalidate_db(); // Targeted DB-query cache flush after bulk seed
        do_action( 'ultraguard_pro/firewall/attack_patterns_seeded', $result );
        return $result;
    }

    // ── Rules ──────────────────────────────────────────────────────────────────

    public function seed_rules( bool $force = false ): array {
        global $wpdb;
        $result = $this->blank_result();
        $current_hash = $force ? '' : (string) get_option( self::RULES_HASH_OPTION, '' );

        $fetched = $this->remote->fetch( self::TYPE_RULES,
            $current_hash, $force );

        if ( $fetched['error'] ) {
            return $this->set_error( $result, 'Fetch failed: ' . $fetched['error'] );
        }

        $data    = $fetched['data'];
        $version = $fetched['version'];
        $hash    = $this->extract_rules_hash( $data );
        $result['source']  = $fetched['source'];
        $result['version'] = $version;

        if ( ! empty( $fetched['up_to_date'] ) ) {
            return $result;
        }

        if ( empty( $data['rules'] ) || ! is_array( $data['rules'] ) ) {
            return $this->set_error( $result, 'No rules array found in payload.' );
        }

        $result['total'] = count( $data['rules'] );

        if ( ! $force && $fetched['source'] !== 'remote'
             && get_option( self::RULES_VERSION_OPTION ) === $version
             && get_option( self::RULES_HASH_OPTION ) === $hash ) {
            $result['skipped'] = $result['total'];
            return $result;
        }

        $table = $wpdb->prefix . 'ultraguard_firewall_rules';
        if ( ! $this->table_exists( $table ) ) {
            return $this->set_error( $result, "Table {$table} does not exist — run plugin activation first." );
        }

        $existing        = $this->load_existing( $table, 'name' );
        $now             = current_time( 'mysql', true );
        $valid_actions   = [ 'block', 'allow', 'log', 'challenge', 'redirect' ];
        $valid_operators = [ 'equals', 'contains', 'starts_with', 'ends_with', 'regex', 'not_equals', 'not_contains' ];
        $valid_fields    = [ 'ip', 'uri', 'user_agent', 'method', 'header', 'body', 'country' ];

        foreach ( $data['rules'] as $r ) {
            if ( empty( $r['name'] ) || empty( $r['match_value'] ) ) {
                $result['skipped']++;
                continue;
            }

            $name  = trim( (string) $r['name'] );
            $value = $this->sanitize_regex_pattern( (string) $r['match_value'] );

            if ( $name === '' || $value === '' )  { $result['skipped']++; continue; }
            if ( isset( $existing[ $name ] ) )    { $result['skipped']++; continue; }

            $action   = sanitize_key( $r['action']         ?? 'block' );
            $operator = sanitize_key( $r['match_operator'] ?? 'contains' );
            $field    = sanitize_key( $r['match_field']    ?? 'uri' );

            // insert_raw() + $internal=true: preserves regex, works in all contexts.
            $insert_id = DatabaseCrud::insert_raw( 'ultraguard_firewall_rules', [
                'name'           => $name,
                'description'    => sanitize_textarea_field( (string) ( $r['description'] ?? '' ) ),
                'rule_type'      => sanitize_key( $r['rule_type'] ?? 'block' ),
                'match_field'    => in_array( $field,    $valid_fields,    true ) ? $field    : 'uri',
                'match_operator' => in_array( $operator, $valid_operators, true ) ? $operator : 'contains',
                'match_value'    => $value,
                'action'         => in_array( $action, $valid_actions, true ) ? $action : 'block',
                'priority'       => max( 1, min( 9999, (int) ( $r['priority'] ?? 100 ) ) ),
                'is_active'      => (int) (bool) ( $r['is_active'] ?? 1 ),
                'hits_count'     => 0,
                'created_at'     => $now,
                'updated_at'     => $now,
            ], [ '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d', '%d', '%s', '%s' ], true );
            $ok = ( $insert_id !== false );

            if ( ! $ok ) {
                \UltraguardPro\core\Logger::error( "[UltraGuard FirewallSeeder] rules insert_raw failed for '{$name}'" );
                $result['skipped']++;
            } else {
                $result['inserted']++;
                $existing[ $name ] = true;
            }
        }

        update_option( self::RULES_VERSION_OPTION, $version, false );
        update_option( self::RULES_HASH_OPTION, $hash, false );
        update_option( self::RULES_SEEDED_OPTION,  time(),   false );
        CacheManager::get_instance()->invalidate_db(); // Targeted DB-query cache flush after bulk seed
        do_action( 'ultraguard_pro/firewall/rules_seeded', $result );
        return $result;
    }

    // ── Private helpers ────────────────────────────────────────────────────────

    private function load_existing( string $table, string $column ): array {
        global $wpdb;
        $safe_table  = esc_sql( $table );
        $safe_column = esc_sql( $column );
        // Direct $wpdb call: intentional exemption from DatabaseCrud routing.
        // Seeders run during first-time activation BEFORE DatabaseTablesModule boots,
        // so DatabaseSchema's registry is empty and DatabaseCrud::get_column() would
        // return [] (get_validated_table_name() returns false for unknown tables).
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
        $rows = $wpdb->get_col( "SELECT `{$safe_column}` FROM `{$safe_table}`" ); // nosemgrep
        return $rows ? array_fill_keys( $rows, true ) : [];
    }

    private function table_exists( string $table ): bool {
        global $wpdb;
        // SHOW TABLES LIKE is a DDL/schema check — not a data CRUD operation.
        // Intentionally exempt from DatabaseCrud routing.
        return (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- query uses prepare(); table name is supplied as a value placeholder.
    }

    /**
     * Sanitize a regex/string pattern for DB storage.
     *
     * Only strips null bytes and validates UTF-8.
     * Does NOT call sanitize_text_field() or wp_strip_all_tags() — those
     * functions destroy patterns containing HTML tokens like /<script[\s>]/i.
     * SQL injection prevention is handled by DatabaseCrud::insert_raw() via %s format.
     */
    private function sanitize_regex_pattern( string $v ): string {
        $v = str_replace( "\x00", '', $v );
        if ( function_exists( 'mb_check_encoding' ) && ! mb_check_encoding( $v, 'UTF-8' ) ) {
            return '';
        }
        return trim( $v );
    }

    private function sanitize_pattern_type( string $v ): string {
        return in_array( $v, [ 'regex', 'string', 'exact', 'prefix', 'suffix' ], true ) ? $v : 'regex';
    }

    private function sanitize_severity( string $v ): string {
        return in_array( $v, [ 'critical', 'high', 'medium', 'low', 'info' ], true ) ? $v : 'medium';
    }

    private function blank_result(): array {
        return [ 'inserted' => 0, 'skipped' => 0, 'total' => 0, 'version' => '', 'source' => 'local', 'error' => null ];
    }

    private function extract_rules_hash( array $data ): string {
        $meta_hash = (string) ( $data['_meta']['hash'] ?? '' );
        if ( '' !== $meta_hash ) {
            return $meta_hash;
        }

        $rules = is_array( $data['rules'] ?? null ) ? $data['rules'] : [];
        if ( empty( $rules ) ) {
            return '';
        }

        return hash( 'sha256', wp_json_encode( $rules ) ?: '[]' );
    }

    private function set_error( array $result, string $msg ): array {
        $result['error'] = $msg;
        \UltraguardPro\core\Logger::error( '[UltraGuard FirewallSeeder] ' . $msg );
        return $result;
    }
}
