<?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_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 core files are staged only in a transient system-temp workspace; private SHIELD-X storage retains signed hash metadata and progress state only.

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

final class CoreFileVerifier
{
    private const RELEASE_BASE_URL = 'https://wordpress.org';
    private const EXCERPT_HEAD_LINES = 500;
    private const EXCERPT_CONTEXT_LINES = 20;
    private const EXCERPT_LINE_BYTES = 4096;
    private const MAX_DOWNLOAD_ARCHIVE_BYTES = 209715200;
    private const MAX_ARCHIVE_ENTRIES = 10000;
    private const MAX_EXTRACTED_ARCHIVE_BYTES = 268435456;

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

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

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

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

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

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

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

    public function __construct(FileStorage $storage, string $rootDir, string $version, string $releaseBaseUrl = self::RELEASE_BASE_URL, string $preparedSourceDir = '')
    {
        $this->storage = $storage;
        $this->rootDir = rtrim($rootDir, '/\\');
        $this->version = trim($version);
        $this->releaseBaseUrl = rtrim($releaseBaseUrl, '/');
        $this->preparedSourceDir = rtrim($preparedSourceDir, '/\\');
        $this->officialHashesCache = null;
        $this->temporaryWorkspaceRoots = [];
    }

    public static function fromWordPress(): self
    {
        $root = WordPressPaths::root();
        $version = function_exists('get_bloginfo') ? (string) get_bloginfo('version') : '';

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

    /**
     * @return array<string,mixed>
     */
    public function state(): array
    {
        $hashes = $this->readHashManifest();
        $sourceDir = $this->preparedSourceDir !== '' ? $this->sourceDirectory() : '';

        return [
            'version' => $this->version,
            'archive_url' => $this->archiveUrl(),
            'cache_dir' => $this->releaseDirectory(),
            'source_dir' => $sourceDir,
            'prepared' => !empty($hashes) || (is_dir($sourceDir) && is_file($sourceDir . '/wp-includes/version.php')),
            'hash_manifest' => $this->hashManifestPath(),
            'hash_count' => count($hashes),
        ];
    }

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

        $this->assertSupportedVersion();
        $this->storage->ensure();

        if ($this->preparedSourceDir !== '') {
            $this->officialHashes();

            return $this->state();
        }

        $hashes = $this->readHashManifest();
        if (!empty($hashes)) {
            $this->officialHashesCache = $hashes;

            return $this->state();
        }

        $workspace = $this->createTemporaryWorkspace('core-release');
        try {
            $archive = $workspace . '/wordpress-' . $this->versionSlug() . '.zip';
            $extractDir = $workspace . '/extracted';
            $this->downloadArchive($this->archiveUrl(), $archive);
            $this->makeDirectory($extractDir);
            $this->extractArchive($archive, $extractDir);
            $sourceDir = $this->sourceDirectoryForWorkspace($workspace);

            if (!is_file($sourceDir . '/wp-includes/version.php')) {
                throw new \RuntimeException('Downloaded WordPress archive did not contain a valid core source tree.');
            }

            $this->verifyExtractedChecksums($sourceDir);
            $hashes = $this->buildHashManifest($sourceDir);
            if (empty($hashes)) {
                throw new \RuntimeException('Downloaded WordPress archive did not contain readable core files to hash.');
            }

            $this->writeHashManifest($hashes);
            $this->officialHashesCache = $hashes;
        } finally {
            $this->removeTemporaryWorkspace($workspace);
        }

        return $this->state();
    }

    /**
     * Start a timeout-safe official core preparation job.
     *
     * @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->assertSupportedVersion();
        $this->storage->ensure();

        if ($force) {
            $this->removePath($this->hashManifestPath());
            $this->officialHashesCache = null;
        }

        $releaseDir = $this->releaseDirectory();
        $this->makeDirectory($releaseDir);
        $this->makeDirectory($this->coreJobDirectory());

        $jobId = $this->newJobId();
        $job = [
            'job_id' => $jobId,
            'status' => 'running',
            'version' => $this->version,
            'force' => $force,
            'stage' => 'download',
            'stage_index' => 0,
            'stage_total' => 4,
            'prepared' => 0,
            'errors' => [],
            'batches' => 0,
            'hash_files' => [],
            'hashes' => [],
            'hash_cursor' => 0,
            // Created in the first batch and removed when the job finishes.
            // It is never inside FileStorage or the plugin directory.
            'workspace' => '',
            'current_message' => 'Downloading official WordPress core ' . $this->version . '.',
            'created_at' => gmdate('c'),
            'updated_at' => gmdate('c'),
        ];

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

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

    /**
     * Continue a timeout-safe official core preparation job.
     *
     * @return array<string,mixed>
     */
    public function runProgressivePrepareBatch(string $jobId, int $hashBatchSize = 250, int $maxSeconds = 10, string $nonceAction = '', string $nonce = ''): array
    {
        $jobId = $this->sanitizeJobId($jobId);
        $guardError = $nonceAction !== ''
            ? $this->adminWriteGuard($nonceAction, $nonce)
            : $this->adminReadGuard();
        if ($guardError !== '') {
            return [
                'job_id' => $jobId,
                'status' => 'failed',
                'percent' => 0,
                'files_processed' => 0,
                'files_total' => 0,
                'batches' => 0,
                'message' => $guardError,
                'scope_label' => $guardError,
            ];
        }

        $job = $this->readCoreJob($jobId);
        if (empty($job)) {
            return [
                'job_id' => $jobId,
                'status' => 'failed',
                'percent' => 0,
                'files_processed' => 0,
                'files_total' => 0,
                'batches' => 0,
                'message' => 'The official WordPress core refresh session was not found.',
                'scope_label' => 'Official WordPress core refresh session is missing.',
            ];
        }

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

        $started = microtime(true);
        $hashBatchSize = max(25, min(500, $hashBatchSize));
        $maxSeconds = max(2, min(20, $maxSeconds));

        try {
            while ((microtime(true) - $started) < $maxSeconds) {
                $stage = (string) ($job['stage'] ?? 'download');

                if ($stage === 'download') {
                    $this->progressiveDownloadCore($job);
                    $this->writeCoreJob($jobId, $job);
                    continue;
                }

                if ($stage === 'extract') {
                    $this->progressiveExtractCore($job);
                    $this->writeCoreJob($jobId, $job);
                    continue;
                }

                if ($stage === 'verify') {
                    $this->progressiveVerifyCore($job);
                    $this->writeCoreJob($jobId, $job);
                    continue;
                }

                if ($stage === 'hash_list') {
                    $this->progressiveBuildHashList($job);
                    $this->writeCoreJob($jobId, $job);
                    continue;
                }

                if ($stage === 'hash') {
                    $this->progressiveHashCoreFiles($job, $hashBatchSize);
                    $this->writeCoreJob($jobId, $job);

                    if ((string) ($job['status'] ?? '') !== 'running') {
                        break;
                    }

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

                throw new \RuntimeException('Official WordPress core refresh entered an unknown stage.');
            }
        } catch (\Throwable $error) {
            $this->cleanupCoreJobWorkspace($job);
            $job['status'] = 'failed';
            $job['message'] = 'Official WordPress core refresh failed: ' . $error->getMessage();
            $job['current_message'] = (string) $job['message'];
            $job['updated_at'] = gmdate('c');
            $this->writeCoreJob($jobId, $job);
        }

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

    public function isCorePath(string $path): bool
    {
        try {
            $path = $this->sanitizeRelativePath($path);
        } catch (\Throwable $error) {
            return false;
        }

        if ($path === 'wp-config.php' || strpos($path, 'wp-content/') === 0) {
            return false;
        }

        if (strpos($path, 'wp-admin/') === 0 || strpos($path, 'wp-includes/') === 0) {
            return true;
        }

        return strpos($path, '/') === false && in_array($path, $this->allowedRootFiles(), true);
    }

    /**
     * @return array<string,string>
     */
    public function officialHashes(): array
    {
        if (is_array($this->officialHashesCache)) {
            return $this->officialHashesCache;
        }

        $hashes = $this->readHashManifest();
        if (!empty($hashes)) {
            $this->officialHashesCache = $hashes;

            return $this->officialHashesCache;
        }

        if ($this->preparedSourceDir !== '') {
            $sourceDir = $this->sourceDirectory();
            if (!is_file($sourceDir . '/wp-includes/version.php')) {
                return [];
            }

            $hashes = $this->buildHashManifest($sourceDir);
            $this->writeHashManifest($hashes);
        } else {
            $this->prepareRelease();
            $hashes = $this->readHashManifest();
        }

        $this->officialHashesCache = $hashes;

        return $this->officialHashesCache;
    }

    public function isOfficialCoreMatch(string $path, string $localHash = ''): bool
    {
        try {
            $path = $this->sanitizeRelativePath($path);
        } catch (\InvalidArgumentException $error) {
            return false;
        }

        if (!$this->isCorePath($path)) {
            return false;
        }

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

        $localHash = $localHash !== '' ? $localHash : (string) hash_file('sha256', $localPath);
        $hashes = $this->officialHashes();
        $cleanHash = (string) ($hashes[$path] ?? '');

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

    /**
     * @return array<string,mixed>
     */
    public function compareFile(string $path): array
    {
        $path = $this->sanitizeRelativePath($path);
        $eligible = $this->isCorePath($path);
        $localPath = $this->localPath($path);
        $localExists = is_file($localPath);
        $localHash = $localExists ? (string) hash_file('sha256', $localPath) : '';
        $hashes = $eligible ? $this->officialHashes() : [];
        $cleanHash = isset($hashes[$path]) ? (string) $hashes[$path] : '';
        $cleanExists = $eligible && preg_match('/^[a-f0-9]{64}$/', $cleanHash) === 1;
        $cleanPath = $this->preparedSourceDir !== '' ? $this->cleanPath($path) : '';
        $matches = $localExists && $cleanExists && $localHash !== '' && hash_equals(strtolower($cleanHash), strtolower($localHash));
        $firstChangedLine = null;
        $localExcerpt = [];
        $cleanExcerpt = [];
        $localLineCount = 0;
        $cleanLineCount = 0;
        $cleanSize = 0;
        $textComparable = false;
        $excerptWindowed = false;

        if ($this->isTextCandidate($path) && $localExists && $cleanExists) {
            try {
                if ($this->preparedSourceDir !== '') {
                    $textCompare = $this->comparePreparedCoreSource($path, $localPath, $cleanHash);
                } else {
                    $textCompare = $this->compareWithTransientCoreSource($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,
            'local_path' => $localPath,
            'clean_path' => $cleanPath,
            '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,
        ];
    }

    /**
     * @return array<string,mixed>
     */
    public function replaceFile(string $path, string $nonceAction = 'shieldx_restore_core_file', string $nonce = ''): array
    {
        $guardError = $this->adminWriteGuard($nonceAction, $nonce);
        if ($guardError !== '') {
            throw new \RuntimeException($guardError);
        }

        $path = $this->sanitizeRelativePath($path);

        if (!$this->isCorePath($path)) {
            throw new \InvalidArgumentException('Only WordPress core files can be restored from the official release.');
        }

        $localPath = $this->localPath($path);
        $hashes = $this->officialHashes();

        if (empty($hashes[$path])) {
            throw new \RuntimeException('The official WordPress release copy is not available for this file.');
        }

        if ($this->preparedSourceDir !== '') {
            return $this->replaceFromPreparedCoreSource($path, $localPath, $hashes);
        }

        return $this->replaceWithTransientCoreSource($path, $localPath, $hashes);
    }

    public function archiveUrl(): string
    {
        $locale = $this->locale();
        if ($locale !== 'en_US') {
            return 'https://downloads.wordpress.org/release/' . rawurlencode($locale) . '/wordpress-' . rawurlencode($this->version) . '.zip';
        }

        return $this->releaseBaseUrl . '/wordpress-' . rawurlencode($this->version) . '.zip';
    }

    private function locale(): string
    {
        $locale = function_exists('get_locale') ? (string) get_locale() : 'en_US';
        $locale = (string) preg_replace('/[^A-Za-z0-9_-]/', '', $locale);

        return $locale !== '' ? $locale : 'en_US';
    }

    private function sourceDirectory(): string
    {
        return $this->preparedSourceDir;
    }

    /**
     * @return array<string,mixed>
     */
    private function comparePreparedCoreSource(string $path, string $localPath, string $expectedHash): array
    {
        $cleanPath = $this->cleanPath($path);
        if (!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;
    }

    /**
     * @return array<string,mixed>
     */
    private function compareWithTransientCoreSource(string $path, string $localPath, string $expectedHash): array
    {
        return $this->withTemporaryCoreSource(function (string $sourceDir) use ($path, $localPath, $expectedHash): array {
            $cleanPath = rtrim($sourceDir, '/\\') . '/' . $path;
            if (!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;
        });
    }

    /**
     * @param array<string,string> $hashes
     * @return array<string,mixed>
     */
    private function replaceFromPreparedCoreSource(string $path, string $localPath, array $hashes): array
    {
        return $this->replaceCoreFileFromSource($path, $localPath, $this->sourceDirectory(), $hashes);
    }

    /**
     * @param array<string,string> $hashes
     * @return array<string,mixed>
     */
    private function replaceWithTransientCoreSource(string $path, string $localPath, array $hashes): array
    {
        return $this->withTemporaryCoreSource(function (string $sourceDir) use ($path, $localPath, $hashes): array {
            return $this->replaceCoreFileFromSource($path, $localPath, $sourceDir, $hashes);
        });
    }

    /**
     * @param array<string,string> $hashes
     * @return array<string,mixed>
     */
    private function replaceCoreFileFromSource(string $path, string $localPath, string $sourceDir, array $hashes): array
    {
        $cleanPath = rtrim($sourceDir, '/\\') . '/' . $path;
        if (!is_file($cleanPath)) {
            throw new \RuntimeException('The official WordPress release copy is not available for this file.');
        }

        $this->assertReplacementSourceIntegrity($path, $cleanPath, $sourceDir, $hashes);
        $this->replaceFileAtomically($cleanPath, $localPath);
        clearstatcache(true, $localPath);

        return [
            'path' => $path,
            'local_path' => $localPath,
            // Core repair no longer persists a copy of executable code.
            'backup_path' => '',
            'hash' => (string) hash_file('sha256', $localPath),
            'size' => (int) filesize($localPath),
            'modified_at' => gmdate('Y-m-d H:i:s', (int) filemtime($localPath)),
        ];
    }

    private function replaceFileAtomically(string $source, string $destination): void
    {
        $this->makeDirectory(dirname($destination));
        $token = bin2hex(random_bytes(12));
        $replacement = $destination . '.shieldx-repair-new-' . $token;
        if (!copy($source, $replacement)) {
            throw new \RuntimeException('Could not stage the official WordPress replacement file.');
        }
        if (!FilesystemAdapter::nativeChmod($replacement, 0644, $this->rootDir)) {
            $this->removePath($replacement);
            throw new \RuntimeException('Could not set safe permissions on the official WordPress replacement file.');
        }

        if (rename($replacement, $destination)) {
            return;
        }

        if (!is_file($destination)) {
            $this->removePath($replacement);
            throw new \RuntimeException('Could not replace the local file with the official WordPress copy.');
        }

        // Windows cannot replace an existing path with rename(). Keep the old
        // file only next to its target until the swap succeeds, then remove it.
        $previous = $destination . '.shieldx-repair-old-' . $token;
        if (!rename($destination, $previous)) {
            $this->removePath($replacement);
            throw new \RuntimeException('Could not stage the existing WordPress core file for replacement.');
        }

        if (!rename($replacement, $destination)) {
            rename($previous, $destination);
            $this->removePath($replacement);
            throw new \RuntimeException('Could not replace the local file with the official WordPress copy.');
        }

        $this->removePath($previous);
    }

    /**
     * @param callable(string):mixed $operation
     * @return mixed
     */
    private function withTemporaryCoreSource(callable $operation)
    {
        $workspace = $this->createTemporaryWorkspace('core-release');
        try {
            $archive = $workspace . '/wordpress-' . $this->versionSlug() . '.zip';
            $extractDir = $workspace . '/extracted';
            $this->downloadArchive($this->archiveUrl(), $archive);
            $this->makeDirectory($extractDir);
            $this->extractArchive($archive, $extractDir);
            $sourceDir = $this->sourceDirectoryForWorkspace($workspace);
            if (!is_file($sourceDir . '/wp-includes/version.php')) {
                throw new \RuntimeException('Downloaded WordPress archive did not contain a valid core source tree.');
            }

            $this->verifyExtractedChecksums($sourceDir);

            return call_user_func($operation, $sourceDir);
        } finally {
            $this->removeTemporaryWorkspace($workspace);
        }
    }

    private function sourceDirectoryForWorkspace(string $workspace): string
    {
        return rtrim($workspace, '/\\') . '/extracted/wordpress';
    }

    private function releaseDirectory(): string
    {
        return $this->storage->baseDir() . '/cache/wordpress-core/' . $this->versionSlug();
    }

    private function versionSlug(): string
    {
        return preg_replace('~[^A-Za-z0-9._-]+~', '-', $this->version) ?: 'unknown';
    }

    private function assertSupportedVersion(): void
    {
        if (!preg_match('~^\d+\.\d+(?:\.\d+)?(?:[-._A-Za-z0-9]+)?$~', $this->version)) {
            throw new \InvalidArgumentException('Installed WordPress version is not a supported release version.');
        }
    }

    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 core 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
    {
        foreach (['_wpnonce', '_ajax_nonce'] as $key) {
            $value = $this->requestNonceValue($key);
            if ($value !== '') {
                return $value;
            }
        }

        return '';
    }

    private function requestNonceValue(string $key): string
    {
        $value = '';

        // phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended -- This helper only retrieves the nonce value so adminWriteGuard() can verify it immediately.
        if (isset($_POST[$key]) && is_scalar($_POST[$key])) {
            // phpcs:ignore 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($_POST[$key]) : $_POST[$key];
        } elseif (isset($_GET[$key]) && is_scalar($_GET[$key])) {
            // phpcs:ignore 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($_GET[$key]) : $_GET[$key];
        }
        // phpcs:enable WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended

        $value = is_scalar($value) ? (string) $value : '';

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

    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)) {
            throw new \InvalidArgumentException('Invalid file path.');
        }

        foreach (explode('/', $path) as $segment) {
            if ($segment === '' || $segment === '.' || $segment === '..') {
                throw new \InvalidArgumentException('Invalid file path.');
            }
        }

        return $path;
    }

    private function localPath(string $path): string
    {
        return $this->rootDir . '/' . $path;
    }

    private function cleanPath(string $path): string
    {
        return $this->sourceDirectory() . '/' . $path;
    }

    /**
     * @param array<string,string> $hashes
     */
    private function assertReplacementSourceIntegrity(string $path, string $cleanPath, string $sourceDir, array $hashes): void
    {
        if (!is_dir($sourceDir) || is_link($sourceDir) || is_link($cleanPath)) {
            throw new \RuntimeException('The official WordPress release copy failed integrity validation.');
        }

        if ($this->preparedSourceDir === '') {
            if ($this->isGroupOrWorldWritable($sourceDir) || $this->isGroupOrWorldWritable($cleanPath)) {
                throw new \RuntimeException('The official WordPress release cache has unsafe permissions.');
            }

            // A repair is rare enough to revalidate the staged source against
            // WordPress.org immediately before copying it into the live site.
            $this->assertNoExtractedSymlinks($sourceDir);
            $this->verifyExtractedChecksums($sourceDir);
        }

        $expected = isset($hashes[$path]) ? (string) $hashes[$path] : '';
        $actual = hash_file('sha256', $cleanPath);

        if ($expected === '' || !is_string($actual) || !hash_equals($expected, strtolower($actual))) {
            throw new \RuntimeException('The official WordPress release copy failed integrity validation.');
        }
    }

    private function isGroupOrWorldWritable(string $path): bool
    {
        $permissions = fileperms($path);

        return $permissions !== false && ($permissions & 0022) !== 0;
    }

    private function hashManifestPath(): string
    {
        return $this->releaseDirectory() . '/core-hashes.json';
    }

    /**
     * @return array<string,string>
     */
    private function readHashManifest(): array
    {
        $path = $this->hashManifestPath();
        if (!is_file($path)) {
            return [];
        }

        $json = file_get_contents($path);
        $decoded = is_string($json) ? \ShieldX\Support\Json::decodeArray($json) : null;
        if (!is_array($decoded) || !$this->hashManifestSignatureValid($decoded) || (string) ($decoded['version'] ?? '') !== $this->version || !isset($decoded['hashes']) || !is_array($decoded['hashes'])) {
            return [];
        }

        $hashes = [];
        foreach ($decoded['hashes'] as $filePath => $hash) {
            $filePath = is_string($filePath) ? $filePath : '';
            $hash = is_string($hash) ? $hash : '';
            if ($filePath !== '' && preg_match('~^[a-f0-9]{64}$~i', $hash)) {
                $hashes[$filePath] = strtolower($hash);
            }
        }

        return $hashes;
    }

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

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

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

            $filePath = $item->getPathname();
            $relative = ScanRuleEngine::normalizePath(substr($filePath, strlen(rtrim($sourceDir, '/\\')) + 1));
            if (!$this->isCorePath($relative)) {
                continue;
            }

            $hash = hash_file('sha256', $filePath);
            if (is_string($hash) && $hash !== '') {
                $hashes[$relative] = strtolower($hash);
            }
        }

        ksort($hashes);

        return $hashes;
    }

    /**
     * @param array<string,string> $hashes
     */
    private function writeHashManifest(array $hashes): void
    {
        $this->makeDirectory(dirname($this->hashManifestPath()));
        $manifest = [
            'version' => $this->version,
            'generated_at' => gmdate('c'),
            'source' => $this->preparedSourceDir !== '' ? 'provided-source' : 'official-transient-download',
            'count' => count($hashes),
            'hashes' => $hashes,
        ];
        $manifest['signature'] = [
            'alg' => 'hmac-sha256',
            'value' => $this->signHashManifest($manifest),
        ];
        file_put_contents($this->hashManifestPath(), (string) wp_json_encode($manifest));
    }

    /**
     * @param array<string,mixed> $manifest
     */
    private function hashManifestSignatureValid(array $manifest): bool
    {
        $signature = isset($manifest['signature']) && is_array($manifest['signature']) ? $manifest['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->signHashManifest($manifest), $value);
    }

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

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

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

    /**
     * @return array<int,string>
     */
    private function allowedRootFiles(): array
    {
        return [
            'index.php',
            'license.txt',
            'readme.html',
            'wp-activate.php',
            'wp-blog-header.php',
            'wp-comments-post.php',
            'wp-config-sample.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',
        ];
    }

    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));
        $tmp = $archive . '.tmp';
        $this->removePath($tmp);

        $fileApi = WordPressPaths::adminIncludesPath('file.php');
        if ($fileApi !== '' && is_file($fileApi)) {
            require_once $fileApi;
        }

        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 WordPress releases.');
        }

        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 WordPress release: ' . $error->getMessage(), 0, $error);
        }

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

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

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

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

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

    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 the official WordPress release.');
        }

        $this->assertTemporaryWorkspaceDestination($destination);

        $zip = new \ZipArchive();
        $opened = $zip->open($archive);

        if ($opened !== true) {
            throw new \RuntimeException('Could not open official WordPress release archive.');
        }

        if ($zip->numFiles < 1 || $zip->numFiles > self::MAX_ARCHIVE_ENTRIES) {
            $zip->close();
            throw new \RuntimeException('Official WordPress release 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 WordPress release 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 WordPress release 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 WordPress release archive contains a symlink entry.');
            }
        }

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

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

    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 WordPress release extraction produced a symlink.');
            }
        }
    }

    private function assertTemporaryWorkspaceDestination(string $destination): void
    {
        $destinationRoot = realpath($destination);
        if (!is_string($destinationRoot) || $destinationRoot === '' || is_link($destination)) {
            throw new \RuntimeException('Official WordPress release extraction must use 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('Official WordPress release extraction must use 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 createTemporaryWorkspace(string $purpose): string
    {
        $base = rtrim((string) sys_get_temp_dir(), '/\\');
        $purpose = preg_replace('/[^a-z0-9-]+/i', '-', $purpose) ?: 'core';
        if ($base === '' || !is_dir($base) || !wp_is_writable($base)) {
            throw new \RuntimeException('A writable system temporary directory is required to verify WordPress core files.');
        }

        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 WordPress core files.');
    }

    private function registerTemporaryWorkspace(string $workspace): bool
    {
        $base = realpath((string) sys_get_temp_dir());
        $root = realpath($workspace);
        if (
            !is_string($base)
            || !is_string($root)
            || $base === ''
            || $root === ''
            || is_link($workspace)
            || !self::pathEqualsOrInside($root, $base)
            || preg_match('~^shieldx-core-release-[a-f0-9]{32}$~', basename($root)) !== 1
        ) {
            return false;
        }

        $this->temporaryWorkspaceRoots[rtrim(str_replace('\\', '/', $root), '/')] = true;

        return true;
    }

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

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

    private function verifyExtractedChecksums(string $sourceDir): void
    {
        $checksums = $this->officialMd5Checksums();
        $checked = 0;

        foreach ($checksums as $path => $expectedMd5) {
            if (!$this->isCorePath($path)) {
                continue;
            }

            $file = rtrim($sourceDir, '/\\') . '/' . $path;
            if (!is_file($file)) {
                throw new \RuntimeException('Official WordPress release is missing a checksummed core file.');
            }

            $actual = hash_file('md5', $file);
            if (!is_string($actual) || !hash_equals(strtolower($expectedMd5), strtolower($actual))) {
                throw new \RuntimeException('Official WordPress release failed checksum verification.');
            }

            $checked++;
        }

        if ($checked < 50) {
            throw new \RuntimeException('Official WordPress checksum manifest did not cover enough core files.');
        }
    }

    /**
     * @return array<string,string>
     */
    private function officialMd5Checksums(): array
    {
        if (!function_exists('wp_remote_get') || !function_exists('wp_remote_retrieve_response_code') || !function_exists('wp_remote_retrieve_body')) {
            throw new \RuntimeException('The WordPress HTTP API is required to verify official WordPress checksums.');
        }

        $url = 'https://api.wordpress.org/core/checksums/1.0/?version=' . rawurlencode($this->version) . '&locale=' . rawurlencode($this->locale());
        try {
            $response = wp_remote_get($url, [
                'timeout' => 30,
                'redirection' => 0,
                'reject_unsafe_urls' => true,
            ]);
        } catch (\Throwable $error) {
            throw new \RuntimeException('Could not fetch official WordPress checksums: ' . $error->getMessage(), 0, $error);
        }

        if (function_exists('is_wp_error') && is_wp_error($response)) {
            throw new \RuntimeException('Could not fetch official WordPress checksums: ' . $response->get_error_message());
        }

        if ((int) wp_remote_retrieve_response_code($response) !== 200) {
            throw new \RuntimeException('Official WordPress checksum endpoint returned an unexpected status.');
        }

        $decoded = \ShieldX\Support\Json::decodeArray((string) wp_remote_retrieve_body($response));
        $rawChecksums = isset($decoded['checksums']) && is_array($decoded['checksums']) ? $decoded['checksums'] : [];
        $checksums = [];

        foreach ($rawChecksums as $path => $md5) {
            $path = is_string($path) ? ScanRuleEngine::normalizePath($path) : '';
            $md5 = is_string($md5) ? strtolower($md5) : '';
            if ($path !== '' && preg_match('/^[a-f0-9]{32}$/', $md5) === 1) {
                $checksums[$path] = $md5;
            }
        }

        return $checksums;
    }

    /**
     * @param array<string,mixed> $job
     */
    private function progressiveDownloadCore(array &$job): void
    {
        if ($this->preparedSourceDir !== '') {
            $job['stage'] = 'hash_list';
            $job['stage_index'] = 3;
            $job['current_message'] = 'Building official WordPress core hash list.';
            $job['updated_at'] = gmdate('c');
            return;
        }

        $workspace = $this->coreWorkspaceForJob($job);
        $archive = $workspace . '/wordpress-' . $this->versionSlug() . '.zip';

        $job['current_message'] = 'Downloading official WordPress core ' . $this->version . '.';
        if (!is_file($archive)) {
            $this->downloadArchive($this->archiveUrl(), $archive);
        }

        $job['stage'] = 'extract';
        $job['stage_index'] = 1;
        $job['current_message'] = 'Extracting official WordPress core package.';
        $job['updated_at'] = gmdate('c');
    }

    /**
     * @param array<string,mixed> $job
     */
    private function progressiveExtractCore(array &$job): void
    {
        $workspace = $this->coreWorkspaceForJob($job);
        $extractDir = $workspace . '/extracted';
        $archive = $workspace . '/wordpress-' . $this->versionSlug() . '.zip';
        $sourceDir = $this->sourceDirectoryForWorkspace($workspace);

        $job['current_message'] = 'Extracting official WordPress core package.';
        if (!is_file($sourceDir . '/wp-includes/version.php')) {
            $this->removePath($extractDir);
            $this->makeDirectory($extractDir);
            $this->extractArchive($archive, $extractDir);
        }

        if (!is_file($sourceDir . '/wp-includes/version.php')) {
            throw new \RuntimeException('Downloaded WordPress archive did not contain a valid core source tree.');
        }

        $job['stage'] = 'verify';
        $job['stage_index'] = 2;
        $job['current_message'] = 'Verifying official WordPress checksums.';
        $job['updated_at'] = gmdate('c');
    }

    /**
     * @param array<string,mixed> $job
     */
    private function progressiveVerifyCore(array &$job): void
    {
        $job['current_message'] = 'Verifying official WordPress checksums.';
        $this->verifyExtractedChecksums($this->sourceDirectoryForCoreJob($job));

        $job['stage'] = 'hash_list';
        $job['stage_index'] = 3;
        $job['current_message'] = 'Listing official WordPress core files for hashing.';
        $job['updated_at'] = gmdate('c');
    }

    /**
     * @param array<string,mixed> $job
     */
    private function progressiveBuildHashList(array &$job): void
    {
        $files = $this->coreHashFileList($this->sourceDirectoryForCoreJob($job));

        $job['hash_files'] = $files;
        $job['hashes'] = [];
        $job['hash_cursor'] = 0;
        $job['stage'] = 'hash';
        $job['stage_index'] = 4;
        $job['current_message'] = 'Hashing official WordPress core files.';
        $job['updated_at'] = gmdate('c');
    }

    /**
     * @param array<string,mixed> $job
     */
    private function progressiveHashCoreFiles(array &$job, int $hashBatchSize): void
    {
        $files = isset($job['hash_files']) && is_array($job['hash_files']) ? array_values($job['hash_files']) : [];
        $hashes = isset($job['hashes']) && is_array($job['hashes']) ? $job['hashes'] : [];
        $cursor = max(0, (int) ($job['hash_cursor'] ?? 0));
        $sourceDir = rtrim($this->sourceDirectoryForCoreJob($job), '/\\');
        $limit = min(count($files), $cursor + $hashBatchSize);

        for ($index = $cursor; $index < $limit; $index++) {
            $relative = is_string($files[$index]) ? $files[$index] : '';
            if ($relative === '') {
                continue;
            }

            $file = $sourceDir . '/' . $relative;
            if (!is_file($file)) {
                throw new \RuntimeException('Official WordPress release is missing a file while hashing.');
            }

            $hash = hash_file('sha256', $file);
            if (is_string($hash) && $hash !== '') {
                $hashes[$relative] = strtolower($hash);
            }
        }

        $job['hashes'] = $hashes;
        $job['hash_cursor'] = $limit;
        $job['batches'] = (int) ($job['batches'] ?? 0) + 1;
        $job['current_message'] = sprintf('Hashing official WordPress core files: %1$d of %2$d complete.', $limit, count($files));
        $job['updated_at'] = gmdate('c');

        if ($limit < count($files)) {
            return;
        }

        ksort($hashes);
        $this->writeHashManifest($hashes);
        $this->officialHashesCache = $hashes;
        $this->cleanupCoreJobWorkspace($job);
        $job['hash_files'] = [];
        $job['hashes'] = [];
        $job['hash_cursor'] = 0;

        $job['status'] = 'completed';
        $job['prepared'] = 1;
        $job['percent'] = 100;
        $job['current_message'] = 'Official WordPress core hashes are ready.';
        $job['updated_at'] = gmdate('c');
    }

    /**
     * @return array<int,string>
     */
    private function coreHashFileList(string $sourceDir): array
    {
        $files = [];
        if (!is_dir($sourceDir)) {
            return $files;
        }

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

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

            $filePath = $item->getPathname();
            $relative = ScanRuleEngine::normalizePath(substr($filePath, strlen(rtrim($sourceDir, '/\\')) + 1));
            if ($this->isCorePath($relative)) {
                $files[] = $relative;
            }
        }

        sort($files);

        return $files;
    }

    /**
     * @param array<string,mixed> $job
     */
    private function coreWorkspaceForJob(array &$job): string
    {
        $workspace = isset($job['workspace']) && is_string($job['workspace']) ? $job['workspace'] : '';
        if ($workspace === '') {
            $workspace = $this->createTemporaryWorkspace('core-release');
            $job['workspace'] = $workspace;

            return $workspace;
        }

        if (!$this->registerTemporaryWorkspace($workspace)) {
            throw new \RuntimeException('Official WordPress core refresh temporary workspace is unavailable.');
        }

        return $workspace;
    }

    /**
     * @param array<string,mixed> $job
     */
    private function sourceDirectoryForCoreJob(array &$job): string
    {
        if ($this->preparedSourceDir !== '') {
            return $this->sourceDirectory();
        }

        return $this->sourceDirectoryForWorkspace($this->coreWorkspaceForJob($job));
    }

    /**
     * @param array<string,mixed> $job
     */
    private function cleanupCoreJobWorkspace(array &$job): void
    {
        $workspace = isset($job['workspace']) && is_string($job['workspace']) ? $job['workspace'] : '';
        if ($workspace !== '' && $this->registerTemporaryWorkspace($workspace)) {
            $this->removeTemporaryWorkspace($workspace);
        }

        unset($job['workspace']);
    }

    /**
     * @param array<string,mixed> $job
     * @return array<string,mixed>
     */
    private function publicProgressivePrepareState(array $job): array
    {
        $status = (string) ($job['status'] ?? 'running');
        $files = isset($job['hash_files']) && is_array($job['hash_files']) ? $job['hash_files'] : [];
        $hashTotal = count($files);
        $hashCursor = min($hashTotal, max(0, (int) ($job['hash_cursor'] ?? 0)));
        $stage = (string) ($job['stage'] ?? 'download');
        $stageIndex = max(0, (int) ($job['stage_index'] ?? 0));
        $stageTotal = max(4, (int) ($job['stage_total'] ?? 4));
        $percent = $status === 'completed' ? 100 : $this->coreProgressPercent($stage, $stageIndex, $stageTotal, $hashCursor, $hashTotal);
        $filesLabel = $stage === 'hash' && $hashTotal > 0
            ? sprintf('%1$d / %2$d files hashed', $hashCursor, $hashTotal)
            : sprintf('%1$d / %2$d stages', min($stageIndex + 1, $stageTotal), $stageTotal);
        $batchesLabel = $status === 'completed'
            ? 'Core package ready'
            : sprintf('%d hash batches', max(0, (int) ($job['batches'] ?? 0)));

        return [
            'job_id' => (string) ($job['job_id'] ?? ''),
            'status' => $status,
            'percent' => $percent,
            'files_processed' => $stage === 'hash' ? $hashCursor : min($stageIndex + 1, $stageTotal),
            'files_total' => $stage === 'hash' && $hashTotal > 0 ? $hashTotal : $stageTotal,
            'files_label' => $filesLabel,
            'batches' => max(0, (int) ($job['batches'] ?? 0)),
            'batches_label' => $batchesLabel,
            'scope_label' => (string) ($job['current_message'] ?? 'Preparing official WordPress core package.'),
            'prepared' => max(0, (int) ($job['prepared'] ?? 0)),
            'message' => (string) ($job['message'] ?? ''),
            'errors' => isset($job['errors']) && is_array($job['errors']) ? $job['errors'] : [],
        ];
    }

    private function coreProgressPercent(string $stage, int $stageIndex, int $stageTotal, int $hashCursor, int $hashTotal): int
    {
        if ($stage === 'download') {
            return 5;
        }
        if ($stage === 'extract') {
            return 25;
        }
        if ($stage === 'verify') {
            return 45;
        }
        if ($stage === 'hash_list') {
            return 55;
        }
        if ($stage === 'hash') {
            return min(99, 55 + (int) floor(($hashCursor / max(1, $hashTotal)) * 44));
        }

        return min(99, (int) floor(($stageIndex / max(1, $stageTotal)) * 100));
    }

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

    private function coreJobPath(string $jobId): string
    {
        return $this->coreJobDirectory() . '/core-release-' . $this->sanitizeJobId($jobId) . '.json';
    }

    private function sanitizeJobId(string $jobId): string
    {
        $jobId = strtolower($jobId);
        if (preg_match('/^[a-f0-9]{32}$/', $jobId) !== 1) {
            throw new \InvalidArgumentException('Invalid official WordPress core refresh session.');
        }

        return $jobId;
    }

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

    /**
     * @return array<string,mixed>
     */
    private function readCoreJob(string $jobId): array
    {
        $path = $this->coreJobPath($jobId);
        if (!is_file($path)) {
            return [];
        }

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

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

    /**
     * @param array<string,mixed> $job
     */
    private function writeCoreJob(string $jobId, array $job): void
    {
        if ($jobId === '') {
            return;
        }

        $path = $this->coreJobPath($jobId);
        $this->makeDirectory(dirname($path));
        $tmp = $path . '.tmp-' . $this->newJobId();
        $json = wp_json_encode($job);
        if (!is_string($json) || file_put_contents($tmp, $json, LOCK_EX) === false) {
            $this->removePath($tmp);
            throw new \RuntimeException('Could not write official WordPress core refresh state.');
        }

        chmod($tmp, 0600);
        if (!rename($tmp, $path)) {
            $this->removePath($tmp);
            throw new \RuntimeException('Could not finalize official WordPress core refresh state.');
        }
        chmod($path, 0600);
    }

    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 core 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 core 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 core 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 core 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 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);
    }
}
