<?php

declare(strict_types=1);

namespace ShieldX\Scanner;

defined('ABSPATH') || exit;

// phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_mkdir,WordPress.WP.AlternativeFunctions.rename_rename,WordPress.WP.AlternativeFunctions.unlink_unlink,WordPress.WP.I18n.NonSingularStringLiteralText -- Malware remediation needs controlled quarantine/restore filesystem moves under SHIELD-X storage after admin approval.

use ShieldX\Maintenance\FileShadowCopyManager;
use ShieldX\Storage\FileStorage;
use ShieldX\Storage\FilesystemAdapter;
use ShieldX\Support\WordPressPaths;

final class ScannerRemediation
{
    private const MAX_CONTENT_BYTES = 262144;

    // Quarantining these files can take WordPress offline. The admin UI offers
    // review, compare, repair, or manual cleanup actions for them instead.
    /** @var array<int,string> */
    private const PROTECTED_QUARANTINE_PATHS = [
        '.htaccess',
        '.user.ini',
        'index.php',
        'php.ini',
        'user.ini',
        'web.config',
        'wp-activate.php',
        'wp-blog-header.php',
        'wp-comments-post.php',
        'wp-config-docker.php',
        'wp-config.php',
        'wp-cron.php',
        'wp-links-opml.php',
        'wp-load.php',
        'wp-login.php',
        'wp-mail.php',
        'wp-settings.php',
        'wp-signup.php',
        'wp-trackback.php',
        'xmlrpc.php',
    ];

    /** @var array<int,string> */
    private const PROTECTED_QUARANTINE_PREFIXES = [
        'wp-admin/',
        'wp-content/mu-plugins/',
        'wp-content/plugins/xerotact-shield-x-extensions/',
        'wp-content/plugins/xerotact-shield-x-security/',
        'wp-includes/',
    ];

    // Drop-ins are single-file bootstrap integrations. Treat them like critical
    // WordPress files even though they live under wp-content.
    /** @var array<int,string> */
    private const PROTECTED_QUARANTINE_DROP_INS = [
        'wp-content/advanced-cache.php',
        'wp-content/blog-deleted.php',
        'wp-content/blog-inactive.php',
        'wp-content/blog-suspended.php',
        'wp-content/db-error.php',
        'wp-content/db.php',
        'wp-content/fatal-error-handler.php',
        'wp-content/install.php',
        'wp-content/maintenance.php',
        'wp-content/object-cache.php',
        'wp-content/sunrise.php',
    ];

    /** @var ScanRepository */
    private $repository;

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

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

    /** @var FilesystemAdapter */
    private $filesystem;

    /** @var FileShadowCopyManager|null */
    private $shadowCopyManager;

    public function __construct(ScanRepository $repository, FileStorage $storage, string $rootDir, ?FilesystemAdapter $filesystem = null)
    {
        $this->repository = $repository;
        $this->storage = $storage;
        $this->rootDir = rtrim($rootDir, '/\\');
        $this->filesystem = $filesystem ?: FilesystemAdapter::fromWordPress();
        $this->shadowCopyManager = new FileShadowCopyManager($storage, $this->rootDir);
    }

    public static function fromWordPress(): self
    {
        $root = WordPressPaths::root();
        if ($root === '' || !is_dir($root) || !is_readable($root)) {
            throw new \RuntimeException('WordPress root directory is unavailable for scanner remediation.');
        }

        return new self(
            ScanRepository::fromWordPress(),
            FileStorage::fromWordPress(),
            $root
        );
    }

    public static function isProtectedQuarantinePath(string $path): bool
    {
        $path = ScanRuleEngine::normalizePath($path);
        if ($path === '' || strpos($path, 'db:') === 0) {
            return false;
        }

        $path = strtolower($path);
        if (in_array($path, self::PROTECTED_QUARANTINE_PATHS, true) || in_array($path, self::PROTECTED_QUARANTINE_DROP_INS, true)) {
            return true;
        }

        foreach (self::PROTECTED_QUARANTINE_PREFIXES as $prefix) {
            if (strpos($path, $prefix) === 0) {
                return true;
            }
        }

        return false;
    }

    public function useShadowCopyManager(FileShadowCopyManager $manager): void
    {
        $this->shadowCopyManager = $manager;
    }

    public function hasExistingFile(string $path): bool
    {
        return $this->existingFilePath($path) !== '';
    }

    /**
     * @return array<string,mixed>
     */
    public function quarantine(string $threatId, int $userId = 0, string $nonceAction = 'shieldx_quarantine_scan_issue', string $nonce = ''): array
    {
        $guardError = $this->adminWriteGuard($nonceAction, $nonce);
        if ($guardError !== '') {
            return $this->result(false, $guardError);
        }

        $issue = $this->repository->issueByThreatId($threatId);
        if (!$issue) {
            return $this->result(false, $this->text('Scanner issue was not found.'));
        }

        $path = (string) ($issue['affected_path'] ?? '');
        if (self::isProtectedQuarantinePath($path)) {
            return $this->result(false, $this->text('This critical WordPress file is protected from quarantine. Use review, compare, or manual repair instead.'), $path);
        }

        $source = $this->existingFilePath($path);
        if ($source === '') {
            return $this->result(false, $this->text('The affected file is missing, outside the site root, or cannot be quarantined.'), $path);
        }

        $this->storage->ensure();
        $directory = $this->quarantineDirectory($threatId);
        $this->makeDirectory($directory);

        $target = $directory . '/' . basename($path) . '.quarantine';
        if (file_exists($target)) {
            $target = $directory . '/' . gmdate('His') . '-' . basename($path) . '.quarantine';
        }

        $hash = hash_file('sha256', $source);
        $size = filesize($source);

        if (!$this->moveFile($source, $target)) {
            return $this->result(false, $this->text('SHIELD-X could not move the file into quarantine. Check filesystem permissions or active lockdown rules.'), $path);
        }

        $this->writeMetadata($directory, [
            'status' => 'quarantined',
            'threat_id' => $threatId,
            'rule_id' => (string) ($issue['rule_id'] ?? ''),
            'type' => (string) ($issue['type'] ?? ''),
            'severity' => (string) ($issue['severity'] ?? ''),
            'original_path' => $path,
            'quarantined_path' => $target,
            'sha256' => is_string($hash) ? $hash : '',
            'size' => is_int($size) ? $size : 0,
            'quarantined_at' => gmdate('c'),
            'quarantined_by' => $userId,
            'history' => [
                [
                    'event' => 'quarantined',
                    'at' => gmdate('c'),
                    'by' => $userId,
                    'message' => $this->text('File moved to quarantine.'),
                ],
            ],
        ]);

        $this->repository->markIssueStatus($threatId, 'quarantined', $userId);

        return $this->result(true, $this->text('File moved to SHIELD-X quarantine.'), $path);
    }

    /**
     * @return array<string,mixed>
     */
    public function restore(string $threatId, int $userId = 0, string $nonceAction = 'shieldx_restore_quarantined_issue', string $nonce = ''): array
    {
        $guardError = $this->adminWriteGuard($nonceAction, $nonce);
        if ($guardError !== '') {
            return $this->result(false, $guardError);
        }

        $metadata = $this->metadata($threatId);
        if (!$metadata) {
            return $this->result(false, $this->text('Quarantine metadata was not found for this issue.'));
        }

        $path = (string) ($metadata['original_path'] ?? '');
        $source = (string) ($metadata['quarantined_path'] ?? '');
        $target = $this->restoreTargetPath($path);

        if ($source === '' || !is_file($source) || $target === '') {
            $this->recordQuarantineEvent($threatId, $metadata, 'restore_failed', $userId, $this->text('Quarantined file or restore target was unavailable.'));
            return $this->result(false, $this->text('The quarantined file or restore target is no longer available.'), $path);
        }

        if (!$this->quarantinedFileMatchesMetadata($metadata, $source)) {
            $this->recordQuarantineEvent($threatId, $metadata, 'restore_failed', $userId, $this->text('Quarantined file hash did not match the signed restore metadata.'));
            return $this->result(false, $this->text('SHIELD-X blocked this restore because the quarantined file no longer matches its signed integrity hash.'), $path);
        }

        if (file_exists($target)) {
            $this->recordQuarantineEvent($threatId, $metadata, 'restore_blocked', $userId, $this->text('A file already exists at the original path.'));
            return $this->result(false, $this->text('A file already exists at the original path. Remove it or review it before restoring quarantine.'), $path);
        }

        $parent = dirname($target);
        if (!is_dir($parent)) {
            $this->makeDirectory($parent);
        }
        if (!is_dir($parent) || !$this->pathIsWritable($parent)) {
            $this->recordQuarantineEvent($threatId, $metadata, 'restore_failed', $userId, $this->text('Restore parent directory could not be recreated.'));
            return $this->result(false, $this->text('SHIELD-X could not recreate the restore directory. Check filesystem permissions.'), $path);
        }

        if (!$this->moveFile($source, $target)) {
            $this->recordQuarantineEvent($threatId, $metadata, 'restore_failed', $userId, $this->text('Filesystem move failed during restore.'));
            return $this->result(false, $this->text('SHIELD-X could not restore the quarantined file. Check filesystem permissions.'), $path);
        }

        $metadata['status'] = 'restored';
        $metadata['restored_at'] = gmdate('c');
        $metadata['restored_by'] = $userId;
        $metadata = $this->appendQuarantineHistory($metadata, 'restored', $userId, $this->text('Quarantined file restored to original path.'));
        $this->writeMetadata($this->quarantineDirectory($threatId), $metadata);
        $this->repository->markIssueStatus($threatId, 'restored', $userId);

        return $this->result(true, $this->text('Quarantined file restored to its original path.'), $path);
    }

    /**
     * @return array<string,mixed>
     */
    public function ignore(string $threatId, string $scope, int $userId = 0, string $nonceAction = 'shieldx_ignore_scan_issue', string $nonce = ''): array
    {
        $guardError = $this->adminWriteGuard($nonceAction, $nonce);
        if ($guardError !== '') {
            return $this->result(false, $guardError);
        }

        $scope = in_array($scope, ['once', 'file', 'rule'], true) ? $scope : 'once';
        $ok = $this->repository->ignoreIssue($threatId, $scope, $userId);

        if (!$ok) {
            return $this->result(false, $this->text('SHIELD-X could not ignore that scanner issue.'));
        }

        if ($scope === 'file') {
            return $this->result(true, $this->text('This file path was added to the scanner ignore list.'));
        }

        if ($scope === 'rule') {
            return $this->result(true, $this->text('This scanner rule was added to the ignore list.'));
        }

        return $this->result(true, $this->text('This scanner issue was ignored for now.'));
    }

    /**
     * @return array<string,mixed>
     */
    public function removeIgnore(string $type, string $value, int $userId = 0, string $nonceAction = 'shieldx_remove_scan_ignore', string $nonce = ''): array
    {
        $guardError = $this->adminWriteGuard($nonceAction, $nonce);
        if ($guardError !== '') {
            return $this->result(false, $guardError);
        }

        $type = $type === 'rule' ? 'rule' : 'path';
        $value = trim($value);
        if ($value === '') {
            return $this->result(false, $this->text('Ignored scanner item was not provided.'));
        }

        $ok = $this->repository->removeIgnoredItem($type, $value, $userId);
        if (!$ok) {
            return $this->result(false, $this->text('SHIELD-X could not remove that scanner ignore item.'));
        }

        return $this->result(true, $this->text('Scanner ignore item was removed.'), $value);
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    public function quarantineHistory(int $limit = 20): array
    {
        $files = glob($this->storage->baseDir() . '/quarantine/*/metadata.json');
        if (!is_array($files)) {
            return [];
        }

        $items = [];
        foreach ($files as $file) {
            $metadata = $this->readJson($file);
            if (empty($metadata)) {
                continue;
            }

            $items[] = $this->formatQuarantineHistoryItem($metadata, $file);
        }

        usort($items, static function (array $left, array $right): int {
            return strtotime((string) ($right['last_event_at'] ?? '')) <=> strtotime((string) ($left['last_event_at'] ?? ''));
        });

        return array_slice($items, 0, max(1, min(100, $limit)));
    }

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

        $items = array_values(array_filter($this->quarantineHistory(100), static function (array $item) use ($path): bool {
            return (string) ($item['original_path'] ?? '') === $path;
        }));

        return array_slice($items, 0, max(1, min(50, $limit)));
    }

    /**
     * @return array<string,mixed>
     */
    public function repairHtaccess(int $userId = 0, string $nonceAction = 'shieldx_repair_htaccess', string $nonce = ''): array
    {
        $guardError = $this->adminWriteGuard($nonceAction, $nonce);
        if ($guardError !== '') {
            return $this->result(false, $guardError, '.htaccess');
        }

        $path = $this->rootDir . '/.htaccess';
        $existing = is_file($path) ? file_get_contents($path) : '';
        $existing = is_string($existing) ? $existing : '';
        $next = $this->replaceWordPressHtaccessBlock($existing);
        $backup = '';

        if ($existing === $next && is_file($path)) {
            $this->recordTrustedBaseline('.htaccess', $path, $userId);
            $recheck = $this->recheckFileAfterGuard('.htaccess');

            return $this->result(true, $this->text('.htaccess already contains the default WordPress rewrite block.'), '.htaccess', ['recheck' => $recheck]);
        }

        if (is_file($path)) {
            $this->storage->ensure();
            $backup = $this->storage->baseDir() . '/restore/htaccess-repair/' . gmdate('Ymd-His') . '/.htaccess';
            $this->makeDirectory(dirname($backup));
            $backupCopy = $this->filesystem->copy($path, $backup, 0644);
            if (empty($backupCopy['ok'])) {
                return $this->result(false, $this->text('SHIELD-X could not back up .htaccess before repair.'), '.htaccess');
            }
        }

        $write = $this->filesystem->putContents($path, $next, 0644);
        if (empty($write['ok'])) {
            return $this->result(false, $this->text('SHIELD-X could not write the repaired .htaccess file.'), '.htaccess');
        }

        $written = file_get_contents($path);
        if (!is_string($written) || $written !== $next) {
            if ($backup !== '' && is_file($backup)) {
                $this->filesystem->copy($backup, $path, 0644);
            }

            return $this->result(false, $this->text('SHIELD-X could not verify the repaired .htaccess file; the previous copy was restored.'), '.htaccess');
        }

        $this->recordTrustedBaseline('.htaccess', $path, $userId);

        $recheck = $this->recheckFileAfterGuard('.htaccess');

        return $this->result(true, $this->text('.htaccess WordPress rewrite block was repaired and backed up.'), '.htaccess', ['recheck' => $recheck]);
    }

    /**
     * @return array<string,mixed>
     */
    public function trustCurrentFile(string $path, int $userId = 0, string $nonceAction = 'shieldx_trust_scanner_file', string $nonce = ''): array
    {
        $guardError = $this->adminWriteGuard($nonceAction, $nonce);
        if ($guardError !== '') {
            return $this->result(false, $guardError, $path);
        }

        $path = $this->normalizeRelativePath($path);
        $source = $this->existingFilePath($path);
        if ($path === '' || $source === '') {
            return $this->result(false, $this->text('The selected file could not be found or trusted.'), $path);
        }

        if (!$this->recordTrustedBaseline($path, $source, $userId)) {
            return $this->result(false, $this->text('SHIELD-X could not update the trusted shadow copy.'), $path);
        }

        $recheck = $this->recheckFileAfterGuard($path);
        if ((int) ($recheck['remaining_count'] ?? 0) > 0) {
            $this->repository->markScannerIssuesForPath($path, 'resolved', $userId);
            $recheck['status'] = 'trusted';
            $recheck['trusted_override'] = true;
            $recheck['remaining_count'] = 0;
        }

        return $this->result(true, $this->text('Current file hash was trusted and scanner findings for this path were resolved.'), $path, ['recheck' => $recheck]);
    }

    /**
     * Trust an administrator-reviewed group without running the malware engine
     * again for every path. The explicit trust action resolves those findings.
     *
     * @param array<int,string> $paths
     * @return array<string,mixed>
     */
    public function trustCurrentFiles(array $paths, int $userId = 0, string $nonceAction = 'shieldx_confirm_shadow_session', string $nonce = ''): array
    {
        $guardError = $this->adminWriteGuard($nonceAction, $nonce);
        if ($guardError !== '') {
            return $this->result(false, $guardError, '', [
                'confirmed_count' => 0,
                'failed_count' => count($paths),
                'confirmed_paths' => [],
                'failed_paths' => array_values(array_map('strval', $paths)),
            ]);
        }

        $paths = array_values(array_unique(array_map('strval', $paths)));
        $confirmedPaths = [];
        $failedPaths = [];
        $shadowManager = $this->shadowCopyManager();
        $shadowManager->beginIndexBatch();

        try {
            foreach ($paths as $candidate) {
                $path = $this->normalizeRelativePath($candidate);
                $source = $this->existingFilePath($path);
                if ($path === '' || $source === '' || !$this->recordTrustedBaseline($path, $source, $userId, false)) {
                    $failedPaths[] = $path !== '' ? $path : $candidate;
                    continue;
                }

                $confirmedPaths[] = $path;
            }
        } finally {
            $shadowManager->commitIndexBatch();
        }

        if ($confirmedPaths !== []) {
            $this->repository->resolveScannerIssuesForInspectedPaths([], $confirmedPaths);
        }

        $failedCount = count($failedPaths);

        return $this->result(
            $failedCount === 0,
            $failedCount === 0
                ? $this->text('All reviewed files were trusted and their scanner findings were resolved.')
                : $this->text('Some reviewed files could not be trusted.'),
            '',
            [
                'confirmed_count' => count($confirmedPaths),
                'failed_count' => $failedCount,
                'confirmed_paths' => $confirmedPaths,
                'failed_paths' => $failedPaths,
            ]
        );
    }

    /**
     * @return array<string,mixed>
     */
    public function previewProtectedConfigCleanup(string $path): array
    {
        $guardError = $this->adminReadGuard();
        if ($guardError !== '') {
            return [
                'ok' => false,
                'path' => $this->normalizeRelativePath($path),
                'message' => $guardError,
                'candidates' => [],
                'candidate_count' => 0,
            ];
        }

        $path = $this->normalizeRelativePath($path);
        if (!$this->isConfigCleanupPath($path)) {
            return [
                'ok' => false,
                'path' => $path,
                'message' => $this->text('Guided cleanup is available for the active wp-config.php file only.'),
                'candidates' => [],
                'candidate_count' => 0,
            ];
        }

        $source = $this->existingFilePath($path);
        if ($source === '') {
            return [
                'ok' => false,
                'path' => $path,
                'message' => $this->text('The selected wp-config.php file could not be found.'),
                'candidates' => [],
                'candidate_count' => 0,
            ];
        }

        $lines = file($source);
        if (!is_array($lines)) {
            return [
                'ok' => false,
                'path' => $path,
                'message' => $this->text('SHIELD-X could not read wp-config.php for cleanup preview.'),
                'candidates' => [],
                'candidate_count' => 0,
            ];
        }

        $candidates = $this->protectedConfigCleanupCandidates($lines);

        return [
            'ok' => true,
            'path' => $path,
            'message' => '',
            'candidates' => $candidates,
            'candidate_count' => count($candidates),
        ];
    }

    /**
     * @param array<int,int|string> $selectedLines
     * @return array<string,mixed>
     */
    public function cleanupProtectedConfig(string $path, array $selectedLines, int $userId = 0, string $nonceAction = 'shieldx_cleanup_config_file', string $nonce = ''): array
    {
        $guardError = $this->adminWriteGuard($nonceAction, $nonce);
        if ($guardError !== '') {
            return $this->result(false, $guardError, $path);
        }

        $path = $this->normalizeRelativePath($path);
        if (!$this->isConfigCleanupPath($path)) {
            return $this->result(false, $this->text('Guided cleanup is available for the active wp-config.php file only.'), $path);
        }

        $source = $this->existingFilePath($path);
        if ($source === '') {
            return $this->result(false, $this->text('The selected wp-config.php file could not be found.'), $path);
        }

        $lines = file($source);
        if (!is_array($lines)) {
            return $this->result(false, $this->text('SHIELD-X could not read wp-config.php for cleanup.'), $path);
        }

        $candidates = $this->protectedConfigCleanupCandidates($lines);
        $candidateLines = [];
        foreach ($candidates as $candidate) {
            $candidateLines[(int) ($candidate['line'] ?? 0)] = true;
        }

        $selected = [];
        foreach ($selectedLines as $line) {
            $line = is_numeric($line) ? (int) $line : 0;
            if ($line > 0 && isset($candidateLines[$line])) {
                $selected[$line] = true;
            }
        }

        if (empty($selected)) {
            return $this->result(false, $this->text('No selected cleanup lines matched the current wp-config.php preview.'), $path);
        }

        $nextLines = [];
        foreach ($lines as $index => $line) {
            $lineNumber = $index + 1;
            if (!isset($selected[$lineNumber])) {
                $nextLines[] = $line;
            }
        }

        $next = implode('', $nextLines);
        if (!$this->wpConfigLooksBootable($next)) {
            return $this->result(false, $this->text('SHIELD-X refused to write wp-config.php because the selected removal would break the WordPress bootstrap markers.'), $path);
        }

        $backup = $this->backupFile($source, $path, 'wp-config-cleanup');
        if ($backup === '') {
            return $this->result(false, $this->text('SHIELD-X could not back up wp-config.php before cleanup.'), $path);
        }

        $write = $this->filesystem->putContents($source, $next, 0644);
        if (empty($write['ok'])) {
            return $this->result(false, $this->text('SHIELD-X could not write the cleaned wp-config.php file.'), $path);
        }

        $recheck = $this->recheckFileAfterGuard($path);

        return $this->result(true, $this->text('Selected wp-config.php lines were removed after creating a backup.'), $path, [
            'backup_path' => $backup,
            'removed_count' => count($selected),
            'recheck' => $recheck,
        ]);
    }

    /**
     * Replace one complete administrator-reviewed text file. The caller must
     * bind the request to the exact displayed SHA-256 so a stale comparison
     * cannot overwrite a changed file.
     *
     * @return array<string,mixed>
     */
    public function replaceCurrentTextFile(string $path, string $expectedHash, string $replacement, int $userId = 0, string $nonceAction = 'shieldx_save_compared_plugin_file', string $nonce = ''): array
    {
        $guardError = $this->adminWriteGuard($nonceAction, $nonce);
        if ($guardError !== '') {
            return $this->result(false, $guardError, $path);
        }

        $path = $this->normalizeRelativePath($path);
        $source = $this->existingFilePath($path);
        if ($path === '' || $source === '') {
            return $this->result(false, $this->text('The selected file could not be found for editing.'), $path);
        }

        $expectedHash = strtolower(trim($expectedHash));
        $currentHash = is_file($source) ? hash_file('sha256', $source) : false;
        if (preg_match('/^[a-f0-9]{64}$/', $expectedHash) !== 1 || !is_string($currentHash) || !hash_equals($expectedHash, $currentHash)) {
            return $this->result(false, $this->text('SHIELD-X blocked this save because the file changed since it was compared.'), $path);
        }

        if (!$this->isEditableTextContents($replacement)) {
            return $this->result(false, $this->text('SHIELD-X can save only complete valid text content without binary bytes.'), $path);
        }
        if (strlen($replacement) > self::MAX_CONTENT_BYTES) {
            return $this->result(false, $this->text('SHIELD-X blocked this save because the complete file exceeds the 256 KB size limit.'), $path);
        }

        $backup = $this->backupFile($source, $path, 'plugin-compare-edit');
        if ($backup === '') {
            return $this->result(false, $this->text('SHIELD-X could not back up the current file before saving edits.'), $path);
        }

        $write = $this->filesystem->putContents($source, $replacement, 0644);
        $writtenHash = is_file($source) ? hash_file('sha256', $source) : false;
        $replacementHash = hash('sha256', $replacement);
        if (empty($write['ok']) || !is_string($writtenHash) || !hash_equals($replacementHash, $writtenHash)) {
            $this->filesystem->copy($backup, $source, 0644);

            return $this->result(false, $this->text('SHIELD-X could not verify the saved file and restored the pre-change copy.'), $path);
        }

        $recheck = $this->recheckFileAfterGuard($path);

        return $this->result(true, $this->text('The edited file was saved, backed up, and rechecked.'), $path, [
            'backup_path' => $backup,
            'recheck' => $recheck,
        ]);
    }

    /**
     * @return array<string,mixed>
     */
    public function recheckFile(string $path, string $nonceAction = 'shieldx_recheck_scanner_file', string $nonce = ''): array
    {
        $guardError = $this->adminWriteGuard($nonceAction, $nonce);
        if ($guardError !== '') {
            return [
                'ok' => false,
                'status' => 'security_check_failed',
                'message' => $guardError,
                'remaining_count' => 0,
                'path' => $this->normalizeRelativePath($path),
                'issues' => [],
            ];
        }

        return $this->recheckFileAfterGuard($path);
    }

    /**
     * @return array<string,mixed>
     */
    private function recheckFileAfterGuard(string $path): array
    {
        $path = $this->normalizeRelativePath($path);
        $source = $this->existingFilePath($path);

        if ($path === '' || $source === '') {
            return [
                'ok' => true,
                'status' => 'missing',
                'message' => $this->text('Recheck skipped because the selected file no longer exists.'),
                'remaining_count' => 0,
                'path' => $path,
                'issues' => [],
            ];
        }

        $file = $this->filePayloadForRecheck($path, $source);
        if (empty($file)) {
            return [
                'ok' => true,
                'status' => 'missing',
                'message' => $this->text('Recheck skipped because SHIELD-X could not read file metadata.'),
                'remaining_count' => 0,
                'path' => $path,
                'issues' => [],
            ];
        }

        $engine = new ScanRuleEngine();
        $issues = [];
        $activeThreatIds = [];

        foreach ($engine->evaluateFile($file) as $issue) {
            $issue['evidence'] = trim((string) ($issue['evidence'] ?? '') . '; scan reason: targeted_recheck', '; ');
            $issues[] = $issue;
            $activeThreatIds[] = (string) ($issue['threat_id'] ?? '');
            $this->repository->upsertIssue($issue);
        }

        $this->repository->recordFileBaseline($file);
        $this->repository->resolveScannerIssuesForInspectedPaths($activeThreatIds, [$path]);

        return [
            'ok' => true,
            'status' => empty($issues) ? 'clean' : 'remaining',
            'message' => empty($issues)
                ? $this->text('Recheck completed. No remaining file finding was found.')
                : sprintf($this->text('Recheck completed. %d file finding still matches.'), count($issues)),
            'remaining_count' => count($issues),
            'path' => $path,
            'issues' => $issues,
        ];
    }

    /**
     * @return array<string,mixed>
     */
    private function result(bool $ok, string $message, string $path = '', array $extra = []): array
    {
        return array_merge([
            'ok' => $ok,
            'message' => $message,
            'path' => $path,
        ], $extra);
    }

    private function text(string $message): string
    {
        return $message;
    }

    private function adminReadGuard(): string
    {
        if (!function_exists('current_user_can')) {
            return $this->text('Administrator permission could not be verified for scanner remediation.');
        }

        $capability = defined('SHIELDX_MANAGE_CAPABILITY') ? (string) SHIELDX_MANAGE_CAPABILITY : 'manage_options';

        return current_user_can($capability)
            ? ''
            : $this->text('Administrator permission is required for scanner remediation.');
    }

    private function adminWriteGuard(string $nonceAction, string $nonce = ''): string
    {
        if (defined('WP_CLI') && WP_CLI) {
            if (getenv('SHIELDX_CLI_CONFIRM') === 'yes') {
                return $this->adminReadGuard() === ''
                    ? ''
                    : $this->text('WP-CLI scanner remediation requires an administrator context. Re-run with --user=<admin>.');
            }

            return $this->text('WP-CLI scanner remediation requires SHIELDX_CLI_CONFIRM=yes and an administrator-gated command.');
        }

        $readGuard = $this->adminReadGuard();
        if ($readGuard !== '') {
            return $readGuard;
        }

        if ($nonceAction === '') {
            return $this->text('Missing security token context.');
        }

        if (!function_exists('wp_verify_nonce')) {
            return $this->text('WordPress nonce verification is not available.');
        }

        if ($nonce === '') {
            $nonce = $this->requestNonce();
        }

        return $nonce !== '' && wp_verify_nonce($nonce, $nonceAction) !== false
            ? ''
            : $this->text('Security check failed. Refresh the page and try again.');
    }

    private function requestNonce(): string
    {
        foreach (['_wpnonce', '_ajax_nonce'] as $key) {
            // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This helper is retrieving the nonce value so adminWriteGuard() can verify it immediately.
            if (isset($_REQUEST[$key]) && is_scalar($_REQUEST[$key])) {
                // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Nonce value is unslashed and sanitized below before wp_verify_nonce().
                $value = $_REQUEST[$key];
                if (function_exists('wp_unslash')) {
                    $value = wp_unslash($value);
                }
                $value = (string) $value;

                return function_exists('sanitize_text_field') ? sanitize_text_field($value) : trim($value);
            }
        }

        return '';
    }

    private function existingFilePath(string $path): string
    {
        $path = $this->normalizeRelativePath($path);
        if ($path === '' || strpos($path, 'db:') === 0) {
            return '';
        }

        $root = realpath($this->rootDir);
        $target = realpath($this->rootDir . '/' . $path);

        if (!$root || !$target || !is_file($target)) {
            return '';
        }

        return $this->isInsideRoot($root, $target) ? $target : '';
    }

    private function restoreTargetPath(string $path): string
    {
        $path = $this->normalizeRelativePath($path);
        if ($path === '' || strpos($path, 'db:') === 0) {
            return '';
        }

        $root = realpath($this->rootDir);
        if (!$root) {
            return '';
        }

        $target = $root . '/' . $path;
        $parent = $this->nearestExistingParent(dirname($target));

        return ($parent === '' || !$this->isInsideRoot($root, $parent)) ? '' : $target;
    }

    private function nearestExistingParent(string $path): string
    {
        $candidate = $path;
        while ($candidate !== '' && !is_dir($candidate)) {
            $next = dirname($candidate);
            if ($next === $candidate) {
                return '';
            }
            $candidate = $next;
        }

        $resolved = realpath($candidate);

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

    private function normalizeRelativePath(string $path): string
    {
        $path = ScanRuleEngine::normalizePath($path);

        if ($path === '' || strpos($path, "\0") !== false || strpos($path, '../') !== false || strpos($path, '..\\') !== false) {
            return '';
        }

        return $path;
    }

    private function recordTrustedBaseline(string $path, string $source, int $userId, bool $resolveIssues = true): bool
    {
        clearstatcache(true, $source);
        $hash = is_file($source) ? hash_file('sha256', $source) : '';
        if (!is_string($hash) || $hash === '') {
            return false;
        }

        $this->repository->recordFileBaseline([
            'path' => $path,
            'hash' => $hash,
            'size' => is_file($source) ? (int) filesize($source) : 0,
            'modified_at' => is_file($source) ? gmdate('Y-m-d H:i:s', (int) filemtime($source)) : gmdate('Y-m-d H:i:s'),
            'plugin_slug' => '',
            'is_core' => $this->isLikelyCoreFile($path),
        ]);
        $shadowResult = $this->shadowCopyManager()->confirmCurrent($path, $userId);
        if (empty($shadowResult['ok'])) {
            return false;
        }

        if ($resolveIssues) {
            $this->repository->markScannerIssuesForPath($path, 'resolved', $userId);
        }

        return true;
    }

    private function shadowCopyManager(): FileShadowCopyManager
    {
        if (!$this->shadowCopyManager instanceof FileShadowCopyManager) {
            $this->shadowCopyManager = FileShadowCopyManager::fromWordPress();
        }

        return $this->shadowCopyManager;
    }

    private function isConfigCleanupPath(string $path): bool
    {
        return $path === 'wp-config.php';
    }

    /**
     * @param array<int,string> $lines
     * @return array<int,array{line:int,reason:string,snippet:string}>
     */
    private function protectedConfigCleanupCandidates(array $lines): array
    {
        $candidates = [];

        foreach ($lines as $index => $line) {
            $reason = $this->configCleanupReason((string) $line);
            if ($reason === '') {
                continue;
            }

            $candidates[] = [
                'line' => $index + 1,
                'reason' => $reason,
                'snippet' => $this->redactedConfigSnippet((string) $line),
            ];
        }

        return $candidates;
    }

    private function configCleanupReason(string $line): string
    {
        $line = $this->withoutSecretConfigDefinitions($line);
        if (trim($line) === '') {
            return '';
        }

        if (preg_match('~\b(?:eval|assert)\s*\(~i', $line)) {
            return $this->text('Dynamic PHP execution');
        }

        if (preg_match('~\b(?:base64_decode|gzinflate|str_rot13)\s*\(~i', $line)) {
            return $this->text('Encoded or obfuscated payload');
        }

        if (preg_match('~\b(?:shell_exec|system|passthru|proc_open|popen|exec)\s*\(~i', $line) || preg_match('~`[^`]+`~', $line)) {
            return $this->text('Operating system command execution');
        }

        if (preg_match('~\b(?:file_get_contents|wp_remote_get|wp_remote_post|wp_remote_request|fopen|readfile|curl_init|curl_setopt|curl_exec)\s*\([^;]{0,220}https?://~i', $line)) {
            return $this->text('Remote payload fetch');
        }

        if (preg_match('~\b(?:include|include_once|require|require_once)\s*\([^;]{0,220}(?:https?://|\$[a-z_][a-z0-9_]*)~i', $line)) {
            return $this->text('Dynamic or remote include');
        }

        if (preg_match('~preg_replace\s*\([^;]{0,220}/[a-z]*e[a-z]*[\'"]~is', $line)) {
            return $this->text('Legacy regex code execution');
        }

        if (preg_match('~\$_(?:GET|POST|REQUEST|COOKIE)\b[^;]{0,220}\b(?:call_user_func|create_function|include|require)\s*\(~i', $line)) {
            return $this->text('Request-controlled execution path');
        }

        return '';
    }

    private function withoutSecretConfigDefinitions(string $line): string
    {
        $filtered = preg_replace(
            '~define\s*\(\s*["\'](?:DB_PASSWORD|AUTH_KEY|SECURE_AUTH_KEY|LOGGED_IN_KEY|NONCE_KEY|AUTH_SALT|SECURE_AUTH_SALT|LOGGED_IN_SALT|NONCE_SALT)["\']\s*,\s*(?:"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')\s*\)\s*;?~i',
            '',
            $line
        );

        return is_string($filtered) ? $filtered : $line;
    }

    private function redactedConfigSnippet(string $line): string
    {
        $line = trim($line);
        $line = (string) preg_replace(
            '~(define\s*\(\s*["\'](?:DB_PASSWORD|AUTH_KEY|SECURE_AUTH_KEY|LOGGED_IN_KEY|NONCE_KEY|AUTH_SALT|SECURE_AUTH_SALT|LOGGED_IN_SALT|NONCE_SALT)["\']\s*,\s*)["\'].*?["\'](\s*\)\s*;?)~i',
            '$1"[redacted]"$2',
            $line
        );

        if (strlen($line) > 220) {
            return substr($line, 0, 217) . '...';
        }

        return $line;
    }

    private function wpConfigLooksBootable(string $contents): bool
    {
        $required = [
            'DB_NAME',
            'DB_USER',
            'DB_PASSWORD',
            'DB_HOST',
            'ABSPATH',
            'wp-settings.php',
        ];

        foreach ($required as $marker) {
            if (strpos($contents, $marker) === false) {
                return false;
            }
        }

        return true;
    }

    private function backupFile(string $source, string $path, string $purpose): string
    {
        $this->storage->ensure();
        $safePurpose = preg_replace('~[^a-z0-9_-]+~i', '-', $purpose) ?: 'repair';
        $backup = $this->storage->baseDir() . '/restore/' . $safePurpose . '/' . gmdate('Ymd-His') . '/' . basename($path);
        $this->makeDirectory(dirname($backup));
        $copy = $this->filesystem->copy($source, $backup, 0644);

        return !empty($copy['ok']) ? $backup : '';
    }

    /**
     * @return array<string,mixed>|null
     */
    private function filePayloadForRecheck(string $path, string $source): ?array
    {
        clearstatcache(true, $source);
        $hash = is_file($source) ? hash_file('sha256', $source) : false;
        if (!is_string($hash) || $hash === '') {
            return null;
        }

        $size = is_file($source) ? (int) filesize($source) : 0;
        $modified = is_file($source) ? (int) filemtime($source) : 0;

        return [
            'path' => $path,
            'absolute_path' => $source,
            'size' => $size,
            'modified_at' => $modified > 0 ? gmdate('Y-m-d H:i:s', $modified) : $this->nowMysql(),
            'plugin_slug' => $this->pluginSlug($path),
            'is_core' => $this->isLikelyCoreFile($path),
            'contents' => $this->readLimitedContents($source, $size),
            'hash' => $hash,
            'official_core_verified' => $this->isOfficialCoreMatch($path, $hash),
            'official_plugin_verified' => $this->isOfficialPluginMatch($path, $hash),
        ];
    }

    private function isOfficialCoreMatch(string $path, string $hash): bool
    {
        if ($hash === '' || !$this->isLikelyCoreFile($path)) {
            return false;
        }

        $version = function_exists('get_bloginfo') ? (string) get_bloginfo('version') : '';

        try {
            return (new CoreFileVerifier($this->storage, $this->rootDir, $version))->isOfficialCoreMatch($path, $hash);
        } catch (\Throwable $error) {
            return false;
        }
    }

    private function isOfficialPluginMatch(string $path, string $hash): bool
    {
        if ($hash === '' || strpos($path, 'wp-content/plugins/') !== 0) {
            return false;
        }

        try {
            return PluginFileVerifier::fromWordPress()->isOfficialPluginMatch($path, $hash);
        } catch (\Throwable $error) {
            return false;
        }
    }

    private function readLimitedContents(string $source, int $size): string
    {
        if ($size > self::MAX_CONTENT_BYTES || !$this->isTextCandidate($source)) {
            return '';
        }

        $contents = file_get_contents($source, false, null, 0, self::MAX_CONTENT_BYTES);

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

    private function isEditableTextContents(string $contents): bool
    {
        if (strpos($contents, "\0") !== false) {
            return false;
        }

        return preg_match('//u', $contents) === 1;
    }

    private function isTextCandidate(string $filePath): bool
    {
        $basename = strtolower(basename($filePath));
        $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));

        if ($basename === '.env' || strpos($basename, '.env.') === 0 || $basename === '.htaccess' || $basename === '.user.ini') {
            return true;
        }

        return in_array($extension, ['php', 'phtml', 'phar', 'inc', 'js', 'html', 'htm', 'txt', 'log', 'sql', 'xml', 'json', 'md', 'bak', 'old', 'orig', 'save', 'swp'], true);
    }

    private function pluginSlug(string $path): string
    {
        if (preg_match('~^wp-content/plugins/([^/]+)/~', $path, $matches)) {
            return (string) $matches[1];
        }

        return '';
    }

    private function nowMysql(): string
    {
        return function_exists('current_time') ? (string) current_time('mysql', true) : gmdate('Y-m-d H:i:s');
    }

    private function replaceWordPressHtaccessBlock(string $contents): string
    {
        $block = $this->defaultWordPressHtaccessBlock();
        if (preg_match('~# BEGIN WordPress\b.*?# END WordPress~is', $contents) === 1) {
            $updated = preg_replace('~# BEGIN WordPress\b.*?# END WordPress~is', $block, $contents, 1);
            return is_string($updated) ? rtrim($updated) . "\n" : $contents;
        }

        return $block . "\n\n" . ltrim($contents);
    }

    private function defaultWordPressHtaccessBlock(): string
    {
        return <<<'HTACCESS'
# BEGIN WordPress
# The directives (lines) between "BEGIN WordPress" and "END WordPress" are
# dynamically generated, and should only be modified via WordPress filters.
# Any changes to the directives between these markers will be overwritten.
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteBase /
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]
</IfModule>
# END WordPress
HTACCESS;
    }

    private function isLikelyCoreFile(string $path): bool
    {
        return strpos($path, 'wp-admin/') === 0
            || strpos($path, 'wp-includes/') === 0
            || (strpos($path, 'wp-content/') !== 0 && strpos($path, '/') === false);
    }

    private function isInsideRoot(string $root, string $path): bool
    {
        $root = rtrim(str_replace('\\', '/', $root), '/');
        $path = rtrim(str_replace('\\', '/', $path), '/');

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

    private function quarantineDirectory(string $threatId): string
    {
        return $this->storage->baseDir() . '/quarantine/' . preg_replace('/[^a-z0-9_-]/i', '-', $threatId);
    }

    private function metadataPath(string $threatId): string
    {
        return $this->quarantineDirectory($threatId) . '/metadata.json';
    }

    /**
     * @return array<string,mixed>|null
     */
    private function metadata(string $threatId): ?array
    {
        $path = $this->metadataPath($threatId);
        $decoded = $this->readJson($path);

        if (empty($decoded) || !$this->verifyMetadataSignature($decoded)) {
            return null;
        }

        return $decoded;
    }

    /**
     * @param array<string,mixed> $metadata
     * @return array<string,mixed>
     */
    private function appendQuarantineHistory(array $metadata, string $event, int $userId, string $message): array
    {
        $history = isset($metadata['history']) && is_array($metadata['history']) ? $metadata['history'] : [];
        $history[] = [
            'event' => $event,
            'at' => gmdate('c'),
            'by' => $userId,
            'message' => $message,
        ];
        $metadata['history'] = $history;
        $metadata['last_event'] = $event;
        $metadata['last_event_at'] = gmdate('c');

        return $metadata;
    }

    /**
     * @param array<string,mixed> $metadata
     */
    private function recordQuarantineEvent(string $threatId, array $metadata, string $event, int $userId, string $message): void
    {
        $metadata['status'] = $event;
        $metadata = $this->appendQuarantineHistory($metadata, $event, $userId, $message);
        $this->writeMetadata($this->quarantineDirectory($threatId), $metadata);
    }

    /**
     * @param array<string,mixed> $metadata
     */
    private function quarantinedFileMatchesMetadata(array $metadata, string $source): bool
    {
        $expectedHash = strtolower(trim((string) ($metadata['sha256'] ?? '')));
        if ($expectedHash === '' || !preg_match('/^[a-f0-9]{64}$/', $expectedHash)) {
            return false;
        }

        $actualHash = is_file($source) ? hash_file('sha256', $source) : false;
        if (!is_string($actualHash) || $actualHash === '') {
            return false;
        }

        return hash_equals($expectedHash, strtolower($actualHash));
    }

    /**
     * @return array<string,mixed>
     */
    private function readJson(string $path): array
    {
        if (!is_file($path)) {
            return [];
        }

        $contents = file_get_contents($path);
        if (!is_string($contents)) {
            return [];
        }

        $decoded = \ShieldX\Support\Json::decodeArray($contents);

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

    /**
     * @param array<string,mixed> $metadata
     * @return array<string,mixed>
     */
    private function formatQuarantineHistoryItem(array $metadata, string $file): array
    {
        $history = isset($metadata['history']) && is_array($metadata['history']) ? array_values(array_filter($metadata['history'], 'is_array')) : [];
        $last = !empty($history) ? (array) $history[count($history) - 1] : [];
        $lastAt = (string) ($last['at'] ?? ($metadata['restored_at'] ?? ($metadata['quarantined_at'] ?? '')));

        return [
            'threat_id' => (string) ($metadata['threat_id'] ?? basename(dirname($file))),
            'rule_id' => (string) ($metadata['rule_id'] ?? ''),
            'type' => (string) ($metadata['type'] ?? ''),
            'severity' => (string) ($metadata['severity'] ?? ''),
            'status' => (string) ($metadata['status'] ?? (!empty($metadata['restored_at']) ? 'restored' : 'quarantined')),
            'original_path' => (string) ($metadata['original_path'] ?? ''),
            'quarantined_path' => (string) ($metadata['quarantined_path'] ?? ''),
            'sha256' => (string) ($metadata['sha256'] ?? ''),
            'size' => (int) ($metadata['size'] ?? 0),
            'quarantined_at' => (string) ($metadata['quarantined_at'] ?? ''),
            'restored_at' => (string) ($metadata['restored_at'] ?? ''),
            'last_event' => (string) ($last['event'] ?? ($metadata['last_event'] ?? 'quarantined')),
            'last_event_at' => $lastAt,
            'last_message' => (string) ($last['message'] ?? ''),
            'history' => $history,
        ];
    }

    /**
     * @param array<string,mixed> $metadata
     */
    private function writeMetadata(string $directory, array $metadata): void
    {
        $metadata['signature'] = [
            'alg' => 'hmac-sha256',
            'value' => $this->signMetadata($metadata),
        ];
        $json = wp_json_encode($metadata, JSON_PRETTY_PRINT);
        if (is_string($json)) {
            file_put_contents($directory . '/metadata.json', $json . "\n", LOCK_EX);
        }
    }

    /**
     * @param array<string,mixed> $metadata
     */
    private function signMetadata(array $metadata): string
    {
        unset($metadata['signature']);
        $this->sortRecursive($metadata);
        $json = wp_json_encode($metadata, JSON_UNESCAPED_SLASHES);

        return is_string($json) ? hash_hmac('sha256', $json, $this->metadataSignatureKey()) : '';
    }

    /**
     * @param array<string,mixed> $metadata
     */
    private function verifyMetadataSignature(array $metadata): bool
    {
        $signature = isset($metadata['signature']) && is_array($metadata['signature'])
            ? $metadata['signature']
            : [];
        $value = (string) ($signature['value'] ?? '');

        return $value !== '' && hash_equals($this->signMetadata($metadata), $value);
    }

    /**
     * @param array<string,mixed> $payload
     */
    private function sortRecursive(array &$payload): void
    {
        ksort($payload);
        foreach ($payload as &$value) {
            if (is_array($value)) {
                $this->sortRecursive($value);
            }
        }
        unset($value);
    }

    private function metadataSignatureKey(): string
    {
        $salt = function_exists('wp_salt') ? (string) wp_salt('auth') : '';

        return hash('sha256', $salt . '|shieldx-quarantine|' . $this->storage->baseDir(), true);
    }

    private function makeDirectory(string $path): void
    {
        if (is_dir($path)) {
            return;
        }

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

        mkdir($path, 0755, true);
    }

    private function pathIsWritable(string $path): bool
    {
        if (function_exists('wp_is_writable')) {
            return wp_is_writable($path);
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Unit tests can run without WordPress; production uses wp_is_writable() above.
        return is_writable($path);
    }

    private function moveFile(string $source, string $target): bool
    {
        $permissions = is_file($source) ? (fileperms($source) & 0777) : null;

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

        if (copy($source, $target)) {
            if (is_int($permissions)) {
                $this->filesystem->chmod($target, $permissions, $this->rootDir);
            }

            if (unlink($source)) {
                return true;
            }

            unlink($target);
            return false;
        }

        return false;
    }
}
