<?php

declare(strict_types=1);

require_once __DIR__ . '/OperatorStateUpdateBoundary.php';
require_once __DIR__ . '/MarketplaceKeyResolver.php';
require_once __DIR__ . '/MarketplaceEnvelope.php';
require_once __DIR__ . '/MarketplaceEnvelopeVerifier.php';

final class MyronUpdatePackageValidator
{
    /**
     * @var list<string>
     */
    private const ALLOWED_ROOT_FILES = [
        '.env.example',
        '.gitignore',
        '.htaccess',
        'CHANGELOG.md',
        'INSTRUCTIONS.md',
        'LICENSE.txt',
        'README.md',
        'index.php',
    ];

    public function __construct(
        private readonly ?MyronMarketplaceKeyResolver $marketplaceKeyResolver = null,
        private readonly string $installedVersion = '',
    ) {
    }

    public function validate(string $zipPath): MyronUpdatePackageResult
    {
        $errors = [];
        $warnings = [];

        if (!class_exists(ZipArchive::class)) {
            return MyronUpdatePackageResult::failure(['PHP ZipArchive extension is required to inspect update packages.']);
        }

        if (!is_file($zipPath)) {
            return MyronUpdatePackageResult::failure(['Update package file does not exist.']);
        }

        $zip = new ZipArchive();
        if ($zip->open($zipPath) !== true) {
            return MyronUpdatePackageResult::failure(['Unable to open update package ZIP.']);
        }

        try {
            $entries = $this->listEntries($zip);
            foreach ($this->listSymlinkEntries($zip) as $symlinkEntry) {
                $errors[] = 'ZIP contains unsupported symlink entry: ' . $symlinkEntry;
            }
            $manifestContents = $zip->getFromName('manifest.json');
            if (!is_string($manifestContents) || trim($manifestContents) === '') {
                return MyronUpdatePackageResult::failure(array_merge($errors, ['Update package must contain manifest.json.']), [], [], $entries);
            }

            try {
                $decoded = json_decode($manifestContents, true, 512, JSON_THROW_ON_ERROR);
                if (!is_array($decoded)) {
                    throw new JsonException('Manifest root is not an object.');
                }
                $manifest = MyronUpdateManifest::fromArray($decoded);
            } catch (Throwable $exception) {
                return MyronUpdatePackageResult::failure(array_merge($errors, ['Invalid update package manifest: ' . $exception->getMessage()]), [], [], $entries);
            }

            $envelope = null;
            if ($manifest->isPackageInstall()) {
                try {
                    if ($manifest->envelopeRef() !== 'marketplace.json') {
                        throw new InvalidArgumentException('Package install manifest envelopeRef must be marketplace.json.');
                    }
                    $envelope = MyronMarketplaceEnvelope::fromZip($zip);
                    $envelope->assertZipInvariants($zip, $entries);
                    if ($manifest->targetRoots() !== $envelope->targetRoots()) {
                        throw new InvalidArgumentException('Package install manifest target roots do not match marketplace envelope target roots.');
                    }
                    foreach ($manifest->targetRoots() as $targetRoot) {
                        if (!MyronOperatorStateUpdateBoundary::isSafeInstallRoot($targetRoot)) {
                            throw new InvalidArgumentException('Package install manifest targetRoot is not an allowed install root.');
                        }
                    }
                    $verifier = new MyronMarketplaceEnvelopeVerifier(
                        $this->marketplaceKeyResolver ?? MyronMarketplaceKeyResolver::fromEnvironment(),
                        $this->installedVersion,
                    );
                    $verifier->verify($zip, $envelope);
                } catch (Throwable $exception) {
                    $errors[] = 'Invalid marketplace envelope: ' . $exception->getMessage();
                }
            } else {
                foreach ($this->validateReleaseNotes($manifest) as $error) {
                    $errors[] = $error;
                }
                foreach ($this->validateTierIsolation($manifest) as $error) {
                    $errors[] = $error;
                }
            }

            $expectedPayloads = [];
            foreach ($manifest->operations() as $operation) {
                $targetPath = $this->normalizePath($operation['path']);
                if ($targetPath === '' || !$this->isSafeApplicationPath($targetPath)) {
                    $errors[] = 'Manifest declares forbidden target path: ' . $operation['path'];
                    continue;
                }

                if ($manifest->isPackageInstall() && !$this->isUnderAnyInstallRoot($targetPath, $manifest->targetRoots())) {
                    $errors[] = 'Package install operation path must stay under targetRoot: ' . $operation['path'];
                    continue;
                }

                if ($operation['operation'] === 'delete') {
                    continue;
                }

                $payloadPath = $this->normalizePath($operation['payload']);
                if ($payloadPath === '' || !str_starts_with($payloadPath, 'files/')) {
                    $errors[] = 'Manifest payload must live under files/: ' . $operation['payload'];
                    continue;
                }

                if (!in_array($payloadPath, $entries, true)) {
                    $errors[] = 'Manifest payload is missing from ZIP: ' . $payloadPath;
                    continue;
                }

                $expectedPayloads[$payloadPath] = $operation['sha256'];
            }

            foreach ($entries as $entry) {
                if ($entry === 'manifest.json' || ($manifest->isPackageInstall() && $entry === 'marketplace.json') || str_ends_with($entry, '/')) {
                    continue;
                }

                if (!$this->isSafeZipEntryPath($entry)) {
                    $errors[] = 'ZIP contains unsafe entry: ' . $entry;
                    continue;
                }

                if (str_starts_with($entry, 'files/')) {
                    if (!isset($expectedPayloads[$entry])) {
                        $errors[] = 'ZIP contains undeclared payload file: ' . $entry;
                        continue;
                    }

                    $contents = $zip->getFromName($entry);
                    if (!is_string($contents)) {
                        $errors[] = 'Unable to read payload file: ' . $entry;
                        continue;
                    }

                    $actualHash = hash('sha256', $contents);
                    if (!hash_equals($expectedPayloads[$entry], $actualHash)) {
                        $errors[] = 'Payload checksum mismatch: ' . $entry;
                    }
                    continue;
                }

                $errors[] = 'ZIP contains unsupported top-level entry: ' . $entry;
            }

            if (!$manifest->shouldRunMigrations()) {
                $warnings[] = 'Package manifest disables migration execution.';
            }

            return $errors === []
                ? MyronUpdatePackageResult::success($manifest->raw(), $warnings, $entries)
                : MyronUpdatePackageResult::failure($errors, $manifest->raw(), $warnings, $entries);
        } finally {
            $zip->close();
        }
    }

    /**
     * @return list<string>
     */
    private function listEntries(ZipArchive $zip): array
    {
        $entries = [];
        for ($index = 0; $index < $zip->numFiles; $index++) {
            $stat = $zip->statIndex($index);
            if (!is_array($stat)) {
                continue;
            }

            $entry = $this->normalizePath((string) ($stat['name'] ?? ''));
            if ($entry !== '') {
                $entries[] = $entry;
            }
        }

        sort($entries);
        return array_values(array_unique($entries));
    }

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

        return false;
    }

    /**
     * @return list<string>
     */
    private function listSymlinkEntries(ZipArchive $zip): array
    {
        $entries = [];
        for ($index = 0; $index < $zip->numFiles; $index++) {
            $stat = $zip->statIndex($index);
            if (!is_array($stat)) {
                continue;
            }

            $attributes = 0;
            $operatingSystem = 0;
            if (!$zip->getExternalAttributesIndex($index, $operatingSystem, $attributes)) {
                continue;
            }

            $mode = ($attributes >> 16) & 0170000;
            if ($mode === 0120000) {
                $entry = $this->normalizePath((string) ($stat['name'] ?? ''));
                if ($entry !== '') {
                    $entries[] = $entry;
                }
            }
        }

        return $entries;
    }

    public function isSafeApplicationPath(string $path): bool
    {
        $path = $this->normalizePath($path);
        if (!$this->isSafeZipEntryPath($path)) {
            return false;
        }

        if (MyronOperatorStateUpdateBoundary::isForbiddenUpdatePath($path)) {
            return false;
        }

        if (str_starts_with($path, 'app/')) {
            return true;
        }

        return in_array($path, self::ALLOWED_ROOT_FILES, true);
    }

    /**
     * @return list<string>
     */
    private function validateReleaseNotes(MyronUpdateManifest $manifest): array
    {
        $notes = $manifest->releaseNotes();
        if ($notes === []) {
            return [];
        }

        $summary = trim((string) ($notes['summary'] ?? ''));
        if ($summary === '') {
            return ['Update package releaseNotes.summary is required.'];
        }
        if (str_word_count($summary) > 45) {
            return ['Update package releaseNotes.summary must stay brief and customer-safe.'];
        }

        $serialized = strtolower(json_encode($notes, JSON_UNESCAPED_SLASHES) ?: '');
        $forbiddenPrivateReference = 'dev-' . 'references';
        foreach ([$forbiddenPrivateReference, 'myroncms-dev', 'private rationale', 'product rationale', 'basement'] as $forbidden) {
            if (str_contains($serialized, $forbidden)) {
                return ['Update package releaseNotes must not expose private development rationale.'];
            }
        }

        return [];
    }

    /**
     * @return list<string>
     */
    private function validateTierIsolation(MyronUpdateManifest $manifest): array
    {
        if (!$this->hasExplicitEntitlementPolicy($manifest)) {
            return [];
        }

        $declaredCapability = $manifest->requiredCapability();
        if ($declaredCapability === '' || $declaredCapability === 'updates.agency') {
            return [];
        }

        $errors = [];
        foreach ($manifest->operations() as $operation) {
            $path = $this->normalizePath($operation['path']);
            $requiredCapability = MyronOperatorStateUpdateBoundary::requiredCapabilityForPath($path);
            if ($requiredCapability === '') {
                continue;
            }
            if (!MyronOperatorStateUpdateBoundary::packageCoversCapability($declaredCapability, $requiredCapability)) {
                $errors[] = 'Package declares ' . $declaredCapability . ' but includes ' . $requiredCapability . ' feature path: ' . $path;
            }
        }

        return $errors;
    }

    private function hasExplicitEntitlementPolicy(MyronUpdateManifest $manifest): bool
    {
        $raw = $manifest->raw();
        foreach (['updateClass', 'update_class', 'requiredCapability', 'required_capability', 'packageTier', 'package_tier'] as $key) {
            if (array_key_exists($key, $raw)) {
                return true;
            }
        }

        return false;
    }

    private function isSafeZipEntryPath(string $path): bool
    {
        $path = $this->normalizePath($path);
        if ($path === '' || str_contains($path, "\0") || str_starts_with($path, '/') || preg_match('#(^|/)\.\.($|/)#', $path)) {
            return false;
        }

        return true;
    }

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

        return $normalized;
    }
}
