<?php

declare(strict_types=1);

namespace ShieldX\Scanner;

defined('ABSPATH') || exit;

// phpcs:disable WordPress.WP.AlternativeFunctions.unlink_unlink,WordPress.WP.AlternativeFunctions.rename_rename,WordPress.WP.AlternativeFunctions.file_system_operations_copy,WordPress.WP.AlternativeFunctions.file_system_operations_mkdir,WordPress.WP.AlternativeFunctions.file_system_operations_rmdir,WordPress.WP.AlternativeFunctions.file_system_operations_chmod,WordPress.WP.AlternativeFunctions.file_system_operations_fopen,WordPress.WP.AlternativeFunctions.file_system_operations_fclose,WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Official packages are staged only in a per-operation system temp workspace; private SHIELD-X storage retains signed hash metadata and job state only.

use ShieldX\Storage\FileStorage;
use ShieldX\Support\WordPressPaths;

final class PluginFileVerifier
{
    private const EXCERPT_HEAD_LINES = 500;
    private const EXCERPT_CONTEXT_LINES = 20;
    private const EXCERPT_LINE_BYTES = 4096;
    private const MAX_MANUAL_ARCHIVE_BYTES = 67108864;
    private const MAX_DOWNLOAD_ARCHIVE_BYTES = 209715200;
    private const MAX_ARCHIVE_ENTRIES = 10000;
    private const MAX_EXTRACTED_ARCHIVE_BYTES = 268435456;
    private const MAX_PLUGIN_HASH_STATE_BYTES = 33554432;

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

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

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

    /** @var callable|null */
    private $installedPluginProvider;

    /** @var callable|null */
    private $metadataProvider;

    /** @var callable|null */
    private $archiveDownloader;

    /** @var array<string,mixed>|null */
    private $stateCache;

    /** @var array<string,bool> */
    private $temporaryWorkspaceRoots;

    public function __construct(FileStorage $storage, string $rootDir, string $pluginsDir, ?callable $installedPluginProvider = null, ?callable $metadataProvider = null, ?callable $archiveDownloader = null)
    {
        $this->storage = $storage;
        $this->rootDir = rtrim($rootDir, '/\\');
        $this->pluginsDir = rtrim($pluginsDir, '/\\');
        $this->installedPluginProvider = $installedPluginProvider;
        $this->metadataProvider = $metadataProvider;
        $this->archiveDownloader = $archiveDownloader;
        $this->stateCache = null;
        $this->temporaryWorkspaceRoots = [];
    }

    public static function fromWordPress(): self
    {
        $root = WordPressPaths::root();
        $plugins = WordPressPaths::pluginParentDir();

        return new self(FileStorage::fromWordPress(), rtrim($root, '/\\'), rtrim($plugins, '/\\'));
    }

    /**
     * @return array<string,mixed>
     */
    public function state(): array
    {
        $installed = $this->installedPlugins();
        $state = $this->readState();
        $prepared = isset($state['plugins']) && is_array($state['plugins']) ? $state['plugins'] : [];
        $lastResult = isset($state['last_result']) && is_array($state['last_result']) ? $state['last_result'] : [];
        $preparedCurrent = 0;
        $hashCount = 0;

        foreach ($installed as $plugin) {
            $slug = (string) ($plugin['slug'] ?? '');
            $version = (string) ($plugin['version'] ?? '');
            $entry = isset($prepared[$slug]) && is_array($prepared[$slug]) ? $prepared[$slug] : [];
            if ($slug !== '' && $version !== '' && (string) ($entry['version'] ?? '') === $version && !empty($entry['hashes']) && is_array($entry['hashes'])) {
                $preparedCurrent++;
                $hashCount += count($entry['hashes']);
            }
        }

        return [
            'cache_dir' => $this->cacheDirectory(),
            'state_file' => $this->statePath(),
            'installed' => $installed,
            'installed_count' => count($installed),
            'prepared_count' => $preparedCurrent,
            'hash_count' => $hashCount,
            'last_result' => $this->withoutResolvedPackageErrors($lastResult, $prepared, $installed),
        ];
    }

    /**
     * Returns display-safe provenance derived from the signed package verifier state.
     *
     * @return array<int,array{type:string,slug:string,name:string,version:string,status:string}>
     */
    public function provenanceEntries(): array
    {
        $installed = $this->installedPlugins();
        $state = $this->readState();
        $prepared = isset($state['plugins']) && is_array($state['plugins']) ? $state['plugins'] : [];
        $entries = [];

        foreach ($installed as $plugin) {
            $slug = (string) ($plugin['slug'] ?? '');
            $version = (string) ($plugin['version'] ?? '');
            if ($slug === '' || $version === '') {
                continue;
            }

            $trusted = isset($prepared[$slug]) && is_array($prepared[$slug]) ? $prepared[$slug] : [];
            $source = (string) ($trusted['source'] ?? '');
            $status = 'unverified';
            if ((string) ($trusted['version'] ?? '') === $version) {
                if ($source === 'wordpress.org') {
                    $status = 'wordpress_org';
                } elseif ($source === 'manual_upload') {
                    $status = 'trusted_package';
                } elseif ($source === 'installed_trusted') {
                    $status = 'trusted_installed';
                }
            }

            $entries[] = [
                'type' => 'plugin',
                'slug' => $slug,
                'name' => (string) ($plugin['name'] ?? $slug),
                'version' => $version,
                'status' => $status,
            ];
        }

        return $entries;
    }

    /**
     * @param array<string,mixed> $entry
     */
    private function isCurrentHashBaseline(array $entry, string $version): bool
    {
        if (
            $version === ''
            || (string) ($entry['version'] ?? '') !== $version
            || empty($entry['hashes'])
            || !is_array($entry['hashes'])
        ) {
            return false;
        }

        return in_array((string) ($entry['source'] ?? ''), ['wordpress.org', 'manual_upload', 'installed_trusted'], true);
    }

    /**
     * @param array<string,mixed> $entry
     */
    private function isCurrentExplicitTrustEntry(array $entry, string $version): bool
    {
        return $this->isCurrentHashBaseline($entry, $version)
            && in_array((string) ($entry['source'] ?? ''), ['manual_upload', 'installed_trusted'], true);
    }

    /**
     * A package refresh must not revive a failure for a plugin that already has
     * a current verified baseline, including an administrator-trusted package.
     *
     * @param array<string,mixed> $result
     * @param array<string,mixed> $prepared
     * @param array<int,array<string,mixed>> $installed
     * @return array<string,mixed>
     */
    private function withoutResolvedPackageErrors(array $result, array $prepared, array $installed): array
    {
        if (!isset($result['errors']) || !is_array($result['errors'])) {
            return $result;
        }

        $versions = [];
        foreach ($installed as $plugin) {
            $slug = (string) ($plugin['slug'] ?? '');
            $version = (string) ($plugin['version'] ?? '');
            if ($slug !== '' && $version !== '') {
                $versions[$slug] = $version;
            }
        }

        $errors = $result['errors'];
        foreach (array_keys($errors) as $slug) {
            $slug = $this->sanitizeSlug((string) $slug);
            $version = isset($versions[$slug]) ? $versions[$slug] : '';
            if ($slug === '' || $version === '') {
                // Package refresh state can outlive an uninstalled plugin. Never
                // display or act on a stale entry just because a new scan has not
                // run since the plugin was removed.
                unset($errors[$slug]);
                continue;
            }

            $entry = isset($prepared[$slug]) && is_array($prepared[$slug]) ? $prepared[$slug] : [];
            if ($this->isCurrentHashBaseline($entry, $version)) {
                unset($errors[$slug]);
            }
        }

        $result['errors'] = $errors;

        return $result;
    }

    /**
     * Build display-safe package refresh failures for admin progress and reports.
     *
     * @param array<string,mixed> $errors
     * @return array<int,array{slug:string,plugin:string,version:string,reason:string}>
     */
    public function packageFailureDetails(array $errors): array
    {
        $details = [];

        foreach ($errors as $slug => $reason) {
            $slug = $this->sanitizeSlug((string) $slug);
            $plugin = $slug !== '' ? $this->installedPluginBySlug($slug) : [];
            if ($slug === '' || empty($plugin)) {
                continue;
            }

            $label = $this->pluginProgressLabel($plugin);
            $details[] = [
                'slug' => $slug,
                'plugin' => $this->truncateMessage($label, 120),
                'version' => (string) ($plugin['version'] ?? ''),
                'reason' => $this->truncateMessage((string) $reason, 260),
            ];
        }

        return $details;
    }

    /**
     * @return array<string,mixed>
     */
    public function prepareAllInstalled(bool $force = false): array
    {
        $guardError = $this->adminReadGuard();
        if ($guardError !== '') {
            throw new \RuntimeException($guardError);
        }

        $this->storage->ensure();
        $this->makeDirectory($this->cacheDirectory());

        $installed = $this->installedPlugins();
        $prepared = 0;
        $skipped = 0;
        $errors = [];

        foreach ($installed as $plugin) {
            $slug = (string) ($plugin['slug'] ?? '');
            $version = (string) ($plugin['version'] ?? '');
            if ($slug === '' || $version === '') {
                $skipped++;
                continue;
            }

            try {
                $this->preparePlugin($slug, $force);
                $prepared++;
            } catch (\Throwable $error) {
                $errors[$slug] = $error->getMessage();
            }
        }

        $this->updateState(function (array $state) use ($installed, $prepared, $skipped, $errors): array {
            $plugins = isset($state['plugins']) && is_array($state['plugins']) ? $state['plugins'] : [];
            $state['last_result'] = [
                'prepared_at' => gmdate('c'),
                'installed' => count($installed),
                'prepared' => $prepared,
                'skipped' => $skipped,
                'errors' => $this->withoutResolvedPackageErrors($errors, $plugins, $installed),
            ];

            return $state;
        });

        return $this->state();
    }

    /**
     * Start a resumable official-plugin package refresh.
     *
     * @return array<string,mixed>
     */
    public function startProgressivePrepare(bool $force = false, string $nonceAction = '', string $nonce = ''): array
    {
        $guardError = $nonceAction !== ''
            ? $this->adminWriteGuard($nonceAction, $nonce)
            : $this->adminReadGuard();
        if ($guardError !== '') {
            throw new \RuntimeException($guardError);
        }

        $this->storage->ensure();
        $this->makeDirectory($this->pluginPackageJobDirectory());

        $installed = $this->installedPlugins();
        $state = $this->readState();
        $prepared = isset($state['plugins']) && is_array($state['plugins']) ? $state['plugins'] : [];
        $queue = [];
        $skipped = 0;

        foreach ($installed as $plugin) {
            $slug = (string) ($plugin['slug'] ?? '');
            $version = (string) ($plugin['version'] ?? '');
            if ($slug === '' || $version === '') {
                $skipped++;
                continue;
            }

            $existing = isset($prepared[$slug]) && is_array($prepared[$slug]) ? $prepared[$slug] : [];
            if ($this->isCurrentExplicitTrustEntry($existing, $version)) {
                $skipped++;
                continue;
            }

            $queue[] = $slug;
        }

        $jobId = $this->newJobId();
        $job = [
            'job_id' => $jobId,
            'status' => empty($queue) ? 'completed' : 'running',
            'created_at' => gmdate('c'),
            'updated_at' => gmdate('c'),
            'force' => $force,
            'queue' => $queue,
            'cursor' => 0,
            'installed' => count($installed),
            'prepared' => 0,
            'skipped' => $skipped,
            'errors' => [],
            'batches' => 0,
            'current_message' => empty($queue)
                ? 'No official plugin packages need refreshing.'
                : $this->nextPluginMessage($queue, 0),
        ];

        $this->writePluginPackageJob($jobId, $job);

        if (empty($queue)) {
            $this->completeProgressivePrepare($job);
            $job = $this->readPluginPackageJob($jobId);
            if (empty($job)) {
                $job = [
                    'job_id' => $jobId,
                    'status' => 'completed',
                    'queue' => [],
                    'cursor' => 0,
                    'installed' => count($installed),
                    'prepared' => 0,
                    'skipped' => $skipped,
                    'errors' => [],
                    'batches' => 0,
                    'current_message' => 'No official plugin packages need refreshing.',
                ];
            }
        }

        return $this->publicProgressivePrepareState($job);
    }

    /**
     * @return array<string,mixed>
     */
    public function runProgressivePrepareBatch(string $jobId, int $pluginsPerBatch = 1, int $maxSeconds = 10, string $nonceAction = '', string $nonce = ''): array
    {
        $jobId = $this->sanitizeJobId($jobId);
        $guardError = $nonceAction !== ''
            ? $this->adminWriteGuard($nonceAction, $nonce)
            : $this->adminReadGuard();
        if ($guardError !== '') {
            return [
                'ok' => false,
                'status' => 'failed',
                'job_id' => $jobId,
                'message' => $guardError,
            ];
        }

        $job = $this->readPluginPackageJob($jobId);
        if (empty($job)) {
            return [
                'ok' => false,
                'status' => 'failed',
                'job_id' => $jobId,
                'message' => 'Official plugin package job was not found.',
            ];
        }

        if ((string) ($job['status'] ?? '') !== 'running') {
            return $this->publicProgressivePrepareState($job);
        }

        $started = microtime(true);
        $pluginsPerBatch = max(1, min(3, $pluginsPerBatch));
        $maxSeconds = max(2, min(20, $maxSeconds));
        $queue = isset($job['queue']) && is_array($job['queue']) ? array_values(array_map('strval', $job['queue'])) : [];
        $cursor = max(0, (int) ($job['cursor'] ?? 0));
        $processed = 0;
        $force = !empty($job['force']);
        $errors = isset($job['errors']) && is_array($job['errors']) ? $job['errors'] : [];

        try {
            while ($cursor < count($queue) && $processed < $pluginsPerBatch) {
                $slug = $this->sanitizeSlug((string) $queue[$cursor]);
                $plugin = $slug !== '' ? $this->installedPluginBySlug($slug) : [];
                $label = !empty($plugin) ? $this->pluginProgressLabel($plugin) : $slug;
                $job['current_message'] = 'Downloading and hashing ' . $label . '.';
                $this->writePluginPackageJob($jobId, $job);

                if ($slug === '') {
                    $job['skipped'] = (int) ($job['skipped'] ?? 0) + 1;
                } else {
                    try {
                        $this->preparePlugin($slug, $force);
                        $job['prepared'] = (int) ($job['prepared'] ?? 0) + 1;
                    } catch (\Throwable $error) {
                        $errors[$slug] = $error->getMessage();
                    }
                }

                $cursor++;
                $processed++;

                if ((microtime(true) - $started) >= $maxSeconds) {
                    break;
                }
            }

            $job['cursor'] = $cursor;
            $job['errors'] = $errors;
            $job['batches'] = (int) ($job['batches'] ?? 0) + 1;
            $job['updated_at'] = gmdate('c');
            $job['current_message'] = $this->nextPluginMessage($queue, $cursor);

            if ($cursor >= count($queue)) {
                return $this->completeProgressivePrepare($job);
            }

            $this->writePluginPackageJob($jobId, $job);

            return $this->publicProgressivePrepareState($job);
        } catch (\Throwable $error) {
            $job['status'] = 'failed';
            $job['message'] = 'Official plugin package refresh failed during batch processing.';
            $job['updated_at'] = gmdate('c');
            $this->writePluginPackageJob($jobId, $job);

            return $this->publicProgressivePrepareState($job);
        }
    }

    /**
     * @return array<string,mixed>
     */
    public function preparePlugin(string $slug, bool $force = false): array
    {
        $guardError = $this->adminReadGuard();
        if ($guardError !== '') {
            throw new \RuntimeException($guardError);
        }

        $slug = $this->sanitizeSlug($slug);
        $plugin = $this->installedPluginBySlug($slug);
        if (empty($plugin)) {
            throw new \InvalidArgumentException('The requested plugin is not installed or is not eligible for WordPress.org comparison.');
        }

        $version = (string) ($plugin['version'] ?? '');
        if ($version === '') {
            throw new \InvalidArgumentException('The installed plugin version is unavailable.');
        }

        $state = $this->readState();
        if (!isset($state['plugins']) || !is_array($state['plugins'])) {
            $state['plugins'] = [];
        }

        $existing = isset($state['plugins'][$slug]) && is_array($state['plugins'][$slug]) ? $state['plugins'][$slug] : [];
        $this->storage->ensure();
        $this->makeDirectory($this->cacheDirectory());

        if ($this->isCurrentExplicitTrustEntry($existing, $version)) {
            return $existing;
        }

        if (!$force && $this->isCurrentHashBaseline($existing, $version)) {
            return $existing;
        }

        $package = $this->packageUrlFor($plugin);
        $workspace = $this->createTemporaryWorkspace('plugin-package');
        try {
            $archive = $workspace . '/package.zip';
            $extractDir = $workspace . '/extracted';
            $this->downloadArchive($package, $archive);
            $this->makeDirectory($extractDir);
            $this->extractArchive($archive, $extractDir);

            $sourceRoot = $this->sourceRoot($extractDir);
            $hashes = $this->buildHashManifest($sourceRoot, $plugin);
            if (empty($hashes)) {
                throw new \RuntimeException('The official plugin package did not contain readable files to hash.');
            }
        } finally {
            $this->removeTemporaryWorkspace($workspace);
        }

        $entry = [
            'slug' => $slug,
            'name' => (string) ($plugin['name'] ?? $slug),
            'version' => $version,
            'plugin_file' => (string) ($plugin['plugin_file'] ?? ''),
            'install_dir' => (string) ($plugin['install_dir'] ?? ''),
            'package_url' => $package,
            'source' => 'wordpress.org',
            'prepared_at' => gmdate('c'),
            'file_count' => count($hashes),
            'hashes' => $hashes,
        ];

        $state = $this->updateState(function (array $state) use ($slug, $version, $entry): array {
            if (!isset($state['plugins']) || !is_array($state['plugins'])) {
                $state['plugins'] = [];
            }

            $existing = isset($state['plugins'][$slug]) && is_array($state['plugins'][$slug])
                ? $state['plugins'][$slug]
                : [];
            if (!$this->isCurrentExplicitTrustEntry($existing, $version)) {
                $state['plugins'][$slug] = $entry;
            }

            return $state;
        });

        return $state['plugins'][$slug];
    }

    /**
     * Prepare clean plugin hashes from an administrator-supplied ZIP when WordPress.org cannot provide the package.
     *
     * @return array<string,mixed>
     */
    public function prepareUploadedPluginPackage(string $slug, string $uploadedArchive, string $originalName = '', string $nonceAction = '', string $nonce = ''): array
    {
        $guardError = $nonceAction !== ''
            ? $this->adminWriteGuard($nonceAction, $nonce)
            : $this->adminReadGuard();
        if ($guardError !== '') {
            throw new \RuntimeException($guardError);
        }

        $slug = $this->sanitizeSlug($slug);
        $plugin = $this->installedPluginBySlug($slug);
        if (empty($plugin)) {
            throw new \InvalidArgumentException('The requested plugin is not installed or is not eligible for plugin comparison.');
        }

        $version = (string) ($plugin['version'] ?? '');
        if ($version === '') {
            throw new \InvalidArgumentException('The installed plugin version is unavailable.');
        }

        if ($uploadedArchive === '' || !is_file($uploadedArchive) || !is_readable($uploadedArchive)) {
            throw new \InvalidArgumentException('Upload a readable plugin ZIP package.');
        }

        $uploadedBytes = filesize($uploadedArchive);
        if (!is_int($uploadedBytes) || $uploadedBytes < 128) {
            throw new \InvalidArgumentException('Uploaded plugin package archive is empty or incomplete.');
        }

        if ($uploadedBytes > self::MAX_MANUAL_ARCHIVE_BYTES) {
            throw new \InvalidArgumentException('Uploaded plugin package is larger than the 64 MB safety limit.');
        }

        $this->storage->ensure();
        $this->makeDirectory($this->cacheDirectory());

        $workspace = $this->createTemporaryWorkspace('plugin-upload');
        try {
            $extractDir = $workspace . '/extracted';
            $this->makeDirectory($extractDir);
            $this->extractArchive($uploadedArchive, $extractDir);
            $sourceRoot = $this->sourceRoot($extractDir);
            $this->validateUploadedPluginIdentity($sourceRoot, $plugin);

            $hashes = $this->buildHashManifest($sourceRoot, $plugin);
            if (empty($hashes)) {
                throw new \RuntimeException('The uploaded plugin package did not contain readable files to hash.');
            }
        } finally {
            $this->removeTemporaryWorkspace($workspace);
        }

        $entry = [
            'slug' => $slug,
            'name' => (string) ($plugin['name'] ?? $slug),
            'version' => $version,
            'plugin_file' => (string) ($plugin['plugin_file'] ?? ''),
            'install_dir' => (string) ($plugin['install_dir'] ?? ''),
            'package_url' => 'manual-upload',
            'source' => 'manual_upload',
            'uploaded_name' => $this->truncateMessage($originalName, 160),
            'prepared_at' => gmdate('c'),
            'file_count' => count($hashes),
            'hashes' => $hashes,
        ];

        $state = $this->updateState(function (array $state) use ($slug, $entry): array {
            if (!isset($state['plugins']) || !is_array($state['plugins'])) {
                $state['plugins'] = [];
            }

            $state['plugins'][$slug] = $entry;
            if (isset($state['last_result']) && is_array($state['last_result'])) {
                if (isset($state['last_result']['errors']) && is_array($state['last_result']['errors'])) {
                    unset($state['last_result']['errors'][$slug]);
                }
                if (!isset($state['last_result']['manual_uploads']) || !is_array($state['last_result']['manual_uploads'])) {
                    $state['last_result']['manual_uploads'] = [];
                }
                $state['last_result']['manual_uploads'][$slug] = gmdate('c');
                $state['last_result']['prepared_at'] = gmdate('c');
            }

            return $state;
        });

        return $state['plugins'][$slug];
    }

    /**
     * Prepare clean plugin hashes by freezing the currently installed plugin files as an admin-trusted baseline.
     *
     * @return array<string,mixed>
     */
    public function trustInstalledPluginVersion(string $slug, string $nonceAction = '', string $nonce = ''): array
    {
        $guardError = $nonceAction !== ''
            ? $this->adminWriteGuard($nonceAction, $nonce)
            : $this->adminReadGuard();
        if ($guardError !== '') {
            throw new \RuntimeException($guardError);
        }

        $slug = $this->sanitizeSlug($slug);
        $plugin = $this->installedPluginBySlug($slug);
        if (empty($plugin)) {
            throw new \InvalidArgumentException('The requested plugin is not installed or is not eligible for plugin comparison.');
        }

        $version = (string) ($plugin['version'] ?? '');
        if ($version === '') {
            throw new \InvalidArgumentException('The installed plugin version is unavailable.');
        }

        $this->storage->ensure();
        $this->makeDirectory($this->cacheDirectory());

        $hashes = $this->buildInstalledPluginHashManifest($plugin);
        if (empty($hashes)) {
            throw new \RuntimeException('The installed plugin did not contain readable files to hash.');
        }

        $entry = [
            'slug' => $slug,
            'name' => (string) ($plugin['name'] ?? $slug),
            'version' => $version,
            'plugin_file' => (string) ($plugin['plugin_file'] ?? ''),
            'install_dir' => (string) ($plugin['install_dir'] ?? ''),
            'package_url' => 'local-install',
            'source' => 'installed_trusted',
            'prepared_at' => gmdate('c'),
            'file_count' => count($hashes),
            'hashes' => $hashes,
        ];

        $this->writeTrustedInstalledEntry($entry);
        $this->stateCache = null;

        return $entry;
    }

    /**
     * @param array<string,mixed> $file
     */
    public function isOfficialPluginMatchForFile(array $file, string $localHash = ''): bool
    {
        $path = (string) ($file['path'] ?? '');

        return $this->isOfficialPluginMatch($path, $localHash !== '' ? $localHash : (string) ($file['hash'] ?? ''));
    }

    public function isOfficialPluginMatch(string $path, string $localHash = ''): bool
    {
        $path = $this->sanitizeRelativePath($path);
        if (!$this->isPluginPath($path)) {
            return false;
        }

        $localPath = $this->rootDir . '/' . $path;
        if (!is_file($localPath)) {
            return false;
        }

        $localHash = $localHash !== '' ? strtolower($localHash) : (string) hash_file('sha256', $localPath);
        if (!preg_match('/^[a-f0-9]{64}$/', $localHash)) {
            return false;
        }

        $plugin = $this->installedPluginForPath($path);
        $slug = (string) ($plugin['slug'] ?? '');
        $version = (string) ($plugin['version'] ?? '');
        if ($slug === '' || $version === '') {
            return false;
        }

        $state = $this->readState();
        $plugins = isset($state['plugins']) && is_array($state['plugins']) ? $state['plugins'] : [];
        $entry = isset($plugins[$slug]) && is_array($plugins[$slug]) ? $plugins[$slug] : [];
        if ((string) ($entry['version'] ?? '') !== $version || empty($entry['hashes']) || !is_array($entry['hashes'])) {
            return false;
        }

        $expected = isset($entry['hashes'][$path]) && is_string($entry['hashes'][$path]) ? strtolower($entry['hashes'][$path]) : '';

        return $expected !== '' && hash_equals($expected, $localHash);
    }

    /**
     * @return array<string,mixed>
     */
    public function compareFile(string $path): array
    {
        $path = $this->sanitizeRelativePath($path);
        $eligible = $this->isPluginPath($path);
        $localPath = $this->rootDir . '/' . $path;
        $localExists = $path !== '' && is_file($localPath);
        $plugin = $eligible ? $this->installedPluginForPath($path) : [];
        $slug = (string) ($plugin['slug'] ?? '');
        $version = (string) ($plugin['version'] ?? '');
        $entry = [];

        if ($eligible && $slug !== '' && $version !== '') {
            $state = $this->readState();
            $plugins = isset($state['plugins']) && is_array($state['plugins']) ? $state['plugins'] : [];
            $entry = isset($plugins[$slug]) && is_array($plugins[$slug]) ? $plugins[$slug] : [];

            if ((string) ($entry['version'] ?? '') !== $version || empty($entry['hashes']) || !is_array($entry['hashes'])) {
                $entry = $this->preparePlugin($slug, false);
            }
        }

        $localHash = $localExists ? (string) hash_file('sha256', $localPath) : '';
        $cleanHash = isset($entry['hashes'][$path]) && is_string($entry['hashes'][$path]) ? strtolower($entry['hashes'][$path]) : '';
        $cleanExists = preg_match('/^[a-f0-9]{64}$/', $cleanHash) === 1;
        $matches = $localExists && $cleanExists && $localHash !== '' && hash_equals($cleanHash, strtolower($localHash));
        $firstChangedLine = null;
        $localExcerpt = [];
        $cleanExcerpt = [];
        $localLineCount = 0;
        $cleanLineCount = 0;
        $cleanSize = 0;
        $textComparable = false;
        $excerptWindowed = false;

        if ($this->isTextCandidate($path) && $localExists && $cleanExists) {
            try {
                $textCompare = $this->compareWithTransientOfficialSource($entry, $plugin, $path, $localPath, $cleanHash);
                if (!empty($textCompare)) {
                    $textComparable = true;
                    $firstChangedLine = $textCompare['first_changed_line'];
                    $localLineCount = (int) $textCompare['local_line_count'];
                    $cleanLineCount = (int) $textCompare['clean_line_count'];
                    $localExcerpt = $textCompare['local_excerpt'];
                    $cleanExcerpt = $textCompare['clean_excerpt'];
                    $excerptWindowed = !empty($textCompare['excerpt_windowed']);
                    $cleanSize = (int) ($textCompare['clean_size'] ?? 0);
                }
            } catch (\Throwable $error) {
                // The signed hash manifest still supplies the match verdict.
                // A transient source is only needed for an optional text diff.
            }
        }

        return [
            'path' => $path,
            'eligible' => $eligible && $slug !== '' && $version !== '',
            'plugin_name' => (string) ($plugin['name'] ?? $slug),
            'plugin_slug' => $slug,
            'plugin_version' => $version,
            'package_url' => (string) ($entry['package_url'] ?? ''),
            'local_path' => $localPath,
            'clean_path' => '',
            'local_exists' => $localExists,
            'clean_exists' => $cleanExists,
            'local_hash' => $localHash,
            'clean_hash' => $cleanHash,
            'local_size' => $localExists ? (int) filesize($localPath) : 0,
            'clean_size' => $cleanSize,
            'matches' => $matches,
            'text_comparable' => $textComparable,
            'first_changed_line' => $firstChangedLine,
            'local_excerpt' => $localExcerpt,
            'clean_excerpt' => $cleanExcerpt,
            'excerpt_start_line' => 1,
            'excerpt_windowed' => $excerptWindowed,
            'local_line_count' => $localLineCount,
            'clean_line_count' => $cleanLineCount,
        ];
    }

    public function isPluginPath(string $path): bool
    {
        $path = $this->sanitizeRelativePath($path);
        if (strpos($path, 'wp-content/plugins/') !== 0) {
            return false;
        }

        return strpos($path, 'wp-content/plugins/xerotact-shield-x-security/') !== 0;
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    private function installedPlugins(): array
    {
        if ($this->installedPluginProvider !== null) {
            try {
                $rawPlugins = call_user_func($this->installedPluginProvider);
                $rawPlugins = is_array($rawPlugins) ? $rawPlugins : [];
            } catch (\Throwable $error) {
                \ShieldX\Support\ErrorLog::write('SHIELD-X plugin verifier installed-plugin provider failed: ' . $error->getMessage());

                return [];
            }
        } else {
            $pluginApi = WordPressPaths::adminIncludesPath('plugin.php');
            if (!function_exists('get_plugins') && $pluginApi !== '' && is_file($pluginApi)) {
                require_once $pluginApi;
            }

            if (!function_exists('get_plugins')) {
                return [];
            }

            try {
                $rawPlugins = get_plugins();
            } catch (\Throwable $error) {
                \ShieldX\Support\ErrorLog::write('SHIELD-X plugin verifier get_plugins failed: ' . $error->getMessage());

                return [];
            }
        }

        $plugins = [];

        foreach ($rawPlugins as $pluginFile => $data) {
            $pluginFile = ScanRuleEngine::normalizePath((string) $pluginFile);
            if ($pluginFile === '' || (defined('SHIELDX_PLUGIN_BASENAME') && $pluginFile === (string) SHIELDX_PLUGIN_BASENAME)) {
                continue;
            }

            $data = is_array($data) ? $data : [];
            $slug = $this->detectWordPressOrgSlug($pluginFile, $data);
            $version = trim((string) ($data['Version'] ?? ''));
            if ($slug === '' || $version === '') {
                continue;
            }

            $installDir = strpos($pluginFile, '/') !== false ? dirname($pluginFile) : '';
            $installDir = $installDir === '.' ? '' : ScanRuleEngine::normalizePath($installDir);

            $plugins[] = [
                'slug' => $slug,
                'name' => (string) ($data['Name'] ?? $slug),
                'version' => $version,
                'plugin_file' => $pluginFile,
                'install_dir' => $installDir,
                'plugin_uri' => (string) ($data['PluginURI'] ?? ''),
            ];
        }

        usort($plugins, static function (array $left, array $right): int {
            return strcmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
        });

        return $plugins;
    }

    /**
     * @return array<string,mixed>
     */
    private function installedPluginBySlug(string $slug): array
    {
        foreach ($this->installedPlugins() as $plugin) {
            if ((string) ($plugin['slug'] ?? '') === $slug) {
                return $plugin;
            }
        }

        return [];
    }

    /**
     * @return array<string,mixed>
     */
    private function installedPluginForPath(string $path): array
    {
        foreach ($this->installedPlugins() as $plugin) {
            $pluginFile = (string) ($plugin['plugin_file'] ?? '');
            $installDir = (string) ($plugin['install_dir'] ?? '');
            if ($pluginFile === '') {
                continue;
            }

            if ($installDir !== '' && strpos($path, 'wp-content/plugins/' . trim($installDir, '/') . '/') === 0) {
                return $plugin;
            }

            if ($installDir === '' && $path === 'wp-content/plugins/' . $pluginFile) {
                return $plugin;
            }
        }

        return [];
    }

    /**
     * @param array<string,mixed> $data
     */
    private function detectWordPressOrgSlug(string $pluginFile, array $data): string
    {
        $pluginUri = (string) ($data['PluginURI'] ?? '');
        if (preg_match('~wordpress\.org/plugins/([^/#?]+)~i', $pluginUri, $matches)) {
            return $this->sanitizeSlug((string) $matches[1]);
        }

        if (strpos($pluginFile, '/') !== false) {
            return $this->sanitizeSlug((string) dirname($pluginFile));
        }

        return $this->sanitizeSlug((string) pathinfo($pluginFile, PATHINFO_FILENAME));
    }

    /**
     * @param array<string,mixed> $plugin
     */
    private function packageUrlFor(array $plugin): string
    {
        $slug = (string) ($plugin['slug'] ?? '');
        $version = (string) ($plugin['version'] ?? '');
        if ($slug === '' || $version === '') {
            throw new \InvalidArgumentException('Plugin slug and version are required.');
        }

        if ($this->metadataProvider !== null) {
            try {
                $info = call_user_func($this->metadataProvider, $plugin);
            } catch (\Throwable $error) {
                throw new \RuntimeException('Could not fetch WordPress.org plugin metadata: ' . $error->getMessage(), 0, $error);
            }
        } else {
            $pluginInstallApi = WordPressPaths::adminIncludesPath('plugin-install.php');
            if (!function_exists('plugins_api') && $pluginInstallApi !== '' && is_file($pluginInstallApi)) {
                require_once $pluginInstallApi;
            }

            if (!function_exists('plugins_api')) {
                throw new \RuntimeException('The WordPress plugin API is required to locate official plugin packages.');
            }

            try {
                $info = plugins_api('plugin_information', [
                    'slug' => $slug,
                    'fields' => [
                        'sections' => false,
                        'description' => false,
                        'short_description' => false,
                        'tested' => false,
                        'requires' => false,
                        'rating' => false,
                        'ratings' => false,
                        'downloaded' => false,
                        'last_updated' => false,
                        'added' => false,
                        'tags' => false,
                        'versions' => true,
                    ],
                ]);
            } catch (\Throwable $error) {
                throw new \RuntimeException('Could not fetch WordPress.org plugin metadata: ' . $error->getMessage(), 0, $error);
            }
        }

        if (function_exists('is_wp_error') && is_wp_error($info)) {
            throw new \RuntimeException('Could not fetch WordPress.org plugin metadata: ' . $info->get_error_message());
        }

        if (!is_object($info)) {
            throw new \RuntimeException('WordPress.org did not return plugin metadata.');
        }

        $package = '';
        if (isset($info->versions) && is_array($info->versions) && isset($info->versions[$version]) && is_string($info->versions[$version])) {
            $package = (string) $info->versions[$version];
        } elseif (isset($info->version, $info->download_link) && (string) $info->version === $version && is_string($info->download_link)) {
            $package = (string) $info->download_link;
        }

        return $this->validatePackageUrl($package, $slug, $version);
    }

    private function validatePackageUrl(string $package, string $slug, string $version): string
    {
        unset($slug, $version);
        $package = trim($package);
        if ($package === '') {
            throw new \RuntimeException('WordPress.org did not provide an official plugin package for this installed version.');
        }

        if (preg_match('#^https://downloads\.wordpress\.org/plugin/[A-Za-z0-9._~%+-]+\.zip$#i', $package) !== 1) {
            throw new \RuntimeException('The plugin package URL is not an official WordPress.org ZIP download.');
        }

        return $package;
    }

    private function downloadArchive(string $url, string $archive): void
    {
        // Archives are permitted only while they are inside a registered
        // per-operation system-temp workspace. FileStorage never holds them.
        $this->assertTemporaryWorkspaceDestination(dirname($archive));
        if ($this->archiveDownloader !== null) {
            try {
                call_user_func($this->archiveDownloader, $url, $archive);
            } catch (\Throwable $error) {
                throw new \RuntimeException('Could not download official plugin package: ' . $error->getMessage(), 0, $error);
            }
            if (!is_file($archive) || filesize($archive) < 256) {
                throw new \RuntimeException('Downloaded plugin package archive is empty or incomplete.');
            }

            if ((int) filesize($archive) > self::MAX_DOWNLOAD_ARCHIVE_BYTES) {
                $this->removePath($archive);
                throw new \RuntimeException('Downloaded plugin package archive exceeds the 200 MB safety limit.');
            }

            return;
        }

        if (!function_exists('wp_remote_get') || !function_exists('wp_remote_retrieve_response_code')) {
            throw new \RuntimeException('The WordPress HTTP API is required to download official plugin packages.');
        }

        $tmp = $archive . '.tmp';
        $this->removePath($tmp);

        try {
            $response = wp_remote_get($url, [
                'timeout' => self::downloadTimeoutSeconds(),
                'redirection' => 0,
                'reject_unsafe_urls' => true,
                'stream' => true,
                'filename' => $tmp,
                'limit_response_size' => self::MAX_DOWNLOAD_ARCHIVE_BYTES + 1,
                'user-agent' => 'SHIELD-X/' . (defined('SHIELDX_VERSION') ? (string) SHIELDX_VERSION : '0.1.0'),
            ]);
        } catch (\Throwable $error) {
            $this->removePath($tmp);
            throw new \RuntimeException('Could not download official plugin package: ' . $error->getMessage(), 0, $error);
        }

        if (function_exists('is_wp_error') && is_wp_error($response)) {
            $this->removePath($tmp);
            throw new \RuntimeException('Could not download official plugin package: ' . $response->get_error_message());
        }

        if ((int) wp_remote_retrieve_response_code($response) !== 200) {
            $this->removePath($tmp);
            throw new \RuntimeException('Official plugin package download returned an unexpected status.');
        }

        if (!is_file($tmp) || filesize($tmp) < 256) {
            $this->removePath($tmp);
            throw new \RuntimeException('Downloaded plugin package archive is empty or incomplete.');
        }

        if ((int) filesize($tmp) > self::MAX_DOWNLOAD_ARCHIVE_BYTES) {
            $this->removePath($tmp);
            throw new \RuntimeException('Downloaded plugin package archive exceeds the 200 MB safety limit.');
        }

        if (!rename($tmp, $archive)) {
            $this->removePath($tmp);
            throw new \RuntimeException('Could not finalize downloaded plugin package.');
        }
    }

    private static function downloadTimeoutSeconds(): int
    {
        $maxExecution = (int) ini_get('max_execution_time');
        if ($maxExecution <= 0) {
            return 300;
        }

        return min(300, max(30, $maxExecution - 15));
    }

    private function extractArchive(string $archive, string $destination): void
    {
        if (!class_exists('\ZipArchive')) {
            throw new \RuntimeException('The PHP zip extension is required to extract official plugin packages.');
        }

        $this->assertTemporaryWorkspaceDestination($destination);

        $zip = new \ZipArchive();
        $opened = $zip->open($archive);
        if ($opened !== true) {
            throw new \RuntimeException('Could not open official plugin package archive.');
        }

        if ($zip->numFiles < 1 || $zip->numFiles > self::MAX_ARCHIVE_ENTRIES) {
            $zip->close();
            throw new \RuntimeException('Official plugin package archive contains too many files.');
        }

        $extractedBytes = 0;

        for ($index = 0; $index < $zip->numFiles; $index++) {
            $name = (string) $zip->getNameIndex($index);
            if ($name === '' || strpos($name, "\0") !== false || strpos($name, '../') !== false || strpos($name, '..\\') !== false || preg_match('~^(?:/|[A-Za-z]:)~', $name)) {
                $zip->close();
                throw new \RuntimeException('Official plugin package archive contains an unsafe path.');
            }

            $stat = $zip->statIndex($index);
            $entryBytes = is_array($stat) && isset($stat['size']) && is_numeric($stat['size'])
                ? (int) $stat['size']
                : -1;
            if ($entryBytes < 0 || $entryBytes > self::MAX_EXTRACTED_ARCHIVE_BYTES || $extractedBytes > self::MAX_EXTRACTED_ARCHIVE_BYTES - $entryBytes) {
                $zip->close();
                throw new \RuntimeException('Official plugin package archive exceeds the 256 MB extraction safety limit.');
            }
            $extractedBytes += $entryBytes;
            $mode = is_array($stat) && isset($stat['external_attributes'])
                ? (((int) $stat['external_attributes']) >> 16)
                : 0;
            if (($mode & 0170000) === 0120000) {
                $zip->close();
                throw new \RuntimeException('Official plugin package archive contains a symlink entry.');
            }
        }

        if (!$zip->extractTo($destination)) {
            $zip->close();
            throw new \RuntimeException('Could not extract official plugin package archive.');
        }

        $zip->close();
        $this->assertNoExtractedSymlinks($destination);
    }

    private function assertTemporaryWorkspaceDestination(string $destination): void
    {
        $destinationRoot = realpath($destination);
        if (!is_string($destinationRoot) || $destinationRoot === '' || is_link($destination)) {
            throw new \RuntimeException('Plugin package extraction target must be inside a SHIELD-X temporary workspace.');
        }

        foreach (array_keys($this->temporaryWorkspaceRoots) as $workspace) {
            $workspaceRoot = realpath($workspace);
            if (is_string($workspaceRoot) && $workspaceRoot !== '' && self::pathEqualsOrInside($destinationRoot, $workspaceRoot)) {
                return;
            }
        }

        throw new \RuntimeException('Plugin package extraction target must be inside a SHIELD-X temporary workspace.');
    }

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

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

    private function assertNoExtractedSymlinks(string $destination): void
    {
        $iterator = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($destination, \FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::SELF_FIRST
        );

        foreach ($iterator as $item) {
            if ($item instanceof \SplFileInfo && $item->isLink()) {
                throw new \RuntimeException('Official plugin package extraction produced a symlink.');
            }
        }
    }

    private function createTemporaryWorkspace(string $purpose): string
    {
        $base = rtrim((string) sys_get_temp_dir(), '/\\');
        $purpose = preg_replace('/[^a-z0-9-]+/i', '-', $purpose) ?: 'package';
        if ($base === '' || !is_dir($base) || !wp_is_writable($base)) {
            throw new \RuntimeException('A writable system temporary directory is required to verify plugin packages.');
        }

        for ($attempt = 0; $attempt < 3; $attempt++) {
            $workspace = $base . '/shieldx-' . $purpose . '-' . bin2hex(random_bytes(16));
            if (mkdir($workspace, 0700)) {
                $normalized = rtrim(str_replace('\\', '/', $workspace), '/');
                $this->temporaryWorkspaceRoots[$normalized] = true;

                return $workspace;
            }
        }

        throw new \RuntimeException('Could not create a temporary workspace to verify the plugin package.');
    }

    private function removeTemporaryWorkspace(string $workspace): void
    {
        $normalized = rtrim(str_replace('\\', '/', $workspace), '/');
        if (!isset($this->temporaryWorkspaceRoots[$normalized])) {
            return;
        }

        $this->removePath($workspace);
        unset($this->temporaryWorkspaceRoots[$normalized]);
    }

    /**
     * @return array<string,string>
     * @param array<string,mixed> $plugin
     */
    private function buildInstalledPluginHashManifest(array $plugin): array
    {
        $pluginFile = ScanRuleEngine::normalizePath((string) ($plugin['plugin_file'] ?? ''));
        $installDir = trim(ScanRuleEngine::normalizePath((string) ($plugin['install_dir'] ?? '')), '/');
        if ($pluginFile === '') {
            throw new \RuntimeException('The installed plugin main file is unavailable.');
        }

        if ($installDir !== '') {
            $source = $this->pluginsDir . '/' . $installDir;
            if (!is_dir($source) || is_link($source)) {
                throw new \RuntimeException('The installed plugin directory is not readable.');
            }

            return $this->buildHashManifest($source, $plugin);
        }

        $source = $this->pluginsDir . '/' . $pluginFile;
        if (!is_file($source) || is_link($source)) {
            throw new \RuntimeException('The installed plugin file is not readable.');
        }

        $hash = hash_file('sha256', $source);
        if (!is_string($hash) || preg_match('/^[a-f0-9]{64}$/i', $hash) !== 1) {
            return [];
        }

        return [
            'wp-content/plugins/' . $pluginFile => strtolower($hash),
        ];
    }

    /**
     * @return array<string,string>
     * @param array<string,mixed> $plugin
     */
    private function buildHashManifest(string $sourceRoot, array $plugin): array
    {
        $hashes = [];
        if (!is_dir($sourceRoot)) {
            return $hashes;
        }

        $installDir = (string) ($plugin['install_dir'] ?? '');
        $localPrefix = $installDir !== ''
            ? 'wp-content/plugins/' . trim($installDir, '/')
            : 'wp-content/plugins';

        $iterator = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($sourceRoot, \FilesystemIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::LEAVES_ONLY
        );

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

            if ($item->isLink()) {
                throw new \RuntimeException('Plugin package source contains a symlink.');
            }

            $filePath = $item->getPathname();
            $relativeInsidePackage = ScanRuleEngine::normalizePath(substr($filePath, strlen(rtrim($sourceRoot, '/\\')) + 1));
            if ($relativeInsidePackage === '') {
                continue;
            }

            $localRelative = $localPrefix . '/' . $relativeInsidePackage;
            $hash = hash_file('sha256', $filePath);
            if (is_string($hash) && preg_match('/^[a-f0-9]{64}$/', $hash)) {
                $hashes[$localRelative] = strtolower($hash);
            }
        }

        ksort($hashes);

        return $hashes;
    }

    private function sourceRoot(string $extractDir): string
    {
        if (!is_dir($extractDir)) {
            return $extractDir;
        }

        $items = scandir($extractDir);
        $items = is_array($items) ? array_values(array_filter($items, static function (string $item): bool {
            return $item !== '.' && $item !== '..' && $item !== '__MACOSX';
        })) : [];

        if (count($items) === 1 && is_dir($extractDir . '/' . $items[0])) {
            return $extractDir . '/' . $items[0];
        }

        return $extractDir;
    }

    /**
     * @param array<string,mixed> $plugin
     */
    private function validateUploadedPluginIdentity(string $sourceRoot, array $plugin): void
    {
        $expectedName = (string) ($plugin['name'] ?? '');
        $expectedVersion = (string) ($plugin['version'] ?? '');
        $expectedNameKey = $this->normalizePluginName($expectedName);
        $matchingNameVersion = '';

        foreach ($this->pluginHeaderCandidatePaths($sourceRoot, $plugin) as $candidate) {
            $headers = $this->readPluginHeaders($candidate);
            $name = (string) ($headers['name'] ?? '');
            $version = (string) ($headers['version'] ?? '');

            if ($name === '' || $version === '') {
                continue;
            }

            if ($this->normalizePluginName($name) !== $expectedNameKey) {
                continue;
            }

            $matchingNameVersion = $version;
            if (hash_equals($expectedVersion, $version)) {
                return;
            }
        }

        if ($matchingNameVersion !== '') {
            throw new \RuntimeException(sprintf(
                'Uploaded plugin package version %1$s does not match the installed version %2$s.',
                $matchingNameVersion,
                $expectedVersion
            ));
        }

        throw new \RuntimeException('Uploaded plugin package does not contain a plugin header matching the installed plugin name and version.');
    }

    /**
     * @param array<string,mixed> $plugin
     * @return array<int,string>
     */
    private function pluginHeaderCandidatePaths(string $sourceRoot, array $plugin): array
    {
        $sourceRoot = rtrim($sourceRoot, '/\\');
        $candidates = [];
        $pluginFile = ScanRuleEngine::normalizePath((string) ($plugin['plugin_file'] ?? ''));
        if ($pluginFile !== '') {
            $mainFile = basename($pluginFile);
            if ($mainFile !== '') {
                $candidates[] = $sourceRoot . '/' . $mainFile;
            }
        }

        $items = is_dir($sourceRoot) ? scandir($sourceRoot) : false;
        if (is_array($items)) {
            foreach ($items as $item) {
                if ($item === '.' || $item === '..' || strtolower(pathinfo($item, PATHINFO_EXTENSION)) !== 'php') {
                    continue;
                }
                $candidates[] = $sourceRoot . '/' . $item;
            }
        }

        $unique = [];
        foreach ($candidates as $candidate) {
            if (is_file($candidate)) {
                $unique[ScanRuleEngine::normalizePath($candidate)] = $candidate;
            }
        }

        return array_values($unique);
    }

    /**
     * @return array{name:string,version:string}
     */
    private function readPluginHeaders(string $path): array
    {
        $raw = file_get_contents($path, false, null, 0, 8192);
        $raw = is_string($raw) ? $raw : '';

        return [
            'name' => $this->pluginHeaderValue($raw, 'Plugin Name'),
            'version' => $this->pluginHeaderValue($raw, 'Version'),
        ];
    }

    private function pluginHeaderValue(string $raw, string $header): string
    {
        if ($raw === '' || preg_match('/^[ \t\/*#@]*' . preg_quote($header, '/') . ':\s*(.+)$/mi', $raw, $matches) !== 1) {
            return '';
        }

        return trim(str_replace('*/', '', (string) $matches[1]));
    }

    private function normalizePluginName(string $name): string
    {
        $plain = function_exists('wp_strip_all_tags')
            ? wp_strip_all_tags($name)
            : (preg_replace('/<[^>]*>/', '', $name) ?: '');
        $decoded = html_entity_decode($plain, ENT_QUOTES, 'UTF-8');
        $decoded = strtolower(trim($decoded));
        $decoded = preg_replace('/[^a-z0-9]+/', ' ', $decoded);

        return trim(is_string($decoded) ? $decoded : '');
    }

    /**
     * @param array<string,mixed> $entry
     * @param array<string,mixed> $plugin
     * @return array<string,mixed>
     */
    private function compareWithTransientOfficialSource(array $entry, array $plugin, string $path, string $localPath, string $expectedHash): array
    {
        if ((string) ($entry['source'] ?? '') !== 'wordpress.org') {
            return [];
        }

        $slug = $this->sanitizeSlug((string) ($plugin['slug'] ?? ''));
        $version = (string) ($plugin['version'] ?? '');
        if ($slug === '' || $version === '' || preg_match('/^[a-f0-9]{64}$/', $expectedHash) !== 1) {
            return [];
        }

        $package = $this->validatePackageUrl((string) ($entry['package_url'] ?? ''), $slug, $version);
        $workspace = $this->createTemporaryWorkspace('plugin-diff');
        try {
            $archive = $workspace . '/package.zip';
            $extractDir = $workspace . '/extracted';
            $this->downloadArchive($package, $archive);
            $this->makeDirectory($extractDir);
            $this->extractArchive($archive, $extractDir);

            $cleanPath = $this->cleanPathForSource($path, $plugin, $this->sourceRoot($extractDir));
            if ($cleanPath === '' || !is_file($cleanPath) || is_link($cleanPath)) {
                return [];
            }

            $cleanHash = hash_file('sha256', $cleanPath);
            if (!is_string($cleanHash) || !hash_equals(strtolower($expectedHash), strtolower($cleanHash))) {
                return [];
            }

            $result = $this->compareTextFiles($localPath, $cleanPath);
            $result['clean_size'] = (int) filesize($cleanPath);

            return $result;
        } finally {
            $this->removeTemporaryWorkspace($workspace);
        }
    }

    /**
     * @param array<string,mixed> $plugin
     */
    private function cleanPathForSource(string $path, array $plugin, string $sourceRoot): string
    {
        $installDir = trim((string) ($plugin['install_dir'] ?? ''), '/');
        $prefix = $installDir !== ''
            ? 'wp-content/plugins/' . $installDir . '/'
            : 'wp-content/plugins/';

        if (strpos($path, $prefix) !== 0) {
            return '';
        }

        $insidePackage = substr($path, strlen($prefix));
        if ($insidePackage === '') {
            return '';
        }

        $candidate = rtrim($sourceRoot, '/\\') . '/' . $insidePackage;
        if (!is_file($candidate)) {
            return '';
        }

        $realSource = realpath($sourceRoot);
        $realCandidate = realpath($candidate);
        if (!is_string($realSource) || !is_string($realCandidate) || strpos(ScanRuleEngine::normalizePath($realCandidate), ScanRuleEngine::normalizePath($realSource) . '/') !== 0) {
            return '';
        }

        return $candidate;
    }

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

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

    /**
     * @return array{first_changed_line:?int,local_excerpt:array<int,array{line:int,text:string}>,clean_excerpt:array<int,array{line:int,text:string}>,local_line_count:int,clean_line_count:int,excerpt_windowed:bool}
     */
    private function compareTextFiles(string $localPath, string $cleanPath): array
    {
        $localHandle = fopen($localPath, 'rb');
        $cleanHandle = fopen($cleanPath, 'rb');
        if (!is_resource($localHandle) || !is_resource($cleanHandle)) {
            if (is_resource($localHandle)) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes a read-only compare handle for an admin-requested plugin file diff.
                fclose($localHandle);
            }
            if (is_resource($cleanHandle)) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes a read-only compare handle for an admin-requested plugin file diff.
                fclose($cleanHandle);
            }

            return [
                'first_changed_line' => null,
                'local_excerpt' => [],
                'clean_excerpt' => [],
                'local_line_count' => 0,
                'clean_line_count' => 0,
                'excerpt_windowed' => false,
            ];
        }

        $lineNumber = 0;
        $firstChangedLine = null;
        $contextRemaining = 0;
        $localLineCount = 0;
        $cleanLineCount = 0;
        $localHead = [];
        $cleanHead = [];
        $previousLocal = [];
        $previousClean = [];
        $localContext = [];
        $cleanContext = [];

        try {
            while (!feof($localHandle) || !feof($cleanHandle)) {
                $lineNumber++;
                $localLine = !feof($localHandle) ? $this->readComparableLine($localHandle) : [false, '', ''];
                $cleanLine = !feof($cleanHandle) ? $this->readComparableLine($cleanHandle) : [false, '', ''];
                $hasLocal = (bool) $localLine[0];
                $hasClean = (bool) $cleanLine[0];
                $localText = (string) $localLine[1];
                $cleanText = (string) $cleanLine[1];
                $localSignature = (string) $localLine[2];
                $cleanSignature = (string) $cleanLine[2];

                if (!$hasLocal && !$hasClean) {
                    break;
                }

                if ($hasLocal) {
                    $localLineCount++;
                }
                if ($hasClean) {
                    $cleanLineCount++;
                }

                if ($lineNumber <= self::EXCERPT_HEAD_LINES) {
                    if ($hasLocal) {
                        $localHead[] = $this->excerptLine($lineNumber, $localText);
                    }
                    if ($hasClean) {
                        $cleanHead[] = $this->excerptLine($lineNumber, $cleanText);
                    }
                }

                if ($firstChangedLine === null) {
                    if (!$hasLocal || !$hasClean || $localSignature !== $cleanSignature) {
                        $firstChangedLine = $lineNumber;
                        $localContext = $previousLocal;
                        $cleanContext = $previousClean;
                        if ($hasLocal) {
                            $localContext[] = $this->excerptLine($lineNumber, $localText);
                        }
                        if ($hasClean) {
                            $cleanContext[] = $this->excerptLine($lineNumber, $cleanText);
                        }
                        $contextRemaining = self::EXCERPT_CONTEXT_LINES;
                    } else {
                        $this->pushExcerptBuffer($previousLocal, $this->excerptLine($lineNumber, $localText));
                        $this->pushExcerptBuffer($previousClean, $this->excerptLine($lineNumber, $cleanText));
                    }
                } elseif ($contextRemaining > 0) {
                    if ($hasLocal) {
                        $localContext[] = $this->excerptLine($lineNumber, $localText);
                    }
                    if ($hasClean) {
                        $cleanContext[] = $this->excerptLine($lineNumber, $cleanText);
                    }
                    $contextRemaining--;
                }
            }
        } finally {
            if (is_resource($localHandle)) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes a read-only compare handle for an admin-requested plugin file diff.
                fclose($localHandle);
            }
            if (is_resource($cleanHandle)) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes a read-only compare handle for an admin-requested plugin file diff.
                fclose($cleanHandle);
            }
        }

        return [
            'first_changed_line' => $firstChangedLine,
            'local_excerpt' => $this->mergeExcerptLines($localHead, $localContext),
            'clean_excerpt' => $this->mergeExcerptLines($cleanHead, $cleanContext),
            'local_line_count' => $localLineCount,
            'clean_line_count' => $cleanLineCount,
            'excerpt_windowed' => $localLineCount > self::EXCERPT_HEAD_LINES || $cleanLineCount > self::EXCERPT_HEAD_LINES,
        ];
    }

    /**
     * @param resource $handle
     * @return array{0:bool,1:string,2:string}
     */
    private function readComparableLine($handle): array
    {
        $hash = hash_init('sha256');
        $text = '';
        $bytes = 0;
        $truncated = false;

        while (true) {
            $chunk = fgets($handle, self::EXCERPT_LINE_BYTES + 1);
            if (!is_string($chunk)) {
                if ($bytes === 0) {
                    return [false, '', ''];
                }
                break;
            }

            $chunkBytes = strlen($chunk);
            $bytes += $chunkBytes;
            hash_update($hash, $chunk);

            $remaining = self::EXCERPT_LINE_BYTES - strlen($text);
            if ($remaining > 0) {
                $text .= substr($chunk, 0, $remaining);
            }
            if ($chunkBytes > $remaining) {
                $truncated = true;
            }

            if (substr($chunk, -1) === "\n" || feof($handle)) {
                break;
            }
        }

        $signature = hash_final($hash) . ':' . $bytes;
        $text = rtrim($text, "\r\n");
        if ($truncated) {
            $text .= ' ... [line truncated; sha256 ' . substr($signature, 0, 12) . ']';
        }

        return [true, $text, $signature];
    }

    /**
     * @param array<int,array{line:int,text:string}> $buffer
     * @param array{line:int,text:string} $line
     */
    private function pushExcerptBuffer(array &$buffer, array $line): void
    {
        $buffer[] = $line;
        if (count($buffer) > self::EXCERPT_CONTEXT_LINES) {
            array_shift($buffer);
        }
    }

    /**
     * @return array{line:int,text:string}
     */
    private function excerptLine(int $line, string $text): array
    {
        if (strlen($text) > self::EXCERPT_LINE_BYTES) {
            $text = substr($text, 0, self::EXCERPT_LINE_BYTES) . ' ... [line truncated]';
        }

        return [
            'line' => $line,
            'text' => $text,
        ];
    }

    /**
     * @param array<int,array{line:int,text:string}> $head
     * @param array<int,array{line:int,text:string}> $context
     * @return array<int,array{line:int,text:string}>
     */
    private function mergeExcerptLines(array $head, array $context): array
    {
        $merged = [];
        foreach (array_merge($head, $context) as $line) {
            $lineNumber = (int) ($line['line'] ?? 0);
            if ($lineNumber > 0) {
                $merged[$lineNumber] = $line;
            }
        }
        ksort($merged);

        return array_values($merged);
    }

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

        if ($path === '' || strpos($path, "\0") !== false || preg_match('~^[A-Za-z]:~', $path)) {
            return '';
        }

        foreach (explode('/', $path) as $segment) {
            if ($segment === '' || $segment === '.' || $segment === '..') {
                return '';
            }
        }

        return $path;
    }

    private function adminReadGuard(): string
    {
        $capability = defined('SHIELDX_MANAGE_CAPABILITY') ? (string) SHIELDX_MANAGE_CAPABILITY : 'manage_options';
        if (!function_exists('current_user_can')) {
            return 'WordPress capability checks are not available.';
        }

        return current_user_can($capability) ? '' : 'Administrator permission is required for official plugin package verification.';
    }

    private function adminWriteGuard(string $nonceAction, string $nonce = ''): string
    {
        $readGuard = $this->adminReadGuard();
        if ($readGuard !== '') {
            return $readGuard;
        }

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

        if (!function_exists('wp_verify_nonce')) {
            return 'WordPress nonce verification is not available.';
        }

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

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

    private function requestNonce(): string
    {
        $rawMethod = 'POST';
        if (isset($_SERVER['REQUEST_METHOD']) && is_scalar($_SERVER['REQUEST_METHOD'])) {
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Unslashed, sanitized, and exact-match validated immediately below.
            $rawMethod = (string) (function_exists('wp_unslash') ? wp_unslash($_SERVER['REQUEST_METHOD']) : $_SERVER['REQUEST_METHOD']);
        }
        $rawMethod = trim($rawMethod);
        $normalizedMethod = function_exists('sanitize_key')
            ? sanitize_key(strtolower($rawMethod))
            : strtolower((string) preg_replace('/[^a-z]/', '', strtolower($rawMethod)));
        $method = hash_equals(strtolower($rawMethod), $normalizedMethod)
            && in_array($normalizedMethod, ['get', 'post'], true)
                ? strtoupper($normalizedMethod)
                : 'POST';
        $sources = [];
        if ($method === 'GET') {
            // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing -- This helper only reads explicit request arrays so adminWriteGuard() can verify the nonce immediately.
            $sources[] = $_GET;
            // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing -- POST is the fallback explicit request array when the form method is GET.
            $sources[] = $_POST;
        } else {
            // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing -- This helper only reads explicit request arrays so adminWriteGuard() can verify the nonce immediately.
            $sources[] = $_POST;
            // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing -- GET is the fallback explicit request array when the form method is not GET.
            $sources[] = $_GET;
        }

        foreach ($sources as $source) {
            foreach (['_wpnonce', '_ajax_nonce'] as $key) {
                // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- This helper only reads explicit request arrays so adminWriteGuard() can verify the nonce immediately.
                if (!isset($source[$key]) || !is_scalar($source[$key])) {
                    continue;
                }

                // 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 = function_exists('wp_unslash') ? wp_unslash($source[$key]) : $source[$key];
                $value = is_scalar($value) ? (string) $value : '';

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

        return '';
    }

    private function sanitizeSlug(string $slug): string
    {
        $slug = strtolower(trim($slug));
        $slug = preg_replace('~[^a-z0-9._-]+~', '-', $slug);
        $slug = is_string($slug) ? trim($slug, '-_.') : '';

        if ($slug === '' || !preg_match('~^[a-z0-9][a-z0-9._-]{0,120}$~', $slug)) {
            return '';
        }

        return $slug;
    }

    private function cacheDirectory(): string
    {
        return $this->storage->baseDir() . '/cache/plugin-hashes';
    }

    private function trustedInstalledDirectory(): string
    {
        return $this->storage->baseDir() . '/profiles/plugin-trust';
    }

    private function trustedInstalledPath(string $slug): string
    {
        return $this->trustedInstalledDirectory() . '/' . $this->sanitizeSlug($slug) . '.json';
    }

    private function statePath(): string
    {
        return $this->cacheDirectory() . '/plugins.json';
    }

    private function pluginPackageJobDirectory(): string
    {
        return $this->cacheDirectory() . '/jobs';
    }

    private function pluginPackageJobPath(string $jobId): string
    {
        return $this->pluginPackageJobDirectory() . '/plugin-packages-' . $this->sanitizeJobId($jobId) . '.json';
    }

    private function sanitizeJobId(string $jobId): string
    {
        $jobId = strtolower(trim($jobId));

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

    private function newJobId(): string
    {
        try {
            return bin2hex(random_bytes(16));
        } catch (\Throwable $error) {
            return '';
        }
    }

    /**
     * @param array<string,mixed> $job
     * @return array<string,mixed>
     */
    private function publicProgressivePrepareState(array $job): array
    {
        $queue = isset($job['queue']) && is_array($job['queue']) ? $job['queue'] : [];
        $total = count($queue);
        $processed = min($total, max(0, (int) ($job['cursor'] ?? 0)));
        $status = (string) ($job['status'] ?? 'running');
        $errors = isset($job['errors']) && is_array($job['errors']) ? $job['errors'] : [];
        $percent = $total > 0 ? (int) floor(($processed / max(1, $total)) * 100) : 100;
        if ($status === 'running') {
            $percent = min(99, $percent);
        }

        return [
            'ok' => $status !== 'failed',
            'status' => $status,
            'job_id' => (string) ($job['job_id'] ?? ''),
            'percent' => max(0, min(100, $percent)),
            'files_processed' => $processed,
            'files_total' => $total,
            'files_label' => sprintf('%1$d / %2$d plugins', $processed, $total),
            'batches' => max(0, (int) ($job['batches'] ?? 0)),
            'batches_label' => sprintf('%1$d prepared, %2$d failed', max(0, (int) ($job['prepared'] ?? 0)), count($errors)),
            'scope_label' => (string) ($job['current_message'] ?? 'Preparing official WordPress.org plugin packages.'),
            'prepared' => max(0, (int) ($job['prepared'] ?? 0)),
            'skipped' => max(0, (int) ($job['skipped'] ?? 0)),
            'errors' => $errors,
            'failure_details' => $this->packageFailureDetails($errors),
            'message' => (string) ($job['message'] ?? ''),
        ];
    }

    /**
     * @param array<string,mixed> $job
     * @return array<string,mixed>
     */
    private function completeProgressivePrepare(array $job): array
    {
        $errors = isset($job['errors']) && is_array($job['errors']) ? $job['errors'] : [];
        $queue = isset($job['queue']) && is_array($job['queue']) ? $job['queue'] : [];

        $installed = $this->installedPlugins();
        $state = $this->updateState(function (array $state) use ($job, $queue, $errors, $installed): array {
            $prepared = isset($state['plugins']) && is_array($state['plugins']) ? $state['plugins'] : [];
            $state['last_result'] = [
                'prepared_at' => gmdate('c'),
                'installed' => max(0, (int) ($job['installed'] ?? count($queue))),
                'prepared' => max(0, (int) ($job['prepared'] ?? 0)),
                'skipped' => max(0, (int) ($job['skipped'] ?? 0)),
                'errors' => $this->withoutResolvedPackageErrors($errors, $prepared, $installed),
            ];

            return $state;
        });
        $errors = isset($state['last_result']['errors']) && is_array($state['last_result']['errors'])
            ? $state['last_result']['errors']
            : [];

        $job['status'] = 'completed';
        $job['cursor'] = count($queue);
        $job['errors'] = $errors;
        $job['updated_at'] = gmdate('c');
        $job['current_message'] = 'Official plugin package refresh complete.';
        $this->writePluginPackageJob((string) ($job['job_id'] ?? ''), $job);

        return $this->publicProgressivePrepareState($job);
    }

    /**
     * @return array<string,mixed>
     */
    private function readPluginPackageJob(string $jobId): array
    {
        $jobId = $this->sanitizeJobId($jobId);
        if ($jobId === '') {
            return [];
        }

        $path = $this->pluginPackageJobPath($jobId);
        if (!is_file($path)) {
            return [];
        }

        $raw = file_get_contents($path);
        $decoded = is_string($raw) ? \ShieldX\Support\Json::decodeArray($raw) : null;

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

    /**
     * @param array<string,mixed> $job
     */
    private function writePluginPackageJob(string $jobId, array $job): void
    {
        $jobId = $this->sanitizeJobId($jobId);
        if ($jobId === '') {
            throw new \RuntimeException('Could not save official plugin package job.');
        }

        $this->makeDirectory($this->pluginPackageJobDirectory());
        $json = wp_json_encode($job, JSON_PRETTY_PRINT);
        if (!is_string($json)) {
            throw new \RuntimeException('Could not encode official plugin package job.');
        }

        $path = $this->pluginPackageJobPath($jobId);
        $tmp = $path . '.tmp';
        file_put_contents($tmp, $json . "\n");
        if (function_exists('chmod')) {
            chmod($tmp, 0600);
        }

        if (!rename($tmp, $path)) {
            $this->removePath($tmp);
            throw new \RuntimeException('Could not save official plugin package job.');
        }
    }

    /**
     * @param array<int,string> $queue
     */
    private function nextPluginMessage(array $queue, int $cursor): string
    {
        if ($cursor >= count($queue)) {
            return 'Finalizing official plugin package hashes.';
        }

        $slug = $this->sanitizeSlug((string) $queue[$cursor]);
        $plugin = $slug !== '' ? $this->installedPluginBySlug($slug) : [];
        $label = !empty($plugin) ? $this->pluginProgressLabel($plugin) : $slug;

        return sprintf('Next official package: %s.', $label);
    }

    /**
     * @param array<string,mixed> $plugin
     */
    private function pluginProgressLabel(array $plugin): string
    {
        $name = (string) ($plugin['name'] ?? $plugin['slug'] ?? 'Plugin');
        $version = (string) ($plugin['version'] ?? '');

        return $version !== '' ? $name . ' ' . $version : $name;
    }

    private function truncateMessage(string $message, int $limit): string
    {
        $message = trim(preg_replace('/\s+/', ' ', $message) ?? $message);
        if ($message === '' || strlen($message) <= $limit) {
            return $message;
        }

        return rtrim(substr($message, 0, max(0, $limit - 3))) . '...';
    }

    /**
     * @return array<string,mixed>
     */
    private function readState(): array
    {
        if (is_array($this->stateCache)) {
            return $this->stateCache;
        }

        return $this->readStateFromDisk();
    }

    /**
     * @return array<string,mixed>
     */
    private function readStateFromDisk(): array
    {
        $path = $this->statePath();
        if (!is_file($path)) {
            $decoded = ['plugins' => []];
        } else {
            $raw = file_get_contents($path);
            $decoded = is_string($raw)
                ? \ShieldX\Support\Json::decodeArray($raw, self::MAX_PLUGIN_HASH_STATE_BYTES)
                : null;
            if (!is_array($decoded) || !$this->pluginStateSignatureValid($decoded)) {
                $decoded = ['plugins' => []];
            }
        }

        if (!isset($decoded['plugins']) || !is_array($decoded['plugins'])) {
            $decoded['plugins'] = [];
        }
        foreach ($this->readTrustedInstalledEntries() as $slug => $entry) {
            $decoded['plugins'][$slug] = $entry;
        }

        $this->stateCache = $decoded;

        return $this->stateCache;
    }

    /**
     * @param callable(array<string,mixed>):array<string,mixed> $mutator
     * @return array<string,mixed>
     */
    private function updateState(callable $mutator): array
    {
        $this->makeDirectory($this->cacheDirectory());
        $lock = fopen($this->statePath() . '.lock', 'c');
        if (!is_resource($lock)) {
            throw new \RuntimeException('Could not open the plugin hash state lock.');
        }

        $locked = false;
        try {
            $locked = flock($lock, LOCK_EX);
            if (!$locked) {
                throw new \RuntimeException('Could not lock the plugin hash state.');
            }

            $state = $mutator($this->readStateFromDisk());
            $this->writeState($state);

            return $state;
        } finally {
            if ($locked) {
                flock($lock, LOCK_UN);
            }
            fclose($lock);
        }
    }

    /**
     * @param array<string,mixed> $state
     */
    private function writeState(array $state): void
    {
        $this->makeDirectory($this->cacheDirectory());
        if (!isset($state['plugins']) || !is_array($state['plugins'])) {
            $state['plugins'] = [];
        }
        foreach ($state['plugins'] as $slug => $entry) {
            if (!is_array($entry) || (string) ($entry['source'] ?? '') !== 'installed_trusted') {
                continue;
            }

            $this->writeTrustedInstalledEntry($entry);
            unset($state['plugins'][$slug]);
        }

        $state['updated_at'] = gmdate('c');
        $state['signature'] = [
            'alg' => 'hmac-sha256',
            'value' => $this->signPluginState($state),
        ];
        $json = wp_json_encode($state, JSON_PRETTY_PRINT);
        if (!is_string($json)) {
            throw new \RuntimeException('Could not encode plugin hash state.');
        }

        $this->writeSignedJsonAtomically($this->statePath(), $json, 'Could not save plugin hash state.');
        foreach ($this->readTrustedInstalledEntries() as $slug => $entry) {
            $state['plugins'][$slug] = $entry;
        }
        $this->stateCache = $state;
    }

    /**
     * @return array<string,array<string,mixed>>
     */
    private function readTrustedInstalledEntries(): array
    {
        $directory = $this->trustedInstalledDirectory();
        if (!is_dir($directory)) {
            return [];
        }

        $paths = glob($directory . '/*.json');
        if (!is_array($paths)) {
            return [];
        }

        $entries = [];
        foreach ($paths as $path) {
            if (!is_string($path) || !is_file($path)) {
                continue;
            }

            $raw = file_get_contents($path);
            $document = is_string($raw)
                ? \ShieldX\Support\Json::decodeArray($raw, self::MAX_PLUGIN_HASH_STATE_BYTES)
                : null;
            if (!is_array($document) || !$this->pluginStateSignatureValid($document)) {
                continue;
            }

            $entry = isset($document['entry']) && is_array($document['entry']) ? $document['entry'] : [];
            $slug = $this->sanitizeSlug((string) ($entry['slug'] ?? ''));
            if (
                $slug === ''
                || basename($path) !== $slug . '.json'
                || (string) ($entry['source'] ?? '') !== 'installed_trusted'
                || (string) ($entry['version'] ?? '') === ''
                || empty($entry['hashes'])
                || !is_array($entry['hashes'])
            ) {
                continue;
            }

            $entries[$slug] = $entry;
        }

        return $entries;
    }

    /**
     * @param array<string,mixed> $entry
     */
    private function writeTrustedInstalledEntry(array $entry): void
    {
        $slug = $this->sanitizeSlug((string) ($entry['slug'] ?? ''));
        if (
            $slug === ''
            || (string) ($entry['source'] ?? '') !== 'installed_trusted'
            || (string) ($entry['version'] ?? '') === ''
            || empty($entry['hashes'])
            || !is_array($entry['hashes'])
        ) {
            throw new \RuntimeException('Could not save the trusted installed plugin manifest.');
        }

        $this->makeDirectory($this->trustedInstalledDirectory());
        $document = [
            'entry' => $entry,
            'updated_at' => gmdate('c'),
        ];
        $document['signature'] = [
            'alg' => 'hmac-sha256',
            'value' => $this->signPluginState($document),
        ];
        $json = wp_json_encode($document, JSON_PRETTY_PRINT);
        if (!is_string($json)) {
            throw new \RuntimeException('Could not encode the trusted installed plugin manifest.');
        }

        $this->writeSignedJsonAtomically(
            $this->trustedInstalledPath($slug),
            $json,
            'Could not save the trusted installed plugin manifest.'
        );
    }

    private function writeSignedJsonAtomically(string $path, string $json, string $failureMessage): void
    {
        $payload = $json . "\n";
        $tmp = $path . '.tmp';
        $written = file_put_contents($tmp, $payload, LOCK_EX);
        if (!is_int($written) || $written !== strlen($payload)) {
            $this->removePath($tmp);
            throw new \RuntimeException($failureMessage);
        }
        if (function_exists('chmod')) {
            chmod($tmp, 0600);
        }

        $raw = file_get_contents($tmp);
        $decoded = is_string($raw)
            ? \ShieldX\Support\Json::decodeArray($raw, self::MAX_PLUGIN_HASH_STATE_BYTES)
            : null;
        if (!is_array($decoded) || !$this->pluginStateSignatureValid($decoded)) {
            $this->removePath($tmp);
            throw new \RuntimeException($failureMessage);
        }

        if (!rename($tmp, $path)) {
            $this->removePath($tmp);
            throw new \RuntimeException($failureMessage);
        }
    }

    /**
     * @param array<string,mixed> $state
     */
    private function pluginStateSignatureValid(array $state): bool
    {
        $signature = isset($state['signature']) && is_array($state['signature']) ? $state['signature'] : [];
        $algorithm = (string) ($signature['alg'] ?? '');
        $value = (string) ($signature['value'] ?? '');
        if ($algorithm !== 'hmac-sha256' || preg_match('/^[a-f0-9]{64}$/i', $value) !== 1) {
            return false;
        }

        return hash_equals($this->signPluginState($state), $value);
    }

    /**
     * @param array<string,mixed> $state
     */
    private function signPluginState(array $state): string
    {
        unset($state['signature']);
        $this->sortPluginState($state);
        $json = wp_json_encode($state, JSON_UNESCAPED_SLASHES);
        $salt = function_exists('wp_salt') ? (string) wp_salt('auth') : '';
        $key = hash('sha256', $salt . '|shieldx-plugin-hash-manifest|' . $this->storage->baseDir(), true);

        return is_string($json) ? hash_hmac('sha256', $json, $key) : '';
    }

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

    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 removePath(string $path): void
    {
        if (!file_exists($path)) {
            return;
        }

        if (is_file($path) || is_link($path)) {
            unlink($path);
            return;
        }

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

        foreach ($items as $item) {
            if ($item === '.' || $item === '..') {
                continue;
            }

            $this->removePath($path . DIRECTORY_SEPARATOR . $item);
        }

        rmdir($path);
    }
}
