<?php

namespace App\Services\Marketplace;

use App\Services\Marketplace\Exceptions\SignatureException;

/**
 * Verifies Ed25519 signature + SHA256 integrity of a downloaded marketplace package.
 *
 * Two-layer verification:
 *  1. Ed25519: signature over the canonical release manifest (keys sorted, no whitespace).
 *  2. SHA256:  hash of the downloaded ZIP file compared to the signed manifest.
 *
 * Requires the php-sodium extension (bundled since PHP 7.2).
 */
class SignatureVerifier
{
    /**
     * Verify a downloaded package ZIP against the registry entry.
     *
     * @param string $zipPath        Absolute path to the downloaded ZIP file.
     * @param array  $registryEntry  Full registry API response for this package.
     *                               Must contain: sha256_hash, signature_b64, manifest_json, key_id,
     *                               slug, version, type, size_bytes, min_cms_version, max_cms_version,
     *                               released_at.
     * @param string $publicKeyB64   Base64-encoded Ed25519 public key (32 bytes decoded).
     *
     * @return array{sha256_verified: bool, sig_verified: bool, key_id: string}
     * @throws SignatureException on any verification failure.
     */
    public function verifyPackage(string $zipPath, array $registryEntry, string $publicKeyB64): array
    {
        // 1. Sodium availability
        if (!extension_loaded('sodium')) {
            throw new SignatureException(
                'The php-sodium extension is required for package signature verification. ' .
                'Enable it in php.ini (extension=sodium).'
            );
        }

        // 2. Canonical manifest consistency check
        //    Rebuilds the manifest from the registry entry and compares it byte-for-byte
        //    with the manifest_json field. This catches tampering where the server sends
        //    different data in the main response vs. the signed manifest.
        $manifestJson = $registryEntry['manifest_json'] ?? '';
        if ($manifestJson === '') {
            throw new SignatureException('Registry entry is missing manifest_json field.');
        }

        $rebuilt = $this->buildCanonicalManifest($registryEntry);
        if (!hash_equals($manifestJson, $rebuilt)) {
            throw new SignatureException(
                'Manifest consistency check failed: registry entry fields do not match manifest_json. ' .
                'Possible tampering detected.'
            );
        }

        // 3. Ed25519 signature verification
        $signatureB64 = $registryEntry['signature_b64'] ?? '';
        if ($signatureB64 === '') {
            throw new SignatureException('Registry entry is missing signature_b64 field.');
        }

        $signature = base64_decode($signatureB64, true);
        if ($signature === false || strlen($signature) !== SODIUM_CRYPTO_SIGN_BYTES) {
            throw new SignatureException('Invalid signature format (expected 64-byte Ed25519 signature, base64).');
        }

        $publicKey = base64_decode($publicKeyB64, true);
        if ($publicKey === false || strlen($publicKey) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
            throw new SignatureException('Invalid public key format (expected 32-byte Ed25519 key, base64).');
        }

        if (!sodium_crypto_sign_verify_detached($signature, $manifestJson, $publicKey)) {
            throw new SignatureException(
                'Ed25519 signature verification failed. ' .
                'The package may have been tampered with or signed by an untrusted key.'
            );
        }

        // 4. SHA256 integrity check of the downloaded ZIP
        $expectedHash = $registryEntry['sha256_hash'] ?? '';
        if ($expectedHash === '') {
            throw new SignatureException('Registry entry is missing sha256_hash field.');
        }

        if (!is_file($zipPath)) {
            throw new SignatureException("ZIP file not found at: {$zipPath}");
        }

        $actualHash = hash_file('sha256', $zipPath);
        if ($actualHash === false) {
            throw new SignatureException('Could not compute SHA256 of downloaded file.');
        }

        if (!hash_equals($expectedHash, $actualHash)) {
            throw new SignatureException(
                "SHA256 mismatch: expected {$expectedHash}, got {$actualHash}. " .
                'The download may be corrupted or have been modified in transit.'
            );
        }

        return [
            'sha256_verified' => true,
            'sig_verified'    => true,
            'key_id'          => (string) ($registryEntry['key_id'] ?? ''),
        ];
    }

    // ─────────────────────────────────────────────────────────
    // Private helpers
    // ─────────────────────────────────────────────────────────

    /**
     * Build the canonical manifest JSON from a registry entry.
     *
     * The canonical form is defined as the JSON encoding of these specific fields,
     * with keys sorted alphabetically and no extra whitespace.
     * This must match the server-side implementation in marketplace:sign.
     *
     * Fields: key_id, max_cms_version, min_cms_version, released_at, sha256,
     *         size_bytes, slug, type, version.
     */
    private function buildCanonicalManifest(array $entry): string
    {
        $manifest = [
            'key_id'           => (string) ($entry['key_id']           ?? ''),
            'max_cms_version'  => (string) ($entry['max_cms_version']  ?? ''),
            'min_cms_version'  => (string) ($entry['min_cms_version']  ?? ''),
            'released_at'      => (string) ($entry['released_at']      ?? ''),
            'sha256'           => (string) ($entry['sha256_hash']       ?? ''),
            'size_bytes'       => (int)    ($entry['size_bytes']        ?? 0),
            'slug'             => (string) ($entry['slug']              ?? ''),
            'type'             => (string) ($entry['type']              ?? ''),
            'version'          => (string) ($entry['version']           ?? ''),
        ];

        // Keys are already in alphabetical order; ksort is a safety net.
        ksort($manifest);

        return json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    }
}
