<?php

declare(strict_types=1);

namespace ShieldX\Maintenance;

defined('ABSPATH') || exit;

// phpcs:disable WordPress.WP.AlternativeFunctions.unlink_unlink,WordPress.WP.AlternativeFunctions.file_system_operations_rmdir,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Cleanup deletes selected log/temp files and plugin/database log tables after admin configuration.

use ShieldX\Security\AuditLogger;
use ShieldX\Storage\DurableJobStore;
use ShieldX\Storage\FileStorage;
use ShieldX\Support\WordPressPaths;

final class LogRotator
{
    private const TREND_SUMMARY_MIN_RETENTION_DAYS = 365;
    private const MAX_FALLBACK_DISCOVERY_FILES = 20000;
    private const MAX_DISCOVERED_LOG_FILES = 500;
    private const LOG_SCAN_BATCH_ENTRIES = 500;
    private const LOG_SCAN_BATCH_SECONDS = 3;
    private const LOG_SCAN_JOB_MAX_AGE_SECONDS = 86400;
    private const TEMP_CLEANUP_MAX_ENTRIES = 20000;
    private const TEMP_CLEANUP_MIN_AGE_SECONDS = 3600;
    private const TEMP_CLEANUP_REPORT_ITEMS = 12;
    private const TEMP_CLEANUP_CONTENT_DIRECTORIES = ['cache', 'upgrade', 'tmp'];
    private const DATABASE_DELETE_BATCH_ROWS = 1000;
    private const DATABASE_DELETE_MAX_BATCHES = 200;

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

    /** @var DurableJobStore */
    private $durableJobs;

    /** @var LogRotationSettings|null */
    private $settings;

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

    /** @var string */
    private $wordpressRoot;

    /** @var AuditLogger|null */
    private $logger;

    /**
     * @param LogRotationSettings|null $settings
     * @param object|null              $wpdb
     */
    public function __construct(FileStorage $storage, ?LogRotationSettings $settings = null, $wpdb = null, string $wordpressRoot = '', ?AuditLogger $logger = null, ?DurableJobStore $durableJobs = null)
    {
        $this->storage = $storage;
        $this->durableJobs = $durableJobs ?: new DurableJobStore();
        $this->settings = $settings;
        $this->wpdb = $wpdb;
        $this->wordpressRoot = $this->normalizeDirectory($wordpressRoot);
        $this->logger = $logger;
    }

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

        $storage = FileStorage::fromWordPress();

        return new self($storage, LogRotationSettings::fromWordPress(), $wpdb, WordPressPaths::root(), new AuditLogger($storage), new DurableJobStore());
    }

    /**
     * @return array<string,array<string,string>>
     */
    public static function targetCatalog(): array
    {
        return [
            'file_daily_logs' => [
                'label' => 'File logs',
                'type' => 'file',
                'description' => 'SHIELD-X daily logs plus runtime log files discovered across the whole WordPress installation.',
            ],
            'db_user_events' => [
                'label' => 'Visitor URL trail',
                'type' => 'database',
                'description' => 'Visitor profiler page, query, browser, country, and referrer records.',
            ],
            'db_event_summaries' => [
                'label' => 'Security trend summaries',
                'type' => 'database',
                'description' => 'Aggregated dashboard chart counters, kept for at least 12 months.',
            ],
            'db_incident_activity' => [
                'label' => 'Incident/Activities',
                'type' => 'database',
                'description' => 'Merged activity-page data from visitor trails and WAF/security counters.',
            ],
            'db_scan_history' => [
                'label' => 'Scan history',
                'type' => 'database',
                'description' => 'Completed file/database/vulnerability scan run history and stored result payloads.',
            ],
            'db_resolved_threats' => [
                'label' => 'Resolved threat history',
                'type' => 'database',
                'description' => 'Resolved, ignored, quarantined, or restored scanner findings. Active issues are never rotated.',
            ],
        ];
    }

    /**
     * @return array<string,mixed>
     */
    public function status(): array
    {
        $settings = $this->settings ? $this->settings->all() : $this->defaultSettings();
        $selected = $this->selectedTargets($settings);
        $excludedFileLogs = $this->excludedFileLogs($settings);
        $fileInventory = $this->fileInventoryFromLastScan($excludedFileLogs);

        return array_merge($settings, [
            'directory' => $this->storage->logDirectory(),
            'file_discovery' => 'Whole-site WordPress runtime log discovery plus SHIELD-X daily logs.',
            'files' => $fileInventory,
            'database_available' => $this->hasDatabase(),
            'target_catalog' => self::targetCatalog(),
            'target_status' => $this->targetStatus($selected, $excludedFileLogs, $fileInventory),
            'last_result' => $this->settings ? $this->settings->lastResult() : [],
            'file_scan' => $this->settings ? $this->settings->lastFileScan() : [],
            'temp_cleanup' => [
                'root' => $this->wordpressRoot(),
                'policy' => 'Stale whole-site temporary artifacts older than one hour. Cleanup targets are listed in the preview.',
                'last_result' => $this->settings ? $this->settings->lastTempCleanup() : [],
            ],
        ]);
    }

    /**
     * @return array<string,mixed>
     */
    public function scanFileLogsForRotation(string $scanId = ''): array
    {
        $this->storage->ensure();
        $this->cleanupExpiredLogScanJobs();

        $settings = $this->settings ? $this->settings->all() : $this->defaultSettings();
        $excludedFileLogs = $this->excludedFileLogs($settings);
        $job = $scanId !== '' ? $this->readLogScanJob($scanId) : null;
        if (!is_array($job)) {
            $job = $this->newLogScanJob();
        }

        $job = $this->processLogScanJob($job);

        if (!empty($job['queue'])) {
            $this->writeLogScanJob($job);

            $files = isset($job['files']) && is_array($job['files']) ? $job['files'] : [];

            return [
                'status' => 'running',
                'scan_id' => (string) ($job['scan_id'] ?? ''),
                'scanned_entries' => (int) ($job['scanned_entries'] ?? 0),
                'discovered_files' => count($files),
                'marked_files' => max(0, count($files) - $this->countExcludedPaths($files, $excludedFileLogs)),
                'excluded_files' => $this->countExcludedPaths($files, $excludedFileLogs),
                'truncated' => !empty($job['truncated']),
                'truncation_reason' => (string) ($job['truncation_reason'] ?? ''),
                'truncation_limit' => max(0, (int) ($job['truncation_limit'] ?? 0)),
            ];
        }

        $inventory = $this->fileInventoryFromFiles(isset($job['files']) && is_array($job['files']) ? $job['files'] : [], $excludedFileLogs);
        $totalFiles = (int) ($inventory['count'] ?? 0);
        $excludedFiles = (int) ($inventory['excluded_count'] ?? 0);
        $result = [
            'status' => 'completed',
            'scan_id' => (string) ($job['scan_id'] ?? ''),
            'scanned_at' => gmdate('Y-m-d H:i:s'),
            'total_files' => $totalFiles,
            'marked_files' => max(0, $totalFiles - $excludedFiles),
            'excluded_files' => $excludedFiles,
            'total_bytes' => (int) ($inventory['total_bytes'] ?? 0),
            'scanned_entries' => (int) ($job['scanned_entries'] ?? 0),
            'truncated' => !empty($job['truncated']),
            'truncation_reason' => (string) ($job['truncation_reason'] ?? ''),
            'truncation_limit' => max(0, (int) ($job['truncation_limit'] ?? 0)),
            'items' => isset($inventory['items']) && is_array($inventory['items']) ? $inventory['items'] : [],
            'recent' => isset($inventory['recent']) && is_array($inventory['recent']) ? $inventory['recent'] : [],
        ];

        if ($this->settings) {
            $this->settings->recordLastFileScan($result);
        }

        $this->deleteLogScanJob((string) ($job['scan_id'] ?? ''));

        return $result;
    }

    /**
     * @return array<string,mixed>
     */
    private function newLogScanJob(): array
    {
        $root = $this->wordpressRoot();
        $queue = [
            ['path' => $this->storage->logDirectory(), 'recursive' => false, 'offset' => 0],
        ];

        if ($root !== '') {
            $queue[] = ['path' => $root, 'recursive' => true, 'offset' => 0];
        }

        return [
            'scan_id' => $this->newLogScanId(),
            'created_at' => gmdate('Y-m-d H:i:s'),
            'updated_at' => gmdate('Y-m-d H:i:s'),
            'queue' => $queue,
            'files' => [],
            'seen_files' => [],
            'scanned_entries' => 0,
            'truncated' => false,
            'truncation_reason' => '',
            'truncation_limit' => 0,
        ];
    }

    private function newLogScanId(): string
    {
        return bin2hex(random_bytes(16));
    }

    /**
     * @param array<string,mixed> $job
     * @return array<string,mixed>
     */
    private function processLogScanJob(array $job): array
    {
        $queue = isset($job['queue']) && is_array($job['queue']) ? array_values($job['queue']) : [];
        $files = isset($job['files']) && is_array($job['files']) ? array_values(array_filter($job['files'], 'is_scalar')) : [];
        $seen = isset($job['seen_files']) && is_array($job['seen_files']) ? $job['seen_files'] : [];
        $scannedEntries = (int) ($job['scanned_entries'] ?? 0);
        $batchEntries = 0;
        $started = microtime(true);

        while (!empty($queue)) {
            if ($batchEntries >= self::LOG_SCAN_BATCH_ENTRIES || microtime(true) - $started >= self::LOG_SCAN_BATCH_SECONDS) {
                break;
            }

            $entry = array_shift($queue);
            if (!is_array($entry)) {
                continue;
            }

            $directory = $this->normalizeDirectory((string) ($entry['path'] ?? ''));
            $recursive = !empty($entry['recursive']);
            $offset = max(0, (int) ($entry['offset'] ?? 0));
            if ($directory === '' || !is_dir($directory) || $this->shouldSkipLogDirectory($directory)) {
                continue;
            }

            try {
                $iterator = new \FilesystemIterator($directory, \FilesystemIterator::SKIP_DOTS);
                if ($offset > 0) {
                    $iterator->seek($offset);
                }
            } catch (\Exception $error) {
                continue;
            }

            $index = $offset;
            while ($iterator->valid()) {
                if ($batchEntries >= self::LOG_SCAN_BATCH_ENTRIES || microtime(true) - $started >= self::LOG_SCAN_BATCH_SECONDS) {
                    array_unshift($queue, [
                        'path' => $directory,
                        'recursive' => $recursive,
                        'offset' => $index,
                    ]);
                    break 2;
                }

                $item = $iterator->current();
                $index++;
                $iterator->next();
                if (!$item instanceof \SplFileInfo) {
                    continue;
                }

                $path = $item->getPathname();
                $batchEntries++;
                $scannedEntries++;

                if (is_dir($path)) {
                    if ($recursive && !$this->shouldSkipLogDirectory($path)) {
                        $queue[] = [
                            'path' => $this->normalizeDirectory($path),
                            'recursive' => true,
                            'offset' => 0,
                        ];
                    }
                    continue;
                }

                if (!is_file($path) || !$this->isRuntimeLogFile($path)) {
                    continue;
                }

                $real = $this->normalizeFile($path);
                if ($real === '' || isset($seen[$real])) {
                    continue;
                }

                $seen[$real] = true;
                $files[] = $real;
                if (count($files) >= self::MAX_DISCOVERED_LOG_FILES) {
                    $queue = [];
                    $job['truncated'] = true;
                    $job['truncation_reason'] = 'file_limit';
                    $job['truncation_limit'] = self::MAX_DISCOVERED_LOG_FILES;
                    break 2;
                }
            }
        }

        $job['queue'] = $queue;
        $job['files'] = array_values($files);
        $job['seen_files'] = $seen;
        $job['scanned_entries'] = $scannedEntries;
        $job['updated_at'] = gmdate('Y-m-d H:i:s');

        return $job;
    }

    private function logScanJobDirectory(): string
    {
        $this->storage->ensure();
        $directory = $this->storage->baseDir() . '/jobs/log-rotation';
        if (!is_dir($directory)) {
            if (function_exists('wp_mkdir_p')) {
                wp_mkdir_p($directory);
            } else {
                // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_mkdir -- Unit tests can run without WordPress; production uses wp_mkdir_p() above.
                mkdir($directory, 0755, true);
            }
        }

        return $directory;
    }

    private function cleanupExpiredLogScanJobs(): void
    {
        $directory = $this->logScanJobDirectory();
        $cutoff = time() - self::LOG_SCAN_JOB_MAX_AGE_SECONDS;

        foreach ($this->globFiles($directory . '/*.json') as $path) {
            $scanId = basename($path, '.json');
            if (preg_match('/^[a-f0-9]{16,40}$/i', $scanId) !== 1 || is_link($path) || !$this->pathIsWithin($path, $directory)) {
                continue;
            }

            $modified = filemtime($path);
            if (!is_int($modified) || $modified >= $cutoff) {
                continue;
            }

            $this->deleteLogScanJob($scanId);
        }
    }

    private function logScanJobPath(string $scanId): string
    {
        if (preg_match('/^[a-f0-9]{16,40}$/i', $scanId) !== 1) {
            throw new \RuntimeException('Invalid log scan job id.');
        }

        return $this->logScanJobDirectory() . '/' . strtolower($scanId) . '.json';
    }

    /**
     * @return array<string,mixed>|null
     */
    private function readLogScanJob(string $scanId): ?array
    {
        if (preg_match('/^[a-f0-9]{16,40}$/i', $scanId) !== 1) {
            return null;
        }

        $durable = $this->durableJobs->read('log_scan', $scanId);
        if (is_array($durable)) {
            return $durable;
        }

        $path = $this->logScanJobPath($scanId);
        if (!is_file($path)) {
            return null;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reads an internal private SHIELD-X log-scan job file.
        $raw = file_get_contents($path);
        $decoded = is_string($raw) ? \ShieldX\Support\Json::decodeArray($raw) : null;

        return is_array($decoded) ? $decoded : null;
    }

    /**
     * @param array<string,mixed> $job
     */
    private function writeLogScanJob(array $job): void
    {
        $scanId = (string) ($job['scan_id'] ?? '');
        $path = $this->logScanJobPath($scanId);
        $durableWritten = $this->durableJobs->save('log_scan', $scanId, $job, self::LOG_SCAN_JOB_MAX_AGE_SECONDS);
        $json = wp_json_encode($job);
        if (!is_string($json)) {
            if ($durableWritten) {
                return;
            }
            throw new \RuntimeException('Log scan job could not be encoded.');
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_tempnam -- Private job JSON needs same-directory temp file for atomic replacement.
        $temporary = tempnam(dirname($path), 'logscan-');
        if (!is_string($temporary)) {
            if ($durableWritten) {
                return;
            }
            throw new \RuntimeException('Log scan job temporary file could not be created.');
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Internal private storage write; atomic temp+rename prevents partial job JSON.
        $fileWritten = file_put_contents($temporary, $json) !== false;
        if ($fileWritten) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Defense-in-depth for private job metadata.
            chmod($temporary, 0600);
            // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Atomic replacement inside SHIELD-X private storage.
            $fileWritten = rename($temporary, $path);
        }
        if (!$fileWritten && is_file($temporary)) {
            if (function_exists('wp_delete_file')) {
                wp_delete_file($temporary);
            } else {
                // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Removes a failed private log-scan temporary file in test environments without wp_delete_file().
                unlink($temporary);
            }
        }
        if (!$fileWritten && !$durableWritten) {
            throw new \RuntimeException('Log scan job could not be persisted.');
        }
    }

    private function deleteLogScanJob(string $scanId): void
    {
        if ($scanId === '' || preg_match('/^[a-f0-9]{16,40}$/i', $scanId) !== 1) {
            return;
        }

        $this->durableJobs->delete('log_scan', $scanId);

        $path = $this->logScanJobPath($scanId);
        if (!is_file($path)) {
            return;
        }

        if (function_exists('wp_delete_file')) {
            wp_delete_file($path);
            return;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Unit tests can run without WordPress; production uses wp_delete_file() above.
        unlink($path);
    }

    /**
     * @return array<string,mixed>
     */
    public function rotate(bool $manual = false): array
    {
        $this->storage->ensure();
        $settings = $this->settings ? $this->settings->all() : $this->defaultSettings();
        $started = gmdate('Y-m-d H:i:s');
        $selected = $this->selectedTargets($settings);
        $excludedFileLogs = $this->excludedFileLogs($settings);
        $fileScan = $this->settings ? $this->settings->lastFileScan() : [];
        $scanTruncated = !empty($fileScan['truncated']);
        $scanTruncationReason = (string) ($fileScan['truncation_reason'] ?? '');
        $scanTruncationLimit = max(0, (int) ($fileScan['truncation_limit'] ?? 0));

        if (empty($settings['enabled']) && !$manual) {
            $remaining = $this->fileInventoryFromLastScan($excludedFileLogs);
            $result = [
                'status' => 'skipped',
                'manual' => $manual,
                'ran_at' => $started,
                'retention_days' => (int) $settings['retention_days'],
                'max_files' => (int) $settings['max_files'],
                'targets' => $selected,
                'excluded_file_logs' => $excludedFileLogs,
                'target_results' => [],
                'deleted_files' => 0,
                'deleted_bytes' => 0,
                'trimmed_files' => 0,
                'trimmed_bytes' => 0,
                'failed_files' => 0,
                'deleted_rows' => 0,
                'remaining_files' => (int) ($remaining['count'] ?? 0),
                'remaining_bytes' => (int) ($remaining['total_bytes'] ?? 0),
                'scan_truncated' => $scanTruncated,
                'scan_truncation_reason' => $scanTruncationReason,
                'scan_truncation_limit' => $scanTruncationLimit,
                'message' => 'Log rotation is disabled.',
            ];
            $this->record($result);

            return $result;
        }

        $targetResults = [];
        $deletedFiles = 0;
        $deletedBytes = 0;
        $failedFiles = 0;
        $deletedRows = 0;
        $hasWarnings = false;

        foreach ($selected as $target) {
            if ($target === 'file_daily_logs') {
                $result = $this->rotateFileLogs((int) $settings['retention_days'], (int) $settings['max_files'], $excludedFileLogs);
            } else {
                $result = $this->rotateDatabaseTarget($target, (int) $settings['retention_days']);
            }

            $targetResults[$target] = $result;
            $deletedFiles += (int) ($result['deleted_files'] ?? 0);
            $deletedBytes += (int) ($result['deleted_bytes'] ?? 0);
            $failedFiles += (int) ($result['failed_files'] ?? 0);
            $deletedRows += (int) ($result['deleted_rows'] ?? 0);

            if (in_array((string) ($result['status'] ?? ''), ['failed', 'completed_with_warnings'], true)) {
                $hasWarnings = true;
            }
        }

        $hasWarnings = $hasWarnings || $failedFiles > 0;

        $remaining = $this->fileInventoryFromFiles($this->rotationLogFiles(), $excludedFileLogs);
        $result = [
            'status' => $hasWarnings ? 'completed_with_warnings' : 'completed',
            'manual' => $manual,
            'ran_at' => $started,
            'retention_days' => (int) $settings['retention_days'],
            'max_files' => (int) $settings['max_files'],
            'targets' => $selected,
            'excluded_file_logs' => $excludedFileLogs,
            'target_results' => $targetResults,
            'deleted_files' => $deletedFiles,
            'deleted_bytes' => $deletedBytes,
            'trimmed_files' => 0,
            'trimmed_bytes' => 0,
            'failed_files' => $failedFiles,
            'deleted_rows' => $deletedRows,
            'remaining_files' => (int) ($remaining['count'] ?? 0),
            'remaining_bytes' => (int) ($remaining['total_bytes'] ?? 0),
            'scan_truncated' => $scanTruncated,
            'scan_truncation_reason' => $scanTruncationReason,
            'scan_truncation_limit' => $scanTruncationLimit,
            'message' => $hasWarnings ? 'Log rotation completed with warnings.' : 'Log rotation completed.',
        ];
        $this->record($result);
        $this->logRotationResult($result);

        return $result;
    }

    /**
     * @return array<string,mixed>
     */
    public function cleanupTemporaryFiles(bool $manual = true): array
    {
        $this->storage->ensure();
        $root = $this->wordpressRoot();
        $started = gmdate('Y-m-d H:i:s');
        if ($root === '' || !is_dir($root)) {
            $result = [
                'status' => 'skipped',
                'manual' => $manual,
                'ran_at' => $started,
                'root' => $root,
                'scanned_entries' => 0,
                'deleted_files' => 0,
                'deleted_bytes' => 0,
                'deleted_directories' => 0,
                'failed_files' => 0,
                'failed_directories' => 0,
                'truncated' => false,
                'deleted' => [],
                'message' => 'WordPress root was not available for temp cleanup.',
            ];
            $this->recordTempCleanup($result);

            return $result;
        }

        $cutoff = time() - self::TEMP_CLEANUP_MIN_AGE_SECONDS;
        $scannedEntries = 0;
        $deletedFiles = 0;
        $deletedBytes = 0;
        $deletedDirectories = 0;
        $failedFiles = 0;
        $failedDirectories = 0;
        $truncated = false;
        $deleted = [];

        try {
            $iterator = new \RecursiveIteratorIterator(
                new \RecursiveCallbackFilterIterator(
                    new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
                    function (\SplFileInfo $current): bool {
                        if ($current->isLink()) {
                            return false;
                        }

                        if (!$current->isDir()) {
                            return true;
                        }

                        return !$this->shouldSkipTemporaryCleanupDirectory($current->getPathname());
                    }
                ),
                \RecursiveIteratorIterator::CHILD_FIRST
            );

            foreach ($iterator as $item) {
                if (!$item instanceof \SplFileInfo) {
                    continue;
                }

                $scannedEntries++;
                if ($scannedEntries > self::TEMP_CLEANUP_MAX_ENTRIES) {
                    $truncated = true;
                    break;
                }

                $path = $item->getPathname();
                if ($item->isLink()) {
                    continue;
                }

                if ($item->isFile()) {
                    if (!$this->isTemporaryCleanupFile($path, $cutoff)) {
                        continue;
                    }

                    $size = is_file($path) ? max(0, (int) filesize($path)) : 0;
                    if ($this->deleteCleanupFile($path)) {
                        $deletedFiles++;
                        $deletedBytes += $size;
                        $this->appendCleanupReportItem($deleted, $path, $size, 'file');
                    } else {
                        $failedFiles++;
                    }
                    continue;
                }

                if ($item->isDir() && $this->isTemporaryCleanupDirectory($path, $cutoff)) {
                    if ($this->removeCleanupDirectory($path)) {
                        $deletedDirectories++;
                        $this->appendCleanupReportItem($deleted, $path, 0, 'directory');
                    } else {
                        $failedDirectories++;
                    }
                }
            }
        } catch (\UnexpectedValueException $error) {
            $truncated = true;
        }

        $failed = $failedFiles + $failedDirectories;
        $result = [
            'status' => $failed > 0 ? 'completed_with_warnings' : 'completed',
            'manual' => $manual,
            'ran_at' => $started,
            'root' => $root,
            'scanned_entries' => $scannedEntries,
            'deleted_files' => $deletedFiles,
            'deleted_bytes' => $deletedBytes,
            'deleted_directories' => $deletedDirectories,
            'failed_files' => $failedFiles,
            'failed_directories' => $failedDirectories,
            'truncated' => $truncated,
            'min_age_seconds' => self::TEMP_CLEANUP_MIN_AGE_SECONDS,
            'deleted' => $deleted,
            'message' => $failed > 0
                ? 'Temporary cleanup completed with warnings.'
                : 'Temporary cleanup completed.',
        ];
        $this->recordTempCleanup($result);
        $this->logTempCleanupResult($result);

        return $result;
    }

    /**
     * @return array<string,mixed>
     */
    public function previewTemporaryFiles(): array
    {
        $this->storage->ensure();
        $root = $this->wordpressRoot();
        if ($root === '' || !is_dir($root)) {
            return [
                'status' => 'skipped',
                'root' => $root,
                'scanned_entries' => 0,
                'candidate_files' => 0,
                'candidate_bytes' => 0,
                'candidate_directories' => 0,
                'truncated' => false,
                'targets' => $this->temporaryCleanupTargets(),
                'items' => [],
                'message' => 'WordPress root was not available for temp cleanup preview.',
            ];
        }

        $cutoff = time() - self::TEMP_CLEANUP_MIN_AGE_SECONDS;
        $scannedEntries = 0;
        $candidateFiles = 0;
        $candidateBytes = 0;
        $candidateDirectories = 0;
        $truncated = false;
        $items = [];

        try {
            $iterator = new \RecursiveIteratorIterator(
                new \RecursiveCallbackFilterIterator(
                    new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
                    function (\SplFileInfo $current): bool {
                        if ($current->isLink()) {
                            return false;
                        }

                        if (!$current->isDir()) {
                            return true;
                        }

                        return !$this->shouldSkipTemporaryCleanupDirectory($current->getPathname());
                    }
                ),
                \RecursiveIteratorIterator::CHILD_FIRST
            );

            foreach ($iterator as $item) {
                if (!$item instanceof \SplFileInfo) {
                    continue;
                }

                $scannedEntries++;
                if ($scannedEntries > self::TEMP_CLEANUP_MAX_ENTRIES) {
                    $truncated = true;
                    break;
                }

                $path = $item->getPathname();
                if ($item->isLink()) {
                    continue;
                }

                if ($item->isFile()) {
                    if (!$this->isTemporaryCleanupFile($path, $cutoff)) {
                        continue;
                    }

                    $size = is_file($path) ? max(0, (int) filesize($path)) : 0;
                    $candidateFiles++;
                    $candidateBytes += $size;
                    $this->appendCleanupReportItem($items, $path, $size, 'file');
                    continue;
                }

                if ($item->isDir() && $this->isTemporaryCleanupDirectory($path, $cutoff)) {
                    $candidateDirectories++;
                    $this->appendCleanupReportItem($items, $path, 0, 'directory');
                }
            }
        } catch (\UnexpectedValueException $error) {
            $truncated = true;
        }

        return [
            'status' => 'ready',
            'root' => $root,
            'scanned_entries' => $scannedEntries,
            'candidate_files' => $candidateFiles,
            'candidate_bytes' => $candidateBytes,
            'candidate_directories' => $candidateDirectories,
            'truncated' => $truncated,
            'min_age_seconds' => self::TEMP_CLEANUP_MIN_AGE_SECONDS,
            'targets' => $this->temporaryCleanupTargets(),
            'items' => $items,
            'message' => 'Temporary cleanup preview completed.',
        ];
    }

    /**
     * @return array<string,mixed>
     */
    private function rotateFileLogs(int $retentionDays, int $maxFiles, array $excludedFileLogs): array
    {
        $deletedFiles = 0;
        $deletedBytes = 0;
        $failedFilePaths = [];
        $excludedMap = $this->excludedFileLogMap($excludedFileLogs);
        $now = time();
        $cutoff = $now - ($retentionDays * 86400);
        $today = $this->siteDate($now);
        $files = $this->rotationLogFiles();
        $keepTodayLogs = true;

        foreach ($files as $file) {
            $file = $this->safeRotationLogFile($file);
            if ($file === '') {
                continue;
            }

            if ($this->fileIsProtectedFromRotation($file, $today, $excludedMap, $keepTodayLogs, $now)) {
                continue;
            }

            if ($this->pathIsOlderThan($file, $cutoff)) {
                $size = is_file($file) ? (int) filesize($file) : 0;
                if (unlink($file)) {
                    $deletedFiles++;
                    $deletedBytes += max(0, $size);
                } else {
                    $failedFilePaths[$file] = true;
                }
            }
        }

        $files = array_values(array_filter($this->rotationLogFiles(), function (string $file) use ($today, $excludedMap, $keepTodayLogs, $now): bool {
            return !$this->fileIsProtectedFromRotation($file, $today, $excludedMap, $keepTodayLogs, $now);
        }));
        usort($files, static function (string $left, string $right): int {
            return ((int) (is_file($left) ? filemtime($left) : 0)) <=> ((int) (is_file($right) ? filemtime($right) : 0));
        });

        $excess = max(0, count($files) - $maxFiles);
        foreach ($files as $file) {
            if ($excess <= 0) {
                break;
            }

            $file = $this->safeRotationLogFile($file);
            if ($file === '') {
                continue;
            }

            if (isset($failedFilePaths[$file])) {
                continue;
            }

            $size = is_file($file) ? (int) filesize($file) : 0;
            if (unlink($file)) {
                $deletedFiles++;
                $deletedBytes += max(0, $size);
                $excess--;
            } else {
                $failedFilePaths[$file] = true;
            }
        }

        $failedFiles = count($failedFilePaths);

        return [
            'status' => $failedFiles > 0 ? 'completed_with_warnings' : 'completed',
            'type' => 'file',
            'deleted_files' => $deletedFiles,
            'deleted_bytes' => $deletedBytes,
            'trimmed_files' => 0,
            'trimmed_bytes' => 0,
            'failed_files' => $failedFiles,
            'deleted_rows' => 0,
            'excluded_files' => $excludedFileLogs,
            'remaining' => $this->fileInventoryFromFiles($this->rotationLogFiles(), $excludedFileLogs),
        ];
    }

    /**
     * @return array<string,mixed>
     */
    private function rotateDatabaseTarget(string $target, int $retentionDays): array
    {
        if (!$this->hasDatabase()) {
            return [
                'status' => 'skipped',
                'type' => 'database',
                'deleted_files' => 0,
                'deleted_bytes' => 0,
                'trimmed_files' => 0,
                'trimmed_bytes' => 0,
                'failed_files' => 0,
                'deleted_rows' => 0,
                'message' => 'Database is not available.',
            ];
        }

        $definition = $this->databaseTargetDefinition($target);
        if (!$definition) {
            return [
                'status' => 'skipped',
                'type' => 'database',
                'deleted_files' => 0,
                'deleted_bytes' => 0,
                'trimmed_files' => 0,
                'trimmed_bytes' => 0,
                'failed_files' => 0,
                'deleted_rows' => 0,
                'message' => 'Unknown database rotation target.',
            ];
        }

        $deletedRows = 0;
        $failed = false;
        foreach ($this->databaseDeleteSqls($definition) as $sql) {
            $cutoff = strpos($sql, '%s') !== false
                ? gmdate('Y-m-d H:i:s', time() - ($this->databaseDeleteRetentionDays($sql, $retentionDays) * 86400))
                : '';
            $deleted = $this->chunkedDatabaseDelete($sql, $cutoff);
            if ($deleted < 0) {
                $failed = true;
                continue;
            }

            $deletedRows += $deleted;
        }

        return [
            'status' => $failed ? 'failed' : 'completed',
            'type' => 'database',
            'deleted_files' => 0,
            'deleted_bytes' => 0,
            'trimmed_files' => 0,
            'trimmed_bytes' => 0,
            'failed_files' => 0,
            'deleted_rows' => $deletedRows,
            'remaining' => $this->databaseTargetInventory($target),
        ];
    }

    /**
     * @param array<int,string> $selected
     * @return array<string,array<string,mixed>>
     */
    private function targetStatus(array $selected, array $excludedFileLogs, ?array $fileInventory = null): array
    {
        $statuses = [];
        $selectedMap = array_fill_keys($selected, true);

        foreach (self::targetCatalog() as $id => $target) {
            $inventory = $id === 'file_daily_logs'
                ? ($fileInventory ?: $this->fileInventoryFromLastScan($excludedFileLogs))
                : $this->databaseTargetInventory($id);

            $statuses[$id] = array_merge($target, [
                'id' => $id,
                'selected' => isset($selectedMap[$id]),
                'available' => $id === 'file_daily_logs' || $this->hasDatabase(),
                'inventory' => $inventory,
            ]);
        }

        return $statuses;
    }

    /**
     * @return array<string,mixed>
     */
    private function fileInventory(array $excludedFileLogs = [], bool $recursive = true): array
    {
        return $this->fileInventoryFromFiles($this->logFiles($recursive), $excludedFileLogs);
    }

    /**
     * @param array<int,mixed> $files
     * @return array<string,mixed>
     */
    private function fileInventoryFromFiles(array $files, array $excludedFileLogs = []): array
    {
        $excludedMap = $this->excludedFileLogMap($excludedFileLogs);
        $items = [];
        $total = 0;
        $excludedCount = 0;
        $unique = [];

        foreach ($files as $file) {
            if (!is_scalar($file)) {
                continue;
            }

            $real = $this->normalizeFile((string) $file);
            if ($real === '' || isset($unique[$real]) || !is_file($real)) {
                continue;
            }

            $unique[$real] = $real;
        }

        $files = array_values($unique);

        usort($files, static function (string $left, string $right): int {
            return ((int) (is_file($right) ? filemtime($right) : 0)) <=> ((int) (is_file($left) ? filemtime($left) : 0));
        });

        foreach ($files as $file) {
            $size = is_file($file) ? (int) filesize($file) : 0;
            $modified = is_file($file) ? filemtime($file) : false;
            $name = $this->fileLogKey($file);
            $excluded = $this->fileIsExcludedFromRotation($file, $excludedMap);
            if ($excluded) {
                $excludedCount++;
            }

            $total += max(0, $size);
            $items[] = [
                'name' => $name,
                'basename' => basename($file),
                'path' => $file,
                'size' => max(0, $size),
                'modified_at' => is_int($modified) ? gmdate('Y-m-d H:i:s', $modified) : '',
                'excluded' => $excluded,
            ];
        }

        return [
            'count' => count($items),
            'total_bytes' => $total,
            'excluded_count' => $excludedCount,
            'items' => $items,
            'recent' => array_slice($items, 0, 8),
        ];
    }

    /**
     * @return array<string,mixed>
     */
    private function fileInventoryFromLastScan(array $excludedFileLogs = []): array
    {
        $scan = $this->settings ? $this->settings->lastFileScan() : [];
        $items = isset($scan['items']) && is_array($scan['items']) ? $scan['items'] : [];
        $files = [];

        foreach ($items as $item) {
            if (is_array($item) && isset($item['path']) && is_scalar($item['path'])) {
                $files[] = (string) $item['path'];
            }
        }

        if (!empty($files)) {
            return $this->fileInventoryFromFiles($files, $excludedFileLogs);
        }

        return $this->fileInventory($excludedFileLogs, false);
    }

    /**
     * @return array<int,string>
     */
    private function rotationLogFiles(): array
    {
        $scan = $this->settings ? $this->settings->lastFileScan() : [];
        $items = isset($scan['items']) && is_array($scan['items']) ? $scan['items'] : [];
        $files = [];

        foreach ($items as $item) {
            if (is_array($item) && isset($item['path']) && is_scalar($item['path'])) {
                $files[] = (string) $item['path'];
            }
        }

        $candidates = $files ? $files : $this->logFiles(true);
        $verified = [];
        foreach ($candidates as $candidate) {
            $file = is_scalar($candidate) ? $this->safeRotationLogFile((string) $candidate) : '';
            if ($file === '' || isset($verified[$file])) {
                continue;
            }

            $verified[$file] = $file;
        }

        return array_values($verified);
    }

    /**
     * The persisted discovery list is advisory only. Revalidate every entry
     * immediately before rotation so a poisoned option cannot turn cleanup into
     * arbitrary file deletion.
     */
    private function safeRotationLogFile(string $path): string
    {
        $path = trim($path);
        if ($path === '' || is_link($path)) {
            return '';
        }

        $file = $this->normalizeFile($path);
        if ($file === '' || !is_file($file) || is_link($file) || !$this->isRuntimeLogFile($file)) {
            return '';
        }

        $logDirectory = $this->normalizeDirectory($this->storage->logDirectory());
        $insideLogDirectory = $logDirectory !== '' && $this->pathIsWithin($file, $logDirectory);
        $root = $this->wordpressRoot();
        if (!$insideLogDirectory && ($root === '' || !$this->pathIsWithin($file, $root))) {
            return '';
        }

        $storageBase = $this->normalizeDirectory($this->storage->baseDir());
        if ($storageBase !== '' && $this->pathIsWithin($file, $storageBase) && !$insideLogDirectory) {
            return '';
        }

        return $file;
    }

    /**
     * @param array<int,mixed>   $files
     * @param array<int,string>  $excludedFileLogs
     */
    private function countExcludedPaths(array $files, array $excludedFileLogs): int
    {
        $excludedMap = $this->excludedFileLogMap($excludedFileLogs);
        $count = 0;

        foreach ($files as $file) {
            if (!is_scalar($file)) {
                continue;
            }

            if ($this->fileIsExcludedFromRotation((string) $file, $excludedMap)) {
                $count++;
            }
        }

        return $count;
    }

    /**
     * @param array<int,string> $excludedFileLogs
     * @return array<string,bool>
     */
    private function excludedFileLogMap(array $excludedFileLogs): array
    {
        $map = [];

        foreach ($excludedFileLogs as $file) {
            $key = strtolower($file);
            if ($key !== '') {
                $map[$key] = true;
            }
        }

        return $map;
    }

    /**
     * @param array<string,bool> $excludedMap
     */
    private function fileIsExcludedFromRotation(string $file, array $excludedMap): bool
    {
        return isset($excludedMap[strtolower($this->fileLogKey($file))])
            || isset($excludedMap[strtolower(basename($file))]);
    }

    /**
     * @return array<string,mixed>
     */
    private function databaseTargetInventory(string $target): array
    {
        if (!$this->hasDatabase()) {
            return [
                'count' => 0,
                'oldest_at' => '',
                'newest_at' => '',
            ];
        }

        $definition = $this->databaseTargetDefinition($target);
        if (!$definition) {
            return [
                'count' => 0,
                'oldest_at' => '',
                'newest_at' => '',
            ];
        }

        $count = 0;
        $oldest = '';
        $newest = '';
        foreach ($this->databaseInventorySqls($definition) as $sql) {
            $row = $this->wpdb->get_row($sql, defined('ARRAY_A') ? ARRAY_A : 'ARRAY_A');
            $row = is_array($row) ? $row : [];

            $count += (int) ($row['record_count'] ?? $row['RECORD_COUNT'] ?? 0);
            $rowOldest = (string) ($row['oldest_at'] ?? $row['OLDEST_AT'] ?? '');
            $rowNewest = (string) ($row['newest_at'] ?? $row['NEWEST_AT'] ?? '');
            if ($rowOldest !== '' && ($oldest === '' || $rowOldest < $oldest)) {
                $oldest = $rowOldest;
            }

            if ($rowNewest !== '' && ($newest === '' || $rowNewest > $newest)) {
                $newest = $rowNewest;
            }
        }

        return [
            'count' => $count,
            'oldest_at' => $oldest,
            'newest_at' => $newest,
        ];
    }

    /**
     * @return array<string,mixed>|null
     */
    private function databaseTargetDefinition(string $target): ?array
    {
        if (!$this->hasDatabase()) {
            return null;
        }

        $prefix = (string) $this->wpdb->prefix;
        $definitions = [
            'db_user_events' => [
                'inventory_sql' => "SELECT COUNT(*) AS record_count, MIN(visited_at) AS oldest_at, MAX(visited_at) AS newest_at FROM {$prefix}shieldx_user_events",
                'delete_sql' => "DELETE FROM {$prefix}shieldx_user_events WHERE visited_at < %s",
            ],
            'db_event_summaries' => [
                'inventory_sql' => "SELECT COUNT(*) AS record_count, MIN(first_seen) AS oldest_at, MAX(last_seen) AS newest_at FROM {$prefix}shieldx_event_summaries",
                'delete_sql' => "DELETE FROM {$prefix}shieldx_event_summaries WHERE last_seen < %s",
            ],
            'db_incident_activity' => [
                'inventory_sqls' => [
                    "SELECT COUNT(*) AS record_count, MIN(visited_at) AS oldest_at, MAX(visited_at) AS newest_at FROM {$prefix}shieldx_user_events",
                    "SELECT COUNT(*) AS record_count, MIN(first_seen) AS oldest_at, MAX(last_seen) AS newest_at FROM {$prefix}shieldx_event_summaries",
                ],
                'delete_sqls' => [
                    "DELETE FROM {$prefix}shieldx_user_events WHERE visited_at < %s",
                    "DELETE FROM {$prefix}shieldx_event_summaries WHERE last_seen < %s",
                ],
            ],
            'db_scan_history' => [
                'inventory_sql' => "SELECT COUNT(*) AS record_count, MIN(started_at) AS oldest_at, MAX(started_at) AS newest_at FROM {$prefix}shieldx_scans",
                'delete_sql' => "DELETE FROM {$prefix}shieldx_scans WHERE started_at < %s",
            ],
            'db_resolved_threats' => [
                'inventory_sql' => "SELECT COUNT(*) AS record_count, MIN(COALESCE(resolved_at, last_detected, first_detected)) AS oldest_at, MAX(COALESCE(resolved_at, last_detected, first_detected)) AS newest_at FROM {$prefix}shieldx_threats WHERE status <> 'active'",
                'delete_sql' => "DELETE FROM {$prefix}shieldx_threats WHERE status <> 'active' AND COALESCE(resolved_at, last_detected, first_detected) < %s",
            ],
        ];

        return $definitions[$target] ?? null;
    }

    /**
     * @param array<string,mixed> $definition
     * @return array<int,string>
     */
    private function databaseInventorySqls(array $definition): array
    {
        if (isset($definition['inventory_sqls']) && is_array($definition['inventory_sqls'])) {
            return array_values(array_filter($definition['inventory_sqls'], 'is_string'));
        }

        return isset($definition['inventory_sql']) && is_string($definition['inventory_sql'])
            ? [$definition['inventory_sql']]
            : [];
    }

    /**
     * @param array<string,mixed> $definition
     * @return array<int,string>
     */
    private function databaseDeleteSqls(array $definition): array
    {
        if (isset($definition['delete_sqls']) && is_array($definition['delete_sqls'])) {
            return array_values(array_filter($definition['delete_sqls'], 'is_string'));
        }

        return isset($definition['delete_sql']) && is_string($definition['delete_sql'])
            ? [$definition['delete_sql']]
            : [];
    }

    /**
     * @return array<int,string>
     */
    private function logFiles(bool $recursive = true): array
    {
        $files = array_merge(
            $this->globFiles($this->storage->logDirectory() . '/*.log'),
            $this->wordpressRuntimeLogFiles($recursive)
        );

        $unique = [];
        foreach ($files as $file) {
            $real = $this->normalizeFile($file);
            if ($real === '' || isset($unique[$real]) || !is_file($real)) {
                continue;
            }

            $unique[$real] = $real;
            if (count($unique) >= self::MAX_DISCOVERED_LOG_FILES) {
                break;
            }
        }

        return array_values($unique);
    }

    /**
     * @return array<int,string>
     */
    private function wordpressRuntimeLogFiles(bool $recursive = true): array
    {
        $root = $this->wordpressRoot();
        if ($root === '' || !is_dir($root)) {
            return [];
        }

        $files = $this->directRuntimeLogFiles($root);
        if (!$recursive) {
            return $files;
        }

        $content = $this->wordpressContentDir();
        if (!is_dir($content)) {
            return $files;
        }

        return array_merge($files, $this->recursiveRuntimeLogFiles($content));
    }

    private function wordpressContentDir(): string
    {
        if ($this->wordpressRoot !== '') {
            return $this->normalizeDirectory($this->wordpressRoot . '/wp-content');
        }

        if (defined('SHIELDX_CONTENT_DIR')) {
            return $this->normalizeDirectory((string) SHIELDX_CONTENT_DIR);
        }

        return WordPressPaths::contentDir();
    }

    private function isTemporaryCleanupFile(string $path, int $cutoff): bool
    {
        $file = $this->normalizeFile($path);
        $root = $this->wordpressRoot();
        if ($root === '' || !$this->pathIsWithin($file, $root) || $this->pathIsWithin($file, $this->storage->baseDir())) {
            return false;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Preflight before deleting stale temporary files selected inside the WordPress root.
        if (!is_file($file) || !is_writable($file) || !$this->pathIsOlderThan($file, $cutoff)) {
            return false;
        }

        if ($this->isTemporaryCleanupProtectionFile($file)) {
            return false;
        }

        if ($this->isTemporaryCleanupContentPath($file, false)) {
            return true;
        }

        $name = strtolower(basename($file));
        if ($name === '.maintenance') {
            return dirname($file) === $root;
        }

        if ($name === '.ds_store' || $name === 'thumbs.db') {
            return true;
        }

        if (preg_match('/\.(?:tmp|temp|part|download|crdownload|swp|swo)$/i', $name) === 1) {
            return true;
        }

        if (preg_match('/^(?:tmp|temp|wp-temp|wordpress-temp)[._-][A-Za-z0-9._-]+$/', $name) === 1) {
            return $this->pathHasTemporaryDirectory($file);
        }

        return false;
    }

    private function isTemporaryCleanupDirectory(string $path, int $cutoff): bool
    {
        $directory = $this->normalizeDirectory($path);
        $root = $this->wordpressRoot();
        if ($root === '' || $directory === $root || !$this->pathIsWithin($directory, $root) || $this->shouldSkipTemporaryCleanupDirectory($directory)) {
            return false;
        }

        if (!$this->pathIsOlderThan($directory, $cutoff) || !$this->directoryIsEmpty($directory)) {
            return false;
        }

        if ($this->isTemporaryCleanupContentPath($directory, true)) {
            return true;
        }

        $name = strtolower(basename($directory));

        return in_array($name, ['tmp', 'temp', 'temporary', 'cache-tmp', 'temp-cache', 'tmp-cache'], true);
    }

    private function isTemporaryCleanupContentPath(string $path, bool $requireChild): bool
    {
        $content = $this->wordpressContentDir();
        if ($content === '') {
            return false;
        }

        $path = $this->normalizeFile($path);
        foreach (self::TEMP_CLEANUP_CONTENT_DIRECTORIES as $child) {
            $directory = $this->normalizeDirectory($content . '/' . $child);
            if ($directory === '' || !$this->pathIsWithin($path, $directory)) {
                continue;
            }

            if ($requireChild && $path === $directory) {
                continue;
            }

            return true;
        }

        return false;
    }

    /**
     * @return array<int,string>
     */
    private function temporaryCleanupTargets(): array
    {
        return array_map(static function (string $child): string {
            return 'wp-content/' . $child . '/*';
        }, self::TEMP_CLEANUP_CONTENT_DIRECTORIES);
    }

    private function isTemporaryCleanupProtectionFile(string $path): bool
    {
        return in_array(strtolower(basename($path)), ['.htaccess', 'web.config', 'index.php'], true);
    }

    private function shouldSkipTemporaryCleanupDirectory(string $path): bool
    {
        $directory = $this->normalizeDirectory($path);
        $root = $this->wordpressRoot();
        if ($root === '' || !$this->pathIsWithin($directory, $root)) {
            return true;
        }

        $storageBase = $this->normalizeDirectory($this->storage->baseDir());
        if ($storageBase !== '' && $this->pathIsWithin($directory, $storageBase)) {
            return true;
        }

        $relative = trim(substr($directory, strlen($root)), '/');
        $segments = $relative === '' ? [] : explode('/', strtolower($relative));
        $skipSegments = [
            '.git',
            '.hg',
            '.svn',
            'node_modules',
            'vendor',
            'backup',
            'backups',
            'backupwordpress',
            'updraft',
            'updraftplus',
            'ai1wm-backups',
            'quarantine',
            'quarantines',
            'restore-point',
            'restore-points',
            'restore_points',
            'snapshots',
        ];

        foreach ($segments as $segment) {
            if (in_array($segment, $skipSegments, true)) {
                return true;
            }
        }

        return false;
    }

    private function pathHasTemporaryDirectory(string $path): bool
    {
        foreach (explode('/', $this->normalizeFile($path)) as $segment) {
            $segment = strtolower($segment);
            if (in_array($segment, ['tmp', 'temp', 'temporary'], true) || substr($segment, -4) === '-tmp' || substr($segment, -4) === '_tmp' || substr($segment, -5) === '-temp' || substr($segment, -5) === '_temp') {
                return true;
            }
        }

        return false;
    }

    private function pathIsOlderThan(string $path, int $cutoff): bool
    {
        $modified = filemtime($path);

        return is_int($modified) && $modified <= $cutoff;
    }

    private function pathIsWithin(string $path, string $directory): bool
    {
        $path = $this->normalizeFile($path);
        $directory = $this->normalizeDirectory($directory);
        if ($path === '' || $directory === '') {
            return false;
        }

        return $path === $directory || strpos($path, $directory . '/') === 0;
    }

    private function deleteCleanupFile(string $path): bool
    {
        $file = $this->normalizeFile($path);
        if ($file === '' || !is_file($file)) {
            return false;
        }

        if (function_exists('wp_delete_file')) {
            wp_delete_file($file);

            return !is_file($file);
        }

        return unlink($file);
    }

    private function removeCleanupDirectory(string $path): bool
    {
        $directory = $this->normalizeDirectory($path);
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Preflight before removing a verified empty temporary directory.
        if ($directory === '' || !is_dir($directory) || !$this->directoryIsEmpty($directory) || !is_writable($directory)) {
            return false;
        }

        return rmdir($directory);
    }

    private function directoryIsEmpty(string $path): bool
    {
        $directory = $this->normalizeDirectory($path);
        if ($directory === '' || !is_dir($directory)) {
            return false;
        }

        $items = scandir($directory);
        if (!is_array($items)) {
            return false;
        }

        foreach ($items as $item) {
            if ($item !== '.' && $item !== '..') {
                return false;
            }
        }

        return true;
    }

    /**
     * @param array<int,array<string,mixed>> $items
     */
    private function appendCleanupReportItem(array &$items, string $path, int $size, string $type): void
    {
        if (count($items) >= self::TEMP_CLEANUP_REPORT_ITEMS) {
            return;
        }

        $items[] = [
            'type' => $type,
            'name' => $this->fileLogKey($path),
            'size' => max(0, $size),
        ];
    }

    /**
     * @return array<int,string>
     */
    private function directRuntimeLogFiles(string $directory): array
    {
        $files = [];
        $items = glob(rtrim($directory, '/\\') . '/*');
        if (!is_array($items)) {
            return [];
        }

        foreach ($items as $item) {
            if (is_file($item) && $this->isRuntimeLogFile($item)) {
                $files[] = $item;
            }
        }

        return $files;
    }

    /**
     * @return array<int,string>
     */
    private function recursiveRuntimeLogFiles(string $root): array
    {
        $files = [];
        $scanned = 0;
        try {
            $iterator = new \RecursiveIteratorIterator(
                new \RecursiveCallbackFilterIterator(
                    new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS),
                    function (\SplFileInfo $current): bool {
                        if (!$current->isDir()) {
                            return true;
                        }

                        return !$this->shouldSkipLogDirectory($current->getPathname());
                    }
                )
            );

            foreach ($iterator as $item) {
                if (!$item instanceof \SplFileInfo || !$item->isFile()) {
                    continue;
                }

                $scanned++;
                if ($scanned > self::MAX_FALLBACK_DISCOVERY_FILES || count($files) >= self::MAX_DISCOVERED_LOG_FILES) {
                    break;
                }

                $path = $item->getPathname();
                if ($this->isRuntimeLogFile($path)) {
                    $files[] = $path;
                }
            }
        } catch (\UnexpectedValueException $error) {
            return $files;
        }

        return $files;
    }

    private function shouldSkipLogDirectory(string $path): bool
    {
        $normalized = $this->normalizeDirectory($path);
        $storageBase = $this->normalizeDirectory($this->storage->baseDir());
        $storageLogDirectory = $this->normalizeDirectory($this->storage->logDirectory());

        if ($storageBase !== '' && strpos($normalized, $storageBase) === 0 && strpos($normalized, $storageLogDirectory) !== 0) {
            return true;
        }

        $segments = explode('/', trim($normalized, '/'));

        return in_array('node_modules', $segments, true)
            || in_array('vendor', $segments, true)
            || in_array('.git', $segments, true);
    }

    private function isRuntimeLogFile(string $path): bool
    {
        $name = strtolower(basename($path));
        if ($this->isDocumentationFile($name)) {
            return false;
        }

        if ($name === 'error_log' || $name === 'php_errorlog') {
            return true;
        }

        if (preg_match('/\.(?:log(?:\.[A-Za-z0-9_-]+)?|jsonl|ndjson)$/i', $name)) {
            return true;
        }

        return $this->pathHasLogDirectory($path) && preg_match('/\.(?:txt|json|csv)$/i', $name) === 1;
    }

    private function pathHasLogDirectory(string $path): bool
    {
        foreach (explode('/', $this->normalizeFile($path)) as $segment) {
            $segment = strtolower($segment);
            if ($segment === 'log' || $segment === 'logs' || $segment === 'debug' || $segment === 'debugs' || substr($segment, -4) === 'logs' || substr($segment, -5) === '-logs' || substr($segment, -5) === '_logs') {
                return true;
            }
        }

        return false;
    }

    private function isDocumentationFile(string $name): bool
    {
        return preg_match('/^(?:change(?:log)?|changelog|readme|license|licence|copying|credits)(?:[-_.].*)?\.(?:txt|md|html?)$/', $name) === 1;
    }

    /**
     * @return array<int,string>
     */
    private function globFiles(string $pattern): array
    {
        $files = glob($pattern);

        return is_array($files) ? array_values(array_filter($files, 'is_file')) : [];
    }

    /**
     * @param array<string,bool> $excludedMap
     */
    private function fileIsProtectedFromRotation(string $file, string $today, array $excludedMap, bool $keepTodayLogs, int $now): bool
    {
        if ($this->fileIsExcludedFromRotation($file, $excludedMap)) {
            return true;
        }

        if (!$keepTodayLogs) {
            return false;
        }

        $logDirectory = $this->normalizeDirectory($this->storage->logDirectory());
        $isShieldXDailyLog = $logDirectory !== ''
            && $this->pathIsWithin($file, $logDirectory)
            && strtolower(basename($file)) === strtolower($today . '.log');

        return $isShieldXDailyLog || $this->fileWasModifiedToday($file, $now);
    }

    private function fileWasModifiedToday(string $file, int $now): bool
    {
        $modified = is_file($file) ? filemtime($file) : false;
        if (!is_int($modified)) {
            return false;
        }

        return $this->siteDate($modified) === $this->siteDate($now);
    }

    private function siteDate(int $timestamp): string
    {
        return function_exists('wp_date') ? wp_date('Y-m-d', $timestamp) : gmdate('Y-m-d', $timestamp);
    }

    private function fileLogKey(string $file): string
    {
        $normalized = $this->normalizeFile($file);
        $root = $this->wordpressRoot();

        if ($root !== '' && strpos($normalized, $root . '/') === 0) {
            return substr($normalized, strlen($root) + 1);
        }

        return basename($file);
    }

    private function wordpressRoot(): string
    {
        if ($this->wordpressRoot !== '') {
            return $this->wordpressRoot;
        }

        $base = $this->normalizeDirectory($this->storage->baseDir());
        $marker = '/wp-content/uploads/.shield-x';
        $position = strrpos($base, $marker);
        if ($position === false) {
            $position = strrpos($base, '/wp-content/uploads/.' . 'shieldx');
        }
        if ($position !== false) {
            return substr($base, 0, $position);
        }

        return '';
    }

    private function normalizeFile(string $path): string
    {
        $real = realpath($path);
        if (is_string($real) && $real !== '') {
            return str_replace('\\', '/', $real);
        }

        return rtrim(str_replace('\\', '/', $path), '/');
    }

    private function normalizeDirectory(string $path): string
    {
        return rtrim($this->normalizeFile($path), '/');
    }

    /**
     * @param array<string,mixed> $settings
     * @return array<int,string>
     */
    private function selectedTargets(array $settings): array
    {
        $targets = isset($settings['targets']) && is_array($settings['targets'])
            ? array_values(array_filter(array_map('strval', $settings['targets'])))
            : array_keys(self::targetCatalog());
        $allowed = array_fill_keys(array_keys(self::targetCatalog()), true);

        return array_values(array_filter(array_unique($targets), static function (string $target) use ($allowed): bool {
            return isset($allowed[$target]);
        }));
    }

    /**
     * @param array<string,mixed> $settings
     * @return array<int,string>
     */
    private function excludedFileLogs(array $settings): array
    {
        return isset($settings['excluded_file_logs']) && is_array($settings['excluded_file_logs'])
            ? LogRotationSettings::cleanFileLogNames($settings['excluded_file_logs'])
            : [];
    }

    /**
     * @return array<string,mixed>
     */
    private function defaultSettings(): array
    {
        return [
            'enabled' => true,
            'retention_days' => 90,
            'max_files' => 90,
            'targets' => array_keys(self::targetCatalog()),
            'excluded_file_logs' => [],
        ];
    }

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

    private function databaseDeleteRetentionDays(string $sql, int $retentionDays): int
    {
        if (strpos($sql, 'shieldx_event_summaries') !== false) {
            return max($retentionDays, self::TREND_SUMMARY_MIN_RETENTION_DAYS);
        }

        return $retentionDays;
    }

    private function chunkedDatabaseDelete(string $sql, string $cutoff = ''): int
    {
        $batchSql = $this->databaseDeleteBatchSql($sql);
        if ($batchSql === '') {
            return -1;
        }

        $deletedRows = 0;
        for ($batch = 0; $batch < self::DATABASE_DELETE_MAX_BATCHES; $batch++) {
            if ($cutoff !== '') {
                $query = $this->wpdb->prepare($batchSql, $cutoff);
            } else {
                $query = $batchSql;
            }

            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Rotation SQL is plugin-owned, table names are fixed, and cutoff values are prepared above when present.
            $deleted = $this->wpdb->query($query);
            if ($deleted === false) {
                return -1;
            }

            $deletedCount = max(0, (int) $deleted);
            $deletedRows += $deletedCount;

            if ($deleted === true || $deletedCount < self::DATABASE_DELETE_BATCH_ROWS) {
                break;
            }
        }

        return $deletedRows;
    }

    private function databaseDeleteBatchSql(string $sql): string
    {
        $sql = rtrim(trim($sql), ';');
        if ($sql === '' || stripos($sql, 'DELETE FROM ') !== 0) {
            return '';
        }

        if (preg_match('/\sLIMIT\s+\d+\s*$/i', $sql) === 1) {
            return $sql;
        }

        return $sql . ' LIMIT ' . self::DATABASE_DELETE_BATCH_ROWS;
    }

    /**
     * @param array<string,mixed> $result
     */
    private function record(array $result): void
    {
        if ($this->settings) {
            $this->settings->recordLastResult($result);
        }
    }

    /**
     * @param array<string,mixed> $result
     */
    private function recordTempCleanup(array $result): void
    {
        if ($this->settings) {
            $this->settings->recordLastTempCleanup($result);
        }
    }

    /**
     * @param array<string,mixed> $result
     */
    private function logRotationResult(array $result): void
    {
        if (!$this->logger) {
            return;
        }

        $context = [
            'status' => (string) ($result['status'] ?? ''),
            'manual' => !empty($result['manual']),
            'retention_days' => (int) ($result['retention_days'] ?? 0),
            'max_files' => (int) ($result['max_files'] ?? 0),
            'targets' => isset($result['targets']) && is_array($result['targets']) ? array_values(array_map('strval', $result['targets'])) : [],
            'deleted_files' => (int) ($result['deleted_files'] ?? 0),
            'deleted_bytes' => (int) ($result['deleted_bytes'] ?? 0),
            'trimmed_files' => (int) ($result['trimmed_files'] ?? 0),
            'trimmed_bytes' => (int) ($result['trimmed_bytes'] ?? 0),
            'failed_files' => (int) ($result['failed_files'] ?? 0),
            'deleted_rows' => (int) ($result['deleted_rows'] ?? 0),
            'remaining_files' => (int) ($result['remaining_files'] ?? 0),
            'remaining_bytes' => (int) ($result['remaining_bytes'] ?? 0),
            'scan_truncated' => !empty($result['scan_truncated']),
        ];

        if ((string) $context['status'] === 'completed_with_warnings') {
            $this->logger->warning('log_rotation.completed', $context);

            return;
        }

        $this->logger->info('log_rotation.completed', $context);
    }

    /**
     * @param array<string,mixed> $result
     */
    private function logTempCleanupResult(array $result): void
    {
        if (!$this->logger) {
            return;
        }

        $context = [
            'status' => (string) ($result['status'] ?? ''),
            'manual' => !empty($result['manual']),
            'scanned_entries' => (int) ($result['scanned_entries'] ?? 0),
            'deleted_files' => (int) ($result['deleted_files'] ?? 0),
            'deleted_bytes' => (int) ($result['deleted_bytes'] ?? 0),
            'deleted_directories' => (int) ($result['deleted_directories'] ?? 0),
            'failed_files' => (int) ($result['failed_files'] ?? 0),
            'failed_directories' => (int) ($result['failed_directories'] ?? 0),
            'truncated' => !empty($result['truncated']),
        ];

        $level = ((int) $context['failed_files'] + (int) $context['failed_directories']) > 0 ? 'warning' : 'info';
        $event = $level === 'warning' ? 'log_rotation.temp_cleanup_warning' : 'log_rotation.temp_cleanup_completed';

        if ($level === 'warning') {
            $this->logger->warning($event, $context);
            return;
        }

        $this->logger->info($event, $context);
    }
}
