<?php

declare(strict_types=1);

final class MyronMarketplaceEnvelope
{
    public const PUBLISHER_FIRST_PARTY = 'myron-first-party';
    public const PUBLISHER_THIRD_PARTY = 'third-party';
    public const TARGET_MODULE = 'module';
    public const TARGET_MODULE_BUNDLE = 'module-bundle';
    public const TARGET_EXTENSION = 'extension';

    /**
     * @param array<string, mixed> $raw
     * @param list<string> $requiredCapabilities
     * @param array<string, mixed> $compatibility
     * @param list<string> $targetRoots
     * @param array<string, mixed> $signing
     * @param array<string, mixed> $review
     */
    private function __construct(
        private readonly array $raw,
        private readonly string $packageId,
        private readonly string $publisher,
        private readonly string $targetType,
        private readonly string $marketplaceSku,
        private readonly string $displayName,
        private readonly string $version,
        private readonly array $requiredCapabilities,
        private readonly array $compatibility,
        private readonly string $extensionPath,
        private readonly array $targetRoots,
        private readonly array $signing,
        private readonly array $review,
    ) {
    }

    /**
     * @param array<string, mixed> $envelope
     */
    public static function fromArray(array $envelope): self
    {
        if ((int) ($envelope['envelopeVersion'] ?? 0) !== 1) {
            throw new InvalidArgumentException('Marketplace envelope must use envelopeVersion 1.');
        }

        $packageId = trim((string) ($envelope['packageId'] ?? ''));
        if (!preg_match('/^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$/', $packageId)) {
            throw new InvalidArgumentException('Marketplace envelope packageId is invalid.');
        }

        $publisher = trim((string) ($envelope['publisher'] ?? ''));
        if (!in_array($publisher, [self::PUBLISHER_FIRST_PARTY, self::PUBLISHER_THIRD_PARTY], true)) {
            throw new InvalidArgumentException('Marketplace envelope publisher is invalid.');
        }

        $targetType = trim((string) ($envelope['targetType'] ?? ''));
        if (!in_array($targetType, [self::TARGET_MODULE, self::TARGET_MODULE_BUNDLE, self::TARGET_EXTENSION], true)) {
            throw new InvalidArgumentException('Marketplace envelope targetType is invalid.');
        }

        if ($targetType === self::TARGET_MODULE && !preg_match('/^[a-z][a-z0-9_]*$/', $packageId)) {
            throw new InvalidArgumentException('Marketplace module packageId must match its app/modules directory name.');
        }
        $marketplaceSku = trim((string) ($envelope['marketplaceSku'] ?? ''));
        if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9_.-]*$/', $marketplaceSku)) {
            throw new InvalidArgumentException('Marketplace envelope marketplaceSku is invalid.');
        }

        $displayName = trim((string) ($envelope['displayName'] ?? ''));
        if ($displayName === '' || strlen($displayName) > 120 || preg_match('/[[:cntrl:]]/', $displayName)) {
            throw new InvalidArgumentException('Marketplace envelope displayName is invalid.');
        }

        $version = trim((string) ($envelope['version'] ?? ''));
        if (!preg_match('/^\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?$/', $version)) {
            throw new InvalidArgumentException('Marketplace envelope version is invalid.');
        }

        $requiredCapabilities = self::normalizeRequiredCapabilities($envelope['requiredCapabilities'] ?? null);
        $compatibility = self::normalizeCompatibility($envelope['compatibility'] ?? null);

        $extensionPath = self::normalizePath((string) ($envelope['extensionPath'] ?? ''));
        if ($extensionPath === '' || !str_starts_with($extensionPath, 'files/') || !str_ends_with($extensionPath, '/') || !self::isSafeRelativePath($extensionPath)) {
            throw new InvalidArgumentException('Marketplace envelope extensionPath is invalid.');
        }
        $targetRoots = self::normalizeTargetRoots($targetType, $packageId, $envelope['targetRoots'] ?? null);

        $signing = self::normalizeSigning($envelope['signing'] ?? null);
        $review = self::normalizeReview($envelope['review'] ?? null, $publisher);

        return new self(
            $envelope,
            $packageId,
            $publisher,
            $targetType,
            $marketplaceSku,
            $displayName,
            $version,
            $requiredCapabilities,
            $compatibility,
            $extensionPath,
            $targetRoots,
            $signing,
            $review,
        );
    }

    public static function fromJson(string $json): self
    {
        $decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
        if (!is_array($decoded)) {
            throw new InvalidArgumentException('Marketplace envelope root must be an object.');
        }

        return self::fromArray($decoded);
    }

    public static function fromZip(ZipArchive $zip): self
    {
        $contents = $zip->getFromName('marketplace.json');
        if (!is_string($contents) || trim($contents) === '') {
            throw new InvalidArgumentException('Package-install ZIP must contain marketplace.json.');
        }

        return self::fromJson($contents);
    }

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

    public function packageId(): string
    {
        return $this->packageId;
    }

    public function publisher(): string
    {
        return $this->publisher;
    }

    public function targetType(): string
    {
        return $this->targetType;
    }

    public function marketplaceSku(): string
    {
        return $this->marketplaceSku;
    }

    public function displayName(): string
    {
        return $this->displayName;
    }

    public function version(): string
    {
        return $this->version;
    }

    /**
     * @return list<string>
     */
    public function requiredCapabilities(): array
    {
        return $this->requiredCapabilities;
    }

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

    public function extensionPath(): string
    {
        return $this->extensionPath;
    }

    public function targetRoot(): string
    {
        if ($this->targetType === self::TARGET_MODULE_BUNDLE) {
            return $this->targetRoots[0] ?? '';
        }
        if ($this->targetType === self::TARGET_MODULE) {
            return 'app/modules/' . $this->packageId . '/';
        }

        return 'app/extensions/' . $this->extensionVendor() . '/';
    }

    /**
     * @return list<string>
     */
    public function targetRoots(): array
    {
        return $this->targetRoots;
    }

    public function expectedPayloadRoot(): string
    {
        if ($this->targetType === self::TARGET_MODULE_BUNDLE) {
            return 'files/';
        }

        return 'files/' . $this->targetRoot();
    }

    public function innerManifestPayloadPath(): string
    {
        if ($this->targetType === self::TARGET_MODULE_BUNDLE) {
            return 'manifest.json';
        }

        return $this->extensionPath . ($this->targetType === self::TARGET_MODULE ? 'module.php' : 'manifest.json');
    }

    public function extensionVendor(): string
    {
        $segments = explode('.', $this->packageId);
        return (string) $segments[0];
    }

    public function signingValue(string $key): string
    {
        return trim((string) ($this->signing[$key] ?? ''));
    }

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

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

    /**
     * @param list<string> $entries
     */
    public function assertZipInvariants(ZipArchive $zip, array $entries): void
    {
        if ($this->extensionPath !== $this->expectedPayloadRoot()) {
            throw new InvalidArgumentException('Marketplace envelope target root does not match extensionPath.');
        }

        $innerManifest = $this->innerManifestPayloadPath();
        if (!in_array($innerManifest, $entries, true)) {
            throw new InvalidArgumentException('Marketplace package inner manifest is missing: ' . $innerManifest);
        }

        if ($this->targetType === self::TARGET_EXTENSION) {
            $this->assertExtensionManifestMatchesEnvelope($zip, $innerManifest);
            return;
        }

        if ($this->targetType === self::TARGET_MODULE_BUNDLE) {
            foreach ($this->targetRoots as $targetRoot) {
                if (!in_array('files/' . $targetRoot . 'module.php', $entries, true)) {
                    throw new InvalidArgumentException('Marketplace module bundle root must include module.php: ' . $targetRoot);
                }
            }
            return;
        }

        if ($this->targetType === self::TARGET_MODULE && !in_array('files/' . $this->targetRoot() . 'module.php', $entries, true)) {
            throw new InvalidArgumentException('Marketplace module package must include module.php.');
        }
    }

    private function assertExtensionManifestMatchesEnvelope(ZipArchive $zip, string $innerManifest): void
    {
        $contents = $zip->getFromName($innerManifest);
        if (!is_string($contents) || trim($contents) === '') {
            throw new InvalidArgumentException('Marketplace extension inner manifest is unreadable.');
        }

        $decoded = json_decode($contents, true);
        if (!is_array($decoded)) {
            throw new InvalidArgumentException('Marketplace extension inner manifest is invalid JSON.');
        }

        $vendor = trim((string) ($decoded['vendor'] ?? ''));
        if ($vendor === '' || $vendor !== $this->extensionVendor()) {
            throw new InvalidArgumentException('extension_vendor_mismatch');
        }

        $version = trim((string) ($decoded['version'] ?? ''));
        if ($version === '' || $version !== $this->version) {
            throw new InvalidArgumentException('Marketplace envelope version does not match the extension manifest version.');
        }
    }

    /**
     * @return list<string>
     */
    private static function normalizeRequiredCapabilities(mixed $raw): array
    {
        if (!is_array($raw)) {
            throw new InvalidArgumentException('Marketplace envelope requiredCapabilities must be a list.');
        }

        $allowed = ['extensions.install' => true, 'modules.install' => true];
        $capabilities = [];
        foreach ($raw as $capability) {
            $capability = trim((string) $capability);
            if ($capability === '') {
                continue;
            }
            if (!isset($allowed[$capability])) {
                throw new InvalidArgumentException('Marketplace envelope declares unknown install capability: ' . $capability);
            }
            $capabilities[] = $capability;
        }

        if ($capabilities === []) {
            throw new InvalidArgumentException('Marketplace envelope must declare at least one required capability.');
        }

        return array_values(array_unique($capabilities));
    }

    /**
     * @return list<string>
     */
    private static function normalizeTargetRoots(string $targetType, string $packageId, mixed $raw): array
    {
        if ($targetType === self::TARGET_MODULE) {
            return ['app/modules/' . $packageId . '/'];
        }
        if ($targetType === self::TARGET_EXTENSION) {
            $segments = explode('.', $packageId);
            return ['app/extensions/' . (string) $segments[0] . '/'];
        }

        if (!is_array($raw)) {
            throw new InvalidArgumentException('Marketplace module bundle targetRoots must be a list.');
        }

        $roots = [];
        foreach ($raw as $root) {
            $normalized = self::normalizePath((string) $root);
            if ($normalized !== '' && !str_ends_with($normalized, '/')) {
                $normalized .= '/';
            }
            if (!preg_match('#^app/modules/[a-z][a-z0-9_]*(?:/[a-z][a-z0-9_]*)*/$#', $normalized)) {
                throw new InvalidArgumentException('Marketplace module bundle declares invalid target root: ' . $normalized);
            }
            $roots[] = $normalized;
        }

        $roots = array_values(array_unique($roots));
        sort($roots, SORT_STRING);
        if ($roots === []) {
            throw new InvalidArgumentException('Marketplace module bundle must declare at least one target root.');
        }

        return $roots;
    }

    /**
     * @return array<string, mixed>
     */
    private static function normalizeCompatibility(mixed $raw): array
    {
        if (!is_array($raw)) {
            throw new InvalidArgumentException('Marketplace envelope compatibility must be an object.');
        }

        $range = trim((string) ($raw['myroncmsVersionRange'] ?? ''));
        if ($range === '') {
            throw new InvalidArgumentException('Marketplace envelope compatibility.myroncmsVersionRange is required.');
        }

        $extensionApiVersion = (int) ($raw['extensionApiVersion'] ?? 0);
        if ($extensionApiVersion < 1) {
            throw new InvalidArgumentException('Marketplace envelope compatibility.extensionApiVersion is invalid.');
        }

        $siteTypes = $raw['siteTypes'] ?? [];
        if ($siteTypes !== [] && !is_array($siteTypes)) {
            throw new InvalidArgumentException('Marketplace envelope compatibility.siteTypes must be a list.');
        }

        $knownGaps = $raw['knownGaps'] ?? [];
        if ($knownGaps !== [] && !is_array($knownGaps)) {
            throw new InvalidArgumentException('Marketplace envelope compatibility.knownGaps must be a list.');
        }

        return [
            'myroncmsVersionRange' => $range,
            'extensionApiVersion' => $extensionApiVersion,
            'siteTypes' => array_values(array_map('strval', is_array($siteTypes) ? $siteTypes : [])),
            'knownGaps' => array_values(array_map('strval', is_array($knownGaps) ? $knownGaps : [])),
        ];
    }

    /**
     * @return array<string, mixed>
     */
    private static function normalizeSigning(mixed $raw): array
    {
        if (!is_array($raw)) {
            throw new InvalidArgumentException('Marketplace envelope signing must be an object.');
        }

        $checksumAlgo = strtolower(trim((string) ($raw['checksumAlgo'] ?? '')));
        $signatureAlgo = strtolower(trim((string) ($raw['signatureAlgo'] ?? '')));
        $envelopeSha256 = strtolower(trim((string) ($raw['envelopeSha256'] ?? '')));
        $innerManifestSha256 = strtolower(trim((string) ($raw['innerManifestSha256'] ?? '')));
        $signature = trim((string) ($raw['signature'] ?? ''));
        $keyId = trim((string) ($raw['keyId'] ?? ''));

        if ($checksumAlgo !== 'sha256' || !in_array($signatureAlgo, ['hmac-sha256', 'ed25519'], true)) {
            throw new InvalidArgumentException('Marketplace envelope signing algorithms are unsupported.');
        }
        if (!preg_match('/^[a-f0-9]{64}$/', $envelopeSha256) || !preg_match('/^[a-f0-9]{64}$/', $innerManifestSha256)) {
            throw new InvalidArgumentException('Marketplace envelope signing hashes are invalid.');
        }
        if ($signature === '' || !preg_match('/^[A-Za-z0-9_+=\/-]+$/', $signature)) {
            throw new InvalidArgumentException('Marketplace envelope signature is invalid.');
        }
        if ($keyId === '') {
            throw new InvalidArgumentException('Marketplace envelope signing keyId is required.');
        }

        return [
            'checksumAlgo' => $checksumAlgo,
            'envelopeSha256' => $envelopeSha256,
            'innerManifestSha256' => $innerManifestSha256,
            'signatureAlgo' => $signatureAlgo,
            'signature' => $signature,
            'keyId' => $keyId,
        ];
    }

    /**
     * @return array<string, mixed>
     */
    private static function normalizeReview(mixed $raw, string $publisher): array
    {
        if ($raw === null && $publisher === self::PUBLISHER_FIRST_PARTY) {
            return [];
        }
        if (!is_array($raw)) {
            throw new InvalidArgumentException('Marketplace envelope review must be an object.');
        }
        if ($publisher === self::PUBLISHER_THIRD_PARTY && trim((string) ($raw['reviewId'] ?? '')) === '') {
            throw new InvalidArgumentException('Third-party marketplace envelopes must declare review.reviewId.');
        }

        return $raw;
    }

    private static function isSafeRelativePath(string $path): bool
    {
        return $path !== '' && !str_contains($path, "\0") && !str_starts_with($path, '/') && !preg_match('#(^|/)\.\.($|/)#', $path);
    }

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

        return $normalized;
    }
}
