<?php

declare(strict_types=1);

namespace ShieldX\Security;

defined('ABSPATH') || exit;

use ShieldX\Storage\FileStorage;

// phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_fopen,WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- WAF stats stream SHIELD-X local audit logs without loading full log files into memory.

final class WafStats
{
    private const MAX_LOG_LINE_BYTES = 65536;
    private const STATE_VERSION = 3;
    private const STATE_TRANSIENT_TTL = 172800;
    private const RECENT_EVENT_POOL_MIN = 50;

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

    public function __construct(FileStorage $storage)
    {
        $this->storage = $storage;
    }

    /**
     * @return array<string,mixed>
     */
    public function summary(int $windowSeconds = 86400, int $eventLimit = 3): array
    {
        $files = $this->logFiles();
        $windowSeconds = max(60, $windowSeconds);
        $eventLimit = max(0, $eventLimit);
        $stateKey = $this->summaryStateCacheKey($windowSeconds, $eventLimit);
        $state = $this->summaryState($stateKey, $files, $windowSeconds, $eventLimit);
        $state = $this->updateSummaryState($state, $files, $windowSeconds, $eventLimit);
        $windowHits = $this->windowHits($state, time() - $windowSeconds);
        $ruleHits = isset($state['rule_hits']) && is_array($state['rule_hits']) ? $state['rule_hits'] : [];
        $events = isset($state['recent_events']) && is_array($state['recent_events']) ? $state['recent_events'] : [];
        arsort($ruleHits);
        $recentClients = $this->recentClients($events, 10);

        $summary = [
            'total_hits' => (int) ($state['total_hits'] ?? 0),
            'blocked_hits' => (int) ($state['blocked_hits'] ?? 0),
            'would_block_hits' => (int) ($state['would_block_hits'] ?? 0),
            'window_seconds' => $windowSeconds,
            'window_hits' => $windowHits,
            'hit_rate_per_hour' => $windowSeconds > 0 ? round($windowHits / ($windowSeconds / 3600), 2) : 0.0,
            'rule_hits' => $ruleHits,
            'last_events' => array_slice($events, 0, $eventLimit),
            'recent_clients' => $recentClients,
        ];

        if ($stateKey !== '' && function_exists('set_transient')) {
            set_transient($stateKey, $state, self::STATE_TRANSIENT_TTL);
        }

        return $summary;
    }

    /**
     * @param array<int,string> $files
     * @return array<string,mixed>
     */
    private function summaryState(string $stateKey, array $files, int $windowSeconds, int $eventLimit): array
    {
        $cached = $stateKey !== '' && function_exists('get_transient') ? get_transient($stateKey) : false;
        $state = is_array($cached) ? $cached : [];
        if (!$this->stateMatchesFiles($state, $files, $windowSeconds, $eventLimit)) {
            return $this->emptyState($files, $windowSeconds, $eventLimit);
        }

        return $state;
    }

    /**
     * @param array<string,mixed> $state
     * @param array<int,string>   $files
     */
    private function stateMatchesFiles(array $state, array $files, int $windowSeconds, int $eventLimit): bool
    {
        if ((int) ($state['version'] ?? 0) !== self::STATE_VERSION
            || (int) ($state['window_seconds'] ?? 0) !== $windowSeconds
            || (int) ($state['event_limit'] ?? 0) !== $eventLimit) {
            return false;
        }

        $knownFiles = isset($state['files']) && is_array($state['files']) ? array_keys($state['files']) : [];
        if ($knownFiles !== $files) {
            return false;
        }

        foreach ($files as $file) {
            $known = isset($state['files'][$file]) && is_array($state['files'][$file]) ? $state['files'][$file] : [];
            $offset = (int) ($known['offset'] ?? 0);
            if (!is_file($file) || (int) filesize($file) < $offset) {
                return false;
            }
        }

        return true;
    }

    /**
     * @param array<int,string> $files
     * @return array<string,mixed>
     */
    private function emptyState(array $files, int $windowSeconds, int $eventLimit): array
    {
        $stateFiles = [];
        foreach ($files as $file) {
            $stateFiles[$file] = [
                'offset' => 0,
                'size' => 0,
                'mtime' => 0,
            ];
        }

        return [
            'version' => self::STATE_VERSION,
            'window_seconds' => $windowSeconds,
            'event_limit' => $eventLimit,
            'files' => $stateFiles,
            'total_hits' => 0,
            'blocked_hits' => 0,
            'would_block_hits' => 0,
            'rule_hits' => [],
            'window_buckets' => [],
            'recent_events' => [],
        ];
    }

    /**
     * @param array<string,mixed> $state
     * @param array<int,string>   $files
     * @return array<string,mixed>
     */
    private function updateSummaryState(array $state, array $files, int $windowSeconds, int $eventLimit): array
    {
        foreach ($files as $file) {
            $known = isset($state['files'][$file]) && is_array($state['files'][$file]) ? $state['files'][$file] : [];
            $offset = max(0, (int) ($known['offset'] ?? 0));
            $size = is_file($file) ? (int) filesize($file) : 0;
            if ($size <= $offset) {
                $state['files'][$file] = [
                    'offset' => $size,
                    'size' => $size,
                    'mtime' => is_file($file) ? (int) filemtime($file) : 0,
                ];
                continue;
            }

            $handle = fopen($file, 'rb');
            if (!$handle) {
                continue;
            }

            if ($offset > 0) {
                fseek($handle, $offset);
            }

            while (($line = self::readBoundedLine($handle)) !== false) {
                $this->recordLine($state, $line, $eventLimit);
            }

            $position = ftell($handle);
            fclose($handle);
            $state['files'][$file] = [
                'offset' => is_int($position) ? $position : $size,
                'size' => $size,
                'mtime' => is_file($file) ? (int) filemtime($file) : 0,
            ];
        }

        $state['window_buckets'] = $this->prunedWindowBuckets(
            isset($state['window_buckets']) && is_array($state['window_buckets']) ? $state['window_buckets'] : [],
            time() - $windowSeconds
        );
        $state['recent_events'] = $this->trimRecentEvents(
            isset($state['recent_events']) && is_array($state['recent_events']) ? $state['recent_events'] : [],
            $eventLimit
        );

        return $state;
    }

    /**
     * @param array<string,mixed> $state
     */
    private function recordLine(array &$state, string $line, int $eventLimit): void
    {
        $record = \ShieldX\Support\Json::decodeArray(trim($line));
        $eventName = is_array($record) ? (string) ($record['event'] ?? '') : '';
        if (!is_array($record) || !in_array($eventName, ['waf.match', 'waf.block'], true)) {
            return;
        }

        $state['total_hits'] = (int) ($state['total_hits'] ?? 0) + 1;
        $timestamp = strtotime((string) ($record['time'] ?? '')) ?: 0;
        if ($timestamp > 0) {
            $bucket = (string) ((int) floor($timestamp / 60) * 60);
            $buckets = isset($state['window_buckets']) && is_array($state['window_buckets']) ? $state['window_buckets'] : [];
            $buckets[$bucket] = (int) ($buckets[$bucket] ?? 0) + 1;
            $state['window_buckets'] = $buckets;
        }

        $context = isset($record['context']) && is_array($record['context']) ? $record['context'] : [];
        $rules = isset($context['rules']) && is_array($context['rules']) ? $context['rules'] : [];
        $action = (string) ($context['action'] ?? ($eventName === 'waf.block' ? 'block' : 'log'));
        $wouldBlock = !empty($context['would_block']);

        if ($action === 'block' || $eventName === 'waf.block') {
            $state['blocked_hits'] = (int) ($state['blocked_hits'] ?? 0) + 1;
        }

        if ($wouldBlock) {
            $state['would_block_hits'] = (int) ($state['would_block_hits'] ?? 0) + 1;
        }

        $ruleHits = isset($state['rule_hits']) && is_array($state['rule_hits']) ? $state['rule_hits'] : [];
        foreach ($rules as $ruleId) {
            $key = (string) $ruleId;
            $ruleHits[$key] = isset($ruleHits[$key]) ? (int) $ruleHits[$key] + 1 : 1;
        }
        $state['rule_hits'] = $ruleHits;

        $ip = self::contextIp($context);
        if ($ip === '') {
            return;
        }

        $events = isset($state['recent_events']) && is_array($state['recent_events']) ? $state['recent_events'] : [];
        $events[] = [
            'fingerprint' => hash('sha256', trim($line)),
            'time' => $record['time'] ?? '',
            'timestamp' => $timestamp,
            'ip' => $ip,
            'browser' => (string) ($context['browser'] ?? 'Unknown'),
            'user_agent' => (string) ($context['user_agent'] ?? ''),
            'method' => (string) ($context['method'] ?? ''),
            'uri' => (string) ($context['uri'] ?? ''),
            'rules' => array_map('strval', $rules),
            'score' => (int) ($context['score'] ?? 0),
            'event' => $eventName,
            'action' => $action,
            'would_block' => $wouldBlock,
            'reason' => (string) ($context['reason'] ?? ''),
            'matched_value' => (string) ($context['matched_value'] ?? ''),
            'temporary_id' => (string) ($context['temporary_id'] ?? ''),
            'expires_at' => (int) ($context['expires_at'] ?? 0),
        ];
        $state['recent_events'] = $this->trimRecentEvents($events, $eventLimit);
    }

    /**
     * @param array<int|string,mixed> $buckets
     * @return array<string,int>
     */
    private function prunedWindowBuckets(array $buckets, int $windowStart): array
    {
        $minimumBucket = (int) floor($windowStart / 60) * 60;
        $pruned = [];

        foreach ($buckets as $bucket => $count) {
            $bucketStart = (int) $bucket;
            if ($bucketStart >= $minimumBucket) {
                $pruned[(string) $bucketStart] = (int) $count;
            }
        }

        ksort($pruned);

        return $pruned;
    }

    /**
     * @param array<string,mixed> $state
     */
    private function windowHits(array $state, int $windowStart): int
    {
        $hits = 0;
        foreach ($this->prunedWindowBuckets(isset($state['window_buckets']) && is_array($state['window_buckets']) ? $state['window_buckets'] : [], $windowStart) as $count) {
            $hits += (int) $count;
        }

        return $hits;
    }

    /**
     * @param array<int,array<string,mixed>> $events
     * @return array<int,array<string,mixed>>
     */
    private function trimRecentEvents(array $events, int $eventLimit): array
    {
        usort(
            $events,
            static function (array $a, array $b): int {
                return ($b['timestamp'] ?? 0) <=> ($a['timestamp'] ?? 0);
            }
        );

        return array_slice($events, 0, max(self::RECENT_EVENT_POOL_MIN, $eventLimit * 4));
    }

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

        if (substr($line, -1) !== "\n" && feof($handle)) {
            if (is_int($start)) {
                fseek($handle, $start);
            }

            return false;
        }

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

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

            if (feof($handle) && is_int($start)) {
                fseek($handle, $start);

                return false;
            }
        }

        if (!$terminated && feof($handle) && is_int($start)) {
            fseek($handle, $start);

            return false;
        }

        return '';
    }

    /**
     * @param array<string,mixed> $context
     */
    private static function contextIp(array $context): string
    {
        $ip = trim((string) ($context['ip'] ?? ''));

        return $ip !== '' && $ip !== 'unknown' && filter_var($ip, FILTER_VALIDATE_IP) ? $ip : '';
    }

    /**
     * @param array<int,array<string,mixed>> $events
     * @return array<int,array<string,mixed>>
     */
    private function recentClients(array $events, int $limit): array
    {
        $clients = [];

        foreach ($events as $event) {
            $ip = (string) ($event['ip'] ?? '');

            if ($ip === '' || $ip === 'unknown' || isset($clients[$ip])) {
                continue;
            }

            $clients[$ip] = [
                'time' => (string) ($event['time'] ?? ''),
                'timestamp' => (int) ($event['timestamp'] ?? 0),
                'ip' => $ip,
                'browser' => (string) ($event['browser'] ?? 'Unknown'),
                'user_agent' => (string) ($event['user_agent'] ?? ''),
                'method' => (string) ($event['method'] ?? ''),
                'uri' => (string) ($event['uri'] ?? ''),
                'action' => (string) ($event['action'] ?? 'log'),
                'reason' => (string) ($event['reason'] ?? ''),
            ];

            if (count($clients) >= $limit) {
                break;
            }
        }

        return array_values($clients);
    }

    /**
     * @return string[]
     */
    private function logFiles(): array
    {
        $files = glob($this->storage->logDirectory() . '/*.log') ?: [];
        rsort($files);

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

    /**
     * @param array<int,string> $files
     */
    private function summaryStateCacheKey(int $windowSeconds, int $eventLimit): string
    {
        $json = wp_json_encode([
            'version' => self::STATE_VERSION,
            'log_dir' => $this->storage->logDirectory(),
            'window' => $windowSeconds,
            'limit' => $eventLimit,
        ]);

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