<?php

declare(strict_types=1);

namespace ShieldX\Security;

defined('ABSPATH') || exit;

use ShieldX\Storage\FileStorage;

// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Timeline queries SHIELD-X-owned tables only; dynamic table names are built from $wpdb->prefix and values are prepared.
// phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_fopen,WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Timeline streams SHIELD-X local JSONL log files without loading them fully into memory.

final class IncidentTimeline
{
    private const MAX_LOG_LINE_BYTES = 65536;
    private const MAX_AUDIT_BYTES_PER_FILE = 1048576;
    private const MIN_AUDIT_LINES_PER_FILE = 200;
    private const DASHBOARD_ACTIVITY_DAYS = 365;
    private const DASHBOARD_ACTIVITY_CACHE_TTL = 300;

    /** @var FileStorage */
    private $storage;

    /** @var object|null */
    private $wpdb;

    /** @var WafSettings */
    private $wafSettings;

    public function __construct(FileStorage $storage, $wpdb = null, ?WafSettings $wafSettings = null)
    {
        $this->storage = $storage;
        $this->wpdb = $wpdb;
        $this->wafSettings = $wafSettings ?: WafSettings::fromWordPress();
    }

    public static function fromWordPress(): self
    {
        global $wpdb;

        return new self(FileStorage::fromWordPress(), $wpdb, WafSettings::fromWordPress());
    }

    /**
     * @return array<string,mixed>
     */
    public function summary(int $limit = 40): array
    {
        $limit = max(5, min(500, $limit));
        $cacheKey = $this->summaryCacheKey($limit);
        if ($cacheKey !== '' && function_exists('get_transient')) {
            $cached = get_transient($cacheKey);
            if (is_array($cached)) {
                return $cached;
            }
        }

        $events = array_merge(
            $this->auditEvents($limit * 4),
            $this->scannerEvents($limit)
        );
        $state = $this->activeBlockEvents();

        usort($events, static function (array $left, array $right): int {
            $timestamp = ((int) ($right['timestamp'] ?? 0)) <=> ((int) ($left['timestamp'] ?? 0));

            return $timestamp !== 0 ? $timestamp : strcmp((string) ($right['id'] ?? ''), (string) ($left['id'] ?? ''));
        });

        $totalAvailable = count($events);
        $categoryCounts = $this->counts($events, 'category');
        $severityCounts = $this->counts($events, 'severity');
        $events = array_slice($events, 0, $limit);

        $summary = [
            'events' => $events,
            'total' => count($events),
            'total_available' => $totalAvailable,
            'category_counts' => $categoryCounts,
            'severity_counts' => $severityCounts,
            'state' => $state,
            'generated_at' => gmdate('c'),
        ];

        if ($cacheKey !== '' && function_exists('set_transient')) {
            set_transient($cacheKey, $summary, 60);
        }

        return $summary;
    }

    /**
     * Returns the lightweight data set used by the WordPress Dashboard widget.
     *
     * The full Incident/Activities page still reads the recent incident feed.
     * Dashboard charts are built from the durable daily counters instead, so a
     * cold dashboard request does not have to parse retained JSONL audit logs.
     *
     * @return array<string,mixed>
     */
    public function dashboardActivitySummary(int $days = self::DASHBOARD_ACTIVITY_DAYS, bool $allowAuditFallback = true): array
    {
        $days = max(1, min(self::DASHBOARD_ACTIVITY_DAYS, $days));
        if (!$this->hasDatabase()) {
            return $allowAuditFallback ? $this->dashboardActivityFallback() : self::emptyDashboardActivitySummary();
        }

        $table = (string) $this->wpdb->prefix . 'shieldx_event_summaries';
        $cacheKey = self::dashboardActivityCacheKey($days, $table);
        if ($cacheKey !== '' && function_exists('get_transient')) {
            $cached = get_transient($cacheKey);
            if (is_array($cached)) {
                return $cached;
            }
        }

        // Bypass the short shared summary cache after this widget cache misses.
        // AuditLogger clears the widget cache after persisting a new counter, so
        // the dashboard immediately reflects the durable aggregate.
        $dailySummaries = $this->dailyActivitySummary($days, true);
        // A persisted counter can legitimately describe an internal event that
        // is not plotted in the dashboard (for example, a storage warning).
        // Do not let that lone counter hide the existing visible signals while
        // the aggregate table is still warming up after an update.
        if (!$this->dashboardHasVisibleSignals($dailySummaries)) {
            if (!$allowAuditFallback) {
                return self::emptyDashboardActivitySummary();
            }

            $fallback = $this->dashboardActivityFallback();
            if ($cacheKey !== '' && function_exists('set_transient')) {
                set_transient($cacheKey, $fallback, self::DASHBOARD_ACTIVITY_CACHE_TTL);
            }

            return $fallback;
        }

        $summary = [
            'events' => [],
            'total' => 0,
            'total_available' => 0,
            'category_counts' => [],
            'severity_counts' => [],
            'state' => [],
            'generated_at' => gmdate('c'),
            'daily_summaries' => $dailySummaries,
        ];

        if ($cacheKey !== '' && function_exists('set_transient')) {
            set_transient($cacheKey, $summary, self::DASHBOARD_ACTIVITY_CACHE_TTL);
        }

        return $summary;
    }

    /**
     * Clears the Dashboard-only aggregate cache after AuditLogger persists
     * fresh counter data. Full timeline caches deliberately keep their existing
     * short TTL because their callers expect a stable page snapshot.
     *
     * @param object|null $wpdb
     */
    public static function invalidateDashboardActivityCache($wpdb = null): void
    {
        if (!function_exists('delete_transient')) {
            return;
        }

        if ($wpdb === null) {
            global $wpdb;
        }

        if (!is_object($wpdb) || !isset($wpdb->prefix)) {
            return;
        }

        $table = (string) $wpdb->prefix . 'shieldx_event_summaries';
        $cacheKey = self::dashboardActivityCacheKey(self::DASHBOARD_ACTIVITY_DAYS, $table);
        if ($cacheKey !== '') {
            delete_transient($cacheKey);
        }
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    public function dailyActivitySummary(int $days = 365, bool $forceRefresh = false): array
    {
        $days = max(1, min(365, $days));
        if (!$this->hasDatabase()) {
            return [];
        }

        $table = (string) $this->wpdb->prefix . 'shieldx_event_summaries';
        $cacheKey = $this->dailySummaryCacheKey($days, $table);
        if (!$forceRefresh && $cacheKey !== '' && function_exists('get_transient')) {
            $cached = get_transient($cacheKey);
            if (is_array($cached)) {
                return $cached;
            }
        }

        $today = strtotime(gmdate('Y-m-d 00:00:00')) ?: time();
        $startTimestamp = strtotime('-' . (string) ($days - 1) . ' days', $today) ?: ($today - (($days - 1) * 86400));
        $startDate = gmdate('Y-m-d', $startTimestamp);
        $sql = $this->prepareIdentifierSql(
            'SELECT summary_date, event_type, severity, `count`, first_seen, last_seen
             FROM %i
             WHERE summary_date >= %s
             ORDER BY summary_date ASC',
            $table,
            [$startDate]
        );
        $rows = $this->rows($sql);
        $summaries = [];

        foreach ($rows as $row) {
            $date = (string) ($row['summary_date'] ?? '');
            if ($date === '' || $date < $startDate) {
                continue;
            }

            $eventType = (string) ($row['event_type'] ?? '');
            $category = $this->auditEventTypeCategory($eventType);
            $count = max(0, (int) ($row['count'] ?? 0));
            if ($category === null || $count <= 0) {
                continue;
            }

            $summaries[] = [
                'summary_date' => $date,
                'timestamp' => strtotime($date . ' 00:00:00') ?: 0,
                'source' => 'summary',
                'source_event' => $eventType,
                'category' => $category,
                'severity' => $this->normalizeSeverity((string) ($row['severity'] ?? 'info')),
                'count' => $count,
                'first_seen' => (string) ($row['first_seen'] ?? ''),
                'last_seen' => (string) ($row['last_seen'] ?? ''),
            ];
        }

        if ($cacheKey !== '' && function_exists('set_transient')) {
            set_transient($cacheKey, $summaries, 60);
        }

        return $summaries;
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    public function wafTrendSummary(int $days = 365): array
    {
        $days = max(1, min(365, $days));
        if (!$this->hasDatabase()) {
            return [];
        }

        $table = (string) $this->wpdb->prefix . 'shieldx_event_summaries';
        $cacheKey = $this->dailySummaryCacheKey($days, $table . '_waf-trends-v1');
        if ($cacheKey !== '' && function_exists('get_transient')) {
            $cached = get_transient($cacheKey);
            if (is_array($cached)) {
                return $cached;
            }
        }

        $today = strtotime(gmdate('Y-m-d 00:00:00')) ?: time();
        $startTimestamp = strtotime('-' . (string) ($days - 1) . ' days', $today) ?: ($today - (($days - 1) * 86400));
        $startDate = gmdate('Y-m-d', $startTimestamp);
        $sql = $this->prepareIdentifierSql(
            'SELECT summary_date, event_type, severity, `count`, first_seen, last_seen
             FROM %i
             WHERE summary_date >= %s AND event_type LIKE %s
             ORDER BY summary_date ASC',
            $table,
            [$startDate, 'waf.%']
        );
        $rows = $this->rows($sql);
        $summaries = [];
        $dedicatedTotals = [];
        $legacyTotals = [];

        foreach ($rows as $row) {
            $date = (string) ($row['summary_date'] ?? '');
            $eventType = (string) ($row['event_type'] ?? '');
            $count = max(0, (int) ($row['count'] ?? 0));
            if ($date === '' || $date < $startDate || $count <= 0) {
                continue;
            }

            if ($eventType === 'waf.hit') {
                $dedicatedTotals[$date] = (int) ($dedicatedTotals[$date] ?? 0) + $count;
                continue;
            }

            if (in_array($eventType, ['waf.match', 'waf.block'], true)) {
                $legacyTotals[$date] = (int) ($legacyTotals[$date] ?? 0) + $count;
                continue;
            }

            $dimension = self::wafTrendDimension($eventType);
            if ($dimension === []) {
                continue;
            }

            $summaries[] = [
                'summary_date' => $date,
                'timestamp' => strtotime($date . ' 00:00:00') ?: 0,
                'source_event' => $eventType,
                'dimension' => $dimension['dimension'],
                'value' => $dimension['value'],
                'count' => $count,
            ];
        }

        foreach ($dedicatedTotals as $date => $count) {
            $summaries[] = self::wafTrendTotalSummary($date, $count, 'waf.hit');
        }

        foreach ($legacyTotals as $date => $count) {
            if (isset($dedicatedTotals[$date])) {
                continue;
            }

            $summaries[] = self::wafTrendTotalSummary($date, $count, 'waf.legacy_total');
        }

        usort($summaries, static function (array $left, array $right): int {
            $timestamp = ((int) ($left['timestamp'] ?? 0)) <=> ((int) ($right['timestamp'] ?? 0));

            return $timestamp !== 0 ? $timestamp : strcmp((string) ($left['source_event'] ?? ''), (string) ($right['source_event'] ?? ''));
        });

        if ($cacheKey !== '' && function_exists('set_transient')) {
            set_transient($cacheKey, $summaries, 60);
        }

        return $summaries;
    }

    private function dailySummaryCacheKey(int $days, string $table): string
    {
        $fingerprint = wp_json_encode([
            'days' => $days,
            'storage' => $this->storage->baseDir(),
            'table' => $table,
        ]);

        return is_string($fingerprint) ? 'shieldx_incident_daily_summary_' . sha1($fingerprint) : '';
    }

    private function dashboardActivityFallback(): array
    {
        $summary = $this->summary(40);
        $summary['daily_summaries'] = [];

        return $summary;
    }

    /**
     * @return array<string,mixed>
     */
    private static function emptyDashboardActivitySummary(): array
    {
        return [
            'events' => [],
            'total' => 0,
            'total_available' => 0,
            'category_counts' => [],
            'severity_counts' => [],
            'state' => [],
            'generated_at' => gmdate('c'),
            'daily_summaries' => [],
        ];
    }

    /**
     * @param array<int,array<string,mixed>> $summaries
     */
    private function dashboardHasVisibleSignals(array $summaries): bool
    {
        foreach ($summaries as $summary) {
            $category = (string) ($summary['category'] ?? '');
            if (in_array($category, ['visit', 'waf', 'blocking', 'login', 'recovery', 'scanner', 'captcha', 'database', 'lockdown', 'backup'], true)) {
                return true;
            }
        }

        return false;
    }

    private static function dashboardActivityCacheKey(int $days, string $table): string
    {
        $fingerprint = wp_json_encode([
            'format' => 2,
            'days' => $days,
            'table' => $table,
        ]);

        return is_string($fingerprint) ? 'shieldx_dashboard_activity_' . sha1($fingerprint) : '';
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    private function auditEvents(int $limit): array
    {
        $limit = max(1, $limit);
        $lineLimit = max(self::MIN_AUDIT_LINES_PER_FILE, $limit * 2);
        $events = [];

        foreach ($this->logFiles() as $file) {
            foreach ($this->recentAuditLines($file, $lineLimit) as $line) {
                $record = \ShieldX\Support\Json::decodeArray(trim($line));
                if (!is_array($record)) {
                    continue;
                }

                $event = $this->auditRecordToEvent($record);
                if ($event !== null) {
                    $events[] = $event;
                }
            }
        }

        usort($events, static function (array $left, array $right): int {
            $timestamp = ((int) ($right['timestamp'] ?? 0)) <=> ((int) ($left['timestamp'] ?? 0));

            return $timestamp !== 0 ? $timestamp : strcmp((string) ($right['id'] ?? ''), (string) ($left['id'] ?? ''));
        });

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

    /**
     * Dashboard and overview timelines need the latest signal quickly; they do
     * not need to stream hundreds of MB of historical audit logs before
     * rendering the first WordPress dashboard widget.
     *
     * @return array<int,string>
     */
    private function recentAuditLines(string $file, int $lineLimit): array
    {
        if (!is_readable($file)) {
            return [];
        }

        $size = (int) filesize($file);
        if ($size <= 0) {
            return [];
        }

        $bytesToRead = min($size, self::MAX_AUDIT_BYTES_PER_FILE);
        $offset = max(0, $size - $bytesToRead);
        $handle = fopen($file, 'rb');
        if (!$handle) {
            return [];
        }

        try {
            if ($offset > 0) {
                fseek($handle, $offset);
                self::readBoundedLine($handle);
            }

            $lines = [];
            while (($line = self::readBoundedLine($handle)) !== false) {
                $lines[] = $line;
                if (count($lines) > $lineLimit) {
                    array_shift($lines);
                }
            }

            return $lines;
        } finally {
            fclose($handle);
        }
    }

    /**
     * @param resource $handle
     * @return string|false
     */
    private static function readBoundedLine($handle)
    {
        $line = fgets($handle, self::MAX_LOG_LINE_BYTES + 1);
        if ($line === false) {
            return false;
        }

        if (strlen($line) <= self::MAX_LOG_LINE_BYTES || substr($line, -1) === "\n") {
            return $line;
        }

        while (($chunk = fgets($handle, self::MAX_LOG_LINE_BYTES + 1)) !== false) {
            if (substr($chunk, -1) === "\n") {
                break;
            }
        }

        return '';
    }

    /**
     * @param array<string,mixed> $record
     * @return array<string,mixed>|null
     */
    private function auditRecordToEvent(array $record): ?array
    {
        $event = (string) ($record['event'] ?? '');
        $context = isset($record['context']) && is_array($record['context']) ? $record['context'] : [];
        $time = (string) ($record['time'] ?? '');
        $timestamp = strtotime($time) ?: 0;

        if ($event === '' || $event === 'maintenance.tick' || $event === 'waf.allow') {
            return null;
        }

        $category = 'system';
        $severity = (string) ($record['severity'] ?? 'info');
        $title = $this->formatEventName($event);
        $subject = '';
        $summary = '';
        $meta = [];

        if ($event === 'visit.page') {
            $category = 'visit';
            $severity = 'info';
            $title = 'Normal page visit';
            $subject = (string) ($context['ip'] ?? 'unknown');
            $summary = trim((string) ($context['method'] ?? 'GET') . ' ' . (string) ($context['path'] ?? '/'));
            $meta = $this->selectedMeta($context, ['browser', 'path']);
        } elseif ($event === 'waf.match') {
            $action = (string) ($context['action'] ?? 'log');
            $wouldBlock = !empty($context['would_block']);
            $category = $action === 'block' ? 'blocking' : 'waf';
            $severity = ($action === 'block' || $wouldBlock) ? 'high' : 'medium';
            if ($action === 'block') {
                $title = 'WAF blocked request';
            } elseif ($wouldBlock) {
                $title = 'WAF would block request';
            } else {
                $title = 'WAF rule match';
            }
            $subject = (string) ($context['ip'] ?? 'unknown');
            $summary = trim((string) ($context['method'] ?? '') . ' ' . (string) ($context['uri'] ?? ''));
            $meta = $this->wafMeta($context);
        } elseif ($event === 'waf.block') {
            $category = 'blocking';
            $severity = 'high';
            $title = 'WAF blocked request';
            $subject = (string) ($context['ip'] ?? 'unknown');
            $summary = trim((string) ($context['reason'] ?? 'block') . ' ' . (string) ($context['matched_value'] ?? ''));
            $meta = $this->wafMeta($context);
        } elseif ($event === 'rate.limit') {
            $category = 'blocking';
            $severity = ((string) ($context['action'] ?? '') === 'block') ? 'high' : 'medium';
            $title = 'Request rate limit';
            $subject = (string) ($context['ip'] ?? 'unknown');
            $summary = sprintf(
                '%s requests in %ss window',
                (string) ($context['count'] ?? 0),
                (string) ($context['window_seconds'] ?? 0)
            );
            $meta = $this->selectedMeta($context, ['mode', 'action', 'limit', 'burst_count', 'burst_limit', 'uri']);
        } elseif (in_array($event, ['login.failure', 'login.lockout_created', 'login.lockout_block'], true)) {
            $category = 'login';
            $severity = $event === 'login.failure' ? 'medium' : 'high';
            $title = $event === 'login.failure' ? 'Login failure' : ($event === 'login.lockout_created' ? 'Login lockout created' : 'Login lockout blocked');
            $subject = (string) ($context['ip'] ?? 'unknown');
            $summary = (string) ($context['username'] ?? $context['user_login'] ?? '');
            $meta = $this->selectedMeta($context, ['username', 'failures', 'lockout_until', 'user_agent']);
        } elseif (in_array($event, ['activity.user_login', 'activity.user_logout'], true)) {
            $category = 'activity';
            $severity = 'info';
            $title = $event === 'activity.user_login' ? 'User login' : 'User logout';
            $subject = (string) ($context['user_login'] ?? '');
            if ($subject === '' && isset($context['user_id'])) {
                $subject = 'User #' . (string) $context['user_id'];
            }
            $summary = trim(($event === 'activity.user_login' ? 'Login' : 'Logout') . ' from ' . (string) ($context['ip'] ?? 'unknown'));
            $meta = $this->selectedMeta($context, ['user_id', 'user_login', 'role', 'ip']);
        } elseif (strpos($event, 'activity.post_') === 0) {
            $category = 'activity';
            $title = $this->activityPostTitle($event);
            $subject = (string) ($context['title'] ?? '');
            if ($subject === '' && isset($context['post_id'])) {
                $subject = 'Post #' . (string) $context['post_id'];
            }
            $summary = $this->activityPostSummary($event, $context);
            $meta = $this->selectedMeta($context, ['post_id', 'post_type', 'old_status', 'new_status', 'changed_fields', 'meta_key', 'taxonomy', 'term_count', 'old_term_count', 'append', 'user_login', 'user_id', 'ip']);
        } elseif (strpos($event, 'activity.') === 0) {
            $category = 'activity';
            $title = $this->activityTitle($event);
            $subject = $this->activitySubject($context);
            $summary = $this->activitySummary($event, $context);
            $meta = $this->selectedMeta($context, [
                'user_id',
                'user_login',
                'role',
                'target_user_id',
                'target_user_login',
                'target_role',
                'changed_fields',
                'old_role',
                'new_role',
                'option',
                'setting_area',
                'old_value',
                'new_value',
                'plugin',
                'theme',
                'upgrader_type',
                'upgrader_action',
                'item_count',
                'taxonomy',
                'term_id',
                'menu_id',
                'menu_item_id',
                'site_id',
                'tables_added',
                'tables_removed',
                'file_changes',
                'scan_id',
                'source',
                'ip',
            ]);
        } elseif (strpos($event, 'login_security.') === 0) {
            $category = 'login';
            $severity = strpos($event, 'emergency') !== false || strpos($event, 'disabled') !== false ? 'high' : 'info';
            $title = $this->formatEventName($event);
            $subject = isset($context['user_id']) ? 'User #' . (string) $context['user_id'] : '';
            $summary = (string) ($context['method'] ?? '');
            $meta = $this->selectedMeta($context, ['user_id', 'method']);
        } elseif ($event === 'human_challenge.verify_failed') {
            $category = 'captcha';
            $severity = 'medium';
            $title = 'Captcha failed';
            $subject = (string) ($context['context'] ?? 'challenge');
            $summary = (string) ($context['message'] ?? 'Cap.js Challenge (Captcha) verification failed.');
            $meta = $this->selectedMeta($context, ['provider', 'context', 'fail_behavior']);
        } elseif (strpos($event, 'lockdown.') === 0 || strpos($event, 'update.lockdown_') === 0) {
            $category = 'lockdown';
            $severity = strpos($event, 'block') !== false ? 'high' : 'medium';
            $title = $this->formatEventName($event);
            $subject = (string) ($context['ip'] ?? $context['operation'] ?? '');
            $summary = (string) ($context['reason'] ?? $context['status'] ?? $context['message'] ?? '');
            $meta = $this->selectedMeta($context, ['operation', 'status', 'ok', 'reason', 'matched_value', 'uri']);
        } elseif (strpos($event, 'database.') === 0) {
            $category = 'database';
        } elseif (strpos($event, 'backup.') === 0 || strpos($event, 'restore_point.') === 0 || strpos($event, 'shadow') !== false) {
            $category = 'backup';
        } elseif (strpos($event, 'scan.') === 0) {
            $category = 'scanner';
            $severity = strpos($event, 'failed') !== false ? 'high' : (strpos($event, 'skipped') !== false ? 'medium' : 'info');
            $title = $this->formatEventName($event);
            $subject = (string) ($context['scan_type'] ?? $context['scan_id'] ?? '');
            $summary = (string) ($context['reason'] ?? $context['status'] ?? '');
            $meta = $this->selectedMeta($context, ['scan_id', 'scan_type', 'issues_found', 'files_scanned', 'database_items_scanned', 'duration_ms']);
        } elseif ($event === 'emergency_recovery.completed') {
            $category = 'recovery';
            $severity = 'high';
            $title = 'Emergency recovery completed';
            $subject = (string) ($context['ip'] ?? '');
            $summary = 'Owner recovery changed one or more SHIELD-X protections.';
            $meta = $this->selectedMeta($context, ['user_id', 'method', 'ip']);
        }

        return $this->event([
            'source' => 'audit',
            'source_event' => $event,
            'time' => $time,
            'timestamp' => $timestamp,
            'category' => $category,
            'severity' => $this->normalizeSeverity($severity),
            'title' => $title,
            'subject' => $subject,
            'summary' => $summary,
            'meta' => $meta,
        ]);
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    private function scannerEvents(int $limit): array
    {
        if (!$this->hasDatabase()) {
            return [];
        }

        $table = (string) $this->wpdb->prefix . 'shieldx_threats';
        $sql = $this->prepareIdentifierSql(
            "SELECT threat_id, rule_id, type, severity, status, source, description, affected_path, first_detected, last_detected
             FROM %i
             WHERE source = %s AND status IN ('active', 'quarantined')
             ORDER BY last_detected DESC
             LIMIT %d",
            $table,
            ['scanner', max(1, min(100, $limit))]
        );
        $rows = $this->rows($sql);
        $events = [];

        foreach ($rows as $row) {
            $path = (string) ($row['affected_path'] ?? '');
            $isDatabase = strpos($path, 'db:') === 0;
            $time = (string) ($row['last_detected'] ?? $row['first_detected'] ?? '');
            $description = (string) ($row['description'] ?? '');
            $events[] = $this->event([
                'source' => 'scanner',
                'source_event' => $isDatabase ? 'database.finding' : 'scanner.finding',
                'time' => $time,
                'timestamp' => strtotime($time) ?: 0,
                'category' => $isDatabase ? 'database' : 'scanner',
                'severity' => $this->normalizeSeverity((string) ($row['severity'] ?? 'medium')),
                'title' => $this->formatEventName((string) ($row['type'] ?? ($isDatabase ? 'database finding' : 'scanner finding'))),
                'subject' => $path,
                'summary' => $this->firstLine($description),
                'meta' => [
                    'threat_id' => (string) ($row['threat_id'] ?? ''),
                    'rule_id' => (string) ($row['rule_id'] ?? ''),
                    'status' => (string) ($row['status'] ?? ''),
                ],
            ]);
        }

        return $events;
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    private function activeBlockEvents(): array
    {
        $settings = $this->wafSettings->all();
        $events = [];
        $now = time();
        $time = gmdate('c', $now);

        foreach ([
            'ip_blocklist' => ['title' => 'Active IP/range block', 'severity' => 'high'],
            'country_blocklist' => ['title' => 'Active country block', 'severity' => 'medium'],
            'user_agent_blocklist' => ['title' => 'Active browser block', 'severity' => 'medium'],
            'referrer_blocklist' => ['title' => 'Active referrer block', 'severity' => 'medium'],
        ] as $key => $definition) {
            foreach ((array) ($settings[$key] ?? []) as $value) {
                $value = trim((string) $value);
                if ($value === '') {
                    continue;
                }

                $events[] = $this->event([
                    'source' => 'settings',
                    'source_event' => $key,
                    'time' => $time,
                    'timestamp' => $now,
                    'category' => 'blocking',
                    'severity' => (string) $definition['severity'],
                    'title' => (string) $definition['title'],
                    'subject' => $value,
                    'summary' => 'Current WAF block setting is active.',
                    'meta' => ['setting' => $key],
                    'state_event' => true,
                ]);
            }
        }

        foreach ((array) ($settings['temporary_blocks'] ?? []) as $block) {
            if (!is_array($block)) {
                continue;
            }

            $expiresAt = (int) ($block['expires_at'] ?? 0);
            $events[] = $this->event([
                'source' => 'settings',
                'source_event' => 'temporary_block',
                'time' => $time,
                'timestamp' => $now,
                'category' => 'blocking',
                'severity' => 'high',
                'title' => 'Temporary block active',
                'subject' => (string) ($block['value'] ?? ''),
                'summary' => 'Temporary ' . (string) ($block['type'] ?? 'block') . ' block expires ' . ($expiresAt > 0 ? gmdate('Y-m-d H:i:s', $expiresAt) : 'soon') . '.',
                'meta' => $this->selectedMeta($block, ['id', 'type', 'expires_at']),
                'state_event' => true,
            ]);
        }

        return $events;
    }

    /**
     * @param array<string,mixed> $input
     * @return array<string,mixed>
     */
    private function event(array $input): array
    {
        $meta = isset($input['meta']) && is_array($input['meta']) ? $input['meta'] : [];
        $event = [
            'source' => (string) ($input['source'] ?? ''),
            'source_event' => (string) ($input['source_event'] ?? ''),
            'time' => (string) ($input['time'] ?? ''),
            'timestamp' => (int) ($input['timestamp'] ?? 0),
            'category' => $this->normalizeCategory((string) ($input['category'] ?? 'system')),
            'severity' => $this->normalizeSeverity((string) ($input['severity'] ?? 'info')),
            'title' => $this->truncate((string) ($input['title'] ?? 'Security event'), 120),
            'subject' => $this->truncate((string) ($input['subject'] ?? ''), 240),
            'summary' => $this->truncate((string) ($input['summary'] ?? ''), 420),
            'meta' => $this->cleanMeta($meta),
            'state_event' => !empty($input['state_event']),
        ];
        $event['id'] = substr(sha1(implode('|', [
            $event['source'],
            $event['source_event'],
            $event['time'],
            $event['category'],
            $event['subject'],
            $event['summary'],
            wp_json_encode($event['meta']),
        ])), 0, 16);

        return $event;
    }

    /**
     * @param array<string,mixed> $context
     * @return array<string,string>
     */
    private function wafMeta(array $context): array
    {
        $meta = [];
        $rule = $this->firstRuleDetail($context);

        if (!empty($rule)) {
            foreach ($this->selectedMeta($rule, ['name', 'id', 'category', 'field']) as $key => $value) {
                $meta[$key === 'name' ? 'rule' : ($key === 'id' ? 'rule_id' : $key)] = $value;
            }
        }

        $meta += $this->selectedMeta($context, ['action', 'would_block', 'score', 'reason', 'matched_value', 'mode', 'engine_version']);

        if (!isset($meta['rule_id']) && isset($context['rules']) && is_array($context['rules'])) {
            $meta['rules'] = implode(', ', array_map('strval', $context['rules']));
        }

        return $meta;
    }

    /**
     * @param array<string,mixed> $context
     * @return array<string,mixed>
     */
    private function firstRuleDetail(array $context): array
    {
        if (!isset($context['rule_details']) || !is_array($context['rule_details'])) {
            return [];
        }

        foreach ($context['rule_details'] as $detail) {
            if (is_array($detail) && !empty($detail)) {
                return $detail;
            }
        }

        return [];
    }

    /**
     * @param array<string,mixed> $source
     * @param array<int,string>   $keys
     * @return array<string,string>
     */
    private function selectedMeta(array $source, array $keys): array
    {
        $meta = [];
        foreach ($keys as $key) {
            if (!array_key_exists($key, $source)) {
                continue;
            }

            $value = $source[$key];
            if (is_bool($value)) {
                $value = $value ? 'yes' : 'no';
            } elseif (is_array($value) || is_object($value)) {
                $encoded = wp_json_encode($value, JSON_UNESCAPED_SLASHES);
                $value = is_string($encoded) ? $encoded : '';
            }

            $value = trim((string) $value);
            if ($value !== '') {
                $meta[$key] = $this->truncate($value, 180);
            }
        }

        return $meta;
    }

    /**
     * @param array<string,mixed> $meta
     * @return array<string,string>
     */
    private function cleanMeta(array $meta): array
    {
        $clean = [];
        foreach ($meta as $key => $value) {
            if (is_array($value) || is_object($value)) {
                $encoded = wp_json_encode($value, JSON_UNESCAPED_SLASHES);
                $value = is_string($encoded) ? $encoded : '';
            }
            $key = preg_replace('/[^a-z0-9_.:-]/i', '', (string) $key) ?: '';
            $value = trim((string) $value);
            if ($key !== '' && $value !== '') {
                $clean[$key] = $this->truncate($value, 180);
            }
        }

        return $clean;
    }

    /**
     * @param array<int,array<string,mixed>> $events
     * @return array<string,int>
     */
    private function counts(array $events, string $key): array
    {
        $counts = [];
        foreach ($events as $event) {
            $value = (string) ($event[$key] ?? '');
            if ($value !== '') {
                $counts[$value] = isset($counts[$value]) ? $counts[$value] + 1 : 1;
            }
        }
        arsort($counts);

        return $counts;
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    private function rows(string $sql): array
    {
        $rows = $this->wpdb->get_results($sql, defined('ARRAY_A') ? ARRAY_A : 'ARRAY_A');

        return is_array($rows) ? $rows : [];
    }

    /**
     * @return array<int,string>
     */
    private function logFiles(): array
    {
        $files = glob($this->storage->logDirectory() . '/*.log*');
        if (!is_array($files)) {
            return [];
        }

        $auditLogs = [];
        foreach ($files as $file) {
            if (!is_file($file) || preg_match('/^(\d{4}-\d{2}-\d{2})\.log(?:\.([1-5]))?$/', basename($file), $matches) !== 1) {
                continue;
            }

            $auditLogs[] = [
                'path' => $file,
                'date' => (string) $matches[1],
                'generation' => isset($matches[2]) ? (int) $matches[2] : 0,
            ];
        }

        usort($auditLogs, static function (array $left, array $right): int {
            $date = strcmp((string) $right['date'], (string) $left['date']);
            if ($date !== 0) {
                return $date;
            }

            $generation = (int) $left['generation'] <=> (int) $right['generation'];

            return $generation !== 0 ? $generation : strcmp((string) $left['path'], (string) $right['path']);
        });

        $files = array_map(static function (array $log): string {
            return (string) $log['path'];
        }, $auditLogs);

        return array_slice($files, 0, 14);
    }

    private function summaryCacheKey(int $limit): string
    {
        $fingerprint = [
            'format' => 2,
            'limit' => $limit,
            'has_database' => $this->hasDatabase(),
            'storage' => $this->storage->baseDir(),
            'threats_table' => $this->hasDatabase() ? (string) $this->wpdb->prefix . 'shieldx_threats' : '',
        ];

        $json = wp_json_encode($fingerprint);

        return is_string($json) ? 'shieldx_incident_timeline_' . sha1($json) : '';
    }

    private function hasDatabase(): bool
    {
        return is_object($this->wpdb)
            && isset($this->wpdb->prefix)
            && method_exists($this->wpdb, 'prepare')
            && method_exists($this->wpdb, 'get_results');
    }

    /**
     * @param array<int,mixed> $args
     */
    private function prepareIdentifierSql(string $sql, string $identifier, array $args = []): string
    {
        if ($this->supportsIdentifierPlaceholders()) {
            $prepared = $this->wpdb->prepare($sql, $identifier, ...$args);

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

        $quoted = $this->quoteIdentifier($identifier);
        if ($quoted === '') {
            return '';
        }

        $prepared = $this->wpdb->prepare(str_replace('%i', $quoted, $sql), ...$args);

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

    private function supportsIdentifierPlaceholders(): bool
    {
        return is_object($this->wpdb)
            && method_exists($this->wpdb, 'has_cap')
            && (bool) $this->wpdb->has_cap('identifier_placeholders');
    }

    private function quoteIdentifier(string $identifier): string
    {
        if (preg_match('/^[A-Za-z0-9_]+$/', $identifier) !== 1) {
            return '';
        }

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

    private function auditEventTypeCategory(string $event): ?string
    {
        if ($event === '' || $event === 'maintenance.tick' || $event === 'waf.allow') {
            return null;
        }

        if ($event === 'waf.hit' || preg_match('/^waf\.(?:action|severity|category)\./', $event) === 1) {
            return null;
        }

        if ($event === 'visit.page') {
            return 'visit';
        }

        if ($event === 'waf.match') {
            return 'waf';
        }

        if ($event === 'waf.block' || $event === 'rate.limit') {
            return 'blocking';
        }

        if (in_array($event, ['login.failure', 'login.lockout_created', 'login.lockout_block'], true) || strpos($event, 'login_security.') === 0) {
            return 'login';
        }

        if ($event === 'human_challenge.verify_failed') {
            return 'captcha';
        }

        if (strpos($event, 'lockdown.') === 0 || strpos($event, 'update.lockdown_') === 0) {
            return 'lockdown';
        }

        if (strpos($event, 'scan.') === 0) {
            return 'scanner';
        }

        if (strpos($event, 'database.') === 0) {
            return 'database';
        }

        if ($event === 'emergency_recovery.completed') {
            return 'recovery';
        }

        if (strpos($event, 'backup.') === 0 || strpos($event, 'restore_point.') === 0 || strpos($event, 'shadow') !== false) {
            return 'backup';
        }

        if (strpos($event, 'activity.') === 0) {
            return 'activity';
        }

        return 'system';
    }

    /**
     * @return array{dimension:string,value:string}|array{}
     */
    private static function wafTrendDimension(string $event): array
    {
        if (preg_match('/^waf\.(action|severity|category)\.([a-z_]+)$/', $event, $matches) !== 1) {
            return [];
        }

        return [
            'dimension' => (string) $matches[1],
            'value' => (string) $matches[2],
        ];
    }

    /**
     * @return array{summary_date:string,timestamp:int,source_event:string,dimension:string,value:string,count:int}
     */
    private static function wafTrendTotalSummary(string $date, int $count, string $sourceEvent): array
    {
        return [
            'summary_date' => $date,
            'timestamp' => strtotime($date . ' 00:00:00') ?: 0,
            'source_event' => $sourceEvent,
            'dimension' => 'total',
            'value' => 'waf_hits',
            'count' => $count,
        ];
    }

    private function normalizeCategory(string $category): string
    {
        return in_array($category, ['visit', 'waf', 'blocking', 'login', 'activity', 'scanner', 'captcha', 'database', 'backup', 'lockdown', 'recovery', 'system'], true)
            ? $category
            : 'system';
    }

    private function activityPostTitle(string $event): string
    {
        if ($event === 'activity.post_published') {
            return 'Post published';
        }

        if ($event === 'activity.post_status_changed') {
            return 'Post status changed';
        }

        if ($event === 'activity.post_deleted') {
            return 'Post deleted';
        }

        if (in_array($event, ['activity.post_meta_added', 'activity.post_meta_updated', 'activity.post_meta_deleted'], true)) {
            return 'Post metadata changed';
        }

        if ($event === 'activity.post_terms_changed') {
            return 'Post terms changed';
        }

        return 'Post updated';
    }

    private function activityTitle(string $event): string
    {
        $titles = [
            'activity.core_updated' => 'WordPress core updated',
            'activity.database_tables_changed' => 'Database tables changed',
            'activity.menu_created' => 'Menu created',
            'activity.menu_deleted' => 'Menu deleted',
            'activity.menu_item_updated' => 'Menu item updated',
            'activity.menu_updated' => 'Menu updated',
            'activity.network_site_archived' => 'Network site archived',
            'activity.network_site_created' => 'Network site created',
            'activity.network_site_deleted' => 'Network site deleted',
            'activity.network_site_not_spam' => 'Network site restored',
            'activity.network_site_spam' => 'Network site marked spam',
            'activity.network_user_added' => 'User added to site',
            'activity.network_user_removed' => 'User removed from site',
            'activity.plugin_activated' => 'Plugin activated',
            'activity.plugin_deactivated' => 'Plugin deactivated',
            'activity.plugin_deleted' => 'Plugin deleted',
            'activity.plugin_installed' => 'Plugin installed',
            'activity.plugin_updated' => 'Plugin updated',
            'activity.setting_added' => 'Setting added',
            'activity.setting_deleted' => 'Setting deleted',
            'activity.setting_updated' => 'Setting updated',
            'activity.site_files_changed' => 'Site files changed',
            'activity.term_created' => 'Term created',
            'activity.term_deleted' => 'Term deleted',
            'activity.term_updated' => 'Term updated',
            'activity.theme_deleted' => 'Theme deleted',
            'activity.theme_installed' => 'Theme installed',
            'activity.theme_switched' => 'Theme switched',
            'activity.theme_updated' => 'Theme updated',
            'activity.user_created' => 'User created',
            'activity.user_deleted' => 'User deleted',
            'activity.user_password_reset' => 'Password reset',
            'activity.user_profile_updated' => 'User profile updated',
            'activity.user_role_changed' => 'User role changed',
            'activity.user_sessions_destroyed' => 'Other sessions terminated',
        ];

        return $titles[$event] ?? $this->formatEventName($event);
    }

    /**
     * @param array<string,mixed> $context
     */
    private function activitySubject(array $context): string
    {
        foreach ([
            'target',
            'target_user_login',
            'plugin',
            'theme',
            'option',
            'taxonomy',
            'tables_added',
            'tables_removed',
        ] as $key) {
            $value = trim((string) ($context[$key] ?? ''));
            if ($value !== '') {
                return $value;
            }
        }

        if (isset($context['target_user_id'])) {
            return 'User #' . (string) $context['target_user_id'];
        }

        if (isset($context['site_id'])) {
            return 'Site #' . (string) $context['site_id'];
        }

        return '';
    }

    /**
     * @param array<string,mixed> $context
     */
    private function activitySummary(string $event, array $context): string
    {
        $summary = trim((string) ($context['summary'] ?? ''));
        if ($summary !== '') {
            return $summary;
        }

        if (isset($context['changed_fields'])) {
            return 'Changed: ' . (string) $context['changed_fields'];
        }

        if (isset($context['old_value']) || isset($context['new_value'])) {
            return trim((string) ($context['old_value'] ?? '') . ' -> ' . (string) ($context['new_value'] ?? ''));
        }

        return $this->activityTitle($event);
    }

    /**
     * @param array<string,mixed> $context
     */
    private function activityPostSummary(string $event, array $context): string
    {
        $postType = (string) ($context['post_type'] ?? 'post');
        $postId = (string) ($context['post_id'] ?? '');

        if ($event === 'activity.post_updated') {
            $fields = (string) ($context['changed_fields'] ?? '');

            return $fields !== '' ? 'Changed: ' . $fields : 'Important content fields changed.';
        }

        if (in_array($event, ['activity.post_meta_added', 'activity.post_meta_updated', 'activity.post_meta_deleted'], true)) {
            return 'Metadata changed: ' . (string) ($context['meta_key'] ?? 'custom field');
        }

        if ($event === 'activity.post_terms_changed') {
            return 'Terms changed: ' . (string) ($context['taxonomy'] ?? 'taxonomy');
        }

        if ($event === 'activity.post_status_changed') {
            return trim((string) ($context['old_status'] ?? '') . ' -> ' . (string) ($context['new_status'] ?? ''));
        }

        if ($event === 'activity.post_deleted') {
            return trim($postType . ' ' . ($postId !== '' ? '#' . $postId . ' ' : '') . 'deleted');
        }

        return trim($postType . ' ' . ($postId !== '' ? '#' . $postId . ' ' : '') . 'published');
    }

    private function normalizeSeverity(string $severity): string
    {
        $severity = strtolower(trim($severity));

        if ($severity === 'warning') {
            $severity = 'medium';
        }

        return in_array($severity, ['critical', 'high', 'medium', 'low', 'info'], true) ? $severity : 'info';
    }

    private function formatEventName(string $event): string
    {
        $event = trim($event);
        if ($event === '') {
            return 'Security event';
        }

        return ucwords(str_replace(['_', '.', '-'], ' ', $event));
    }

    private function firstLine(string $text): string
    {
        $text = trim($text);
        if ($text === '') {
            return '';
        }

        $parts = preg_split('/\R+/', $text);

        return is_array($parts) ? trim((string) ($parts[0] ?? '')) : $text;
    }

    private function truncate(string $value, int $limit): string
    {
        $value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]+/', ' ', $value) ?: '';
        $value = trim($value);

        return strlen($value) > $limit ? substr($value, 0, max(0, $limit - 3)) . '...' : $value;
    }
}
