<?php

declare(strict_types=1);

namespace SiteFort\Modules\Reporting;

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

use SiteFort\Includes\Interfaces\Module;
use SiteFort\Includes\Module_Loader;
use SiteFort\Includes\SettingsStore;
use SiteFort\Includes\ServerConfigWriter;
use SiteFort\Modules\Firewall\FirewallConfigService;
use SiteFort\Modules\Firewall\ExtendedProtection\Manager as ExtendedProtectionManager;
use SiteFort\Modules\Firewall\FirewallLogger;
use SiteFort\Modules\Hardening\Hardening;
use SiteFort\Modules\Hardening\LockoutRegistry;

/**
 * The Reporting module class.
 *
 * Handles generating security reports and summaries.
 *
 */
class Reporting implements Module {

	private $plugin_name;
	private $version;

	/**
	 * Default digest settings.
	 */
	private const DIGEST_DEFAULTS = [
		'digest_enabled'   => false,
		'digest_frequency' => 'weekly',
		'digest_recipient' => '',
	];

	/**
	 * EmailDigest instance.
	 */
	private ?EmailDigest $email_digest = null;

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

		return '`' . str_replace( '`', '``', $table ) . '`';
	}

	private static function format_utc_timestamp( int $timestamp ): string {
		$timestamp = max( 0, $timestamp );
		return gmdate( 'Y-m-d\TH:i:s\Z', $timestamp );
	}

	private static function parse_utc_timestamp( string $timestamp ): int {
		$timestamp = trim( $timestamp );
		if ( '' === $timestamp ) {
			return 0;
		}

		if ( ctype_digit( $timestamp ) ) {
			return (int) $timestamp;
		}

		$normalized = (string) preg_replace( '/\s+/', 'T', $timestamp, 1 );
		$normalized = (string) preg_replace( '/\s+(Z|[+-]\d{2}:?\d{2})$/i', '$1', $normalized );
		if ( preg_match( '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/', $normalized ) ) {
			$normalized .= 'Z';
		} elseif ( ! preg_match( '/(?:Z|[+-]\d{2}:?\d{2})$/i', $normalized ) ) {
			return 0;
		}

		$parsed = strtotime( $normalized );
		return false === $parsed ? 0 : (int) $parsed;
	}

	private static function site_timezone_cache_key(): string {
		$timezone = wp_timezone();
		$now      = new \DateTimeImmutable( 'now', $timezone );
		return md5( $timezone->getName() . '|' . (string) $timezone->getOffset( $now ) );
	}

	private static function site_today_start_timestamp(): int {
		$today = new \DateTimeImmutable( 'now', wp_timezone() );
		return $today->setTime( 0, 0, 0 )->getTimestamp();
	}

	/** @return array<int,array{date:string,start:int,end:int}> */
	private static function dashboard_day_windows( int $now, int $days = 7 ): array {
		$timezone = wp_timezone();
		$today    = ( new \DateTimeImmutable( '@' . max( 0, $now ) ) )
			->setTimezone( $timezone )
			->setTime( 0, 0, 0 );
		$windows  = [];

		for ( $i = max( 1, $days ) - 1; $i >= 0; $i-- ) {
			$start = $today->modify( '-' . $i . ' days' );
			$end   = $start->modify( '+1 day' );

			$windows[] = [
				'date'  => $start->format( 'Y-m-d' ),
				'start' => $start->getTimestamp(),
				'end'   => $end->getTimestamp(),
			];
		}

		return $windows;
	}

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

	public function initialize( \SiteFort\Includes\Module_Loader $loader ) {
        // Bust the dashboard stats cache when anything material changes.
        $loader->add_action( 'sitefort_settings_updated',        $this, 'bust_stats_cache', 10, 0 );
        $loader->add_action( 'sitefort_settings_saved_firewall', $this, 'bust_stats_cache', 10, 0 );
        $loader->add_action( 'sitefort_lockout_activated',       $this, 'bust_stats_cache', 10, 0 );
        $loader->add_action( 'sitefort_lockout_cleared',         $this, 'bust_stats_cache', 10, 0 );
        $loader->add_action( 'sitefort_firewall_lockout_changed',$this, 'bust_stats_cache', 10, 0 );
        $loader->add_action( 'sitefort_scan_completed',          $this, 'bust_stats_cache', 10, 0 );
        $loader->add_action( 'sitefort_scan_results_changed',    $this, 'bust_stats_cache', 10, 0 );
        $loader->add_action( 'sitefort_issue_resolved',          $this, 'bust_stats_cache', 10, 0 );
        $loader->add_action( 'sitefort_audit_log_created',       $this, 'bust_stats_cache', 10, 0 );
        $loader->add_action( 'sitefort_server_rule_state_changed', $this, 'bust_stats_cache', 10, 0 );

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

        // Initialize Email Digest.
        $digest_settings = $this->get_digest_settings();
        $this->email_digest = new EmailDigest( $digest_settings );

        // Register the cron hook for email digest.
        $loader->add_action( 'sitefort_email_digest', $this->email_digest, 'send_digest', 10, 0 );

        // Schedule digest on init (only reschedules if settings changed).
        $this->email_digest->schedule_digest();
	}


    /**
     * Get digest settings merged with defaults.
     *
     * @return array
     */
    private function get_digest_settings(): array {
        $saved = SettingsStore::get_field( SettingsStore::COL_STATE, 'digest_settings', [] );
        if ( ! is_array( $saved ) ) {
            $saved = [];
        }
        return array_merge( self::DIGEST_DEFAULTS, $saved );
    }

    public function register_routes() {
        register_rest_route( $this->plugin_name . '/v1', '/reporting/stats', array(
            'methods' => 'GET',
            'callback' => array( $this, 'get_stats' ),
            'permission_callback' => function () {
                return \SiteFort\Includes\Capabilities::can_manage_plugin();
            },
        ) );
    }

    public function get_stats( \WP_REST_Request $request ) {
        // Return a cached response for up to 60 seconds to avoid hammering the
        // DB when the "Refresh Data" button is clicked rapidly.
        $firewall_settings = FirewallConfigService::load_effective_settings();
        $cache_key = 'sitefort_dashboard_stats_'
            . FirewallConfigService::get_origin_enforcement_mode( $firewall_settings )
            . '_'
            . self::site_timezone_cache_key();
        $cached    = get_transient( $cache_key );
        if ( false !== $cached && is_array( $cached ) ) {
            return rest_ensure_response( $cached );
        }

        global $wpdb;
        $audit_table = self::table_identifier( $wpdb->prefix . 'sitefort_audit_log' );
        $fw_table    = self::table_identifier( $wpdb->prefix . 'sitefort_firewall_events' );
        $scan_table  = self::table_identifier( $wpdb->prefix . 'sitefort_scan_queue' );
        $fw_table_ready = FirewallLogger::is_schema_current();

        $now            = time();
        $since          = $now - ( 90 * DAY_IN_SECONDS );
        $current_blog_id = get_current_blog_id();

        // Count only authentication security events from the audit log (login
        // failures, IP lockouts, user lockouts). Other warning/error entries
        // (plugin deactivations, settings changes, API errors, etc.) are admin
        // activity, not security blocks. Firewall blocks are counted separately.
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $audit_blocks = (int) $wpdb->get_var( $wpdb->prepare(
            "SELECT COUNT(*) FROM $audit_table WHERE created_at >= %d AND category = 'authentication' AND level IN ('warning', 'critical', 'error')",
            $since
        ) );
        $fw_blocks = 0;
        if ( $fw_table_ready ) {
            $fw_blocks = (int) $wpdb->get_var( $wpdb->prepare(
                "SELECT COUNT(*) FROM $fw_table WHERE event_time >= %d AND blog_id = %d",
                $since,
                $current_blog_id
            ) );
        }
        // Purged rows (row cap, manual clear) still count toward the total.
        $fw_blocks += FirewallLogger::count_purged_since( $since, $current_blog_id );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $total_blocks = $audit_blocks + $fw_blocks;

        $chart_data = [];
        $windows    = self::dashboard_day_windows( $now, 7 );

        // Bucket both tables' rows into their day window with a single
        // CASE-based GROUP BY per table instead of 2 COUNT(*) queries per day.
        $case_sql   = 'CASE';
        $case_binds = [];
        foreach ( $windows as $idx => $window ) {
            $case_sql    .= ' WHEN created_at >= %d AND created_at < %d THEN %d';
            $case_binds[] = $window['start'];
            $case_binds[] = $window['end'];
            $case_binds[] = $idx;
        }
        $case_sql     .= ' END';
        $range_start   = $windows[0]['start'];
        $range_end     = $windows[ count( $windows ) - 1 ]['end'];
        $audit_day_counts = array_fill( 0, count( $windows ), 0 );
        $fw_day_counts    = array_fill( 0, count( $windows ), 0 );

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $case_sql/$case_binds add a matched %d per dashboard window; sniff can't see the dynamic count.
        $audit_bucket_rows = $wpdb->get_results( $wpdb->prepare(
            "SELECT ($case_sql) AS bucket, COUNT(*) AS cnt
             FROM $audit_table
             WHERE created_at >= %d AND created_at < %d
               AND category = 'authentication'
               AND level IN ('warning', 'critical', 'error')
             GROUP BY bucket",
            ...array_merge( $case_binds, [ $range_start, $range_end ] )
        ) );
        foreach ( $audit_bucket_rows as $row ) {
            if ( null !== $row->bucket && isset( $audit_day_counts[ (int) $row->bucket ] ) ) {
                $audit_day_counts[ (int) $row->bucket ] = (int) $row->cnt;
            }
        }

        if ( $fw_table_ready ) {
            $fw_bucket_rows = $wpdb->get_results( $wpdb->prepare(
                "SELECT ($case_sql) AS bucket, COUNT(*) AS cnt
                 FROM $fw_table
                 WHERE event_time >= %d AND event_time < %d AND blog_id = %d
                 GROUP BY bucket",
                ...array_merge( $case_binds, [ $range_start, $range_end, $current_blog_id ] )
            ) );
            foreach ( $fw_bucket_rows as $row ) {
                if ( null !== $row->bucket && isset( $fw_day_counts[ (int) $row->bucket ] ) ) {
                    $fw_day_counts[ (int) $row->bucket ] = (int) $row->cnt;
                }
            }
        }
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber

        foreach ( $windows as $idx => $window ) {
            $fw_day_count  = $fw_day_counts[ $idx ] + FirewallLogger::count_purged_between( $window['start'], $window['end'], $current_blog_id );
            $chart_data[] = [
                'date'  => $window['date'],
                'count' => $audit_day_counts[ $idx ] + $fw_day_count,
            ];
        }

        // UNION authentication threat events with firewall attack types.
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        if ( $fw_table_ready ) {
            $type_rows = $wpdb->get_results( $wpdb->prepare(
                "SELECT name, COUNT(*) AS cnt
                 FROM (
                     SELECT event AS name FROM $audit_table
                     WHERE created_at >= %d AND category = 'authentication' AND level IN ('warning', 'critical', 'error')
                     UNION ALL
                     SELECT attack_type AS name FROM $fw_table
                     WHERE event_time >= %d AND blog_id = %d
                 ) combined
                 GROUP BY name ORDER BY cnt DESC LIMIT 10",
                $since, $since, $current_blog_id
            ), ARRAY_A );
        } else {
            $type_rows = $wpdb->get_results( $wpdb->prepare(
                "SELECT event AS name, COUNT(*) AS cnt
                 FROM $audit_table
                 WHERE created_at >= %d AND category = 'authentication' AND level IN ('warning', 'critical', 'error')
                 GROUP BY event ORDER BY cnt DESC LIMIT 10",
                $since
            ), ARRAY_A );
        }
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        $types_data = [];
        if ( is_array( $type_rows ) ) {
            foreach ( $type_rows as $row ) {
                $types_data[] = [ 'name' => $row['name'], 'value' => (int) $row['cnt'] ];
            }
        }

        // Canonical queue status is "complete". Include legacy "completed"
        // for backward compatibility with older installations/data.
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $scans_run = (int) $wpdb->get_var( $wpdb->prepare(
            "SELECT COUNT(*) FROM $scan_table WHERE status IN (%s, %s)",
            'complete',
            'completed'
        ) );

        $issues_resolved = (int) $wpdb->get_var( $wpdb->prepare(
            "SELECT COUNT(*) FROM $audit_table WHERE event IN (%s, %s, %s)",
            'scan_action_repair',
            'scan_action_delete',
            'scan_action_quarantine'
        ) );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        // Combines audit_log security events with firewall block queue entries.
        // Admin activity (logins, plugin updates, settings changes) is excluded
        // here - it belongs in the full Audit Log, not the security feed.
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $latest_rows = $wpdb->get_results( $wpdb->prepare(
            "SELECT id, event, level, user_id, ip_address, created_at
             FROM $audit_table
             WHERE created_at >= %d
               AND event IN (%s, %s, %s, %s)
             ORDER BY created_at DESC LIMIT 20",
            $since,
            'Login Failed',
            'IP Lockout',
            'User Lockout',
            'REST API Blocked'
        ), ARRAY_A );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        $latest_formatted = [];
        if ( is_array( $latest_rows ) ) {
            foreach ( $latest_rows as $log ) {
                $latest_formatted[] = [
                    'id'          => (string) $log['id'],
                    'timestamp'   => self::format_utc_timestamp( (int) $log['created_at'] ),
                    'event_time'  => (int) $log['created_at'],
                    'event'       => $log['event'],
                    'severity'    => $log['level'],
                    'source'      => 'authentication',
                    'user_id'     => (int) $log['user_id'],
                    'ip_address'  => $log['ip_address'] ?? '',
                    'occurrences' => 1,
                ];
            }
        }

        // Pull firewall block events from the dedicated DB table.
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $fw_rows = [];
        if ( $fw_table_ready ) {
            $fw_rows = $wpdb->get_results( $wpdb->prepare(
                "SELECT id, attack_type, ip_address, event_time
                 FROM $fw_table WHERE event_time >= %d AND blog_id = %d ORDER BY event_time DESC LIMIT 20",
                $since,
                $current_blog_id
            ), ARRAY_A );
        }
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        if ( is_array( $fw_rows ) ) {
            foreach ( $fw_rows as $row ) {
                $latest_formatted[] = [
                    'id'          => 'fw_' . $row['id'],
                    'timestamp'   => self::format_utc_timestamp( (int) $row['event_time'] ),
                    'event_time'  => (int) $row['event_time'],
                    'event'       => (string) $row['attack_type'],
                    'severity'    => FirewallLogger::get_severity( (string) $row['attack_type'] ),
                    'source'      => 'firewall',
                    'user_id'     => 0,
                    'ip_address'  => (string) $row['ip_address'],
                    'occurrences' => 1,
                ];
            }
        }

        // Merge audit_log + firewall events: sort newest-first, keep top 20.
        usort(
            $latest_formatted,
            static fn( array $a, array $b ): int => (int) $b['event_time'] <=> (int) $a['event_time']
        );
        $latest_formatted = self::collapse_dashboard_feed_events( $latest_formatted, 20 );

        $hardening_settings = SettingsStore::get( SettingsStore::COL_HARDENING );

        $firewall_enabled = FirewallConfigService::is_origin_enforcement_active( $firewall_settings );
        $firewall_mode    = FirewallConfigService::get_origin_enforcement_mode( $firewall_settings );

        // Use the same key the Hardening module actually writes: 'limit_login_attempts_enabled'.
        $login_protection = ! empty( $hardening_settings['limit_login_attempts_enabled'] );

        $extended_waf_status = [
            'active' => false,
        ];
        if ( class_exists( ExtendedProtectionManager::class ) ) {
            $extended_waf_status = ExtendedProtectionManager::get_status();
        }

        // Use the canonical hardening baseline so reporting matches setup
        // widgets, posture floors, and the active controls surfaced in Console.
        $hardening_tweaks = is_array( $hardening_settings )
            ? Hardening::count_effective_hardening_controls( $hardening_settings )
            : 0;

        // Determine overall health level.
        $health = 'protected';
        $health_issues = [];

        if ( ! $firewall_enabled ) {
            $health = 'at_risk';
            $health_issues[] = [
                'id'           => $firewall_mode === FirewallConfigService::MODE_DISABLED ? 'firewall_disabled' : 'firewall_not_enforcing',
                'severity'     => 'high',
                'title'        => $firewall_mode === FirewallConfigService::MODE_DISABLED
                    ? 'Web Application Firewall is disabled'
                    : 'Web Application Firewall is not enforcing at the origin',
                'description'  => $firewall_mode === FirewallConfigService::MODE_DISABLED
                    ? 'Your site is not protected against SQL injection, cross-site scripting, and other common attack vectors.'
                    : 'SiteFort is holding local firewall enforcement because visitor IP detection has not been safely validated for this environment.',
                'action_label' => $firewall_mode === FirewallConfigService::MODE_DISABLED ? 'Enable Firewall' : 'Fix IP Detection',
                'action_target' => 'firewall',
            ];
        }
        if ( ! $login_protection ) {
            if ( $health !== 'at_risk' ) {
                $health = 'needs_attention';
            }
            $health_issues[] = [
                'id'           => 'login_protection_disabled',
                'severity'     => 'high',
                'title'        => 'Login protection is not configured',
                'description'  => 'Brute force attacks, credential stuffing, and automated login attempts are not being blocked.',
                'action_label' => 'Configure Login Security',
                'action_target' => 'login-security',
            ];
        }
        if ( $scans_run === 0 ) {
            if ( $health === 'protected' ) {
                $health = 'needs_attention';
            }
            $health_issues[] = [
                'id'           => 'no_scans',
                'severity'     => 'medium',
                'title'        => 'No security scans have been performed',
                'description'  => 'Run a scan to check for malware, suspicious files, and known vulnerabilities in your plugins and themes.',
                'action_label' => 'Run First Scan',
                'action_target' => 'scanner',
            ];
        }
        if ( $hardening_tweaks === 0 ) {
            if ( $health === 'protected' ) {
                $health = 'needs_attention';
            }
            $health_issues[] = [
                'id'           => 'no_hardening',
                'severity'     => 'medium',
                'title'        => 'No hardening tweaks are enabled',
                'description'  => 'Enable security hardening to hide sensitive information, block file editor access, and restrict unnecessary features.',
                'action_label' => 'Review Hardening',
                'action_target' => 'hardening',
            ];
        }

        $server_rule_issue = self::build_server_rule_health_issue();
        if ( $server_rule_issue !== null ) {
            if ( $server_rule_issue['severity'] === 'high' ) {
                $health = 'at_risk';
            } elseif ( $health === 'protected' ) {
                $health = 'needs_attention';
            }
            $health_issues[] = $server_rule_issue;
        }

        $thirty_days_ago = $now - ( 30 * DAY_IN_SECONDS );

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $lockout_rows = $wpdb->get_results( $wpdb->prepare(
            "SELECT event, COUNT(*) AS cnt
             FROM $audit_table
             WHERE created_at >= %d AND event IN ('IP Lockout', 'User Lockout')
             GROUP BY event",
            $thirty_days_ago
        ), ARRAY_A );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        $lockout_stats = [ 'total_30d' => 0, 'ip_lockouts' => 0, 'user_lockouts' => 0 ];
        if ( is_array( $lockout_rows ) ) {
            foreach ( $lockout_rows as $row ) {
                $cnt = (int) $row['cnt'];
                $lockout_stats['total_30d'] += $cnt;
                if ( $row['event'] === 'IP Lockout' ) {
                    $lockout_stats['ip_lockouts'] = $cnt;
                } elseif ( $row['event'] === 'User Lockout' ) {
                    $lockout_stats['user_lockouts'] = $cnt;
                }
            }
        }

        $banned_ips_raw  = $firewall_settings['banned_ips'] ?? '';
        $banned_ips_list = array_filter( array_map( 'trim', explode( "\n", $banned_ips_raw ) ) );
        $banned_ips_count = count( $banned_ips_list );

        $allowlist_raw   = $firewall_settings['allowlist'] ?? '';
        $allowlist_list  = array_filter( array_map( 'trim', explode( "\n", $allowlist_raw ) ) );
        $allowlist_count = count( $allowlist_list );

        $issues_table = self::table_identifier( $wpdb->prefix . 'sitefort_issues' );
        $vuln_summary = [
            'total_active' => 0,
            'by_severity'  => [ 'critical' => 0, 'high' => 0, 'medium' => 0, 'low' => 0 ],
            'max_severity' => 'none',
        ];

        // Only query if the table exists (scanner may not have run yet).
        // Use esc_like() so that any underscores in the table prefix are not
        // treated as single-character LIKE wildcards.
        $issues_table_name = trim( $issues_table, '`' );
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Checks existence of a plugin-owned issues table before summarizing findings.
        $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $issues_table_name ) ) );
        if ( $table_exists ) {
            // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $vuln_rows = $wpdb->get_results(
                "SELECT severity, COUNT(*) AS cnt
                 FROM $issues_table
                 WHERE is_ignored = 0 AND resolved_at = 0 AND type = 'vulnerability'
                 GROUP BY severity",
                ARRAY_A
            );
            // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

            $sev_priority = [ 'critical' => 4, 'high' => 3, 'medium' => 2, 'low' => 1 ];
            $max_priority = 0;

            if ( is_array( $vuln_rows ) ) {
                foreach ( $vuln_rows as $row ) {
                    $sev = strtolower( $row['severity'] );
                    $cnt = (int) $row['cnt'];
                    $vuln_summary['total_active'] += $cnt;
                    if ( isset( $vuln_summary['by_severity'][ $sev ] ) ) {
                        $vuln_summary['by_severity'][ $sev ] = $cnt;
                    }
                    if ( isset( $sev_priority[ $sev ] ) && $sev_priority[ $sev ] > $max_priority ) {
                        $max_priority = $sev_priority[ $sev ];
                        $vuln_summary['max_severity'] = $sev;
                    }
                }
            }
        }

        // Use queue updated_at as the completion timestamp. Fallback to
        // created_at for older rows, then to state option if queue has none.
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $last_scan_ts   = $wpdb->get_var( $wpdb->prepare(
            "SELECT MAX(CASE WHEN updated_at > 0 THEN updated_at ELSE created_at END)
             FROM $scan_table
             WHERE status IN (%s, %s)",
            'complete',
            'completed'
        ) );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $state_last_scan = (int) SettingsStore::get_field( SettingsStore::COL_STATE, 'last_scan_time', 0 );
        $last_scan_time  = $last_scan_ts
            ? (int) $last_scan_ts
            : ( $state_last_scan > 0 ? $state_last_scan : null );

        $malware_status = 'not_scanned';
        if ( $scans_run > 0 ) {
            $malware_status = 'clean';
            if ( $table_exists ) {
                // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                $malware_count = (int) $wpdb->get_var( $wpdb->prepare(
                    "SELECT COUNT(*) FROM $issues_table WHERE is_ignored = 0 AND resolved_at = 0 AND type = %s",
                    'malware'
                ) );
                // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                if ( $malware_count > 0 ) {
                    $malware_status = 'infected';
                }
            }
        }

        $plugin_updates     = 0;
        $theme_updates      = 0;
        $core_update        = false;

        $plugin_update_data = get_site_transient( 'update_plugins' );
        if ( is_object( $plugin_update_data ) && ! empty( $plugin_update_data->response ) ) {
            if ( ! function_exists( 'get_plugins' ) ) {
                require_once ABSPATH . 'wp-admin/includes/plugin.php';
            }
            $installed = get_plugins();
            foreach ( (array) $plugin_update_data->response as $plugin_file => $update_info ) {
                if ( ! isset( $installed[ $plugin_file ] ) ) {
                    continue; // Plugin was removed; transient is stale.
                }
                $current = $installed[ $plugin_file ]['Version'] ?? '0';
                $newest  = is_object( $update_info ) ? ( $update_info->new_version ?? '0' ) : '0';
                if ( version_compare( $current, $newest, '<' ) ) {
                    $plugin_updates++;
                }
            }
        }

        $theme_update_data = get_site_transient( 'update_themes' );
        if ( is_object( $theme_update_data ) && ! empty( $theme_update_data->response ) ) {
            $installed_themes = wp_get_themes();
            foreach ( (array) $theme_update_data->response as $stylesheet => $update_info ) {
                if ( ! isset( $installed_themes[ $stylesheet ] ) ) {
                    continue; // Theme removed; transient is stale.
                }
                $current = $installed_themes[ $stylesheet ]->get( 'Version' ) ?? '0';
                $newest  = is_array( $update_info ) ? ( $update_info['new_version'] ?? '0' ) : '0';
                if ( version_compare( $current, $newest, '<' ) ) {
                    $theme_updates++;
                }
            }
        }

        $core_update_data = get_site_transient( 'update_core' );
        if ( is_object( $core_update_data ) && ! empty( $core_update_data->updates ) ) {
            foreach ( (array) $core_update_data->updates as $core_item ) {
                if ( isset( $core_item->response ) && ! in_array( $core_item->response, [ 'noupdate', 'development' ], true ) ) {
                    $core_update = true;
                    break;
                }
            }
        }

        $today_start         = self::site_today_start_timestamp();
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $failed_logins_today = (int) $wpdb->get_var( $wpdb->prepare(
            "SELECT COUNT(*) FROM $audit_table WHERE created_at >= %d AND event = %s",
            $today_start,
            'Login Failed'
        ) );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        $active_lockouts      = LockoutRegistry::get_active_lockouts( 10 );
        $active_ip_lockouts   = $active_lockouts['ips'];
        $active_user_lockouts = $active_lockouts['users'];

        $ip_lockout_meta = get_option( 'sitefort_ip_lockout_meta', [] );
        $ip_lockout_meta = is_array( $ip_lockout_meta ) ? $ip_lockout_meta : [];
        $source_counts   = [];

        foreach ( $banned_ips_list as $ip ) {
            $meta   = $ip_lockout_meta[ $ip ] ?? [];
            $source = ! empty( $meta['source'] ) ? $meta['source'] : 'manual';
            $source_counts[ $source ] = ( $source_counts[ $source ] ?? 0 ) + 1;
        }
        arsort( $source_counts );
        $bans_by_source = [];
        foreach ( $source_counts as $source => $count ) {
            $bans_by_source[] = [ 'source' => $source, 'count' => $count ];
        }

        $site_health      = \SiteFort\Modules\Cloud\Services\SiteHealthScore::calculate();
        $health_label     = (string) ( $site_health['health_label'] ?? 'Fair' );
        $posture_label    = (string) ( $site_health['posture_label'] ?? $health_label );
        $scanner_label    = (string) ( $site_health['scanner_label'] ?? 'Secure' );
        $has_scan_baseline = ! empty( $site_health['has_scan_baseline'] );
        $scanner_summary  = isset( $site_health['scanner_summary'] ) && is_array( $site_health['scanner_summary'] )
            ? $site_health['scanner_summary']
            : [
                'total_active' => 0,
                'by_severity'  => [ 'critical' => 0, 'high' => 0, 'medium' => 0, 'low' => 0 ],
                'max_severity' => 'none',
                'by_type'      => [],
            ];

        $stats = [
            'total_blocks'          => $total_blocks,
            'scans_run'             => $scans_run,
            'vulnerabilities_fixed' => $issues_resolved,
            'attacks_over_time'     => $chart_data,
            'attack_types'          => $types_data,
            'latest_logs'           => $latest_formatted,
            'system_health'         => [
                'status'           => $health,
                'firewall'         => $firewall_enabled,
                'login_protection' => $login_protection,
                'server_level_waf'       => ! empty( $extended_waf_status['active'] ),
                'hardening_tweaks'       => $hardening_tweaks,
                'hardening_tweaks_total' => count( Hardening::get_recommended_hardening_keys() ),
                'issues'                 => $health_issues,
            ],
            'lockout_stats'         => $lockout_stats,
            'banned_ips_count'      => $banned_ips_count,
            'allowlist_count'       => $allowlist_count,
            'vulnerability_summary' => $vuln_summary,
            'health_label'          => $health_label,
            'posture_label'         => $posture_label,
            'scanner_label'         => $scanner_label,
            'has_scan_baseline'     => $has_scan_baseline,
            'scanner_summary'       => $scanner_summary,
            'last_scan_time'        => $last_scan_time,
            'malware_status'        => $malware_status,
            'updates_needed'        => [
                'plugins' => $plugin_updates,
                'themes'  => $theme_updates,
                'core'    => $core_update,
            ],
            'failed_logins_today'   => $failed_logins_today,
            'active_lockouts'       => [
                'ips'   => $active_ip_lockouts,
                'users' => $active_user_lockouts,
            ],
            'bans_by_source'        => $bans_by_source,
            'recent_traffic'        => (function () use ( $wpdb, $fw_table, $current_blog_id, $fw_table_ready ) {
                if ( ! $fw_table_ready ) {
                    return [];
                }

                // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                $rows = $wpdb->get_results(
                    $wpdb->prepare(
                        "SELECT ip_address, attack_type, event_time
                         FROM {$fw_table}
                         WHERE blog_id = %d
                         ORDER BY event_time DESC
                         LIMIT 5",
                        $current_blog_id
                    ),
                    ARRAY_A
                );
                // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                return $rows ?: [];
            })(),
        ];

        // Cache for 60 seconds.  Busted automatically via the action hooks
        // registered in initialize() whenever settings or scan data change.
        set_transient( $cache_key, $stats, 60 );

		return rest_ensure_response( $stats );
	}

    /**
     * Build a dashboard health issue when server hardening is enabled but not
     * enforcing on the live site.
     *
     * @return array<string,string>|null
     */
    private static function build_server_rule_health_issue(): ?array {
        $state = ServerConfigWriter::get_state();
        if ( empty( $state['desired_rules_present'] ) ) {
            return null;
        }

        $banner = is_array( $state['banner'] ?? null ) ? (array) $state['banner'] : [];
        if ( empty( $banner['show'] ) ) {
            return null;
        }

        $verification = is_array( $state['verification'] ?? null ) ? (array) $state['verification'] : [];
        $mode         = is_string( $state['mode'] ?? null ) ? (string) $state['mode'] : 'none';
        $status       = is_string( $verification['status'] ?? null ) ? (string) $verification['status'] : 'pending';
        $severity     = in_array( $status, [ 'failed' ], true ) || in_array( $mode, [ 'manual_only', 'sync_failed', 'repair_required' ], true )
            ? 'high'
            : 'medium';

        $title = is_string( $banner['title'] ?? null ) ? trim( (string) $banner['title'] ) : '';
        if ( $title === '' ) {
            $title = 'Server hardening needs attention';
        }

        $description = is_string( $banner['message'] ?? null ) ? trim( (string) $banner['message'] ) : '';
        if ( $description === '' ) {
            $description = 'SiteFort could not confirm that server-level hardening protections are active on the live site.';
        }

        return [
            'id'            => 'server_hardening_attention',
            'severity'      => $severity,
            'title'         => $title,
            'description'   => $description,
            'action_label'  => 'Review Hardening',
            'action_target' => 'hardening',
        ];
    }

    /**
     * Collapse adjacent duplicate security events into short burst summaries.
     *
     * Dashboard feed should highlight what is happening now, not render every
     * individual repeated event row. Events are considered part of the same burst
     * only when the source, event, severity, user, and IP all match and they
     * occur within 15 minutes of the latest item in that burst.
     *
     * @param array<int,array<string,mixed>> $events
     * @param int                            $limit
     * @return array<int,array<string,mixed>>
     */
    private static function collapse_dashboard_feed_events( array $events, int $limit = 20 ): array {
        $collapsed    = [];
        $burst_window = 15 * MINUTE_IN_SECONDS;

        foreach ( $events as $event ) {
            $event_time = (int) ( $event['event_time'] ?? 0 );
            if ( $event_time <= 0 ) {
                $event_time = self::parse_utc_timestamp( (string) ( $event['timestamp'] ?? '' ) );
            }

            $event['event_time']  = $event_time;
            $event['occurrences'] = max( 1, (int) ( $event['occurrences'] ?? 1 ) );

            $last_index = count( $collapsed ) - 1;
            if ( $last_index >= 0 ) {
                $last_event = $collapsed[ $last_index ];
                $correlated_firewall_enforcement =
                    ( $last_event['source'] ?? '' ) === 'firewall'
                    && ( $event['source'] ?? '' ) === 'firewall'
                    && (string) ( $last_event['ip_address'] ?? '' ) !== ''
                    && (string) ( $last_event['ip_address'] ?? '' ) === (string) ( $event['ip_address'] ?? '' )
                    && ( (int) $last_event['event_time'] - $event_time ) <= $burst_window
                    && self::is_sensitive_path_escalation_pair( (string) ( $last_event['event'] ?? '' ), (string) ( $event['event'] ?? '' ) );

                if ( $correlated_firewall_enforcement ) {
                    $collapsed[ $last_index ]['event'] = 'Sensitive Path Escalation';
                    $collapsed[ $last_index ]['severity'] = self::max_dashboard_severity(
                        (string) ( $last_event['severity'] ?? 'warning' ),
                        (string) ( $event['severity'] ?? 'warning' )
                    );
                    $collapsed[ $last_index ]['occurrences'] = (int) $last_event['occurrences'] + (int) $event['occurrences'];
                    continue;
                }

                $same_event = ( $last_event['event'] ?? '' ) === ( $event['event'] ?? '' )
                    && ( $last_event['severity'] ?? '' ) === ( $event['severity'] ?? '' )
                    && ( $last_event['source'] ?? '' ) === ( $event['source'] ?? '' )
                    && (int) ( $last_event['user_id'] ?? 0 ) === (int) ( $event['user_id'] ?? 0 )
                    && (string) ( $last_event['ip_address'] ?? '' ) === (string) ( $event['ip_address'] ?? '' );

                if ( $same_event && ( (int) $last_event['event_time'] - $event_time ) <= $burst_window ) {
                    $collapsed[ $last_index ]['occurrences'] = (int) $last_event['occurrences'] + (int) $event['occurrences'];
                    continue;
                }
            }

            $collapsed[] = $event;
        }

        return array_slice( $collapsed, 0, $limit );
    }

    private static function is_sensitive_path_escalation_pair( string $left, string $right ): bool {
        $pair = [ $left, $right ];
        sort( $pair );

        return $pair === [ 'IP Ban', 'Sensitive Path' ]
            || $pair === [ 'IP Ban', 'Sensitive Path Escalation' ]
            || $pair === [ 'IP Ban', 'Scanner Probe' ];
    }

    private static function max_dashboard_severity( string $left, string $right ): string {
        $rank = [
            'critical' => 4,
            'error'    => 3,
            'warning'  => 2,
            'info'     => 1,
        ];

        return ( $rank[ $left ] ?? 0 ) >= ( $rank[ $right ] ?? 0 ) ? $left : $right;
    }

    /**
     * Bust the dashboard stats cache.
     *
     * Hooked into any action that changes the underlying data so the next
     * REST call reflects the new state without waiting for the 60 s TTL.
     */
    public function bust_stats_cache() {
        delete_transient( 'sitefort_dashboard_stats' );
        $timezone_key = self::site_timezone_cache_key();
        foreach ( [
            FirewallConfigService::MODE_DISABLED,
            FirewallConfigService::MODE_PENDING_VALIDATION,
            FirewallConfigService::MODE_MONITOR,
            FirewallConfigService::MODE_ENFORCE,
        ] as $mode ) {
            delete_transient( 'sitefort_dashboard_stats_' . $mode );
            delete_transient( 'sitefort_dashboard_stats_' . $mode . '_' . $timezone_key );
        }
    }

}
