<?php

declare(strict_types=1);

namespace ShieldX\Security;

defined('ABSPATH') || exit;

use ShieldX\Storage\FileStorage;

// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Buffered audit summaries update SHIELD-X-owned dashboard counter rows; identifiers use %i when supported or strict whitelisted backticks.
// phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_fopen,WordPress.WP.AlternativeFunctions.file_system_operations_fclose,WordPress.WP.AlternativeFunctions.unlink_unlink,WordPress.WP.AlternativeFunctions.rename_rename -- Audit log appends and rotation need flock(), append locking, and atomic local-file fallbacks inside private SHIELD-X storage.

final class AuditLogger
{
    private const MAX_DAILY_LOG_BYTES = 10485760;
    private const MAX_ROTATED_LOG_GENERATIONS = 5;
    private const MAX_RETRY_BUFFER_ENTRIES = 250;

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

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

    /**
     * @var array<int,array{severity:string,event:string,context:array<string,mixed>,record:array<string,mixed>}>
     */
    private $buffer = [];

    /** @var bool */
    private $shutdownRegistered = false;

    /** @var bool */
    private $writeFailurePending = false;

    public function __construct(FileStorage $storage, $wpdb = null)
    {
        $this->storage = $storage;
        if ($wpdb === null) {
            global $wpdb;
        }

        $this->wpdb = is_object($wpdb) ? $wpdb : null;
    }

    public function __destruct()
    {
        $this->flush();
    }

    /**
     * @param array<string,mixed> $context
     */
    public function info(string $event, array $context = []): void
    {
        $this->write('info', $event, $context);
    }

    /**
     * @param array<string,mixed> $context
     */
    public function warning(string $event, array $context = []): void
    {
        $this->write('warning', $event, $context);
    }

    /**
     * @param array<string,mixed> $context
     */
    public function critical(string $event, array $context = []): void
    {
        $this->write('critical', $event, $context);
    }

    /**
     * @param array<string,mixed> $context
     */
    private function write(string $severity, string $event, array $context): void
    {
        // Audit files are often retained longer than request logs. Redact before
        // buffering so neither the persistent record nor extension hooks can
        // expose credentials supplied by an otherwise safe caller.
        $context = self::redactedContext($context);
        $record = [
            'time' => gmdate('c'),
            'severity' => $severity,
            'event' => $event,
            'context' => $context,
        ];

        $this->buffer[] = [
            'severity' => $severity,
            'event' => $event,
            'context' => $context,
            'record' => $record,
        ];

        if (!$this->shutdownRegistered) {
            $this->shutdownRegistered = true;
            register_shutdown_function([$this, 'flush']);
        }

        if (function_exists('do_action')) {
            try {
                do_action('shieldx_audit_event', $severity, $event, $context, $record);
            } catch (\Throwable $error) {
                \ShieldX\Support\ErrorLog::write('shieldx_audit_event listener failed: ' . $error->getMessage());
            }
        }
    }

    /**
     * @param array<string,mixed> $context
     * @return array<string,mixed>
     */
    private static function redactedContext(array $context, int $depth = 0, array $sensitiveValues = []): array
    {
        $redacted = [];
        $sensitiveValues = array_merge($sensitiveValues, self::sensitiveContextValues($context));

        foreach ($context as $key => $value) {
            $key = (string) $key;
            if (self::isSensitiveContextKey($key) || self::isSensitiveMetaKey($key, $value)) {
                $redacted[$key] = '[redacted]';
                continue;
            }

            if (is_array($value)) {
                $redacted[$key] = $depth >= 4 ? '[complex value]' : self::redactedContext($value, $depth + 1, $sensitiveValues);
                continue;
            }

            $redacted[$key] = self::redactKnownSensitiveValues(self::redactedHookValue($value), $sensitiveValues);
        }

        return $redacted;
    }

    private static function isSensitiveContextKey(string $key): bool
    {
        return preg_match('~(?:password|passwd|pwd|secret|token|salt|nonce|session|cookie|authorization|bearer|client[_-]?secret|refresh[_-]?token|access[_-]?token|credential|(?:^|_)(?:auth|secret|api|private|license|access)[_-]?key(?:$|_)|(?:^|_)(?:email|e-mail|recipient|recipients)$)~i', $key) === 1;
    }

    /**
     * @param mixed $value
     */
    private static function isSensitiveMetaKey(string $key, $value): bool
    {
        if (strtolower($key) !== 'meta_key' || !is_string($value)) {
            return false;
        }

        return preg_match('~(?:^|[_-])(?:auth|secret|api|private|license|access)[_-]?key(?:$|[_-])~i', $value) === 1;
    }

    /**
     * @param array<string,mixed> $context
     * @return array<int,string>
     */
    private static function sensitiveContextValues(array $context): array
    {
        $values = [];

        foreach ($context as $key => $value) {
            if (!is_string($value) || $value === '') {
                continue;
            }

            $key = (string) $key;
            if (self::isSensitiveContextKey($key) || self::isSensitiveMetaKey($key, $value)) {
                $values[] = $value;
            }
        }

        return $values;
    }

    /**
     * @param mixed $value
     * @param array<int,string> $sensitiveValues
     * @return mixed
     */
    private static function redactKnownSensitiveValues($value, array $sensitiveValues)
    {
        if (!is_string($value) || $sensitiveValues === []) {
            return $value;
        }

        foreach (array_unique($sensitiveValues) as $sensitiveValue) {
            if ($sensitiveValue === '') {
                continue;
            }

            $value = str_ireplace($sensitiveValue, '[redacted]', $value);
        }

        return $value;
    }

    /**
     * @param mixed $value
     * @return mixed
     */
    private static function redactedHookValue($value)
    {
        if (!is_scalar($value)) {
            return $value === null ? null : '[complex value]';
        }

        if (!is_string($value)) {
            return $value;
        }

        $value = (string) preg_replace('#\bBearer\s+[A-Za-z0-9._~+/\-=]+#i', 'Bearer [redacted]', $value);
        $value = (string) preg_replace('~((?:^|[?&;\s])(?:license[_-]?key|token|secret|password|passwd|pwd|nonce|api[_-]?key|access[_-]?token|refresh[_-]?token|key|code|signature|session(?:[_-]?id)?|jwt|auth|state)=)[^&#;\s]*~i', '$1[redacted]', $value);

        return $value;
    }

    public function flush(): void
    {
        if ($this->buffer === []) {
            return;
        }

        $pending = $this->buffer;
        $this->buffer = [];
        $retryPending = $pending;
        if ($this->writeFailurePending) {
            $pending[] = $this->writeFailureRecord();
        }

        $this->storage->ensure();
        $file = $this->storage->logFile();
        $lines = [];
        $writtenEntries = [];

        foreach ($pending as $entry) {
            $record = $entry['record'];
            $json = wp_json_encode($record, JSON_INVALID_UTF8_SUBSTITUTE);

            if (is_string($json)) {
                $lines[] = $json;
                $writtenEntries[] = $entry;
            }
        }

        if ($lines === []) {
            return;
        }

        $payload = implode(PHP_EOL, $lines) . PHP_EOL;
        if (!$this->writeLogPayload($file, $payload) && !$this->writeLogPayload($file, $payload)) {
            $this->restoreFailedEntries($retryPending);
            $this->writeFailurePending = true;

            return;
        }

        $this->writeFailurePending = false;
        $this->recordDailySummaries($writtenEntries);
    }

    /**
     * A dedicated companion lock remains stable while the active log is renamed
     * during rotation. Locking only the active inode would leave a race once it
     * becomes the first generation and another writer opens a new active file.
     */
    private function writeLogPayload(string $file, string $payload): bool
    {
        $lockFile = dirname($file) . '/.shieldx-audit.lock';
        $lock = @fopen($lockFile, 'c');
        if (!is_resource($lock) || !@flock($lock, LOCK_EX)) {
            if (is_resource($lock)) {
                @fclose($lock);
            }

            return false;
        }

        try {
            $this->capLogFile($file);
            $written = @file_put_contents($file, $payload, FILE_APPEND | LOCK_EX);

            return is_int($written) && $written === strlen($payload);
        } finally {
            @flock($lock, LOCK_UN);
            @fclose($lock);
        }
    }

    /**
     * @param array<int,array{severity:string,event:string,context:array<string,mixed>,record:array<string,mixed>}> $pending
     */
    private function restoreFailedEntries(array $pending): void
    {
        $this->buffer = array_slice(array_merge($pending, $this->buffer), -self::MAX_RETRY_BUFFER_ENTRIES);
    }

    /**
     * @return array{severity:string,event:string,context:array<string,mixed>,record:array<string,mixed>}
     */
    private function writeFailureRecord(): array
    {
        $record = [
            'time' => gmdate('c'),
            'severity' => 'warning',
            'event' => 'audit.write_failed',
            'context' => [
                'summary' => 'A previous audit log write failed and was retried.',
            ],
        ];

        return [
            'severity' => 'warning',
            'event' => 'audit.write_failed',
            'context' => $record['context'],
            'record' => $record,
        ];
    }

    private function capLogFile(string $file): void
    {
        clearstatcache(true, $file);
        if (!is_file($file) || (int) filesize($file) < self::MAX_DAILY_LOG_BYTES) {
            return;
        }

        // Retain as many previous generations as possible, but never let a
        // failed shift leave the active log above its hard cap.
        $this->rotateLogGenerations($file);

        $rotated = $file . '.1';
        if ($this->moveFile($file, $rotated)) {
            return;
        }

        $contents = @file_get_contents($file);
        if (!is_string($contents)) {
            return;
        }

        $written = @file_put_contents($rotated, $contents, LOCK_EX);
        if (is_int($written) && $written === strlen($contents)) {
            @file_put_contents($file, '', LOCK_EX);
        }
    }

    private function rotateLogGenerations(string $file): bool
    {
        $oldest = $file . '.' . self::MAX_ROTATED_LOG_GENERATIONS;
        if (is_file($oldest)) {
            $this->deleteFile($oldest);
        }

        for ($generation = self::MAX_ROTATED_LOG_GENERATIONS - 1; $generation >= 1; $generation--) {
            $source = $file . '.' . $generation;
            if (!is_file($source)) {
                continue;
            }

            $destination = $file . '.' . ($generation + 1);
            if (!$this->moveFile($source, $destination)) {
                return false;
            }
        }

        return true;
    }

    private function deleteFile(string $file): bool
    {
        if (function_exists('wp_delete_file')) {
            if ((bool) @wp_delete_file($file)) {
                return true;
            }
        }

        global $wp_filesystem;

        if (!is_object($wp_filesystem) && function_exists('WP_Filesystem')) {
            WP_Filesystem();
        }

        if (is_object($wp_filesystem) && method_exists($wp_filesystem, 'delete')) {
            if ((bool) @$wp_filesystem->delete($file, false, 'f')) {
                return true;
            }
        }

        return @unlink($file);
    }

    private function moveFile(string $source, string $destination): bool
    {
        global $wp_filesystem;

        if (!is_object($wp_filesystem) && function_exists('WP_Filesystem')) {
            WP_Filesystem();
        }

        if (is_object($wp_filesystem) && method_exists($wp_filesystem, 'move')) {
            if ((bool) @$wp_filesystem->move($source, $destination, true)) {
                return true;
            }
        }

        if (@rename($source, $destination)) {
            return true;
        }

        if (!@copy($source, $destination)) {
            return false;
        }

        return @unlink($source);
    }

    /**
     * @param array<int,array{severity:string,event:string,context:array<string,mixed>,record:array<string,mixed>}> $pending
     */
    private function recordDailySummaries(array $pending): void
    {
        if (!$this->hasDatabase() || $pending === []) {
            return;
        }

        $summaries = [];
        foreach ($pending as $entry) {
            $event = $this->cleanEventType((string) ($entry['event'] ?? ''));
            $severity = $this->cleanSeverity((string) ($entry['severity'] ?? 'info'));
            if ($event === '') {
                continue;
            }

            $context = isset($entry['context']) && is_array($entry['context']) ? $entry['context'] : [];
            $record = isset($entry['record']) && is_array($entry['record']) ? $entry['record'] : [];
            $timestamp = strtotime((string) ($record['time'] ?? '')) ?: time();
            $date = gmdate('Y-m-d', $timestamp);
            $time = gmdate('Y-m-d H:i:s', $timestamp);

            foreach ($this->dailySummaryEventTypes($event, $context) as $summaryEvent) {
                if (!$this->shouldRecordDailySummary($summaryEvent, $severity)) {
                    continue;
                }

                $key = $date . '|' . $summaryEvent . '|' . $severity;
                if (!isset($summaries[$key])) {
                    $summaries[$key] = [
                        'summary_date' => $date,
                        'event_type' => $summaryEvent,
                        'severity' => $severity,
                        'count' => 0,
                        'first_seen' => $time,
                        'last_seen' => $time,
                        'sample_event_id' => substr(hash('sha256', $key . '|' . $time), 0, 32),
                    ];
                }

                $summaries[$key]['count'] = (int) $summaries[$key]['count'] + 1;
                if ($time < (string) $summaries[$key]['first_seen']) {
                    $summaries[$key]['first_seen'] = $time;
                }
                if ($time > (string) $summaries[$key]['last_seen']) {
                    $summaries[$key]['last_seen'] = $time;
                }
            }
        }

        foreach ($summaries as $summary) {
            $this->upsertDailySummary($summary);
        }

        // The WordPress Dashboard chart is served from these counters. Clear
        // only its dedicated cache once the batch has been persisted; the full
        // incident timeline keeps its existing stable snapshot TTL.
        IncidentTimeline::invalidateDashboardActivityCache($this->wpdb);
    }

    /**
     * @param array<string,mixed> $context
     * @return array<int,string>
     */
    private function dailySummaryEventTypes(string $event, array $context): array
    {
        $events = [$event];
        if (!in_array($event, ['waf.match', 'waf.block'], true)) {
            return $events;
        }

        if (WafTrendClassifier::isFollowupBlock($event, $context)) {
            return $events;
        }

        $events[] = 'waf.hit';
        $events[] = 'waf.action.' . WafTrendClassifier::action($event, $context);
        $events[] = 'waf.severity.' . WafTrendClassifier::severity($event, $context);
        $events[] = 'waf.category.' . WafTrendClassifier::category($event, $context);

        return array_values(array_unique(array_map([$this, 'cleanEventType'], $events)));
    }

    private function shouldRecordDailySummary(string $event, string $severity): bool
    {
        if ($severity !== 'info') {
            return true;
        }

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

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

        foreach ([
            'activity.',
            'backup.',
            'database.',
            'emergency_recovery.',
            'human_challenge.',
            'lockdown.',
            'login.',
            'login_security.',
            'restore_point.',
            'scan.',
            'update.',
            'waf.',
        ] as $prefix) {
            if (strpos($event, $prefix) === 0) {
                return true;
            }
        }

        return false;
    }

    /**
     * @param array<string,mixed> $summary
     */
    private function upsertDailySummary(array $summary): void
    {
        $table = (string) $this->wpdb->prefix . 'shieldx_event_summaries';
        $sql = $this->prepareIdentifierSql(
            'INSERT INTO %i (`summary_date`, `event_type`, `severity`, `count`, `first_seen`, `last_seen`, `sample_event_id`)
             VALUES (%s, %s, %s, %d, %s, %s, %s)
             ON DUPLICATE KEY UPDATE
                `count` = `count` + VALUES(`count`),
                `first_seen` = LEAST(`first_seen`, VALUES(`first_seen`)),
                `last_seen` = GREATEST(`last_seen`, VALUES(`last_seen`)),
                `sample_event_id` = IF(`sample_event_id` IS NULL OR `sample_event_id` = "", VALUES(`sample_event_id`), `sample_event_id`)',
            $table,
            [
                (string) ($summary['summary_date'] ?? ''),
                (string) ($summary['event_type'] ?? ''),
                (string) ($summary['severity'] ?? 'info'),
                max(1, (int) ($summary['count'] ?? 1)),
                (string) ($summary['first_seen'] ?? ''),
                (string) ($summary['last_seen'] ?? ''),
                (string) ($summary['sample_event_id'] ?? ''),
            ]
        );

        if (is_string($sql) && $sql !== '') {
            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Prepared above; table name is the plugin-owned summaries table.
            $this->wpdb->query($sql);
        }
    }

    /**
     * @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 hasDatabase(): bool
    {
        return is_object($this->wpdb)
            && isset($this->wpdb->prefix)
            && method_exists($this->wpdb, 'prepare')
            && method_exists($this->wpdb, 'query');
    }

    private function cleanEventType(string $event): string
    {
        $event = strtolower(trim($event));
        $event = preg_replace('/[^a-z0-9_.:-]/', '', $event) ?: '';

        return substr($event, 0, 50);
    }

    private function cleanSeverity(string $severity): string
    {
        $severity = strtolower(trim($severity));
        $severity = preg_replace('/[^a-z0-9_-]/', '', $severity) ?: 'info';

        return substr($severity, 0, 20);
    }
}
