<?php
declare(strict_types=1);

namespace SafeTun\Backend\Config;

use SafeTun\Backend\Crypto\KeyLoader;
use SafeTun\Backend\Crypto\ReleaseManifest;
use SafeTun\Backend\Exception\ConfigException;
use SafeTun\Backend\Net\IpNetwork;

final class ConfigValidator
{
    /** @var list<string> */
    private array $errors = [];
    /** @var list<string> */
    private array $warnings = [];
    /** @var array<string,true> */
    private array $permissionCheckedPaths = [];

    /** @var list<string> */
    private const TRANSPORTS = [
        'wireguard_udp', 'quic_tls', 'ikev2_ipsec', 'tls_tcp_443', 'wss_tls_443',
        'openvpn_udp', 'openvpn_tcp_443', 'sit_6in4', 'ipip_4in4', 'gre',
    ];
    /** @var list<string> */
    private const ENCRYPTED_TRANSPORTS = [
        'wireguard_udp', 'quic_tls', 'ikev2_ipsec', 'tls_tcp_443', 'wss_tls_443',
        'openvpn_udp', 'openvpn_tcp_443',
    ];

    public function validate(ParsedConfig $parsed, string $configPath, ?string $documentRoot = null): ValidatedConfig
    {
        $this->errors = [];
        $this->warnings = [];
        $this->permissionCheckedPaths = [];
        $config = $parsed->values;
        $this->validateNode($config, $this->schema(), 'config');
        if ($this->errors === []) {
            $this->semanticValidation($config, $configPath, $documentRoot);
        }
        if ($this->errors !== []) {
            throw new ConfigException($this->errors);
        }
        return new ValidatedConfig($config, $parsed->sha256, $this->warnings);
    }

    /** @return array<string,mixed> */
    private function schema(): array
    {
        $object = static fn (array $properties): array => ['object' => $properties];
        $list = static fn (mixed $of): array => ['list' => $of];
        $map = static fn (mixed $of): array => ['map' => $of];
        $enum = static fn (array $values): array => ['enum' => $values];
        $nullable = static fn (mixed $of): array => ['nullable' => $of];

        return $object([
            'app' => $object([
                'environment' => $enum(['production', 'development', 'test']),
                'debug' => 'bool', 'issuer' => 'string', 'audience' => 'string',
                'timezone' => 'string', 'api_base_urls' => $list('string'),
            ]),
            'api' => $object([
                'require_https' => 'bool', 'max_body_bytes' => 'int', 'challenge_ttl_seconds' => 'int',
                'request_clock_skew_seconds' => 'int', 'rate_limit_window_seconds' => 'int',
                'challenge_rate_limit' => 'int', 'protected_rate_limit' => 'int',
                'release_rate_limit' => 'int',
            ]),
            'authorization' => $object([
                'client_ip_mode' => $enum(['direct', 'trusted_proxy']),
                'allowed_iran_cidrs' => $list('string'), 'denied_cidrs' => $list('string'),
                'trusted_proxy_cidrs' => $list('string'), 'trusted_ip_headers' => $list('string'),
                'require_public_ip' => 'bool', 'dangerously_allow_all' => 'bool',
                'revoked_node_key_hashes' => $list('string'), 'max_edges_per_hub' => $nullable('int'),
            ]),
            'access_policy' => $object([
                'policy_id' => 'string', 'hub_features' => $list('string'), 'edge_features' => $list('string'),
            ]),
            'endpoint_policy' => $object([
                'mode' => $enum(['explicit_pair_only']), 'allowed_foreign_cidrs' => $list('string'),
                'max_foreign_endpoints_per_hub' => 'int', 'allow_arbitrary_target_scan' => 'bool',
                'require_reverse_probe' => 'bool',
            ]),
            'lease' => $object([
                'ttl_seconds' => 'int', 'renew_after_seconds' => 'int', 'offline_grace_seconds' => 'int',
                'clock_skew_seconds' => 'int', 'fail_closed_after_grace' => 'bool',
                'active_key_id' => 'string', 'private_key_path' => 'string',
                'verification_keys' => $map('string'),
            ]),
            'pair' => $object([
                'ttl_seconds' => 'int', 'offline_grace_seconds' => 'int', 'active_key_id' => 'string',
                'private_key_path' => 'string', 'verification_keys' => $map('string'),
            ]),
            'transports' => $object([
                'easy_profile' => $enum(['smart_secure']), 'security_floor' => $enum(['encrypted']),
                'priority' => $list('string'),
                'enabled' => $object(array_fill_keys(self::TRANSPORTS, 'bool')),
                'allow_plain_raw_in_easy' => 'bool', 'allow_plain_raw_in_advanced' => 'bool',
                'tcp_ports' => $list('int'), 'udp_ports' => $list('int'),
                'owned_tls_server_name' => 'string', 'certificate_path' => 'string',
                'certificate_key_path' => 'string',
            ]),
            'diagnostics' => $object([
                'enabled' => 'bool', 'bidirectional' => 'bool', 'timeout_ms' => 'int',
                'probe_rounds' => 'int', 'tcp_ports' => $list('int'), 'udp_ports' => $list('int'),
                'raw_protocols' => $list('int'), 'pmtu_probe' => 'bool', 'short_throughput_probe' => 'bool',
                'max_probe_bytes' => 'int', 'packet_capture' => 'bool', 'redact_support_bundle' => 'bool',
            ]),
            'health' => $object([
                'interval_seconds' => 'int', 'failure_threshold' => 'int', 'recovery_threshold' => 'int',
                'max_backoff_seconds' => 'int', 'make_before_break' => 'bool', 'hot_standby' => 'bool',
                'hysteresis_seconds' => 'int',
            ]),
            'forwarding' => $object([
                'default_mode' => $enum(['full_port', 'explicit', 'range']),
                'tcp_ranges' => $list('string'), 'udp_ranges' => $list('string'),
                'protected_tcp_ports' => $list('int'), 'protected_udp_ports' => $list('int'),
                'remote_ssh_port' => 'int', 'control_port' => 'int',
            ]),
            'network' => $object([
                'auto_interface' => 'bool', 'auto_route' => 'bool', 'auto_mtu' => 'bool',
                'pmtu_probe' => 'bool', 'mss_clamp' => $enum(['auto', 'disabled']), 'safe_mtu_fallback' => 'int',
            ]),
            'optimizer' => $object([
                'profile' => $enum(['balanced', 'low-latency', 'throughput', 'low-memory', 'custom']),
                'bbr' => $enum(['auto', 'enabled', 'disabled']), 'qdisc' => $enum(['auto', 'fq', 'disabled']),
                'buffer_tuning' => $enum(['auto', 'enabled', 'disabled']), 'replace_kernel' => 'bool',
                'automatic_reboot' => 'bool', 'backup_before_change' => 'bool', 'rollback_on_failure' => 'bool',
            ]),
            'assigned_source_ip' => $object([
                'enabled' => 'bool', 'allowed_source_cidrs' => $list('string'),
                'require_local_or_provider_routed' => 'bool', 'require_reverse_path_probe' => 'bool',
                'advanced_only' => 'bool',
            ]),
            'release' => $object([
                'channel' => $enum(['stable', 'beta']), 'manifest_url' => 'string',
                'manifest_path' => 'string', 'manifest_signature_path' => 'string',
                'active_key_id' => 'string', 'release_public_key_path' => 'string',
                'require_signature' => 'bool', 'allow_foreign_direct_download' => 'bool',
                'iran_hub_pushes_edge' => 'bool',
            ]),
            'runtime' => $object([
                'nonce_dir' => 'string', 'rate_limit_dir' => 'string', 'cache_dir' => 'string',
                'audit_log_dir' => 'string', 'retention_days' => 'int',
            ]),
        ]);
    }

    private function validateNode(mixed $value, mixed $schema, string $path): void
    {
        if (is_string($schema)) {
            $valid = match ($schema) {
                'string' => is_string($value), 'int' => is_int($value), 'bool' => is_bool($value),
                'float' => is_float($value), default => false,
            };
            if (!$valid) {
                $this->errors[] = "{$path} must be {$schema}; got " . get_debug_type($value) . '.';
            }
            return;
        }
        if (!is_array($schema)) {
            $this->errors[] = "Internal schema error at {$path}.";
            return;
        }
        if (isset($schema['nullable'])) {
            if ($value !== null) {
                $this->validateNode($value, $schema['nullable'], $path);
            }
            return;
        }
        if (isset($schema['enum'])) {
            if (!is_string($value) || !in_array($value, $schema['enum'], true)) {
                $this->errors[] = "{$path} has an unsupported value.";
            }
            return;
        }
        if (isset($schema['list'])) {
            if (!is_array($value) || !array_is_list($value)) {
                $this->errors[] = "{$path} must be a list.";
                return;
            }
            foreach ($value as $index => $item) {
                $this->validateNode($item, $schema['list'], "{$path}[{$index}]");
            }
            return;
        }
        if (isset($schema['map'])) {
            if (!is_array($value) || ($value !== [] && array_is_list($value))) {
                $this->errors[] = "{$path} must be a string-keyed map.";
                return;
            }
            foreach ($value as $key => $item) {
                if (!is_string($key) || !preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/D', $key)) {
                    $this->errors[] = "{$path} contains an invalid map key.";
                    continue;
                }
                $this->validateNode($item, $schema['map'], "{$path}.{$key}");
            }
            return;
        }
        if (isset($schema['object'])) {
            if (!is_array($value) || ($value !== [] && array_is_list($value))) {
                $this->errors[] = "{$path} must be an object-like array.";
                return;
            }
            $properties = $schema['object'];
            foreach (array_keys($value) as $key) {
                if (!array_key_exists($key, $properties)) {
                    $this->errors[] = "{$path}.{$key} is an unknown configuration key.";
                }
            }
            foreach ($properties as $key => $childSchema) {
                if (!array_key_exists($key, $value)) {
                    $this->errors[] = "{$path}.{$key} is required.";
                    continue;
                }
                $this->validateNode($value[$key], $childSchema, "{$path}.{$key}");
            }
            return;
        }
        $this->errors[] = "Internal schema error at {$path}.";
    }

    /** @param array<string,mixed> $config */
    private function semanticValidation(array &$config, string $configPath, ?string $documentRoot): void
    {
        if ($config['app']['environment'] === 'production' && $config['app']['debug']) {
            $this->errors[] = 'config.app.debug must be false in production.';
        }
        if (!in_array($config['app']['timezone'], timezone_identifiers_list(), true)) {
            $this->errors[] = 'config.app.timezone is not a recognized timezone.';
        }
        $this->httpsUrl($config['app']['issuer'], 'config.app.issuer');
        foreach ($config['app']['api_base_urls'] as $index => $url) {
            $this->httpsUrl($url, "config.app.api_base_urls[{$index}]");
        }
        if ($config['app']['audience'] === '' || strlen($config['app']['audience']) > 128) {
            $this->errors[] = 'config.app.audience must contain 1-128 characters.';
        }

        foreach ([
            'max_body_bytes' => [1024, 1048576], 'challenge_ttl_seconds' => [30, 600],
            'request_clock_skew_seconds' => [5, 300], 'rate_limit_window_seconds' => [1, 3600],
            'challenge_rate_limit' => [1, 10000], 'protected_rate_limit' => [1, 10000],
            'release_rate_limit' => [1, 10000],
        ] as $key => [$minimum, $maximum]) {
            $this->integerRange($config['api'][$key], $minimum, $maximum, "config.api.{$key}");
        }
        if (!$config['api']['require_https']) {
            $this->errors[] = 'config.api.require_https must remain true.';
        }

        $dangerous = $config['authorization']['dangerously_allow_all'];
        foreach ([
            ['authorization', 'allowed_iran_cidrs', true], ['authorization', 'denied_cidrs', false],
            ['authorization', 'trusted_proxy_cidrs', false], ['endpoint_policy', 'allowed_foreign_cidrs', true],
            ['assigned_source_ip', 'allowed_source_cidrs', true],
        ] as [$section, $key, $mustBePublic]) {
            $config[$section][$key] = $this->normalizeCidrs(
                $config[$section][$key], "config.{$section}.{$key}", (bool) $mustBePublic, $dangerous,
            );
        }
        if ($config['authorization']['allowed_iran_cidrs'] === [] && !$dangerous) {
            $this->warnings[] = 'Authorization is safely disabled until allowed_iran_cidrs is populated.';
        }
        if ($dangerous) {
            $this->warnings[] = 'DANGER: dangerously_allow_all is enabled; every non-denied public source can authorize.';
        }
        if (!$config['authorization']['require_public_ip']) {
            $this->errors[] = 'config.authorization.require_public_ip must remain true for IP-only authorization.';
        }
        $headers = array_map('strtolower', $config['authorization']['trusted_ip_headers']);
        if (count($headers) !== count(array_unique($headers))) {
            $this->errors[] = 'config.authorization.trusted_ip_headers contains duplicates.';
        }
        foreach ($headers as $header) {
            if (!in_array($header, ['cf-connecting-ip', 'x-forwarded-for'], true)) {
                $this->errors[] = 'Only CF-Connecting-IP and X-Forwarded-For may be trusted IP headers.';
            }
        }
        if ($config['authorization']['client_ip_mode'] === 'direct'
            && $config['authorization']['trusted_proxy_cidrs'] !== []) {
            $this->errors[] = 'trusted_proxy_cidrs must be empty in direct client_ip_mode.';
        }
        if ($config['authorization']['client_ip_mode'] === 'trusted_proxy'
            && ($config['authorization']['trusted_proxy_cidrs'] === [] || $headers === [])) {
            $this->errors[] = 'trusted_proxy mode requires proxy CIDRs and at least one trusted header.';
        }
        foreach ($config['authorization']['revoked_node_key_hashes'] as $hash) {
            if (!preg_match('/^[0-9a-f]{64}$/D', $hash)) {
                $this->errors[] = 'revoked_node_key_hashes entries must be lowercase SHA-256 hex.';
            }
        }
        if ($config['authorization']['max_edges_per_hub'] !== null) {
            $this->integerRange($config['authorization']['max_edges_per_hub'], 1, 100000, 'config.authorization.max_edges_per_hub');
        }

        if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/D', $config['access_policy']['policy_id'])) {
            $this->errors[] = 'config.access_policy.policy_id is invalid.';
        }
        foreach (['hub_features', 'edge_features'] as $featureList) {
            $this->uniqueStrings($config['access_policy'][$featureList], "config.access_policy.{$featureList}");
            foreach ($config['access_policy'][$featureList] as $feature) {
                if (!in_array($feature, ['tcp_forward', 'udp_forward', 'full_port', 'l3', 'smart_auto'], true)) {
                    $this->errors[] = "config.access_policy.{$featureList} contains an unknown feature.";
                }
            }
        }
        $this->integerRange($config['endpoint_policy']['max_foreign_endpoints_per_hub'], 1, 32, 'config.endpoint_policy.max_foreign_endpoints_per_hub');
        if ($config['endpoint_policy']['allow_arbitrary_target_scan']) {
            $this->errors[] = 'allow_arbitrary_target_scan must remain false.';
        }

        $this->integerRange($config['lease']['ttl_seconds'], 60, 86400, 'config.lease.ttl_seconds');
        $this->integerRange($config['lease']['renew_after_seconds'], 1, 86399, 'config.lease.renew_after_seconds');
        $this->integerRange($config['lease']['offline_grace_seconds'], 0, 604800, 'config.lease.offline_grace_seconds');
        $this->integerRange($config['lease']['clock_skew_seconds'], 0, 300, 'config.lease.clock_skew_seconds');
        if ($config['lease']['renew_after_seconds'] >= $config['lease']['ttl_seconds']) {
            $this->errors[] = 'config.lease.renew_after_seconds must be less than ttl_seconds.';
        }
        if (!$config['lease']['fail_closed_after_grace']) {
            $this->errors[] = 'config.lease.fail_closed_after_grace must remain true.';
        }
        $this->integerRange($config['pair']['ttl_seconds'], 60, 86400, 'config.pair.ttl_seconds');
        $this->integerRange($config['pair']['offline_grace_seconds'], 0, 604800, 'config.pair.offline_grace_seconds');
        $this->validateSigningDomain($config['lease'], 'config.lease', $documentRoot);
        $this->validateSigningDomain($config['pair'], 'config.pair', $documentRoot);
        $this->verifyActiveKeyPair($config['lease'], 'config.lease');
        $this->verifyActiveKeyPair($config['pair'], 'config.pair');

        $this->uniqueStrings($config['transports']['priority'], 'config.transports.priority');
        foreach ($config['transports']['priority'] as $transport) {
            if (!in_array($transport, self::TRANSPORTS, true)) {
                $this->errors[] = "Unknown transport in priority: {$transport}.";
            }
        }
        $hasEncryptedEasy = false;
        foreach (self::ENCRYPTED_TRANSPORTS as $transport) {
            $hasEncryptedEasy = $hasEncryptedEasy || $config['transports']['enabled'][$transport];
        }
        if (!$hasEncryptedEasy || $config['transports']['allow_plain_raw_in_easy']) {
            $this->errors[] = 'EASY requires at least one enabled encrypted transport and must forbid plain raw transports.';
        }
        $this->ports($config['transports']['tcp_ports'], 'config.transports.tcp_ports');
        $this->ports($config['transports']['udp_ports'], 'config.transports.udp_ports');
        if (!preg_match('/^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}$/D', $config['transports']['owned_tls_server_name'])) {
            $this->errors[] = 'config.transports.owned_tls_server_name must be a DNS hostname.';
        }
        $this->requiredFile($config['transports']['certificate_path'], false, 'config.transports.certificate_path', $documentRoot);
        $this->requiredFile($config['transports']['certificate_key_path'], true, 'config.transports.certificate_key_path', $documentRoot);

        foreach (['tcp_ports', 'udp_ports'] as $key) {
            $this->ports($config['diagnostics'][$key], "config.diagnostics.{$key}");
        }
        $this->uniqueIntegers($config['diagnostics']['raw_protocols'], 'config.diagnostics.raw_protocols');
        foreach ($config['diagnostics']['raw_protocols'] as $protocol) {
            if (!in_array($protocol, [4, 41, 47], true)) {
                $this->errors[] = 'diagnostics.raw_protocols only supports 4, 41, and 47.';
            }
        }
        $this->integerRange($config['diagnostics']['timeout_ms'], 100, 30000, 'config.diagnostics.timeout_ms');
        $this->integerRange($config['diagnostics']['probe_rounds'], 1, 20, 'config.diagnostics.probe_rounds');
        $this->integerRange($config['diagnostics']['max_probe_bytes'], 1, 16 * 1024 * 1024, 'config.diagnostics.max_probe_bytes');
        if ($config['diagnostics']['packet_capture'] || !$config['diagnostics']['redact_support_bundle']) {
            $this->errors[] = 'Packet capture must be disabled and support-bundle redaction enabled.';
        }

        foreach (['interval_seconds', 'failure_threshold', 'recovery_threshold', 'max_backoff_seconds', 'hysteresis_seconds'] as $key) {
            $this->integerRange($config['health'][$key], 1, 86400, "config.health.{$key}");
        }
        if ($config['health']['recovery_threshold'] < $config['health']['failure_threshold']) {
            $this->errors[] = 'health.recovery_threshold must be at least failure_threshold.';
        }

        $config['forwarding']['tcp_ranges'] = $this->portRanges($config['forwarding']['tcp_ranges'], 'config.forwarding.tcp_ranges');
        $config['forwarding']['udp_ranges'] = $this->portRanges($config['forwarding']['udp_ranges'], 'config.forwarding.udp_ranges');
        $this->ports($config['forwarding']['protected_tcp_ports'], 'config.forwarding.protected_tcp_ports');
        $this->ports($config['forwarding']['protected_udp_ports'], 'config.forwarding.protected_udp_ports');
        $this->integerRange($config['forwarding']['remote_ssh_port'], 1, 65535, 'config.forwarding.remote_ssh_port');
        $this->integerRange($config['forwarding']['control_port'], 1, 65535, 'config.forwarding.control_port');
        if ($config['forwarding']['remote_ssh_port'] === $config['forwarding']['control_port']) {
            $this->errors[] = 'Remote SSH and SafeTun control ports must differ.';
        }
        foreach (['remote_ssh_port', 'control_port'] as $key) {
            if (!in_array($config['forwarding'][$key], $config['forwarding']['protected_tcp_ports'], true)) {
                $this->errors[] = "forwarding.{$key} must be listed in protected_tcp_ports.";
            }
        }
        foreach (array_unique(array_merge($config['transports']['tcp_ports'], $config['diagnostics']['tcp_ports'])) as $port) {
            if (!in_array($port, $config['forwarding']['protected_tcp_ports'], true)) {
                $this->errors[] = "TCP control/probe port {$port} must be listed in protected_tcp_ports.";
            }
        }
        foreach (array_unique(array_merge($config['transports']['udp_ports'], $config['diagnostics']['udp_ports'])) as $port) {
            if (!in_array($port, $config['forwarding']['protected_udp_ports'], true)) {
                $this->errors[] = "UDP control/probe port {$port} must be listed in protected_udp_ports.";
            }
        }

        $this->integerRange($config['network']['safe_mtu_fallback'], 576, 9000, 'config.network.safe_mtu_fallback');
        if ($config['optimizer']['replace_kernel'] || $config['optimizer']['automatic_reboot']
            || !$config['optimizer']['backup_before_change'] || !$config['optimizer']['rollback_on_failure']) {
            $this->errors[] = 'Optimizer safety flags forbid kernel replacement/reboot and require backup/rollback.';
        }
        if ($config['assigned_source_ip']['enabled'] && $config['assigned_source_ip']['allowed_source_cidrs'] === []) {
            $this->errors[] = 'Assigned source IP cannot be enabled with an empty allowed_source_cidrs list.';
        }
        if (!$config['assigned_source_ip']['require_local_or_provider_routed']
            || !$config['assigned_source_ip']['require_reverse_path_probe'] || !$config['assigned_source_ip']['advanced_only']) {
            $this->errors[] = 'Assigned source IP safety checks and advanced-only gate must remain enabled.';
        }

        $this->httpsUrl($config['release']['manifest_url'], 'config.release.manifest_url');
        if (!$config['release']['require_signature'] || $config['release']['allow_foreign_direct_download']
            || !$config['release']['iran_hub_pushes_edge']) {
            $this->errors[] = 'Release policy must require signatures, forbid foreign direct download, and use Hub push.';
        }
        if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/D', $config['release']['active_key_id'])) {
            $this->errors[] = 'config.release.active_key_id is invalid.';
        }
        $this->requiredFile($config['release']['release_public_key_path'], false, 'config.release.release_public_key_path', $documentRoot);
        $this->requiredFile($config['release']['manifest_path'], false, 'config.release.manifest_path', $documentRoot);
        $this->requiredFile($config['release']['manifest_signature_path'], false, 'config.release.manifest_signature_path', $documentRoot);
        try {
            KeyLoader::public($config['release']['release_public_key_path']);
        } catch (\Throwable $exception) {
            $this->errors[] = 'config.release.release_public_key_path is not a valid Ed25519 public key.';
        }
        $this->validateReleaseEnvelope($config['release']);

        $this->integerRange($config['runtime']['retention_days'], 1, 365, 'config.runtime.retention_days');
        foreach (['nonce_dir', 'rate_limit_dir', 'cache_dir', 'audit_log_dir'] as $key) {
            $this->requiredDirectory($config['runtime'][$key], "config.runtime.{$key}", $documentRoot);
        }
        $this->requiredDirectory(dirname($configPath), 'config private directory', $documentRoot);
        $this->requiredDirectory(dirname($config['lease']['private_key_path']), 'online key directory', $documentRoot);
        $this->requiredDirectory(dirname($config['release']['manifest_path']), 'release manifest directory', $documentRoot);
        $this->requiredFile($configPath, true, 'config file', $documentRoot);
    }

    /** @param array<string,mixed> $domain */
    private function validateSigningDomain(array $domain, string $path, ?string $documentRoot): void
    {
        if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/D', $domain['active_key_id'])) {
            $this->errors[] = "{$path}.active_key_id is invalid.";
        }
        if ($domain['verification_keys'] === [] || !array_key_exists($domain['active_key_id'], $domain['verification_keys'])) {
            $this->errors[] = "{$path}.verification_keys must contain the active key id.";
        }
        $this->requiredFile($domain['private_key_path'], true, "{$path}.private_key_path", $documentRoot);
        foreach ($domain['verification_keys'] as $kid => $keyPath) {
            $this->requiredFile($keyPath, false, "{$path}.verification_keys.{$kid}", $documentRoot);
            try {
                KeyLoader::public($keyPath);
            } catch (\Throwable) {
                $this->errors[] = "{$path}.verification_keys.{$kid} is not a valid Ed25519 public key.";
            }
        }
    }

    /** @param array<string,mixed> $domain */
    private function verifyActiveKeyPair(array $domain, string $path): void
    {
        try {
            $secret = KeyLoader::secret($domain['private_key_path']);
            $public = KeyLoader::public($domain['verification_keys'][$domain['active_key_id']] ?? '');
            if (!hash_equals(sodium_crypto_sign_publickey_from_secretkey($secret), $public)) {
                $this->errors[] = "{$path} active public/private Ed25519 keys do not match.";
            }
            sodium_memzero($secret);
        } catch (\Throwable) {
            $this->errors[] = "{$path}.private_key_path is not a valid Ed25519 secret key.";
        }
    }

    /** @param list<string> $values @return list<string> */
    private function normalizeCidrs(array $values, string $path, bool $mustBePublic, bool $dangerouslyAllowAll): array
    {
        $normalized = [];
        $networks = [];
        foreach ($values as $index => $value) {
            try {
                $network = IpNetwork::parse($value);
            } catch (\InvalidArgumentException) {
                $this->errors[] = "{$path}[{$index}] is not a valid IPv4/IPv6 CIDR or single IP.";
                continue;
            }
            if ($network->isUniversal() && !$dangerouslyAllowAll) {
                $this->errors[] = "{$path}[{$index}] uses /0 without dangerously_allow_all.";
            }
            if ($mustBePublic && !$network->isPublicNetwork() && !($network->isUniversal() && $dangerouslyAllowAll)) {
                $this->errors[] = "{$path}[{$index}] is not wholly public-routable.";
            }
            foreach ($networks as $priorIndex => $prior) {
                if ($network->canonical === $prior->canonical) {
                    $this->errors[] = "{$path}[{$index}] duplicates entry {$priorIndex}.";
                } elseif ($network->overlaps($prior)) {
                    $this->errors[] = "{$path}[{$index}] overlaps entry {$priorIndex}.";
                }
            }
            $networks[] = $network;
            $normalized[] = $network->canonical;
        }
        return $normalized;
    }

    /** @param list<int> $ports */
    private function ports(array $ports, string $path): void
    {
        $this->uniqueIntegers($ports, $path);
        foreach ($ports as $index => $port) {
            $this->integerRange($port, 1, 65535, "{$path}[{$index}]");
        }
    }

    /** @param list<string> $ranges @return list<string> */
    private function portRanges(array $ranges, string $path): array
    {
        $parsed = [];
        $normalized = [];
        foreach ($ranges as $index => $range) {
            if (!preg_match('/^(0|[1-9][0-9]{0,4})-(0|[1-9][0-9]{0,4})$/D', $range, $matches)) {
                $this->errors[] = "{$path}[{$index}] must use start-end syntax.";
                continue;
            }
            $start = (int) $matches[1];
            $end = (int) $matches[2];
            if ($start < 1 || $end > 65535 || $start > $end) {
                $this->errors[] = "{$path}[{$index}] is outside 1-65535 or reversed.";
                continue;
            }
            foreach ($parsed as $priorIndex => [$priorStart, $priorEnd]) {
                if ($start <= $priorEnd && $end >= $priorStart) {
                    $this->errors[] = "{$path}[{$index}] overlaps entry {$priorIndex}.";
                }
            }
            $parsed[] = [$start, $end];
            $normalized[] = "{$start}-{$end}";
        }
        return $normalized;
    }

    /** @param list<string> $values */
    private function uniqueStrings(array $values, string $path): void
    {
        foreach ($values as $index => $value) {
            if ($value === '' || strlen($value) > 128 || !preg_match('/^[A-Za-z0-9][A-Za-z0-9._:-]*$/D', $value)) {
                $this->errors[] = "{$path}[{$index}] is invalid.";
            }
        }
        if (count($values) !== count(array_unique($values))) {
            $this->errors[] = "{$path} contains duplicates.";
        }
    }

    /** @param list<int> $values */
    private function uniqueIntegers(array $values, string $path): void
    {
        if (count($values) !== count(array_unique($values, SORT_REGULAR))) {
            $this->errors[] = "{$path} contains duplicates.";
        }
    }

    private function integerRange(int $value, int $minimum, int $maximum, string $path): void
    {
        if ($value < $minimum || $value > $maximum) {
            $this->errors[] = "{$path} must be between {$minimum} and {$maximum}.";
        }
    }

    private function httpsUrl(string $url, string $path): void
    {
        $parts = parse_url($url);
        if (!is_array($parts) || ($parts['scheme'] ?? null) !== 'https' || !isset($parts['host'])
            || isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment'])) {
            $this->errors[] = "{$path} must be an HTTPS URL without credentials or fragment.";
        }
    }

    private function requiredFile(string $path, bool $secret, string $label, ?string $documentRoot): void
    {
        if (!is_file($path) || is_link($path) || !is_readable($path)) {
            $this->errors[] = "{$label} must be an existing readable non-symlink file.";
            return;
        }
        if ($documentRoot !== null && $documentRoot !== '' && $this->inside($path, $documentRoot)) {
            $this->errors[] = "{$label} must be outside the HTTP document root.";
        }
        if ($secret) {
            $this->checkPrivatePermissions($path, $label, false);
        }
    }

    private function requiredDirectory(string $path, string $label, ?string $documentRoot): void
    {
        if (!is_dir($path) || is_link($path) || !is_readable($path) || !is_writable($path)) {
            $this->errors[] = "{$label} must be an existing readable/writable non-symlink directory.";
            return;
        }
        if ($documentRoot !== null && $documentRoot !== '' && $this->inside($path, $documentRoot)) {
            $this->errors[] = "{$label} must be outside the HTTP document root.";
        }
        $this->checkPrivatePermissions($path, $label, true);
    }

    private function checkPrivatePermissions(string $path, string $label, bool $directory): void
    {
        $resolved = realpath($path);
        $cacheKey = is_string($resolved) ? strtolower($resolved) : strtolower($path);
        if (isset($this->permissionCheckedPaths[$cacheKey])) {
            return;
        }
        $this->permissionCheckedPaths[$cacheKey] = true;
        if (PHP_OS_FAMILY === 'Windows') {
            $this->warnings[] = "{$label}: POSIX permissions cannot be verified on Windows.";
            return;
        }
        $permissions = @fileperms($path);
        if (!is_int($permissions)) {
            $this->errors[] = "Unable to inspect permissions for {$label}.";
            return;
        }
        $mode = $permissions & 0777;
        if (($mode & 0077) !== 0 || ($directory && ($mode & 0700) !== 0700)) {
            $expected = $directory ? '0700' : '0600';
            $this->errors[] = "{$label} must have {$expected} permissions (or stricter).";
        }
    }

    private function inside(string $path, string $root): bool
    {
        $resolvedPath = realpath($path);
        $resolvedRoot = realpath($root);
        if (!is_string($resolvedPath) || !is_string($resolvedRoot)) {
            return false;
        }
        $separator = DIRECTORY_SEPARATOR;
        $resolvedRoot = rtrim($resolvedRoot, '/\\') . $separator;
        $candidate = rtrim($resolvedPath, '/\\') . (is_dir($resolvedPath) ? $separator : '');
        if (PHP_OS_FAMILY === 'Windows') {
            $resolvedRoot = strtolower($resolvedRoot);
            $candidate = strtolower($candidate);
        }
        return str_starts_with($candidate, $resolvedRoot);
    }

    /** @param array<string,mixed> $release */
    private function validateReleaseEnvelope(array $release): void
    {
        if (!is_file($release['manifest_path']) || !is_file($release['manifest_signature_path'])
            || !is_file($release['release_public_key_path'])) {
            return;
        }
        try {
            $raw = file_get_contents($release['manifest_path']);
            $signatureFile = file_get_contents($release['manifest_signature_path']);
            if (!is_string($raw) || !is_string($signatureFile) || strlen($raw) > 1024 * 1024) {
                throw new \RuntimeException('Unreadable release manifest.');
            }
            $signature = ReleaseManifest::decodeSignature($signatureFile);
            ReleaseManifest::verifyDetached(
                $raw,
                $signature,
                KeyLoader::public($release['release_public_key_path']),
            );
            $manifest = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
            if (!is_array($manifest) || array_is_list($manifest)
                || ($manifest['channel'] ?? null) !== $release['channel']
                || ($manifest['release_key_id'] ?? null) !== $release['active_key_id']
                || !is_string($manifest['version'] ?? null)
                || !is_array($manifest['artifacts'] ?? null) || !array_is_list($manifest['artifacts'])) {
                throw new \RuntimeException('Release manifest fields are invalid.');
            }
        } catch (\Throwable) {
            $this->errors[] = 'Configured release manifest/signature is invalid or does not match the release public key.';
        }
    }
}
