<?php

declare(strict_types=1);

namespace ShieldX\Security;

defined('ABSPATH') || exit;

use ShieldX\Support\Json;

final class VirtualPatchBundleVerifier
{
    public const SCHEMA = 'shieldx.virtual-patch-bundle.v1';
    public const REPOSITORY_SCHEMA = 'shieldx.virtual-patch-repository.v1';
    private const MAX_BUNDLE_BYTES = 1048576;
    private const MAX_DEFINITIONS = 500;
    private const MAX_PATTERNS = 16;
    private const MAX_PATTERN_BYTES = 512;
    private const PINNED_KEY_FILES = [
        'xerotact-ti-2026-01' => 'threat-intelligence-public.pem',
    ];

    /** @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 : self::pinnedPublicKeys();
        $this->clock = $clock ?: static function (): int {
            return time();
        };
    }

    /**
     * @return array{ok:bool,error_code:string,message:string,bundle:array<string,mixed>}
     */
    public function verifyJson(string $json, int $lastSequence = 0, array $expectedRepository = []): array
    {
        if ($json === '' || strlen($json) > self::MAX_BUNDLE_BYTES) {
            return $this->failure('bundle_size', 'The definition update has an invalid size.');
        }

        $bundle = Json::decodeArrayRaw($json, self::MAX_BUNDLE_BYTES);
        if (!is_array($bundle)) {
            return $this->failure('invalid_json', 'The definition update is not valid.');
        }

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

    /**
     * @param array<string,mixed> $bundle
     * @return array{ok:bool,error_code:string,message:string,bundle:array<string,mixed>}
     */
    public function verify(array $bundle, int $lastSequence = 0, array $expectedRepository = []): array
    {
        $schema = (string) ($bundle['schema'] ?? '');
        if (!in_array($schema, [self::SCHEMA, self::REPOSITORY_SCHEMA], true)) {
            return $this->failure('schema', 'The definition update uses an unsupported schema.');
        }

        $repositoryId = '';
        $repositoryComponent = [];
        if ($schema === self::REPOSITORY_SCHEMA) {
            $repositoryId = (string) ($bundle['repository_id'] ?? '');
            $repositoryComponent = isset($bundle['component']) && is_array($bundle['component']) ? $bundle['component'] : [];
            if (!$this->validRepository($repositoryId, $repositoryComponent)) {
                return $this->failure('repository', 'The definition repository identifier or component is invalid.');
            }
            if ($expectedRepository !== [] && !$this->matchesExpectedRepository($bundle, $expectedRepository)) {
                return $this->failure('repository_mismatch', 'The definition repository does not match the verified catalog.');
            }
        }

        $sequence = (int) ($bundle['sequence'] ?? 0);
        if ($sequence <= 0 || $sequence <= $lastSequence) {
            return $this->failure('replay', 'The definition update is older than the installed update.');
        }

        $type = (string) ($bundle['type'] ?? '');
        if (!in_array($type, ['full', 'incremental'], true)) {
            return $this->failure('type', 'The definition update type is unsupported.');
        }
        if ($type === 'incremental'
            && ((int) ($bundle['base_sequence'] ?? 0) !== $lastSequence || $sequence !== $lastSequence + 1)) {
            return $this->failure('sequence_gap', 'The incremental definition update does not follow the installed update.');
        }

        $version = (string) ($bundle['version'] ?? '');
        if (preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/', $version) !== 1) {
            return $this->failure('version', 'The definition update version is invalid.');
        }

        $issuedAt = $this->timestamp($bundle['issued_at'] ?? '');
        $expiresAt = $this->timestamp($bundle['expires_at'] ?? '');
        $now = (int) call_user_func($this->clock);
        if ($issuedAt <= 0 || $issuedAt > $now + 300 || $expiresAt <= $now || $expiresAt <= $issuedAt) {
            return $this->failure('freshness', 'The definition update is not currently valid.');
        }

        $definitions = $bundle['definitions'] ?? null;
        $revocations = $bundle['revocations'] ?? [];
        if (!is_array($definitions) || count($definitions) > self::MAX_DEFINITIONS || !is_array($revocations)) {
            return $this->failure('definitions', 'The definition update contains an invalid definition list.');
        }

        $ids = [];
        foreach ($definitions as $index => $definition) {
            if (!is_array($definition)) {
                return $this->failure('definition_schema', 'A virtual patch definition is invalid.');
            }
            $validation = $this->validateDefinition($definition);
            if (!$validation['ok']) {
                return $this->failure($validation['error_code'], 'Definition ' . ((int) $index + 1) . ': ' . $validation['message']);
            }
            $id = (string) $definition['id'];
            if ($schema === self::REPOSITORY_SCHEMA && !$this->definitionBelongsToRepository($definition, $repositoryId, $repositoryComponent)) {
                return $this->failure('definition_repository', 'A definition belongs to a different component repository.');
            }
            if (isset($ids[$id])) {
                return $this->failure('duplicate_definition', 'The definition update contains duplicate identifiers.');
            }
            $ids[$id] = true;
        }

        foreach ($revocations as $revocation) {
            $reason = is_array($revocation) ? trim((string) ($revocation['reason'] ?? '')) : '';
            if (!is_array($revocation)
                || !$this->validId((string) ($revocation['id'] ?? ''))
                || strlen($reason) > 500) {
                return $this->failure('revocation_schema', 'The definition update contains an invalid revocation.');
            }
        }

        $keyId = (string) ($bundle['key_id'] ?? '');
        $signature = base64_decode((string) ($bundle['signature'] ?? ''), true);
        if (!isset($this->publicKeys[$keyId]) || !is_string($signature) || $signature === '') {
            return $this->failure('signature', 'The definition update signature is not trusted.');
        }

        $signed = $bundle;
        unset($signed['signature']);
        $verified = openssl_verify(
            VirtualPatchCanonicalizer::json($signed),
            $signature,
            $this->publicKeys[$keyId],
            OPENSSL_ALGO_SHA256
        );
        if ($verified !== 1) {
            return $this->failure('signature', 'The definition update signature could not be verified.');
        }

        return [
            'ok' => true,
            'error_code' => '',
            'message' => 'The definition update is verified.',
            'bundle' => $bundle,
        ];
    }

    /**
     * @param array<string,mixed> $definition
     * @return array{ok:bool,error_code:string,message:string}
     */
    private function validateDefinition(array $definition): array
    {
        $id = (string) ($definition['id'] ?? '');
        if (!$this->validId($id) || (int) ($definition['revision'] ?? 0) <= 0) {
            return $this->definitionFailure('definition_id', 'The identifier or revision is invalid.');
        }
        if (!in_array((string) ($definition['severity'] ?? ''), ['low', 'medium', 'high', 'critical'], true)) {
            return $this->definitionFailure('definition_severity', 'The severity is invalid.');
        }

        $component = $definition['component'] ?? null;
        if (!is_array($component)
            || !in_array((string) ($component['type'] ?? ''), ['wordpress', 'plugin', 'theme', 'software'], true)
            || !$this->validSlug((string) ($component['slug'] ?? ''))
            || trim((string) ($component['affected_versions'] ?? '')) === '') {
            return $this->definitionFailure('definition_component', 'The affected component is invalid.');
        }

        $expiresAt = $this->timestamp($definition['expires_at'] ?? '');
        if ($expiresAt <= 0) {
            return $this->definitionFailure('definition_expiry', 'The expiration time is invalid.');
        }

        $matcher = $definition['matcher'] ?? null;
        if (!is_array($matcher)) {
            return $this->definitionFailure('definition_matcher', 'The matcher is missing.');
        }
        $methods = $this->strings($matcher['methods'] ?? [], 8, 12);
        $prefixes = $this->strings($matcher['path_prefixes'] ?? [], 16, 240);
        $fields = $this->strings($matcher['fields'] ?? [], 8, 32);
        $patterns = $this->strings($matcher['patterns'] ?? [], self::MAX_PATTERNS, self::MAX_PATTERN_BYTES);
        if ($methods === [] || $prefixes === [] || $fields === [] || $patterns === []) {
            return $this->definitionFailure('definition_matcher', 'The matcher must be bounded to a method, path, field, and pattern.');
        }
        foreach ($methods as $method) {
            if (!in_array($method, ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'], true)) {
                return $this->definitionFailure('definition_method', 'The matcher contains an unsupported request method.');
            }
        }
        foreach ($prefixes as $prefix) {
            if ($prefix[0] !== '/' || strpos($prefix, '..') !== false || strpos($prefix, "\0") !== false) {
                return $this->definitionFailure('definition_path', 'The matcher contains an unsafe path prefix.');
            }
        }
        foreach ($fields as $field) {
            if (!in_array($field, ['uri', 'path', 'query', 'headers', 'body', 'all'], true)) {
                return $this->definitionFailure('definition_field', 'The matcher contains an unsupported request field.');
            }
        }
        foreach ($patterns as $pattern) {
            if (@preg_match($pattern, '') === false) {
                return $this->definitionFailure('definition_pattern', 'The matcher contains an invalid pattern.');
            }
        }

        if (isset($definition['edge'])) {
            if (!is_array($definition['edge'])) {
                return $this->definitionFailure('definition_edge', 'The edge matcher is invalid.');
            }
            $expression = trim((string) ($definition['edge']['cloudflare_expression'] ?? ''));
            if ($expression !== '' && (strlen($expression) > 4096 || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', $expression) === 1)) {
                return $this->definitionFailure('definition_edge', 'The Cloudflare expression is invalid.');
            }
        }

        $expectedHash = VirtualPatchCanonicalizer::definitionHash($definition);
        if (!hash_equals($expectedHash, strtolower((string) ($definition['definition_hash'] ?? '')))) {
            return $this->definitionFailure('definition_hash', 'The definition hash does not match its contents.');
        }

        return ['ok' => true, 'error_code' => '', 'message' => ''];
    }

    /** @return array<string,string> */
    public static function pinnedPublicKeys(): array
    {
        $keys = [];
        $directory = dirname(__DIR__, 2) . '/resources/';
        foreach (self::PINNED_KEY_FILES as $keyId => $filename) {
            $path = $directory . $filename;
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local pinned key, never a URL.
            $key = is_readable($path) ? file_get_contents($path) : false;
            if (is_string($key) && trim($key) !== '') {
                $keys[$keyId] = $key;
            }
        }

        return $keys;
    }

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

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

    private function validId(string $value): bool
    {
        return preg_match('/^SX-VP-[A-Z0-9][A-Z0-9._-]{2,60}$/', $value) === 1;
    }

    private function validSlug(string $value): bool
    {
        return preg_match('/^[a-z0-9][a-z0-9._-]{0,190}$/', $value) === 1;
    }

    /** @param array<string,mixed> $component */
    private function validRepository(string $repositoryId, array $component): bool
    {
        $type = (string) ($component['type'] ?? '');
        $slug = (string) ($component['slug'] ?? '');
        if ($repositoryId === 'platform') {
            return $type === 'platform' && $slug === 'platform';
        }

        return in_array($type, ['plugin', 'theme'], true)
            && $this->validSlug($slug)
            && $repositoryId === $type . ':' . $slug;
    }

    /**
     * @param array<string,mixed> $bundle
     * @param array<string,mixed> $expected
     */
    private function matchesExpectedRepository(array $bundle, array $expected): bool
    {
        $component = isset($bundle['component']) && is_array($bundle['component']) ? $bundle['component'] : [];
        $expectedComponent = isset($expected['component']) && is_array($expected['component']) ? $expected['component'] : [];

        return (string) ($bundle['repository_id'] ?? '') === (string) ($expected['repository_id'] ?? '')
            && (int) ($bundle['sequence'] ?? 0) === (int) ($expected['sequence'] ?? 0)
            && (string) ($bundle['version'] ?? '') === (string) ($expected['version'] ?? '')
            && (string) ($bundle['expires_at'] ?? '') === (string) ($expected['expires_at'] ?? '')
            && (string) ($component['type'] ?? '') === (string) ($expectedComponent['type'] ?? '')
            && (string) ($component['slug'] ?? '') === (string) ($expectedComponent['slug'] ?? '');
    }

    /**
     * @param array<string,mixed> $definition
     * @param array<string,mixed> $repositoryComponent
     */
    private function definitionBelongsToRepository(array $definition, string $repositoryId, array $repositoryComponent): bool
    {
        $component = isset($definition['component']) && is_array($definition['component']) ? $definition['component'] : [];
        $type = (string) ($component['type'] ?? '');
        $slug = (string) ($component['slug'] ?? '');
        if ($repositoryId === 'platform') {
            return in_array($type, ['wordpress', 'software'], true);
        }

        return $type === (string) ($repositoryComponent['type'] ?? '')
            && $slug === (string) ($repositoryComponent['slug'] ?? '');
    }

    /**
     * @param mixed $values
     * @return array<int,string>
     */
    private function strings($values, int $maxItems, int $maxBytes): array
    {
        if (!is_array($values) || count($values) > $maxItems) {
            return [];
        }
        $result = [];
        foreach ($values as $value) {
            if (!is_scalar($value)) {
                return [];
            }
            $value = trim((string) $value);
            if ($value === '' || strlen($value) > $maxBytes) {
                return [];
            }
            $result[] = $value;
        }

        return $result;
    }

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

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