<?php

declare(strict_types=1);

namespace ShieldX\Maintenance;

defined('ABSPATH') || exit;

// phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_mkdir,WordPress.WP.AlternativeFunctions.file_system_operations_is_writable,WordPress.WP.AlternativeFunctions.file_system_operations_chmod,WordPress.WP.AlternativeFunctions.file_system_operations_fopen,WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- SHIELD-X owns this private backup storage tree, copies files for recovery, and streams bounded shadow-compare excerpts from private files.

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

final class FileShadowCopyManager
{
    private const EXCERPT_HEAD_LINES = 500;
    private const EXCERPT_CONTEXT_LINES = 20;
    private const EXCERPT_LINE_BYTES = 4096;

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

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

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

    /** @var int */
    private $indexBatchDepth;

    /** @var bool */
    private $indexDirty;

    public function __construct(FileStorage $storage, string $rootDir = '')
    {
        $this->storage = $storage;
        $this->rootDir = rtrim(str_replace('\\', '/', $rootDir), '/');
        $this->indexCache = null;
        $this->indexBatchDepth = 0;
        $this->indexDirty = false;
    }

    public static function fromWordPress(): self
    {
        return new self(
            FileStorage::fromWordPress(),
            WordPressPaths::root()
        );
    }

    public function hasTrustedCopy(string $relativePath): bool
    {
        $path = $this->normalizeRelativePath($relativePath);
        if ($path === '' || $this->isIgnoredRuntimePath($path)) {
            return false;
        }

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

        return is_array($item) && !empty($item['shadow_available']) && is_file($this->shadowPath($path));
    }

    /**
     * @param array<string,mixed> $metadata
     * @return array<string,mixed>
     */
    public function ensureTrusted(string $relativePath, string $sourcePath, string $hash, array $metadata = [], int $userId = 0): array
    {
        $path = $this->normalizeRelativePath($relativePath);
        $source = $this->safeSourcePath($path, $sourcePath);
        $hash = $this->sanitizeHash($hash);
        if ($path === '' || $source === '' || $hash === '' || $this->isIgnoredRuntimePath($path)) {
            return $this->result(false, 'The selected file could not be shadowed.', $path);
        }

        $item = $this->item($path);
        if (is_array($item) && (string) ($item['trusted_hash'] ?? '') === $hash && !empty($item['shadow_available']) && is_file($this->shadowPath($path))) {
            return $this->result(true, 'Shadow copy already matches the trusted file.', $path, ['item' => $item]);
        }

        return $this->captureTrusted($path, $source, $hash, $metadata, $userId, 'scanner_baseline');
    }

    /**
     * @param array<string,mixed> $metadata
     * @return array<string,mixed>
     */
    public function markChanged(string $relativePath, string $sourcePath, string $previousHash, string $currentHash, array $metadata = []): array
    {
        $path = $this->normalizeRelativePath($relativePath);
        $source = $this->safeSourcePath($path, $sourcePath);
        $previousHash = $this->sanitizeHash($previousHash);
        $currentHash = $this->sanitizeHash($currentHash);
        if ($path === '' || $source === '' || $previousHash === '' || $currentHash === '' || $this->isIgnoredRuntimePath($path)) {
            return $this->result(false, 'The selected file change could not be tracked.', $path);
        }

        $index = $this->index();
        $item = isset($index[$path]) && is_array($index[$path]) ? $index[$path] : $this->emptyItem($path);
        $shadowAvailable = !empty($item['shadow_available']) && is_file($this->shadowPath($path));
        if ((string) ($item['trusted_hash'] ?? '') === '') {
            $item['trusted_hash'] = $previousHash;
        }
        $now = gmdate('c');
        $scanId = $this->sanitizeScanId((string) ($metadata['scan_id'] ?? ''));
        $scanStartedAt = (string) ($metadata['scan_started_at'] ?? $now);

        $item['path'] = $path;
        $item['status'] = 'pending';
        $item['previous_hash'] = $previousHash;
        $item['current_hash'] = $currentHash;
        $item['current_size'] = $this->fileSize($source);
        $item['current_modified_at'] = (string) ($metadata['modified_at'] ?? $this->fileModifiedAt($source));
        $item['changed_at'] = (string) ($item['changed_at'] ?? $now);
        $item['last_seen_at'] = $now;
        if ($scanId !== '') {
            $item['last_scan_id'] = $scanId;
            $item['last_scan_at'] = $scanStartedAt !== '' ? $scanStartedAt : $now;
        }
        $item['detections'] = max(1, (int) ($item['detections'] ?? 0) + 1);
        $item['shadow_available'] = $shadowAvailable;
        $item['shadow_path'] = $this->shadowPath($path);

        $index[$path] = $item;
        $this->writeIndex($index);

        return $this->result(true, $shadowAvailable ? 'Previous trusted copy is available.' : 'File change tracked, but no earlier shadow copy exists yet.', $path, ['item' => $item]);
    }

    /**
     * @return array<string,mixed>
     */
    public function confirmCurrent(string $relativePath, int $userId = 0): array
    {
        $path = $this->normalizeRelativePath($relativePath);
        $source = $this->sourceFromRelative($path);
        if ($path === '' || $source === '') {
            return $this->result(false, 'The selected file could not be found.', $path);
        }

        $hash = $this->fileHash($source);
        if ($hash === '') {
            return $this->result(false, 'Could not hash the selected file.', $path);
        }

        return $this->captureTrusted($path, $source, $hash, [
            'modified_at' => $this->fileModifiedAt($source),
        ], $userId, 'admin_confirmed');
    }

    /**
     * @return array<string,mixed>
     */
    public function restore(string $relativePath, int $userId = 0): array
    {
        $path = $this->normalizeRelativePath($relativePath);
        $target = $this->sourceFromRelative($path, false);
        $shadow = $this->shadowPath($path);
        if ($path === '' || $target === '' || !is_file($shadow)) {
            return $this->result(false, 'Shadow copy was not found for this file.', $path);
        }

        $this->ensureDirectory(dirname($target));
        if (!copy($shadow, $target)) {
            return $this->result(false, 'Could not restore the shadow copy to the live file.', $path);
        }

        clearstatcache(true, $target);
        $hash = $this->fileHash($target);
        $index = $this->index();
        $item = isset($index[$path]) && is_array($index[$path]) ? $index[$path] : $this->emptyItem($path);
        $item['status'] = 'trusted';
        $item['current_hash'] = $hash;
        $item['current_size'] = $this->fileSize($target);
        $item['current_modified_at'] = $this->fileModifiedAt($target);
        $item['restored_at'] = gmdate('c');
        $item['restored_by'] = $userId;
        $item['shadow_available'] = true;
        $item['shadow_path'] = $shadow;
        $index[$path] = $item;
        $this->writeIndex($index);

        return $this->result(true, 'Shadow copy restored.', $path, ['item' => $item]);
    }

    /**
     * @return array<string,mixed>
     */
    public function compareFile(string $relativePath): array
    {
        $path = $this->normalizeRelativePath($relativePath);
        if ($path === '') {
            return [
                'path' => '',
                'error' => 'The selected file path is not valid.',
            ];
        }

        $currentPath = $this->sourceFromRelative($path, false);
        $shadowPath = $this->shadowPath($path);
        $currentExists = $currentPath !== '' && is_file($currentPath);
        $shadowExists = is_file($shadowPath);
        $currentHash = $currentExists ? $this->fileHash($currentPath) : '';
        $shadowHash = $shadowExists ? $this->fileHash($shadowPath) : '';
        $matches = $currentExists && $shadowExists && $currentHash !== '' && hash_equals($shadowHash, $currentHash);
        $firstChangedLine = null;
        $currentExcerpt = [];
        $shadowExcerpt = [];
        $currentLineCount = 0;
        $shadowLineCount = 0;
        $textComparable = $this->isTextCandidate($path) && $currentExists && $shadowExists;
        $excerptWindowed = false;

        if ($textComparable) {
            $textCompare = $this->compareTextFiles($currentPath, $shadowPath);
            $firstChangedLine = $textCompare['first_changed_line'];
            $currentLineCount = (int) $textCompare['current_line_count'];
            $shadowLineCount = (int) $textCompare['shadow_line_count'];
            $currentExcerpt = $textCompare['current_excerpt'];
            $shadowExcerpt = $textCompare['shadow_excerpt'];
            $excerptWindowed = !empty($textCompare['excerpt_windowed']);
        }

        return [
            'path' => $path,
            'current_path' => $currentPath,
            'shadow_path' => $shadowPath,
            'current_exists' => $currentExists,
            'shadow_exists' => $shadowExists,
            'current_hash' => $currentHash,
            'shadow_hash' => $shadowHash,
            'current_size' => $currentExists ? $this->fileSize($currentPath) : 0,
            'shadow_size' => $shadowExists ? $this->fileSize($shadowPath) : 0,
            'matches' => $matches,
            'text_comparable' => $textComparable,
            'first_changed_line' => $firstChangedLine,
            'current_excerpt' => $currentExcerpt,
            'shadow_excerpt' => $shadowExcerpt,
            'excerpt_windowed' => $excerptWindowed,
            'current_line_count' => $currentLineCount,
            'shadow_line_count' => $shadowLineCount,
        ];
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    public function pending(int $limit = 20): array
    {
        return $this->items($limit, 'pending');
    }

    /**
     * Return only the safe state needed to verify a currently pending change.
     * Internal storage paths are deliberately not exposed to extension hooks.
     *
     * @return array<string,mixed>|null
     */
    public function pendingItem(string $relativePath): ?array
    {
        $path = $this->normalizeRelativePath($relativePath);
        $item = $path !== '' ? $this->item($path) : null;
        if (!is_array($item) || (string) ($item['status'] ?? '') !== 'pending' || empty($item['shadow_available'])) {
            return null;
        }

        return [
            'path' => $path,
            'status' => 'pending',
            'trusted_hash' => (string) ($item['trusted_hash'] ?? ''),
            'current_hash' => (string) ($item['current_hash'] ?? ''),
            'shadow_available' => true,
            'last_scan_id' => (string) ($item['last_scan_id'] ?? ''),
        ];
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    public function recent(int $limit = 20): array
    {
        return $this->items($limit, '');
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    public function changedForScan(string $scanId, int $limit = 50): array
    {
        return array_slice($this->changedForScanItems($scanId), 0, max(1, min(200, $limit)));
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    public function allChangedForScan(string $scanId): array
    {
        return $this->changedForScanItems($scanId);
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    private function changedForScanItems(string $scanId): array
    {
        $scanId = $this->sanitizeScanId($scanId);
        if ($scanId === '') {
            return [];
        }

        $items = array_values(array_filter($this->index(), static function ($item) use ($scanId): bool {
            if (!is_array($item) || (string) ($item['status'] ?? '') !== 'pending') {
                return false;
            }

            $trustedHash = (string) ($item['trusted_hash'] ?? '');
            $currentHash = (string) ($item['current_hash'] ?? '');

            return (string) ($item['last_scan_id'] ?? '') === $scanId
                && $trustedHash !== ''
                && $currentHash !== ''
                && $trustedHash !== $currentHash;
        }));

        usort($items, static function (array $left, array $right): int {
            $leftTime = strtotime((string) ($left['last_seen_at'] ?? $left['changed_at'] ?? '')) ?: 0;
            $rightTime = strtotime((string) ($right['last_seen_at'] ?? $right['changed_at'] ?? '')) ?: 0;

            return $rightTime <=> $leftTime;
        });

        return $items;
    }

    /**
     * @return array<string,mixed>
     */
    public function summary(int $limit = 12): array
    {
        $items = $this->recent(200);
        $pending = array_values(array_filter($items, static function (array $item): bool {
            return (string) ($item['status'] ?? '') === 'pending';
        }));
        $available = array_values(array_filter($items, static function (array $item): bool {
            return !empty($item['shadow_available']);
        }));

        return [
            'directory' => $this->shadowDirectory(),
            'index_path' => $this->indexPath(),
            'total' => count($items),
            'pending' => count($pending),
            'available' => count($available),
            'recent' => array_slice($items, 0, max(1, min(50, $limit))),
            'pending_items' => array_slice($pending, 0, max(1, min(50, $limit))),
        ];
    }

    public function beginIndexBatch(): void
    {
        $this->indexBatchDepth++;
    }

    public function commitIndexBatch(): void
    {
        if ($this->indexBatchDepth <= 0) {
            return;
        }

        $this->indexBatchDepth--;
        if ($this->indexBatchDepth === 0 && $this->indexDirty) {
            $items = is_array($this->indexCache) ? $this->indexCache : [];
            $this->indexDirty = false;
            $this->persistIndex($items);
        }
    }

    private function captureTrusted(string $path, string $source, string $hash, array $metadata, int $userId, string $reason): array
    {
        $this->storage->ensure();
        $shadow = $this->shadowPath($path);
        $this->ensureDirectory(dirname($shadow));

        if (!copy($source, $shadow)) {
            return $this->result(false, 'Could not write the shadow copy.', $path);
        }

        $index = $this->index();
        $item = isset($index[$path]) && is_array($index[$path]) ? $index[$path] : $this->emptyItem($path);
        $now = gmdate('c');
        $item['path'] = $path;
        $item['status'] = 'trusted';
        $item['trusted_hash'] = $hash;
        $item['trusted_size'] = $this->fileSize($source);
        $item['trusted_modified_at'] = (string) ($metadata['modified_at'] ?? $this->fileModifiedAt($source));
        $item['trusted_at'] = $now;
        $item['trusted_by'] = $userId;
        $item['trusted_reason'] = $reason;
        $item['shadow_available'] = true;
        $item['shadow_path'] = $shadow;
        $item['current_hash'] = $hash;
        $item['current_size'] = $this->fileSize($source);
        $item['current_modified_at'] = (string) ($metadata['modified_at'] ?? $this->fileModifiedAt($source));
        $item['last_seen_at'] = $now;
        $item['confirmed_at'] = $reason === 'admin_confirmed' ? $now : (string) ($item['confirmed_at'] ?? '');
        $item['confirmed_by'] = $reason === 'admin_confirmed' ? $userId : (int) ($item['confirmed_by'] ?? 0);
        unset($item['previous_hash'], $item['changed_at'], $item['last_scan_id'], $item['last_scan_at']);

        $index[$path] = $item;
        $this->writeIndex($index);

        return $this->result(true, 'Trusted shadow copy updated.', $path, ['item' => $item]);
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    private function items(int $limit, string $status): array
    {
        $items = array_values($this->index());
        $items = array_values(array_filter($items, static function ($item) use ($status): bool {
            return is_array($item) && ($status === '' || (string) ($item['status'] ?? '') === $status);
        }));

        usort($items, static function (array $left, array $right): int {
            $leftTime = strtotime((string) ($left['last_seen_at'] ?? $left['trusted_at'] ?? '')) ?: 0;
            $rightTime = strtotime((string) ($right['last_seen_at'] ?? $right['trusted_at'] ?? '')) ?: 0;

            return $rightTime <=> $leftTime;
        });

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

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

        $path = $this->indexPath();
        if (!is_file($path) || !is_readable($path)) {
            $this->indexCache = [];

            return [];
        }

        $contents = file_get_contents($path);
        $decoded = is_string($contents) ? \ShieldX\Support\Json::decodeArray($contents) : null;
        if (!is_array($decoded)) {
            $this->indexCache = [];

            return [];
        }

        $items = isset($decoded['items']) && is_array($decoded['items']) ? $decoded['items'] : [];
        $normalized = [];
        foreach ($items as $path => $item) {
            if (!is_array($item)) {
                continue;
            }

            $relative = $this->normalizeRelativePath((string) ($item['path'] ?? $path));
            if ($relative !== '' && !$this->isIgnoredRuntimePath($relative)) {
                $item['path'] = $relative;
                $item['shadow_path'] = $this->shadowPath($relative);
                $item['shadow_available'] = !empty($item['shadow_available']) && is_file($this->shadowPath($relative));
                $normalized[$relative] = $item;
            }
        }

        // Scans can ask about thousands of files; keep this decoded index hot for the request.
        $this->indexCache = $normalized;

        return $this->indexCache;
    }

    /**
     * @param array<string,array<string,mixed>> $items
     */
    private function writeIndex(array $items): void
    {
        ksort($items);

        if ($this->indexBatchDepth > 0) {
            $this->indexCache = $items;
            $this->indexDirty = true;
            return;
        }

        $this->persistIndex($items);
    }

    /**
     * @param array<string,array<string,mixed>> $items
     */
    private function persistIndex(array $items): void
    {
        $this->storage->ensure();
        $this->ensureDirectory($this->shadowDirectory());
        ksort($items);

        $payload = [
            'format' => 'shieldx-shadow-index-v1',
            'updated_at' => gmdate('c'),
            'items' => $items,
        ];
        $json = wp_json_encode($payload);
        if (is_string($json)) {
            file_put_contents($this->indexPath(), $json . "\n", LOCK_EX);
            $this->indexCache = $items;
        }
    }

    /**
     * @return array<string,mixed>|null
     */
    private function item(string $relativePath): ?array
    {
        $items = $this->index();

        return isset($items[$relativePath]) && is_array($items[$relativePath]) ? $items[$relativePath] : null;
    }

    /**
     * @return array<string,mixed>
     */
    private function emptyItem(string $path): array
    {
        return [
            'path' => $path,
            'status' => 'trusted',
            'trusted_hash' => '',
            'trusted_size' => 0,
            'trusted_modified_at' => '',
            'trusted_at' => '',
            'trusted_by' => 0,
            'trusted_reason' => '',
            'current_hash' => '',
            'current_size' => 0,
            'current_modified_at' => '',
            'last_seen_at' => '',
            'shadow_available' => false,
            'shadow_path' => $this->shadowPath($path),
            'detections' => 0,
        ];
    }

    private function sourceFromRelative(string $relativePath, bool $mustExist = true): string
    {
        $relativePath = $this->normalizeRelativePath($relativePath);
        if ($relativePath === '') {
            return '';
        }

        $root = $this->rootPath();
        $candidate = $root . '/' . $relativePath;
        if (!$this->isInsideRoot($candidate)) {
            return '';
        }

        return (!$mustExist || is_file($candidate)) ? $candidate : '';
    }

    private function safeSourcePath(string $relativePath, string $sourcePath): string
    {
        $relativePath = $this->normalizeRelativePath($relativePath);
        if ($relativePath === '' || $sourcePath === '' || !is_file($sourcePath)) {
            return '';
        }

        $sourceReal = realpath($sourcePath);
        if (!is_string($sourceReal) || !$this->isInsideRoot($sourceReal)) {
            return '';
        }

        $expected = $this->sourceFromRelative($relativePath);

        return $expected !== '' && realpath($expected) === $sourceReal ? $sourceReal : '';
    }

    private function shadowPath(string $relativePath): string
    {
        $hash = hash('sha256', $this->normalizeRelativePath($relativePath));

        return $this->shadowDirectory() . '/files/' . substr($hash, 0, 2) . '/' . $hash . '.shadow';
    }

    public function shadowDirectory(): string
    {
        return $this->storage->baseDir() . '/shadow';
    }

    private function indexPath(): string
    {
        return $this->shadowDirectory() . '/index.json';
    }

    private function rootPath(): string
    {
        $root = $this->rootDir !== '' ? $this->rootDir : WordPressPaths::root();
        $root = $root !== '' ? $root : getcwd();
        $root = is_string($root) ? $root : sys_get_temp_dir();

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

    private function normalizeRelativePath(string $path): string
    {
        $path = ScanRuleEngine::normalizePath($path);
        if ($path === '' || strpos($path, "\0") !== false || strpos($path, '../') !== false || strpos($path, '..\\') !== false || strpos($path, 'db:') === 0) {
            return '';
        }

        if (preg_match('/^[A-Za-z]:\//', $path) === 1 || strpos($path, '/') === 0) {
            $root = $this->rootPath();
            if ($root !== '' && strpos($path, $root . '/') === 0) {
                $path = substr($path, strlen($root) + 1);
            } else {
                return '';
            }
        }

        return ltrim($path, '/');
    }

    private function isIgnoredRuntimePath(string $relativePath): bool
    {
        $path = strtolower($this->normalizeRelativePath($relativePath));
        if ($path === '') {
            return true;
        }

        if ($this->isManagedStoragePath($path)) {
            return true;
        }

        $basename = basename($path);
        if ($basename === 'debug.log' || $basename === 'error_log') {
            return true;
        }

        return preg_match('/\.log(?:\.\d+)?(?:\.gz)?$/', $path) === 1;
    }

    private function isManagedStoragePath(string $relativePath): bool
    {
        $root = $this->rootPath();
        $storage = rtrim(str_replace('\\', '/', $this->storage->baseDir()), '/');
        if ($root === '' || $storage === '' || $storage === $root || strpos($storage, $root . '/') !== 0) {
            return false;
        }

        $storageRelative = strtolower(ltrim(substr($storage, strlen($root) + 1), '/'));
        $relativePath = strtolower(ltrim($relativePath, '/'));

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

    private function isInsideRoot(string $path): bool
    {
        $real = realpath($path);
        $root = realpath($this->rootPath());
        if (!is_string($real) || !is_string($root)) {
            $real = str_replace('\\', '/', $path);
            $root = $this->rootPath();
        } else {
            $real = str_replace('\\', '/', $real);
            $root = str_replace('\\', '/', $root);
        }

        $root = rtrim($root, '/');

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

    private function fileHash(string $path): string
    {
        $hash = is_file($path) ? hash_file('sha256', $path) : false;

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

    private function fileSize(string $path): int
    {
        return is_file($path) ? (int) filesize($path) : 0;
    }

    private function fileModifiedAt(string $path): string
    {
        $modified = is_file($path) ? (int) filemtime($path) : 0;

        return $modified > 0 ? gmdate('Y-m-d H:i:s', $modified) : gmdate('Y-m-d H:i:s');
    }

    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,current_excerpt:array<int,array{line:int,text:string}>,shadow_excerpt:array<int,array{line:int,text:string}>,current_line_count:int,shadow_line_count:int,excerpt_windowed:bool}
     */
    private function compareTextFiles(string $currentPath, string $shadowPath): array
    {
        $currentHandle = fopen($currentPath, 'rb');
        $shadowHandle = fopen($shadowPath, 'rb');
        if (!is_resource($currentHandle) || !is_resource($shadowHandle)) {
            if (is_resource($currentHandle)) {
                fclose($currentHandle);
            }
            if (is_resource($shadowHandle)) {
                fclose($shadowHandle);
            }

            return [
                'first_changed_line' => null,
                'current_excerpt' => [],
                'shadow_excerpt' => [],
                'current_line_count' => 0,
                'shadow_line_count' => 0,
                'excerpt_windowed' => false,
            ];
        }

        $lineNumber = 0;
        $firstChangedLine = null;
        $contextRemaining = 0;
        $currentLineCount = 0;
        $shadowLineCount = 0;
        $currentHead = [];
        $shadowHead = [];
        $previousCurrent = [];
        $previousShadow = [];
        $currentContext = [];
        $shadowContext = [];

        try {
            while (!feof($currentHandle) || !feof($shadowHandle)) {
                $lineNumber++;
                $currentRaw = !feof($currentHandle) ? fgets($currentHandle) : false;
                $shadowRaw = !feof($shadowHandle) ? fgets($shadowHandle) : false;
                $hasCurrent = is_string($currentRaw);
                $hasShadow = is_string($shadowRaw);
                $currentText = $hasCurrent ? rtrim($currentRaw, "\r\n") : '';
                $shadowText = $hasShadow ? rtrim($shadowRaw, "\r\n") : '';

                if (!$hasCurrent && !$hasShadow) {
                    break;
                }

                if ($hasCurrent) {
                    $currentLineCount++;
                }
                if ($hasShadow) {
                    $shadowLineCount++;
                }

                if ($lineNumber <= self::EXCERPT_HEAD_LINES) {
                    if ($hasCurrent) {
                        $currentHead[] = $this->excerptLine($lineNumber, $currentText);
                    }
                    if ($hasShadow) {
                        $shadowHead[] = $this->excerptLine($lineNumber, $shadowText);
                    }
                }

                if ($firstChangedLine === null) {
                    if (!$hasCurrent || !$hasShadow || $currentText !== $shadowText) {
                        $firstChangedLine = $lineNumber;
                        $currentContext = $previousCurrent;
                        $shadowContext = $previousShadow;
                        if ($hasCurrent) {
                            $currentContext[] = $this->excerptLine($lineNumber, $currentText);
                        }
                        if ($hasShadow) {
                            $shadowContext[] = $this->excerptLine($lineNumber, $shadowText);
                        }
                        $contextRemaining = self::EXCERPT_CONTEXT_LINES;
                    } else {
                        $this->pushExcerptBuffer($previousCurrent, $this->excerptLine($lineNumber, $currentText));
                        $this->pushExcerptBuffer($previousShadow, $this->excerptLine($lineNumber, $shadowText));
                    }
                } elseif ($contextRemaining > 0) {
                    if ($hasCurrent) {
                        $currentContext[] = $this->excerptLine($lineNumber, $currentText);
                    }
                    if ($hasShadow) {
                        $shadowContext[] = $this->excerptLine($lineNumber, $shadowText);
                    }
                    $contextRemaining--;
                }
            }
        } finally {
            if (is_resource($currentHandle)) {
                fclose($currentHandle);
            }
            if (is_resource($shadowHandle)) {
                fclose($shadowHandle);
            }
        }

        return [
            'first_changed_line' => $firstChangedLine,
            'current_excerpt' => $this->mergeExcerptLines($currentHead, $currentContext),
            'shadow_excerpt' => $this->mergeExcerptLines($shadowHead, $shadowContext),
            'current_line_count' => $currentLineCount,
            'shadow_line_count' => $shadowLineCount,
            'excerpt_windowed' => $currentLineCount > self::EXCERPT_HEAD_LINES || $shadowLineCount > self::EXCERPT_HEAD_LINES,
        ];
    }

    /**
     * @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 sanitizeHash(string $hash): string
    {
        $hash = strtolower(trim($hash));

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

    private function sanitizeScanId(string $scanId): string
    {
        $scanId = strtolower(trim($scanId));

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

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

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

        mkdir($path, 0755, true);
    }

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