<?php

declare(strict_types=1);

namespace ShieldX\Security;

defined('ABSPATH') || exit;

use ShieldX\Support\Json;

final class VirtualPatchCatalogVerifier
{
    public const SCHEMA = 'shieldx.virtual-patch-catalog.v1';
    private const MAX_CATALOG_BYTES = 262144;
    private const MAX_REPOSITORIES = 1000;

    /** @var array<string,string> */
    private $publicKeys;

    /** @var callable */
    private $clock;

    /** @param array<string,string>|null $publicKeys */
    public function __construct(?array $publicKeys = null, ?callable $clock = null)
    {
        $this->publicKeys = $publicKeys !== null ? $publicKeys : VirtualPatchBundleVerifier::pinnedPublicKeys();
        $this->clock = $clock ?: static function (): int {
            return time();
        };
    }

    /** @return array{ok:bool,error_code:string,message:string,catalog:array<string,mixed>} */
    public function verifyJson(string $json, int $lastSequence = 0): array
    {
        if ($json === '' || strlen($json) > self::MAX_CATALOG_BYTES) {
            return $this->failure('catalog_size', 'The threat catalog has an invalid size.');
        }
        $catalog = Json::decodeArrayRaw($json, self::MAX_CATALOG_BYTES);
        if (!is_array($catalog)) {
            return $this->failure('invalid_json', 'The threat catalog is not valid.');
        }

        return $this->verify($catalog, $lastSequence);
    }

    /**
     * @param array<string,mixed> $catalog
     * @return array{ok:bool,error_code:string,message:string,catalog:array<string,mixed>}
     */
    public function verify(array $catalog, int $lastSequence = 0): array
    {
        if ((string) ($catalog['schema'] ?? '') !== self::SCHEMA) {
            return $this->failure('schema', 'The threat catalog uses an unsupported schema.');
        }
        $sequence = (int) ($catalog['sequence'] ?? 0);
        if ($sequence <= 0 || $sequence <= $lastSequence) {
            return $this->failure('replay', 'The threat catalog is older than the installed catalog.');
        }
        $version = (string) ($catalog['version'] ?? '');
        if (preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/', $version) !== 1) {
            return $this->failure('version', 'The threat catalog version is invalid.');
        }
        $issuedAt = $this->timestamp($catalog['issued_at'] ?? '');
        $expiresAt = $this->timestamp($catalog['expires_at'] ?? '');
        $now = (int) call_user_func($this->clock);
        if ($issuedAt <= 0 || $issuedAt > $now + 300 || $expiresAt <= $now || $expiresAt <= $issuedAt) {
            return $this->failure('freshness', 'The threat catalog is not currently valid.');
        }

        $repositories = $catalog['repositories'] ?? null;
        if (!is_array($repositories) || count($repositories) > self::MAX_REPOSITORIES) {
            return $this->failure('repositories', 'The threat catalog contains an invalid repository list.');
        }
        $ids = [];
        foreach ($repositories as $index => $entry) {
            if (!is_array($entry)) {
                return $this->failure('repository_schema', 'Catalog repository ' . ((int) $index + 1) . ' is invalid.');
            }
            $error = $this->validateEntry($entry);
            if ($error !== '') {
                return $this->failure('repository_schema', 'Catalog repository ' . ((int) $index + 1) . ': ' . $error);
            }
            $id = (string) $entry['repository_id'];
            if (isset($ids[$id])) {
                return $this->failure('duplicate_repository', 'The threat catalog contains duplicate repository identifiers.');
            }
            $ids[$id] = true;
        }

        $keyId = (string) ($catalog['key_id'] ?? '');
        $signature = base64_decode((string) ($catalog['signature'] ?? ''), true);
        if (!isset($this->publicKeys[$keyId]) || !is_string($signature) || $signature === '') {
            return $this->failure('signature', 'The threat catalog signature is not trusted.');
        }
        $signed = $catalog;
        unset($signed['signature']);
        if (openssl_verify(VirtualPatchCanonicalizer::json($signed), $signature, $this->publicKeys[$keyId], OPENSSL_ALGO_SHA256) !== 1) {
            return $this->failure('signature', 'The threat catalog signature could not be verified.');
        }

        return [
            'ok' => true,
            'error_code' => '',
            'message' => 'The threat catalog is verified.',
            'catalog' => $catalog,
        ];
    }

    /** @param array<string,mixed> $entry */
    private function validateEntry(array $entry): string
    {
        $repositoryId = (string) ($entry['repository_id'] ?? '');
        $component = isset($entry['component']) && is_array($entry['component']) ? $entry['component'] : [];
        $type = (string) ($component['type'] ?? '');
        $slug = (string) ($component['slug'] ?? '');
        if (!$this->validRepositoryId($repositoryId, $type, $slug)) {
            return 'The repository identifier or component is invalid.';
        }
        if ((int) ($entry['sequence'] ?? 0) <= 0
            || preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/', (string) ($entry['version'] ?? '')) !== 1
            || preg_match('/^[a-f0-9]{64}$/', (string) ($entry['sha256'] ?? '')) !== 1
            || (int) ($entry['bytes'] ?? 0) <= 0
            || (int) ($entry['bytes'] ?? 0) > 1048576
            || (int) ($entry['definition_count'] ?? -1) < 0
            || (int) ($entry['definition_count'] ?? 0) > 500
            || $this->timestamp($entry['expires_at'] ?? '') <= (int) call_user_func($this->clock)
        ) {
            return 'The repository artifact metadata is invalid.';
        }
        $selectors = $entry['selectors'] ?? [];
        if (!is_array($selectors) || count($selectors) > 64 || ($repositoryId !== 'platform' && $selectors === [])) {
            return 'The repository selectors are invalid.';
        }
        foreach ($selectors as $selector) {
            if (!is_scalar($selector) || !VirtualPatchVersionMatcher::isValidConstraint((string) $selector)) {
                return 'A repository version selector is invalid.';
            }
        }

        return '';
    }

    private function validRepositoryId(string $repositoryId, string $type, string $slug): bool
    {
        if ($repositoryId === 'platform') {
            return $type === 'platform' && $slug === 'platform';
        }
        if (!in_array($type, ['plugin', 'theme'], true)
            || preg_match('/^[a-z0-9][a-z0-9._-]{0,190}$/', $slug) !== 1) {
            return false;
        }

        return $repositoryId === $type . ':' . $slug;
    }

    /** @param mixed $value */
    private function timestamp($value): int
    {
        if (!is_scalar($value)) {
            return 0;
        }
        $timestamp = strtotime((string) $value);

        return $timestamp === false ? 0 : $timestamp;
    }

    /** @return array{ok:bool,error_code:string,message:string,catalog:array<string,mixed>} */
    private function failure(string $code, string $message): array
    {
        return ['ok' => false, 'error_code' => $code, 'message' => $message, 'catalog' => []];
    }
}
