<?php

declare(strict_types=1);

require_once dirname(__DIR__) . '/license/CoreAttributionPolicy.php';
require_once __DIR__ . '/OperatorStateSnapshot.php';
require_once __DIR__ . '/UpdateRuntimeIntegritySnapshot.php';
require_once __DIR__ . '/MarketplaceEnvelope.php';

final class MyronUpdateApplicationService
{
    private readonly Closure $migrationRunner;
    private readonly Closure $definitionSeeder;
    private readonly Closure $packagePublishHook;

    public function __construct(
        private readonly string $projectRoot,
        private readonly string $appRoot,
        private readonly MyronUpdatePackageValidator $validator,
        private readonly MyronUpdateAuditRepository $auditRepository,
        ?callable $migrationRunner = null,
        ?callable $definitionSeeder = null,
        ?callable $packagePublishHook = null,
        private readonly ?PDO $runtimeStatePdo = null,
    ) {
        $this->migrationRunner = Closure::fromCallable($migrationRunner ?? static function (): void {
            MyronMigrationRunner::migrate(myronDatabase());
        });
        $this->definitionSeeder = Closure::fromCallable($definitionSeeder ?? static function (): void {
            myronReseedDefinitionsRuntime();
        });
        $this->packagePublishHook = Closure::fromCallable($packagePublishHook ?? static function (): void {
        });
    }

    /**
     * @param array<string, mixed> $uploadedFile
     * @param array<string, mixed> $actor
     * @return array<string, mixed>
     */
    public function applyUploadedPackage(array $uploadedFile, array $actor): array
    {
        $errorCode = (int) ($uploadedFile['error'] ?? UPLOAD_ERR_NO_FILE);
        if ($errorCode !== UPLOAD_ERR_OK) {
            throw new RuntimeException('Upload failed before update validation. Error code: ' . $errorCode);
        }

        $tmpName = (string) ($uploadedFile['tmp_name'] ?? '');
        if ($tmpName === '' || !is_file($tmpName)) {
            throw new RuntimeException('Uploaded update package is missing.');
        }

        if (PHP_SAPI !== 'cli' && !is_uploaded_file($tmpName)) {
            throw new RuntimeException('Uploaded update package did not arrive through a trusted upload channel.');
        }

        return $this->applyPackagePath($tmpName, (string) ($actor['id'] ?? ''));
    }

    /**
     * @return array<string, mixed>
     */
    public function applyPackagePath(string $zipPath, string $actorUserId): array
    {
        if (!class_exists(ZipArchive::class)) {
            throw new RuntimeException('PHP ZipArchive extension is required before applying updates.');
        }

        $packageHash = is_file($zipPath) ? hash_file('sha256', $zipPath) : '';
        $validation = $this->validator->validate($zipPath);
        if (!$validation->ok) {
            $this->auditRepository->recordValidationFailure($actorUserId, $packageHash ?: '', $validation->errors);
            throw new RuntimeException('Update package rejected: ' . implode(' ', $validation->errors));
        }

        $manifest = MyronUpdateManifest::fromArray($validation->manifest);
        if ($manifest->isPackageInstall()) {
            return $this->applyPackageInstall($zipPath, $actorUserId, $packageHash ?: '', $manifest);
        }

        $this->assertPackageEntitled($manifest);
        $auditId = $this->auditRepository->begin($actorUserId, $packageHash ?: '', $manifest->raw());
        $stageRoot = $this->updatesRoot() . DIRECTORY_SEPARATOR . $auditId;
        $extractRoot = $stageRoot . DIRECTORY_SEPARATOR . 'extract';
        $backupRoot = $stageRoot . DIRECTORY_SEPARATOR . 'backup';
        $appliedOperations = [];
        $operatorStateSnapshot = MyronOperatorStateSnapshot::capture($this->projectRoot);
        $runtimeIntegritySnapshot = MyronUpdateRuntimeIntegritySnapshot::capture(
            $this->projectRoot,
            $this->runtimeIntegrityPdo()
        );
        $runtimeIntegrityBefore = $runtimeIntegritySnapshot->evidence();
        $attributionSourceWasInstalled = is_file($this->projectRoot . '/app/system/license/CoreAttributionPolicy.php')
            || is_file($this->projectRoot . '/app/modules/content/public/PublishedPageHtmlAssembler.php');

        try {
            $this->assertWritableUpdateWorkspace($stageRoot);
            $this->assertWritableTargets($manifest);
            $this->extractPackage($zipPath, $extractRoot);

            foreach ($manifest->operations() as $operation) {
                $targetRelative = $this->normalizePath($operation['path']);
                $targetPath = $this->projectRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $targetRelative);
                $backupPath = $backupRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $targetRelative);

                if (is_file($targetPath)) {
                    $this->ensureDirectory(dirname($backupPath));
                    if (!copy($targetPath, $backupPath)) {
                        throw new RuntimeException('Unable to back up file before update: ' . $targetRelative);
                    }
                }

                if ($operation['operation'] === 'delete') {
                    if (file_exists($targetPath) && !unlink($targetPath)) {
                        throw new RuntimeException('Unable to delete target file: ' . $targetRelative);
                    }
                    $this->invalidateRuntimeFileCache($targetPath);
                    $appliedOperations[] = ['operation' => 'delete', 'path' => $targetRelative];
                    continue;
                }

                $payloadPath = $extractRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $this->normalizePath($operation['payload']));
                if (!is_file($payloadPath)) {
                    throw new RuntimeException('Staged payload file is missing: ' . $operation['payload']);
                }

                $this->ensureDirectory(dirname($targetPath));
                if (!copy($payloadPath, $targetPath)) {
                    throw new RuntimeException('Unable to copy update payload into place: ' . $targetRelative);
                }

                $actualHash = hash_file('sha256', $targetPath);
                if (!hash_equals($operation['sha256'], is_string($actualHash) ? $actualHash : '')) {
                    throw new RuntimeException('Target checksum mismatch after update copy: ' . $targetRelative);
                }
                $this->invalidateRuntimeFileCache($targetPath);

                $appliedOperations[] = ['operation' => $operation['operation'], 'path' => $targetRelative];
            }

            if ($manifest->shouldRunMigrations()) {
                ($this->migrationRunner)();
            }

            if ($manifest->shouldReseedRuntimeDefinitions()) {
                ($this->definitionSeeder)();
            }

            $runtimeIntegritySnapshot->assertPageOwnedContentUnchanged();
            try {
                $operatorStateSnapshot->assertUnchanged();
            } catch (RuntimeException $exception) {
                $operatorStateSnapshot->restore();
                throw $exception;
            }
            if ($attributionSourceWasInstalled) {
                try {
                    MyronCoreAttributionPolicy::assertInstalledSourceIntact($this->projectRoot);
                } catch (RuntimeException $exception) {
                    $operatorStateSnapshot->restore();
                    throw $exception;
                }
            }

            $details = [
                'packageId' => $manifest->packageId(),
                'targetVersion' => $manifest->targetVersion(),
                'packageHash' => $packageHash,
                'appliedOperations' => $appliedOperations,
                'backupPath' => $backupRoot,
                'ranMigrations' => $manifest->shouldRunMigrations(),
                'reseededRuntimeDefinitions' => $manifest->shouldReseedRuntimeDefinitions(),
                'definitionImpactPaths' => $manifest->definitionImpactPaths(),
                'runtimeIntegrityBefore' => $runtimeIntegrityBefore,
                'runtimeIntegrityAfter' => MyronUpdateRuntimeIntegritySnapshot::capture(
                    $this->projectRoot,
                    $this->runtimeIntegrityPdo()
                )->evidence(),
            ];
            $this->auditRepository->finish($auditId, 'succeeded', $details);
            $this->invalidatePublicPageCache('application update applied: ' . $manifest->targetVersion());

            return $details + ['auditId' => $auditId];
        } catch (Throwable $exception) {
            $rollbackErrors = [];
            try {
                $runtimeIntegritySnapshot->restore();
            } catch (Throwable $rollbackException) {
                $rollbackErrors[] = $rollbackException->getMessage();
            }
            try {
                $operatorStateSnapshot->restore();
            } catch (Throwable $rollbackException) {
                $rollbackErrors[] = $rollbackException->getMessage();
            }
            $restoreDetails = $this->restoreBackups($backupRoot, $appliedOperations);
            $this->auditRepository->finish($auditId, 'failed', [
                'packageId' => $manifest->packageId(),
                'targetVersion' => $manifest->targetVersion(),
                'packageHash' => $packageHash,
                'appliedOperations' => $appliedOperations,
                'restoredFiles' => $restoreDetails,
                'runtimeIntegrityBefore' => $runtimeIntegrityBefore,
                'rollbackErrors' => $rollbackErrors,
            ], $exception->getMessage());

            if ($rollbackErrors !== []) {
                throw new RuntimeException(
                    $exception->getMessage() . ' Rollback was incomplete: ' . implode(' ', $rollbackErrors),
                    0,
                    $exception
                );
            }
            throw $exception;
        }
    }

    private function updatesRoot(): string
    {
        return $this->appRoot . DIRECTORY_SEPARATOR . 'storage' . DIRECTORY_SEPARATOR . 'updates';
    }

    private function assertPackageEntitled(MyronUpdateManifest $manifest): void
    {
        $capability = $manifest->requiredCapability();
        if ($capability === '' || !function_exists('myronEntitlementGate')) {
            return;
        }

        try {
            $decision = myronEntitlementGate()->evaluate($capability);
        } catch (Throwable) {
            throw new RuntimeException('Update package is not available for the current license.');
        }

        if (!in_array((string) ($decision['outcome'] ?? ''), ['allowed', 'warn'], true)) {
            throw new RuntimeException('Update package is not available for the current license.');
        }
    }

    /**
     * @return array<string, mixed>
     */
    private function applyPackageInstall(string $zipPath, string $actorUserId, string $packageHash, MyronUpdateManifest $manifest): array
    {
        $envelope = $this->readMarketplaceEnvelope($zipPath);
        $auditId = $this->auditRepository->begin($actorUserId, $packageHash, $manifest->raw());
        $stageRoot = $this->updatesRoot() . DIRECTORY_SEPARATOR . $auditId;
        $extractRoot = $stageRoot . DIRECTORY_SEPARATOR . 'extract';
        $appliedOperations = [];
        $siblingPaths = [];
        $publishedPaths = [];
        $backupPaths = [];
        $ledger = function_exists('myronModulePackageInstallLedger') ? myronModulePackageInstallLedger() : null;
        $ledgerSnapshot = $ledger instanceof MyronModulePackageInstallLedger ? $ledger->snapshotState() : null;
        $ledgerChanged = false;
        $operatorStateSnapshot = MyronOperatorStateSnapshot::capture($this->projectRoot);

        try {
            $this->assertEnvelopePolicy($envelope);
            $this->assertEnvelopeEntitled($envelope);
            $this->normalizedPackageVersion($manifest, $envelope);
            $this->assertWritableUpdateWorkspace($stageRoot);
            $this->assertPackageInstallTarget($manifest);
            $this->extractPackage($zipPath, $extractRoot);

            $targetRoots = $this->normalizeInstallRoots($manifest->targetRoots());
            foreach ($targetRoots as $targetRoot) {
                $destinationPath = $this->destinationPathForInstallRoot($targetRoot);
                $siblingPath = $destinationPath . '.installing.' . $auditId;
                if (file_exists($siblingPath)) {
                    $this->removeTree($siblingPath);
                }
                $this->ensureDirectory($siblingPath);
                $siblingPaths[$targetRoot] = $siblingPath;
            }

            foreach ($manifest->operations() as $operation) {
                $targetRelative = $this->normalizePath($operation['path']);
                $targetRoot = $this->installRootForPath($targetRelative, $targetRoots);
                if ($targetRoot === '') {
                    throw new RuntimeException('Package install operation path is outside declared roots: ' . $targetRelative);
                }
                $insideDestination = substr($targetRelative, strlen($targetRoot));
                if ($insideDestination === '' || str_starts_with($insideDestination, '/')) {
                    throw new RuntimeException('Package install operation path is invalid: ' . $targetRelative);
                }

                $payloadPath = $extractRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $this->normalizePath($operation['payload']));
                if (!is_file($payloadPath)) {
                    throw new RuntimeException('Staged package payload is missing: ' . $operation['payload']);
                }

                $targetPath = $siblingPaths[$targetRoot] . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $insideDestination);
                $this->ensureDirectory(dirname($targetPath));
                if (!copy($payloadPath, $targetPath)) {
                    throw new RuntimeException('Unable to copy package payload into staging: ' . $targetRelative);
                }

                $actualHash = hash_file('sha256', $targetPath);
                if (!hash_equals($operation['sha256'], is_string($actualHash) ? $actualHash : '')) {
                    throw new RuntimeException('Target checksum mismatch after package install copy: ' . $targetRelative);
                }

                $appliedOperations[] = ['operation' => 'add', 'path' => $targetRelative];
            }

            $existingInstall = $this->existingPackageDisposition($manifest, $envelope, $targetRoots, $packageHash);
            if ($existingInstall !== null && empty($existingInstall['upgradeRequired'])) {
                foreach ($siblingPaths as $siblingPath) {
                    if ($siblingPath !== '' && is_dir($siblingPath)) {
                        $this->removeTree($siblingPath);
                    }
                }
                $siblingPaths = [];

                $installMode = (string) $existingInstall['installMode'];
                $bootstrapMigrationRequired = $installMode === 'reconciled_upgrade';
                if (!$bootstrapMigrationRequired) {
                    $this->loadPublishedModuleRoots($targetRoots);
                    ($this->migrationRunner)();
                }

                $details = [
                    'packageId' => $manifest->packageId(),
                    'targetVersion' => $manifest->targetVersion(),
                    'packageHash' => $packageHash,
                    'targetType' => $envelope->targetType(),
                    'publisher' => $envelope->publisher(),
                    'marketplaceSku' => $envelope->marketplaceSku(),
                    'destination' => $targetRoots[0] ?? '',
                    'destinationRoots' => $targetRoots,
                    'appliedOperations' => [],
                    'verifiedOperations' => $existingInstall['verifiedOperations'],
                    'installMode' => $installMode,
                    'priorVersion' => (string) ($existingInstall['priorVersion'] ?? ''),
                    'previousPackageHash' => (string) ($existingInstall['previousPackageHash'] ?? ''),
                    'ranMigrations' => !$bootstrapMigrationRequired,
                    'bootstrapMigrationRequired' => $bootstrapMigrationRequired,
                    'reseededRuntimeDefinitions' => false,
                ];
                $detailsWithAudit = $details + ['auditId' => $auditId];
                if ($ledger instanceof MyronModulePackageInstallLedger) {
                    $ledger->recordInstall($detailsWithAudit);
                    $ledgerChanged = true;
                }
                $this->auditRepository->finish($auditId, 'succeeded', $details);

                return $detailsWithAudit;
            }

            $upgradeRequired = $existingInstall !== null && !empty($existingInstall['upgradeRequired']);
            if ($upgradeRequired) {
                foreach ($targetRoots as $targetRoot) {
                    $destinationPath = $this->destinationPathForInstallRoot($targetRoot);
                    $backupPath = $stageRoot . DIRECTORY_SEPARATOR . 'backup-roots' . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, rtrim($targetRoot, '/'));
                    $this->ensureDirectory(dirname($backupPath));
                    if (!rename($destinationPath, $backupPath)) {
                        throw new RuntimeException('atomic_backup_failed: unable to stage existing Module root ' . $targetRoot);
                    }
                    $backupPaths[$targetRoot] = $backupPath;
                }
                $appliedOperations = $this->classifyUpgradeOperations($manifest, $backupPaths);
            } else {
                foreach ($targetRoots as $targetRoot) {
                    if (file_exists($this->destinationPathForInstallRoot($targetRoot))) {
                        throw new RuntimeException('target_already_installed: ' . $targetRoot);
                    }
                }
            }

            $publishedIndex = 0;
            foreach ($targetRoots as $targetRoot) {
                $destinationPath = $this->destinationPathForInstallRoot($targetRoot);
                if (!rename($siblingPaths[$targetRoot], $destinationPath)) {
                    throw new RuntimeException('atomic_publish_failed: package install rename failed for ' . $targetRoot);
                }
                $publishedPaths[$targetRoot] = $destinationPath;
                unset($siblingPaths[$targetRoot]);
                $publishedIndex++;
                ($this->packagePublishHook)('after_publish', $targetRoot, $publishedIndex);
            }

            $bootstrapMigrationRequired = $upgradeRequired;
            if (!$upgradeRequired) {
                $this->loadPublishedModuleRoots($targetRoots);
                ($this->migrationRunner)();
            }
            $operatorStateSnapshot->assertUnchanged();
            $this->invalidateModuleRootRuntimeCaches($targetRoots);

            $details = [
                'packageId' => $manifest->packageId(),
                'targetVersion' => $manifest->targetVersion(),
                'packageHash' => $packageHash,
                'targetType' => $envelope->targetType(),
                'publisher' => $envelope->publisher(),
                'marketplaceSku' => $envelope->marketplaceSku(),
                'destination' => $targetRoots[0] ?? '',
                'destinationRoots' => $targetRoots,
                'appliedOperations' => $appliedOperations,
                'installMode' => $upgradeRequired ? 'upgraded' : 'installed',
                'priorVersion' => (string) ($existingInstall['priorVersion'] ?? ''),
                'previousPackageHash' => (string) ($existingInstall['previousPackageHash'] ?? ''),
                'ranMigrations' => !$bootstrapMigrationRequired,
                'bootstrapMigrationRequired' => $bootstrapMigrationRequired,
                'reseededRuntimeDefinitions' => false,
            ];
            $detailsWithAudit = $details + ['auditId' => $auditId];
            if ($ledger instanceof MyronModulePackageInstallLedger) {
                $ledger->recordInstall($detailsWithAudit);
                $ledgerChanged = true;
            }
            $this->auditRepository->finish($auditId, 'succeeded', $details);
            $this->invalidatePublicPageCache('Module package ' . ($upgradeRequired ? 'upgraded: ' : 'installed: ') . $envelope->marketplaceSku());

            return $detailsWithAudit;
        } catch (Throwable $exception) {
            $pendingCleanup = false;
            foreach ($siblingPaths as $siblingPath) {
                if ($siblingPath !== '' && is_dir($siblingPath)) {
                    $this->removeTree($siblingPath);
                }
            }
            foreach ($publishedPaths as $publishedPath) {
                if ($publishedPath !== '' && is_dir($publishedPath)) {
                    $this->removeTree($publishedPath);
                }
            }
            foreach (array_reverse($targetRoots ?? []) as $targetRoot) {
                $backupPath = (string) ($backupPaths[$targetRoot] ?? '');
                $destinationPath = $this->destinationPathForInstallRoot($targetRoot);
                if ($backupPath !== '' && is_dir($backupPath) && !file_exists($destinationPath)) {
                    if (!rename($backupPath, $destinationPath)) {
                        $pendingCleanup = true;
                    } else {
                        $this->invalidateModuleRootRuntimeCaches([$targetRoot]);
                    }
                }
            }
            if ($ledgerChanged && $ledger instanceof MyronModulePackageInstallLedger) {
                try {
                    $ledger->restoreState($ledgerSnapshot);
                } catch (Throwable) {
                    $pendingCleanup = true;
                }
            }
            $this->auditRepository->finish($auditId, 'failed', [
                'packageId' => $manifest->packageId(),
                'targetVersion' => $manifest->targetVersion(),
                'packageHash' => $packageHash,
                'targetType' => $envelope->targetType(),
                'publisher' => $envelope->publisher(),
                'marketplaceSku' => $envelope->marketplaceSku(),
                'destination' => $manifest->targetRoot(),
                'destinationRoots' => $manifest->targetRoots(),
                'appliedOperations' => $appliedOperations,
                'restoredRoots' => array_keys($backupPaths),
                'pendingCleanup' => $pendingCleanup,
                'siblingPath' => '',
            ], $exception->getMessage());

            throw $exception;
        }
    }

    /**
     * @param list<string> $targetRoots
     */
    private function loadPublishedModuleRoots(array $targetRoots): void
    {
        if (!class_exists(MyronModuleLoader::class) || !function_exists('myronModuleRegistry')) {
            return;
        }

        $moduleSlugs = [];
        foreach ($targetRoots as $targetRoot) {
            if (preg_match('#^app/modules/(.+?)/?$#', $targetRoot, $matches) === 1) {
                $moduleSlugs[] = (string) $matches[1];
            }
        }
        $moduleSlugs = array_values(array_unique($moduleSlugs));
        if ($moduleSlugs === []) {
            return;
        }

        MyronModuleLoader::load(myronModuleRegistry(), $this->appRoot, $moduleSlugs);
    }

    private function assertEnvelopePolicy(MyronMarketplaceEnvelope $envelope): void
    {
        if (in_array($envelope->targetType(), [MyronMarketplaceEnvelope::TARGET_MODULE, MyronMarketplaceEnvelope::TARGET_MODULE_BUNDLE], true)
            && $envelope->publisher() !== MyronMarketplaceEnvelope::PUBLISHER_FIRST_PARTY) {
            throw new RuntimeException('module_third_party_publisher: Module packages must come from MyronCMS first-party.');
        }
    }

    private function assertEnvelopeEntitled(MyronMarketplaceEnvelope $envelope): void
    {
        if (!function_exists('myronEntitlementGate')) {
            return;
        }

        foreach ($envelope->requiredCapabilities() as $capability) {
            try {
                $decision = myronEntitlementGate()->evaluate($capability);
            } catch (Throwable) {
                throw new RuntimeException('capability_not_entitled: Your license does not include this capability.');
            }

            if ((string) ($decision['outcome'] ?? '') !== 'allowed') {
                throw new RuntimeException('capability_not_entitled: Your license does not include this capability.');
            }
        }
        if (in_array($envelope->targetType(), [MyronMarketplaceEnvelope::TARGET_MODULE, MyronMarketplaceEnvelope::TARGET_MODULE_BUNDLE], true)) {
            $sku = $envelope->marketplaceSku();
            if ($sku !== '' && function_exists('myronLicenseRepository')) {
                $snapshot = myronLicenseRepository()->currentSnapshot();
                $active = $snapshot instanceof MyronEntitlementSnapshot && $snapshot->status === MyronLicenseStatus::ACTIVE;
                if ($active) {
                    $active = false;
                    foreach ($snapshot->activeSkus as $activeSku) {
                        if ((string) ($activeSku['id'] ?? '') === $sku) {
                            $active = true;
                            break;
                        }
                    }
                }
                if (!$active) {
                    throw new RuntimeException('target_package_not_active: Your license does not include this Module package.');
                }
            }
        }
    }

    private function assertPackageInstallTarget(MyronUpdateManifest $manifest): void
    {
        $targetRoots = $this->normalizeInstallRoots($manifest->targetRoots());
        foreach ($targetRoots as $targetRoot) {
            if (!MyronOperatorStateUpdateBoundary::isSafeInstallRoot($targetRoot)) {
                throw new RuntimeException('Package install targetRoot is not allowed: ' . $targetRoot);
            }

            $parent = dirname($this->destinationPathForInstallRoot($targetRoot));
            $this->ensureDirectory($parent);
            if (!is_writable($parent)) {
                throw new RuntimeException('Package install target parent is not writable: ' . dirname($targetRoot));
            }
        }
    }

    /**
     * @param list<string> $targetRoots
     * @return array{installMode:string,verifiedOperations:list<array<string,string>>,upgradeRequired:bool,priorVersion:string,previousPackageHash:string}|null
     */
    private function existingPackageDisposition(MyronUpdateManifest $manifest, MyronMarketplaceEnvelope $envelope, array $targetRoots, string $packageHash): ?array
    {
        if (!in_array($envelope->targetType(), [MyronMarketplaceEnvelope::TARGET_MODULE, MyronMarketplaceEnvelope::TARGET_MODULE_BUNDLE], true)) {
            return null;
        }

        $existingRootCount = 0;
        foreach ($targetRoots as $targetRoot) {
            if (file_exists($this->destinationPathForInstallRoot($targetRoot))) {
                $existingRootCount++;
            }
        }
        if ($existingRootCount === 0) {
            return null;
        }
        if ($existingRootCount !== count($targetRoots)) {
            throw new RuntimeException('package_target_conflict: installed Module bundle has only some declared roots.');
        }

        $inspection = $this->inspectExistingPackageInstall($manifest, $targetRoots);
        $ledger = function_exists('myronModulePackageInstallLedger') ? myronModulePackageInstallLedger() : null;
        $entry = $ledger instanceof MyronModulePackageInstallLedger ? $ledger->findBySku($envelope->marketplaceSku()) : null;
        $priorVersion = trim((string) ($entry['packageVersion'] ?? ''));
        $previousPackageHash = trim((string) ($entry['packageHash'] ?? ''));

        if ($entry === null) {
            if ($ledger instanceof MyronModulePackageInstallLedger) {
                foreach ($ledger->list() as $trustedEntry) {
                    $trustedRoots = $this->normalizeInstallRoots(is_array($trustedEntry['targetRoots'] ?? null) ? $trustedEntry['targetRoots'] : []);
                    if (array_intersect($trustedRoots, $targetRoots) !== []) {
                        throw new RuntimeException('package_target_conflict: declared roots belong to a different trusted Module SKU.');
                    }
                }
            }
            if (!$inspection['matches']) {
                throw new RuntimeException('package_target_conflict: existing Module roots differ and have no trusted package ledger entry.');
            }
            return [
                'installMode' => 'reconciled_existing',
                'verifiedOperations' => $inspection['verifiedOperations'],
                'upgradeRequired' => false,
                'priorVersion' => '',
                'previousPackageHash' => '',
            ];
        }

        $entryRoots = $this->normalizeInstallRoots(is_array($entry['targetRoots'] ?? null) ? $entry['targetRoots'] : []);
        if ($entryRoots !== $targetRoots
            || (string) ($entry['publisher'] ?? '') !== MyronMarketplaceEnvelope::PUBLISHER_FIRST_PARTY
            || (string) ($entry['packageId'] ?? '') !== $manifest->packageId()) {
            throw new RuntimeException('package_target_conflict: trusted ledger identity does not match the incoming Module package.');
        }

        $candidateVersion = $this->normalizedPackageVersion($manifest, $envelope);
        $comparator = new MyronVersionComparator();
        try {
            $comparison = $comparator->compare($candidateVersion, $priorVersion);
        } catch (Throwable) {
            throw new RuntimeException('package_version_invalid: trusted and incoming Module versions must be semantic versions.');
        }
        if ($comparison < 0) {
            throw new RuntimeException('package_version_downgrade: Module package downgrades are not allowed.');
        }
        if ($comparison === 0) {
            if (!hash_equals($previousPackageHash, $packageHash)) {
                throw new RuntimeException('package_version_hash_conflict: same-version Module package hash differs from the trusted ledger.');
            }
            if (!$inspection['matches']) {
                throw new RuntimeException('package_target_conflict: trusted same-version Module files differ from the signed package.');
            }
            return [
                'installMode' => 'already_installed',
                'verifiedOperations' => $inspection['verifiedOperations'],
                'upgradeRequired' => false,
                'priorVersion' => $priorVersion,
                'previousPackageHash' => $previousPackageHash,
            ];
        }

        return [
            'installMode' => $inspection['matches'] ? 'reconciled_upgrade' : 'upgraded',
            'verifiedOperations' => $inspection['verifiedOperations'],
            'upgradeRequired' => !$inspection['matches'],
            'priorVersion' => $priorVersion,
            'previousPackageHash' => $previousPackageHash,
        ];
    }

    /**
     * @param list<string> $targetRoots
     * @return array{matches:bool,verifiedOperations:list<array<string,string>>}
     */
    private function inspectExistingPackageInstall(MyronUpdateManifest $manifest, array $targetRoots): array
    {
        foreach ($targetRoots as $targetRoot) {
            $destinationPath = $this->destinationPathForInstallRoot($targetRoot);
            if (!is_dir($destinationPath)) {
                throw new RuntimeException('package_target_conflict: existing package root is missing or is not a directory: ' . $targetRoot);
            }
        }

        $declaredPaths = [];
        $verifiedOperations = [];
        $matches = true;
        foreach ($manifest->operations() as $operation) {
            $targetRelative = $this->normalizePath($operation['path']);
            $targetRoot = $this->installRootForPath($targetRelative, $targetRoots);
            if ($targetRoot === '') {
                throw new RuntimeException('Package install operation path is outside declared roots: ' . $targetRelative);
            }

            $targetPath = $this->projectRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $targetRelative);
            if (!is_file($targetPath)) {
                $matches = false;
                $declaredPaths[$targetRelative] = true;
                continue;
            }

            $actualHash = hash_file('sha256', $targetPath);
            if (!hash_equals((string) $operation['sha256'], is_string($actualHash) ? $actualHash : '')) {
                $matches = false;
            } else {
                $verifiedOperations[] = ['operation' => 'verify_existing', 'path' => $targetRelative];
            }

            $declaredPaths[$targetRelative] = true;
        }

        foreach ($targetRoots as $targetRoot) {
            $this->assertNoUndeclaredExecutableFiles($targetRoot, $declaredPaths);
        }

        return ['matches' => $matches, 'verifiedOperations' => $verifiedOperations];
    }

    private function normalizedPackageVersion(MyronUpdateManifest $manifest, MyronMarketplaceEnvelope $envelope): string
    {
        $comparator = new MyronVersionComparator();
        try {
            $manifestVersion = $comparator->normalize($manifest->targetVersion());
            $envelopeVersion = $comparator->normalize($envelope->version());
        } catch (Throwable) {
            throw new RuntimeException('package_version_invalid: Module package version is invalid.');
        }
        if ($manifestVersion !== $envelopeVersion) {
            throw new RuntimeException('package_version_mismatch: manifest and marketplace envelope versions differ.');
        }
        return $manifestVersion;
    }

    /**
     * @param array<string,string> $backupPaths
     * @return list<array{operation:string,path:string}>
     */
    private function classifyUpgradeOperations(MyronUpdateManifest $manifest, array $backupPaths): array
    {
        $declared = [];
        $operations = [];
        $targetRoots = array_keys($backupPaths);
        foreach ($manifest->operations() as $operation) {
            $path = $this->normalizePath($operation['path']);
            $root = $this->installRootForPath($path, $targetRoots);
            $inside = $root !== '' ? substr($path, strlen($root)) : '';
            $previousPath = $root !== '' ? $backupPaths[$root] . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $inside) : '';
            $operations[] = ['operation' => $previousPath !== '' && is_file($previousPath) ? 'replace' : 'add', 'path' => $path];
            $declared[$path] = true;
        }

        foreach ($backupPaths as $root => $backupPath) {
            $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($backupPath, FilesystemIterator::SKIP_DOTS));
            foreach ($iterator as $item) {
                if (!($item instanceof SplFileInfo) || !$item->isFile() || $item->isLink()) {
                    continue;
                }
                $inside = str_replace(DIRECTORY_SEPARATOR, '/', substr($item->getPathname(), strlen($backupPath) + 1));
                $path = $root . $inside;
                if (!isset($declared[$path])) {
                    $operations[] = ['operation' => 'delete', 'path' => $path];
                }
            }
        }
        usort($operations, static fn (array $left, array $right): int => strcmp($left['path'], $right['path']));
        return $operations;
    }

    /**
     * @param array<string, bool> $declaredPaths
     */
    private function assertNoUndeclaredExecutableFiles(string $targetRoot, array $declaredPaths): void
    {
        $destinationPath = $this->destinationPathForInstallRoot($targetRoot);
        $iterator = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($destinationPath, FilesystemIterator::SKIP_DOTS)
        );

        foreach ($iterator as $item) {
            $pathname = (string) $item->getPathname();
            $relative = $this->normalizePath(substr($pathname, strlen($this->projectRoot) + 1));
            if ($item->isLink()) {
                throw new RuntimeException('package_target_conflict: existing package root contains unsupported symlink: ' . $relative);
            }
            if (!$item->isFile() || isset($declaredPaths[$relative])) {
                continue;
            }
            if ($this->isExecutablePackageFile($relative)) {
                throw new RuntimeException('package_target_conflict: existing package root contains undeclared executable file: ' . $relative);
            }
        }
    }

    private function isExecutablePackageFile(string $path): bool
    {
        $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));
        return in_array($extension, ['php', 'phtml', 'phar', 'inc'], true) || basename($path) === '.htaccess';
    }

    private function readMarketplaceEnvelope(string $zipPath): MyronMarketplaceEnvelope
    {
        $zip = new ZipArchive();
        if ($zip->open($zipPath) !== true) {
            throw new RuntimeException('Unable to open package install ZIP.');
        }

        try {
            return MyronMarketplaceEnvelope::fromZip($zip);
        } finally {
            $zip->close();
        }
    }

    private function assertWritableUpdateWorkspace(string $stageRoot): void
    {
        $root = $this->updatesRoot();
        $this->ensureDirectory($root);
        if (!is_writable($root)) {
            throw new RuntimeException('Update workspace is not writable: app/storage/updates');
        }

        $this->ensureDirectory($stageRoot);
    }

    private function assertWritableTargets(MyronUpdateManifest $manifest): void
    {
        foreach ($manifest->operations() as $operation) {
            $targetRelative = $this->normalizePath($operation['path']);
            if (!$this->validator->isSafeApplicationPath($targetRelative)) {
                throw new RuntimeException('Manifest target path is not allowed: ' . $targetRelative);
            }

            $targetPath = $this->projectRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $targetRelative);
            if (is_dir($targetPath)) {
                throw new RuntimeException('Update target is a directory, not a file: ' . $targetRelative);
            }

            if (file_exists($targetPath) && !is_writable($targetPath)) {
                throw new RuntimeException('Update target is not writable: ' . $targetRelative);
            }

            $parent = $this->nearestExistingParent(dirname($targetPath));
            if ($parent === null || !is_writable($parent)) {
                throw new RuntimeException('Update target parent is not writable: ' . $targetRelative);
            }
        }
    }

    private function extractPackage(string $zipPath, string $extractRoot): void
    {
        $this->ensureDirectory($extractRoot);
        $zip = new ZipArchive();
        if ($zip->open($zipPath) !== true) {
            throw new RuntimeException('Unable to open update package for extraction.');
        }

        try {
            if (!$zip->extractTo($extractRoot)) {
                throw new RuntimeException('Unable to extract update package.');
            }
        } finally {
            $zip->close();
        }
    }

    /**
     * @return list<string>
     */
    private function restoreBackups(string $backupRoot, array $appliedOperations = []): array
    {
        $restored = [];
        if (is_dir($backupRoot)) {
            $iterator = new RecursiveIteratorIterator(
                new RecursiveDirectoryIterator($backupRoot, FilesystemIterator::SKIP_DOTS)
            );
            foreach ($iterator as $item) {
                if (!$item->isFile()) {
                    continue;
                }

                $relative = $this->normalizePath(substr((string) $item->getPathname(), strlen($backupRoot) + 1));
                $target = $this->projectRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative);
                $this->ensureDirectory(dirname($target));
                if (copy((string) $item->getPathname(), $target)) {
                    $this->invalidateRuntimeFileCache($target);
                    $restored[] = $relative;
                }
            }
        }
        foreach ($appliedOperations as $operation) {
            if (!is_array($operation) || ($operation['operation'] ?? '') !== 'add') {
                continue;
            }
            $relative = $this->normalizePath((string) ($operation['path'] ?? ''));
            $backup = $backupRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative);
            $target = $this->projectRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relative);
            if ($relative !== '' && !is_file($backup) && is_file($target) && unlink($target)) {
                $this->invalidateRuntimeFileCache($target);
                $restored[] = $relative . ' (removed added file)';
            }
        }

        return $restored;
    }

    private function runtimeIntegrityPdo(): PDO
    {
        if ($this->runtimeStatePdo instanceof PDO) {
            return $this->runtimeStatePdo;
        }
        if (function_exists('myronDatabase')) {
            $pdo = myronDatabase();
            if ($pdo instanceof PDO) {
                return $pdo;
            }
        }

        throw new RuntimeException('Application updates require a database connection for the runtime integrity checkpoint.');
    }

    private function nearestExistingParent(string $path): ?string
    {
        $current = $path;
        while ($current !== '' && $current !== dirname($current)) {
            if (file_exists($current)) {
                return is_dir($current) ? $current : null;
            }
            $current = dirname($current);
        }

        return is_dir($current) ? $current : null;
    }

    private function ensureDirectory(string $path): void
    {
        if (!is_dir($path) && !mkdir($path, 0775, true) && !is_dir($path)) {
            throw new RuntimeException('Unable to create directory: ' . $path);
        }
    }

    private function invalidateRuntimeFileCache(string $path): void
    {
        clearstatcache(true, $path);
        if ($this->isPhpRuntimeFile($path) && function_exists('opcache_invalidate')) {
            @opcache_invalidate($path, true);
        }
    }

    /**
     * @param list<string> $targetRoots
     */
    private function invalidateModuleRootRuntimeCaches(array $targetRoots): void
    {
        foreach ($targetRoots as $targetRoot) {
            $root = $this->destinationPathForInstallRoot($targetRoot);
            if (!is_dir($root)) {
                continue;
            }
            $iterator = new RecursiveIteratorIterator(
                new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS)
            );
            foreach ($iterator as $item) {
                if ($item instanceof SplFileInfo && $item->isFile() && !$item->isLink()) {
                    $this->invalidateRuntimeFileCache($item->getPathname());
                }
            }
            clearstatcache(true, $root);
        }
    }

    private function invalidatePublicPageCache(string $reason): void
    {
        if (class_exists(MyronPublicPageCache::class)) {
            MyronPublicPageCache::fromProjectRoot($this->projectRoot)->invalidateAll($reason);
        } elseif (function_exists('myronInvalidatePublicPageCache')) {
            myronInvalidatePublicPageCache($reason);
        }

        if (class_exists(MyronPublicPageCacheWarmCoordinator::class)) {
            try {
                MyronPublicPageCacheWarmCoordinator::fromProjectRoot($this->projectRoot)->schedulePostResponseWarm();
            } catch (Throwable) {
                // Cache warming coordination must never block a successful application update.
            }
        }
    }

    private function isPhpRuntimeFile(string $path): bool
    {
        return in_array(strtolower(pathinfo($path, PATHINFO_EXTENSION)), ['php', 'phtml', 'phar', 'inc'], true);
    }

    private function removeTree(string $path): void
    {
        if (!file_exists($path)) {
            return;
        }

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

        $items = scandir($path);
        foreach ($items === false ? [] : $items as $item) {
            if ($item === '.' || $item === '..') {
                continue;
            }
            $this->removeTree($path . DIRECTORY_SEPARATOR . $item);
        }
        rmdir($path);
    }

    private function normalizeInstallRoot(string $path): string
    {
        $path = $this->normalizePath($path);
        if ($path !== '' && !str_ends_with($path, '/')) {
            $path .= '/';
        }

        return $path;
    }

    /**
     * @param list<string> $paths
     * @return list<string>
     */
    private function normalizeInstallRoots(array $paths): array
    {
        $roots = [];
        foreach ($paths as $path) {
            $normalized = $this->normalizeInstallRoot($path);
            if ($normalized !== '') {
                $roots[] = $normalized;
            }
        }

        $roots = array_values(array_unique($roots));
        sort($roots, SORT_STRING);
        return $roots;
    }

    private function destinationPathForInstallRoot(string $targetRoot): string
    {
        return $this->projectRoot . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, rtrim($targetRoot, '/'));
    }

    /**
     * @param list<string> $targetRoots
     */
    private function installRootForPath(string $targetRelative, array $targetRoots): string
    {
        foreach ($targetRoots as $targetRoot) {
            if (str_starts_with($targetRelative, $targetRoot)) {
                return $targetRoot;
            }
        }

        return '';
    }

    private function normalizePath(string $path): string
    {
        $normalized = str_replace('\\', '/', trim($path));
        while (str_starts_with($normalized, './')) {
            $normalized = substr($normalized, 2);
        }

        return $normalized;
    }
}
