<?php

declare(strict_types=1);

namespace ShieldX\Scanner;

defined('ABSPATH') || exit;

// phpcs:disable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- SHIELD-X uses plugin-owned table names built from $wpdb->prefix; value placeholders are still prepared where user data is used.

class ScanRepository
{
    private const IGNORED_PATHS_OPTION = 'shieldx_scanner_ignored_paths';
    private const IGNORED_PATH_HASHES_OPTION = 'shieldx_scanner_ignored_path_hashes';
    private const IGNORED_RULES_OPTION = 'shieldx_scanner_ignored_rules';
    private const TABLES = ['files', 'scans', 'threats'];
    private const MAX_STORED_RESULT_ITEMS = 500;

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

    public function __construct($wpdb = null)
    {
        $this->wpdb = $wpdb;
    }

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

        return new self($wpdb);
    }

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

    public function startScan(string $scanId, string $type): void
    {
        if (!$this->available()) {
            return;
        }

        $this->wpdb->insert($this->table('scans'), [
            'scan_id' => $scanId,
            'type' => $type,
            'status' => 'running',
            'started_at' => current_time('mysql', true),
            'files_total' => 0,
            'files_scanned' => 0,
            'threats_found' => 0,
            'threats_cleaned' => 0,
            'results' => null,
        ]);
    }

    /**
     * @param array<string,mixed> $results
     */
    public function finishScan(string $scanId, string $status, int $filesTotal, int $filesScanned, int $threatsFound, array $results): void
    {
        if (!$this->available()) {
            return;
        }

        $json = wp_json_encode($this->cappedScanResults($results));

        $this->wpdb->update(
            $this->table('scans'),
            [
                'status' => $status,
                'completed_at' => current_time('mysql', true),
                'files_total' => $filesTotal,
                'files_scanned' => $filesScanned,
                'threats_found' => $threatsFound,
                'results' => is_string($json) ? $json : null,
            ],
            ['scan_id' => $scanId]
        );
    }

    public function failRunningScansOlderThan(string $cutoffMysql, string $reason): void
    {
        if (!$this->available() || $cutoffMysql === '') {
            return;
        }

        $json = wp_json_encode([
            'status' => 'failed',
            'error' => $reason,
        ]);

        $this->wpdb->query($this->wpdb->prepare(
            'UPDATE ' . $this->table('scans') . " SET status = 'failed', completed_at = %s, results = %s WHERE status = 'running' AND started_at < %s",
            current_time('mysql', true),
            is_string($json) ? $json : null,
            $cutoffMysql
        ));
    }

    /**
     * @param array<string,mixed> $results
     * @return array<string,mixed>
     */
    private function cappedScanResults(array $results): array
    {
        foreach (['issues', 'shadow_changed_items'] as $key) {
            if (!isset($results[$key]) || !is_array($results[$key])) {
                continue;
            }

            $count = count($results[$key]);
            if ($count <= self::MAX_STORED_RESULT_ITEMS) {
                continue;
            }

            $results[$key] = array_slice(array_values($results[$key]), 0, self::MAX_STORED_RESULT_ITEMS);
            $results[$key . '_stored_count'] = self::MAX_STORED_RESULT_ITEMS;
            $results[$key . '_overflow_count'] = $count - self::MAX_STORED_RESULT_ITEMS;
        }

        return $results;
    }

    /**
     * @return array<string,mixed>|null
     */
    public function fileRecord(string $path): ?array
    {
        if (!$this->available()) {
            return null;
        }

        $row = $this->wpdb->get_row($this->wpdb->prepare(
            'SELECT file_hash, file_size, last_modified, metadata_changed, scan_time FROM ' . $this->table('files') . ' WHERE file_path = %s LIMIT 1',
            $path
        ), ARRAY_A);

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

    /**
     * @param array<int,string> $paths
     * @return array<string,array<string,mixed>>
     */
    public function fileRecords(array $paths): array
    {
        if (!$this->available()) {
            return [];
        }

        $paths = $this->cleanPathList($paths);
        if (empty($paths)) {
            return [];
        }

        $records = [];
        foreach (array_chunk($paths, 500) as $chunk) {
            $placeholders = implode(',', array_fill(0, count($chunk), '%s'));
            $query = 'SELECT file_path, file_hash, file_size, last_modified, metadata_changed, scan_time FROM ' . $this->table('files') . " WHERE file_path IN ({$placeholders})";
            // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Placeholder list is generated from sanitized path count; every value is passed to prepare.
            $rows = $this->rows($this->wpdb->prepare(
                $query,
                ...$chunk
            ));

            foreach ($rows as $row) {
                $path = (string) ($row['file_path'] ?? '');
                if ($path === '') {
                    continue;
                }

                unset($row['file_path']);
                $records[$path] = $row;
            }
        }

        return $records;
    }

    /**
     * @param array<int,string> $paths
     * @return array<int,string>
     */
    public function activeScannerIssuePaths(array $paths): array
    {
        if (!$this->available()) {
            return [];
        }

        $paths = $this->cleanPathList($paths);
        if (empty($paths)) {
            return [];
        }

        $active = [];
        foreach (array_chunk($paths, 500) as $chunk) {
            $placeholders = implode(',', array_fill(0, count($chunk), '%s'));
            $query = 'SELECT DISTINCT affected_path FROM ' . $this->table('threats') . " WHERE source = 'scanner' AND status = 'active' AND affected_path IN ({$placeholders})";
            // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Placeholder list is generated from sanitized path count; every value is passed to prepare.
            $rows = $this->rows($this->wpdb->prepare(
                $query,
                ...$chunk
            ));

            foreach ($rows as $row) {
                $path = (string) ($row['affected_path'] ?? '');
                if ($path !== '') {
                    $active[] = $path;
                }
            }
        }

        return array_values(array_unique($active));
    }

    /**
     * @param array<string,mixed> $file
     */
    public function recordFileBaseline(array $file): void
    {
        if (!$this->available()) {
            return;
        }

        $path = (string) ($file['path'] ?? '');
        $hash = (string) ($file['hash'] ?? '');

        if ($path === '' || $hash === '') {
            return;
        }

        $data = [
            'file_path' => $path,
            'file_hash' => $hash,
            'file_size' => (int) ($file['size'] ?? 0),
            'last_modified' => (string) ($file['modified_at'] ?? current_time('mysql', true)),
            'metadata_changed' => (string) ($file['metadata_changed_at'] ?? $file['modified_at'] ?? current_time('mysql', true)),
            'plugin_slug' => (string) ($file['plugin_slug'] ?? ''),
            'is_core' => !empty($file['is_core']) ? 1 : 0,
            'is_modified' => 0,
            'scan_time' => current_time('mysql', true),
        ];

        $this->wpdb->query($this->wpdb->prepare(
            'INSERT INTO ' . $this->table('files') . ' (file_path, file_hash, file_size, last_modified, metadata_changed, plugin_slug, is_core, is_modified, scan_time)
             VALUES (%s, %s, %d, %s, %s, %s, %d, %d, %s)
             ON DUPLICATE KEY UPDATE
                file_hash = VALUES(file_hash),
                file_size = VALUES(file_size),
                last_modified = VALUES(last_modified),
                metadata_changed = VALUES(metadata_changed),
                plugin_slug = VALUES(plugin_slug),
                is_core = VALUES(is_core),
                is_modified = VALUES(is_modified),
                scan_time = VALUES(scan_time)',
            [
                $data['file_path'],
                $data['file_hash'],
                $data['file_size'],
                $data['last_modified'],
                $data['metadata_changed'],
                $data['plugin_slug'],
                $data['is_core'],
                $data['is_modified'],
                $data['scan_time'],
            ]
        ));
    }

    /**
     * @param array<string,mixed> $issue
     */
    public function upsertIssue(array $issue): void
    {
        if (!$this->available()) {
            return;
        }

        $threatId = (string) ($issue['threat_id'] ?? '');

        if ($threatId === '') {
            return;
        }

        if ($this->isIssuePersistentlyIgnored($issue)) {
            $this->markIssueStatus($threatId, 'ignored', 0);
            return;
        }

        $now = current_time('mysql', true);
        $existingId = $this->wpdb->get_var($this->wpdb->prepare(
            'SELECT id FROM ' . $this->table('threats') . ' WHERE threat_id = %s LIMIT 1',
            $threatId
        ));
        $description = (string) ($issue['description'] ?? '');
        $evidence = (string) ($issue['evidence'] ?? '');

        if ($evidence !== '') {
            $description .= "\n\nEvidence: " . $evidence;
        }

        $data = [
            'rule_id' => (string) ($issue['rule_id'] ?? ''),
            'type' => (string) ($issue['type'] ?? 'scanner'),
            'severity' => (string) ($issue['severity'] ?? 'medium'),
            'status' => 'active',
            'source' => 'scanner',
            'description' => $description,
            'affected_path' => (string) ($issue['affected_path'] ?? ''),
            'last_detected' => $now,
            'resolved_at' => null,
            'resolved_by' => null,
        ];

        if ($existingId) {
            $this->wpdb->update($this->table('threats'), $data, ['threat_id' => $threatId]);
            return;
        }

        $data['threat_id'] = $threatId;
        $data['first_detected'] = $now;
        $this->wpdb->insert($this->table('threats'), $data);
    }

    /**
     * @return array<string,mixed>|null
     */
    public function issueByThreatId(string $threatId): ?array
    {
        if (!$this->available() || !$this->validThreatId($threatId)) {
            return null;
        }

        $row = $this->row($this->wpdb->prepare(
            'SELECT * FROM ' . $this->table('threats') . ' WHERE threat_id = %s LIMIT 1',
            $threatId
        ));

        return $row ? $this->formatIssue($row) : null;
    }

    public function markIssueStatus(string $threatId, string $status, int $userId = 0): bool
    {
        if (!$this->available() || !$this->validThreatId($threatId)) {
            return false;
        }

        $allowed = ['active', 'resolved', 'ignored', 'quarantined', 'restored'];
        if (!in_array($status, $allowed, true)) {
            return false;
        }

        $data = ['status' => $status];
        if ($status !== 'active') {
            $data['resolved_at'] = current_time('mysql', true);
            $data['resolved_by'] = max(0, $userId);
        } else {
            $data['resolved_at'] = null;
            $data['resolved_by'] = null;
        }

        return $this->wpdb->update($this->table('threats'), $data, ['threat_id' => $threatId]) !== false;
    }

    public function ignoreIssue(string $threatId, string $scope, int $userId = 0): bool
    {
        $issue = $this->issueByThreatId($threatId);
        if (!$issue) {
            return false;
        }

        if ($scope === 'file') {
            $path = (string) ($issue['affected_path'] ?? '');
            if ($path === '' || strpos($path, 'db:') === 0) {
                return false;
            }

            $hash = $this->currentIssueFileHash($issue);
            if ($hash === '') {
                return false;
            }

            if (!$this->addIgnoredPath($path, $hash)) {
                return false;
            }
            $this->markScannerIssuesForPath($path, 'ignored', $userId);
            return true;
        }

        if ($scope === 'rule') {
            $ruleId = (string) ($issue['rule_id'] ?? '');
            if ($ruleId === '') {
                return false;
            }

            if (!$this->addIgnoredRule($ruleId)) {
                return false;
            }
            $this->markScannerIssuesForRule($ruleId, 'ignored', $userId);
            return true;
        }

        return $this->markIssueStatus($threatId, 'ignored', $userId);
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    public function quarantinedIssues(int $limit = 10): array
    {
        if (!$this->available()) {
            return [];
        }

        $rows = $this->rows($this->wpdb->prepare(
            "SELECT * FROM " . $this->table('threats') . " WHERE source = 'scanner' AND status = 'quarantined' ORDER BY resolved_at DESC, last_detected DESC LIMIT %d",
            max(1, min(100, $limit))
        ));

        return array_map([$this, 'formatIssue'], $rows);
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    public function issuesForPath(string $path, int $limit = 10): array
    {
        if (!$this->available() || $path === '') {
            return [];
        }

        $rows = $this->rows($this->wpdb->prepare(
            "SELECT * FROM " . $this->table('threats') . " WHERE source = 'scanner' AND status = 'active' AND affected_path = %s ORDER BY FIELD(severity, 'critical', 'high', 'medium', 'low'), last_detected DESC LIMIT %d",
            $path,
            max(1, min(50, $limit))
        ));

        return array_map([$this, 'formatIssue'], $rows);
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    public function activeIssues(int $limit = 100): array
    {
        if (!$this->available()) {
            return [];
        }

        $rows = $this->rows($this->wpdb->prepare(
            "SELECT * FROM " . $this->table('threats') . " WHERE source = 'scanner' AND status = 'active' ORDER BY FIELD(severity, 'critical', 'high', 'medium', 'low'), last_detected DESC LIMIT %d",
            max(1, min(500, $limit))
        ));

        return array_map([$this, 'formatIssue'], $rows);
    }

    public function activeIssueCountForPath(string $path): int
    {
        if (!$this->available() || $path === '') {
            return 0;
        }

        return (int) $this->wpdb->get_var($this->wpdb->prepare(
            'SELECT COUNT(*) FROM ' . $this->table('threats') . " WHERE source = 'scanner' AND status = 'active' AND affected_path = %s",
            $path
        ));
    }

    /**
     * @return array{paths:array<int,string>,rules:array<int,string>}
     */
    public function ignoredCatalog(): array
    {
        return [
            'paths' => $this->ignoredPaths(),
            'rules' => $this->ignoredRules(),
        ];
    }

    public function removeIgnoredItem(string $type, string $value, int $userId = 0): bool
    {
        $type = $type === 'rule' ? 'rule' : 'path';
        $value = trim($value);
        if ($value === '') {
            return false;
        }

        if ($type === 'rule') {
            $removed = $this->removeOptionListValue(self::IGNORED_RULES_OPTION, $value);
            if ($removed) {
                $this->reactivateIgnoredScannerIssues('rule_id', $value);
            }

            return $removed;
        }

        $removed = $this->removeOptionListValue(self::IGNORED_PATHS_OPTION, $value);
        if ($removed) {
            if (!$this->removeIgnoredPathHashes($value)) {
                return false;
            }
            $this->reactivateIgnoredScannerIssues('affected_path', $value);
        }

        return $removed;
    }

    /**
     * @param array<int,string> $activeThreatIds
     * @param array<int,string> $inspectedPaths
     */
    public function resolveScannerIssuesForInspectedPaths(array $activeThreatIds, array $inspectedPaths): void
    {
        if (!$this->available()) {
            return;
        }

        $inspectedPaths = array_values(array_unique(array_filter(array_map('strval', $inspectedPaths))));

        if (empty($inspectedPaths)) {
            return;
        }

        $activeThreatIds = array_values(array_unique(array_filter(array_map('strval', $activeThreatIds))));
        $now = current_time('mysql', true);

        foreach (array_chunk($inspectedPaths, 1000) as $pathChunk) {
            $pathPlaceholders = implode(',', array_fill(0, count($pathChunk), '%s'));

            if (empty($activeThreatIds)) {
                $sql = 'UPDATE ' . $this->table('threats') . " SET status = 'resolved', resolved_at = %s WHERE source = 'scanner' AND status = 'active' AND affected_path IN ({$pathPlaceholders})";
                $this->wpdb->query($this->wpdb->prepare($sql, array_merge([$now], $pathChunk)));
                continue;
            }

            $threatPlaceholders = implode(',', array_fill(0, count($activeThreatIds), '%s'));
            $sql = 'UPDATE ' . $this->table('threats') . " SET status = 'resolved', resolved_at = %s WHERE source = 'scanner' AND status = 'active' AND affected_path IN ({$pathPlaceholders}) AND threat_id NOT IN ({$threatPlaceholders})";
            $this->wpdb->query($this->wpdb->prepare($sql, array_merge([$now], $pathChunk, $activeThreatIds)));
        }
    }

    /**
     * Resolve scanner findings only after a complete deterministic database phase.
     *
     * @param array<int,string> $activeThreatIds
     * @param array<int,string> $pathPrefixes
     */
    public function resolveScannerIssuesForPathPrefixes(array $activeThreatIds, array $pathPrefixes): void
    {
        if (!$this->available()) {
            return;
        }

        $pathPrefixes = array_values(array_unique(array_filter(array_map('strval', $pathPrefixes))));
        $activeThreatIds = array_values(array_unique(array_filter(array_map('strval', $activeThreatIds))));
        if ($pathPrefixes === []) {
            return;
        }

        $now = current_time('mysql', true);
        foreach ($pathPrefixes as $prefix) {
            $like = method_exists($this->wpdb, 'esc_like') ? $this->wpdb->esc_like($prefix) . '%' : addcslashes($prefix, '_%\\') . '%';
            if ($activeThreatIds === []) {
                $sql = 'UPDATE ' . $this->table('threats') . " SET status = 'resolved', resolved_at = %s WHERE source = 'scanner' AND status = 'active' AND affected_path LIKE %s";
                $this->wpdb->query($this->wpdb->prepare($sql, $now, $like));
                continue;
            }

            $threatPlaceholders = implode(',', array_fill(0, count($activeThreatIds), '%s'));
            $sql = 'UPDATE ' . $this->table('threats') . " SET status = 'resolved', resolved_at = %s WHERE source = 'scanner' AND status = 'active' AND affected_path LIKE %s AND threat_id NOT IN ({$threatPlaceholders})";
            $this->wpdb->query($this->wpdb->prepare($sql, array_merge([$now, $like], $activeThreatIds)));
        }
    }

    /**
     * @param array<int,string> $paths
     * @param array<int,string> $types
     */
    public function resolveScannerIssuesForPathTypes(array $paths, array $types): void
    {
        if (!$this->available()) {
            return;
        }

        $paths = array_values(array_unique(array_filter(array_map('strval', $paths))));
        $types = array_values(array_unique(array_filter(array_map('strval', $types))));

        if (empty($paths) || empty($types)) {
            return;
        }

        $now = current_time('mysql', true);
        $pathPlaceholders = implode(',', array_fill(0, count($paths), '%s'));
        $typePlaceholders = implode(',', array_fill(0, count($types), '%s'));
        $sql = 'UPDATE ' . $this->table('threats') . " SET status = 'resolved', resolved_at = %s WHERE source = 'scanner' AND status = 'active' AND affected_path IN ({$pathPlaceholders}) AND type IN ({$typePlaceholders})";

        $this->wpdb->query($this->wpdb->prepare($sql, array_merge([$now], $paths, $types)));
    }

    public function markScannerIssuesForPath(string $path, string $status, int $userId = 0): bool
    {
        if (!$this->available() || $path === '') {
            return false;
        }

        $data = [
            'status' => $status,
            'resolved_at' => current_time('mysql', true),
            'resolved_by' => max(0, $userId),
        ];

        return $this->wpdb->update($this->table('threats'), $data, [
            'source' => 'scanner',
            'affected_path' => $path,
            'status' => 'active',
        ]) !== false;
    }

    public function markScannerIssuesForRule(string $ruleId, string $status, int $userId = 0): bool
    {
        if (!$this->available() || $ruleId === '') {
            return false;
        }

        $data = [
            'status' => $status,
            'resolved_at' => current_time('mysql', true),
            'resolved_by' => max(0, $userId),
        ];

        return $this->wpdb->update($this->table('threats'), $data, [
            'source' => 'scanner',
            'rule_id' => $ruleId,
            'status' => 'active',
        ]) !== false;
    }

    public function resolveVerifiedCoreFileIssues(string $path, int $userId = 0): int
    {
        if (!$this->available() || $path === '' || strpos($path, 'db:') === 0) {
            return 0;
        }

        $count = (int) $this->wpdb->get_var($this->wpdb->prepare(
            'SELECT COUNT(*) FROM ' . $this->table('threats') . " WHERE source = 'scanner' AND status = 'active' AND affected_path = %s",
            $path
        ));

        if ($count <= 0) {
            return 0;
        }

        $this->wpdb->update(
            $this->table('threats'),
            [
                'status' => 'resolved',
                'resolved_at' => current_time('mysql', true),
                'resolved_by' => max(0, $userId),
            ],
            [
                'source' => 'scanner',
                'affected_path' => $path,
                'status' => 'active',
            ]
        );

        return $count;
    }

    /**
     * @return array<string,mixed>
     */
    public function summary(int $issueLimit = 10, int $scanLimit = 5): array
    {
        if (!$this->available()) {
            return self::emptySummary();
        }

        $issueLimit = max(1, min(100, $issueLimit));
        $scanLimit = max(1, min(50, $scanLimit));
        $activeIssuesSql = $this->prepareTableSql(
            'SELECT COUNT(*) FROM %i WHERE source = %s AND status = %s',
            'threats',
            ['scanner', 'active']
        );
        $trackedFilesSql = $this->prepareTableSql(
            'SELECT COUNT(*) FROM %i',
            'files'
        );
        $lastScanSql = $this->prepareTableSql(
            'SELECT * FROM %i ORDER BY started_at DESC, id DESC LIMIT 1',
            'scans'
        );
        $issuesSql = $this->prepareTableSql(
            "SELECT * FROM %i WHERE source = %s AND status = %s ORDER BY FIELD(severity, 'critical', 'high', 'medium', 'low'), last_detected DESC LIMIT %d",
            'threats',
            ['scanner', 'active', $issueLimit]
        );
        $scanRunsSql = $this->prepareTableSql(
            'SELECT * FROM %i ORDER BY started_at DESC, id DESC LIMIT %d',
            'scans',
            [$scanLimit]
        );

        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- prepareTableSql() allowlists the table and prepares both the %i identifier and value placeholders.
        $activeIssues = $activeIssuesSql !== '' ? (int) $this->wpdb->get_var($activeIssuesSql) : 0;
        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- prepareTableSql() allowlists and prepares the %i table identifier.
        $trackedFiles = $trackedFilesSql !== '' ? (int) $this->wpdb->get_var($trackedFilesSql) : 0;
        $lastScan = $lastScanSql !== '' ? $this->row($lastScanSql) : null;
        $issues = $issuesSql !== '' ? $this->rows($issuesSql) : [];
        $scanRuns = $scanRunsSql !== '' ? $this->rows($scanRunsSql) : [];

        return [
            'engine_version' => ScanRuleEngine::ENGINE_VERSION,
            'mode' => 'local_quick_scan',
            'active_issues' => $activeIssues,
            'tracked_files' => $trackedFiles,
            'severity_counts' => $this->counts('severity'),
            'type_counts' => $this->counts('type'),
            'last_scan' => $this->formatScan($lastScan),
            'issues' => array_map([$this, 'formatIssue'], $issues),
            'scan_runs' => array_map([$this, 'formatScan'], $scanRuns),
            'quarantined_issues' => $this->quarantinedIssues(10),
            'ignored' => $this->ignoredCatalog(),
        ];
    }

    /**
     * @return array<string,mixed>|null
     */
    public function scanById(string $scanId): ?array
    {
        if (!$this->available() || !preg_match('/^[a-f0-9]{32}$/i', $scanId)) {
            return null;
        }

        $row = $this->row($this->wpdb->prepare(
            "SELECT * FROM " . $this->table('scans') . " WHERE scan_id = %s LIMIT 1",
            $scanId
        ));

        if (!$row) {
            return null;
        }

        return $this->formatScanDetail($row);
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    public function recentShadowScans(int $limit = 8): array
    {
        if (!$this->available()) {
            return [];
        }

        $rows = $this->rows($this->wpdb->prepare(
            "SELECT * FROM " . $this->table('scans') . " ORDER BY started_at DESC, id DESC LIMIT %d",
            max(10, min(80, $limit * 10))
        ));
        $scans = [];

        foreach ($rows as $row) {
            $scan = $this->formatScanDetail($row);
            if (!is_array($scan)) {
                continue;
            }

            $shadowItems = isset($scan['shadow_changed_items']) && is_array($scan['shadow_changed_items']) ? $scan['shadow_changed_items'] : [];
            if ((string) ($scan['scan_mode'] ?? '') !== 'shadow' && empty($shadowItems)) {
                continue;
            }

            $scans[] = $scan;
            if (count($scans) >= max(1, min(20, $limit))) {
                break;
            }
        }

        return $scans;
    }

    /**
     * @return array<string,mixed>
     */
    public static function emptySummary(): array
    {
        return [
            'engine_version' => ScanRuleEngine::ENGINE_VERSION,
            'mode' => 'local_quick_scan',
            'active_issues' => 0,
            'tracked_files' => 0,
            'severity_counts' => [],
            'type_counts' => [],
            'last_scan' => null,
            'issues' => [],
            'scan_runs' => [],
            'quarantined_issues' => [],
            'ignored' => ['paths' => [], 'rules' => []],
        ];
    }

    private function table(string $name): string
    {
        if (!in_array($name, self::TABLES, true)) {
            return '';
        }

        return (string) $this->wpdb->prefix . 'shieldx_' . $name;
    }

    /**
     * @param array<int,mixed> $args
     */
    private function prepareTableSql(string $sql, string $table, array $args = []): string
    {
        $tableName = $this->table($table);
        if ($tableName === '' || !method_exists($this->wpdb, 'prepare')) {
            return '';
        }

        if ($this->supportsIdentifierPlaceholders()) {
            $prepared = $this->wpdb->prepare($sql, $tableName, ...$args);

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

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

        $sql = str_replace('%i', $quoted, $sql);
        if ($args === []) {
            return $sql;
        }

        $prepared = $this->wpdb->prepare($sql, ...$args);

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

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

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

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

    /**
     * @return array<string,mixed>|null
     */
    private function row(string $sql): ?array
    {
        if ($sql === '') {
            return null;
        }

        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Callers pass only wpdb::prepare() or prepareTableSql() output.
        $row = $this->wpdb->get_row($sql, ARRAY_A);

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

    /**
     * @return array<int,array<string,mixed>>
     */
    private function rows(string $sql): array
    {
        if ($sql === '') {
            return [];
        }

        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Callers pass only wpdb::prepare() or prepareTableSql() output.
        $rows = $this->wpdb->get_results($sql, ARRAY_A);

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

    /**
     * @return array<string,int>
     */
    private function counts(string $column): array
    {
        $allowed = ['severity', 'type'];
        if (!in_array($column, $allowed, true)) {
            return [];
        }

        $sql = $this->prepareTableSql(
            'SELECT ' . $column . ', COUNT(*) as count FROM %i WHERE source = %s AND status = %s GROUP BY ' . $column,
            'threats',
            ['scanner', 'active']
        );
        if ($sql === '') {
            return [];
        }

        $rows = $this->rows($sql);
        $counts = [];

        foreach ($rows as $row) {
            $key = (string) ($row[$column] ?? '');
            if ($key !== '') {
                $counts[$key] = (int) ($row['count'] ?? 0);
            }
        }

        return $counts;
    }

    /**
     * @param array<int,string> $paths
     * @return array<int,string>
     */
    private function cleanPathList(array $paths): array
    {
        $clean = [];
        foreach ($paths as $path) {
            $path = trim((string) $path);
            if ($path !== '') {
                $clean[] = $path;
            }
        }

        return array_values(array_unique($clean));
    }

    /**
     * @param array<string,mixed>|null $row
     * @return array<string,mixed>|null
     */
    private function formatScan(?array $row): ?array
    {
        if (!$row) {
            return null;
        }

        $shadowChangedItems = $this->scanResultsValue($row, 'shadow_changed_items', []);
        $shadowChangedItems = is_array($shadowChangedItems) ? array_values(array_filter($shadowChangedItems, 'is_array')) : [];

        return [
            'scan_id' => (string) ($row['scan_id'] ?? ''),
            'type' => (string) ($row['type'] ?? ''),
            'status' => (string) ($row['status'] ?? ''),
            'started_at' => (string) ($row['started_at'] ?? ''),
            'completed_at' => (string) ($row['completed_at'] ?? ''),
            'files_total' => (int) ($row['files_total'] ?? 0),
            'files_scanned' => (int) ($row['files_scanned'] ?? 0),
            'threats_found' => (int) ($row['threats_found'] ?? 0),
            'scope' => (string) ($this->scanResultsValue($row, 'scope', 'all')),
            'scope_label' => (string) ($this->scanResultsValue($row, 'scope_label', 'Full site baseline')),
            'files_skipped' => (int) ($this->scanResultsValue($row, 'files_skipped', 0)),
            'files_hash_checked' => (int) ($this->scanResultsValue($row, 'files_hash_checked', 0)),
            'files_changed' => (int) ($this->scanResultsValue($row, 'files_changed', 0)),
            'files_new' => (int) ($this->scanResultsValue($row, 'files_new', 0)),
            'shadow_copies_created' => (int) ($this->scanResultsValue($row, 'shadow_copies_created', 0)),
            'shadow_changes_pending' => (int) ($this->scanResultsValue($row, 'shadow_changes_pending', 0)),
            'shadow_changed_count' => count($shadowChangedItems),
            'shadow_changed_items' => $shadowChangedItems,
            'scan_mode' => (string) ($this->scanResultsValue($row, 'scan_mode', 'scan')),
            'database_selected' => !empty($this->scanResultsValue($row, 'database_selected', false)),
            'database_available' => !empty($this->scanResultsValue($row, 'database_available', false)),
            'database_items_scanned' => (int) ($this->scanResultsValue($row, 'database_items_scanned', 0)),
            'database_issues_found' => (int) ($this->scanResultsValue($row, 'database_issues_found', 0)),
            'duration_ms' => (int) ($this->scanResultsValue($row, 'duration_ms', 0)),
            'smart_mode' => (string) ($this->scanResultsValue($row, 'smart_mode', 'mtime_hash_incremental')),
        ];
    }

    /**
     * @param array<string,mixed> $row
     * @return array<string,mixed>
     */
    private function formatScanDetail(array $row): array
    {
        $scan = $this->formatScan($row) ?: [];
        $results = $this->decodedResults($row);
        $issues = isset($results['issues']) && is_array($results['issues']) ? $results['issues'] : [];

        $scan['results'] = $results;
        $scan['issues'] = array_values(array_filter($issues, 'is_array'));
        $scan['error'] = (string) ($results['error'] ?? '');
        $scan['truncated'] = !empty($results['truncated']);
        $scan['scope_path'] = (string) ($results['scope_path'] ?? '');
        $scan['engine_version'] = (string) ($results['engine_version'] ?? ScanRuleEngine::ENGINE_VERSION);

        return $scan;
    }

    /**
     * @param array<string,mixed> $row
     * @param mixed $default
     * @return mixed
     */
    private function scanResultsValue(array $row, string $key, $default)
    {
        $decoded = $this->decodedResults($row);

        if (!is_array($decoded) || !array_key_exists($key, $decoded)) {
            return $default;
        }

        return $decoded[$key];
    }

    /**
     * @param array<string,mixed> $row
     * @return array<string,mixed>
     */
    private function decodedResults(array $row): array
    {
        if (!isset($row['results']) || !is_string($row['results']) || $row['results'] === '') {
            return [];
        }

        $decoded = \ShieldX\Support\Json::decodeArray($row['results']);

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

    /**
     * @param array<string,mixed> $row
     * @return array<string,mixed>
     */
    private function formatIssue(array $row): array
    {
        return [
            'threat_id' => (string) ($row['threat_id'] ?? ''),
            'source' => (string) ($row['source'] ?? ''),
            'rule_id' => (string) ($row['rule_id'] ?? ''),
            'type' => (string) ($row['type'] ?? ''),
            'severity' => (string) ($row['severity'] ?? ''),
            'status' => (string) ($row['status'] ?? ''),
            'description' => (string) ($row['description'] ?? ''),
            'affected_path' => (string) ($row['affected_path'] ?? ''),
            'first_detected' => (string) ($row['first_detected'] ?? ''),
            'last_detected' => (string) ($row['last_detected'] ?? ''),
            'resolved_at' => (string) ($row['resolved_at'] ?? ''),
            'resolved_by' => (int) ($row['resolved_by'] ?? 0),
        ];
    }

    private function validThreatId(string $threatId): bool
    {
        return preg_match('/^sx-[a-f0-9]{16,40}$/i', $threatId) === 1;
    }

    /**
     * @param array<string,mixed> $issue
     */
    private function isIssuePersistentlyIgnored(array $issue): bool
    {
        $path = (string) ($issue['affected_path'] ?? '');
        $ruleId = (string) ($issue['rule_id'] ?? '');
        $hash = $this->issueContentHash($issue);

        return ($path !== '' && $hash !== '' && in_array($this->ignoredPathHashKey($path, $hash), $this->ignoredPathHashes(), true))
            || ($ruleId !== '' && in_array($ruleId, $this->ignoredRules(), true));
    }

    /**
     * @return array<int,string>
     */
    private function ignoredPaths(): array
    {
        return $this->optionList(self::IGNORED_PATHS_OPTION);
    }

    /**
     * @return array<int,string>
     */
    private function ignoredPathHashes(): array
    {
        return $this->optionList(self::IGNORED_PATH_HASHES_OPTION);
    }

    /**
     * @return array<int,string>
     */
    private function ignoredRules(): array
    {
        return $this->optionList(self::IGNORED_RULES_OPTION);
    }

    private function addIgnoredPath(string $path, string $hash): bool
    {
        $pathAdded = $this->addOptionListValue(self::IGNORED_PATHS_OPTION, $path);
        $hashAdded = $pathAdded && $this->addOptionListValue(self::IGNORED_PATH_HASHES_OPTION, $this->ignoredPathHashKey($path, $hash));
        if ($pathAdded && !$hashAdded) {
            $this->removeOptionListValue(self::IGNORED_PATHS_OPTION, $path);
        }

        return $pathAdded && $hashAdded;
    }

    private function addIgnoredRule(string $ruleId): bool
    {
        return $this->addOptionListValue(self::IGNORED_RULES_OPTION, $ruleId);
    }

    private function ignoredPathHashKey(string $path, string $hash): string
    {
        return $path . '|' . strtolower($hash);
    }

    /**
     * @param array<string,mixed> $issue
     */
    private function issueContentHash(array $issue): string
    {
        $hash = strtolower((string) ($issue['content_hash'] ?? $issue['file_hash'] ?? $issue['hash'] ?? ''));

        return preg_match('/^[a-f0-9]{64}$/', $hash) === 1 ? $hash : '';
    }

    /**
     * @param array<string,mixed> $issue
     */
    private function currentIssueFileHash(array $issue): string
    {
        $hash = $this->issueContentHash($issue);
        if ($hash !== '') {
            return $hash;
        }

        $path = (string) ($issue['affected_path'] ?? '');
        if ($path === '' || strpos($path, 'db:') === 0 || !defined('ABSPATH')) {
            return '';
        }

        $absolute = rtrim(str_replace('\\', '/', (string) ABSPATH), '/') . '/' . ltrim(str_replace('\\', '/', $path), '/');
        if (!is_file($absolute) || is_link($absolute)) {
            return '';
        }

        $computed = hash_file('sha256', $absolute);

        return is_string($computed) && preg_match('/^[a-f0-9]{64}$/', $computed) === 1 ? strtolower($computed) : '';
    }

    /**
     * @return array<int,string>
     */
    private function optionList(string $option): array
    {
        if (!function_exists('get_option')) {
            return [];
        }

        try {
            $value = get_option($option, []);
        } catch (\Throwable $error) {
            \ShieldX\Support\ErrorLog::write(sprintf('SHIELD-X scanner option %s read failed: %s', $option, $error->getMessage()));

            return [];
        }
        if (!is_array($value)) {
            return [];
        }

        $items = [];
        foreach ($value as $item) {
            if (is_scalar($item)) {
                $item = trim((string) $item);
                if ($item !== '') {
                    $items[$item] = $item;
                }
            }
        }

        return array_values($items);
    }

    private function addOptionListValue(string $option, string $value): bool
    {
        if (!function_exists('update_option')) {
            return false;
        }

        $value = trim($value);
        if ($value === '') {
            return false;
        }

        $items = $this->optionList($option);
        $items[] = $value;
        $next = array_values(array_unique($items));

        try {
            update_option($option, $next, false);
        } catch (\Throwable $error) {
            \ShieldX\Support\ErrorLog::write(sprintf('SHIELD-X scanner option %s write failed: %s', $option, $error->getMessage()));

            return false;
        }

        return in_array($value, $this->optionList($option), true);
    }

    private function removeOptionListValue(string $option, string $value): bool
    {
        if (!function_exists('update_option')) {
            return false;
        }

        $items = $this->optionList($option);
        $filtered = array_values(array_filter($items, static function (string $item) use ($value): bool {
            return $item !== $value;
        }));

        if (count($filtered) === count($items)) {
            return false;
        }

        try {
            update_option($option, $filtered, false);
        } catch (\Throwable $error) {
            \ShieldX\Support\ErrorLog::write(sprintf('SHIELD-X scanner option %s removal failed: %s', $option, $error->getMessage()));

            return false;
        }

        return !in_array($value, $this->optionList($option), true);
    }

    private function removeIgnoredPathHashes(string $path): bool
    {
        if (!function_exists('update_option')) {
            return false;
        }

        $prefix = $path . '|';
        $existing = $this->ignoredPathHashes();
        $items = array_values(array_filter($existing, static function (string $item) use ($prefix): bool {
            return strpos($item, $prefix) !== 0;
        }));

        try {
            update_option(self::IGNORED_PATH_HASHES_OPTION, $items, false);
        } catch (\Throwable $error) {
            \ShieldX\Support\ErrorLog::write('SHIELD-X scanner ignored path hash removal failed: ' . $error->getMessage());

            return false;
        }

        if (count($items) === count($existing)) {
            return true;
        }

        foreach ($this->ignoredPathHashes() as $item) {
            if (strpos($item, $prefix) === 0) {
                return false;
            }
        }

        return true;
    }

    private function reactivateIgnoredScannerIssues(string $column, string $value): void
    {
        if (!$this->available() || !in_array($column, ['affected_path', 'rule_id'], true)) {
            return;
        }

        $this->wpdb->update($this->table('threats'), [
            'status' => 'active',
            'resolved_at' => null,
            'resolved_by' => null,
        ], [
            'source' => 'scanner',
            $column => $value,
            'status' => 'ignored',
        ]);
    }
}
