use crate::analysis::timing::TimingHandle;
use crate::audit::{AuditObjectKind, AuditObjectResult, ObjectAuditEntry, sha256_hex_from_32};
use crate::data_model::aspa::{AspaDecodeError, AspaObject, AspaValidateError};
use crate::data_model::common::BigUnsigned;
use crate::data_model::manifest::ManifestObject;
use crate::data_model::rc::{
    AsIdentifierChoice, AsResourceSet, IpAddressChoice, IpAddressOrRange, IpPrefix as RcIpPrefix,
    ResourceCertificate,
};
use crate::data_model::roa::{IpPrefix, RoaAfi, RoaDecodeError, RoaObject, RoaValidateError};
use crate::data_model::signed_object::SignedObjectVerifyError;
use crate::parallel::config::ParallelPhase2Config;
use crate::parallel::object_worker::{
    ObjectTaskExecutor, ObjectWorkerPool, ObjectWorkerSubmitError,
};
use crate::policy::{Policy, ResourceValidationMode, SignedObjectFailurePolicy};
use crate::report::{RfcRef, Warning};
use crate::storage::{
    PackFile, PackTime, RoaCacheObjectMeta, RoaCacheProjection, VcirLocalOutput,
    VcirLocalOutputPayload, VcirOutputType, VcirSourceObjectType,
};
use crate::validation::cert_path::{CertPathError, validate_signed_object_ee_cert_path_fast};
use crate::validation::manifest::PublicationPointData;
use crate::validation::publication_point::PublicationPointSnapshot;
use sha2::Digest;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use x509_parser::prelude::FromDer;
use x509_parser::x509::SubjectPublicKeyInfo;

const RFC_NONE: &[RfcRef] = &[];
const RFC_CRLDP: &[RfcRef] = &[RfcRef("RFC 6487 §4.8.6")];
const RFC_CRLDP_AND_LOCKED_PACK: &[RfcRef] =
    &[RfcRef("RFC 6487 §4.8.6"), RfcRef("RFC 9286 §4.2.1")];

fn sha256_hex_to_32(hex_value: &str) -> [u8; 32] {
    let bytes = hex::decode(hex_value).expect("internal sha256 hex should decode");
    let mut out = [0u8; 32];
    out.copy_from_slice(&bytes);
    out
}

fn sha256_hex(bytes: &[u8]) -> String {
    hex::encode(sha2::Sha256::digest(bytes))
}

fn decode_resource_certificate_with_policy(
    der: &[u8],
    policy: &Policy,
) -> Result<ResourceCertificate, crate::data_model::rc::ResourceCertificateDecodeError> {
    if policy.strict.name {
        ResourceCertificate::decode_der_with_strict_name(der)
    } else {
        ResourceCertificate::decode_der(der)
    }
}

#[derive(Clone, Debug)]
pub(crate) struct VerifiedIssuerCrl {
    crl: crate::data_model::crl::RpkixCrl,
    revoked_serials: std::collections::HashSet<Vec<u8>>,
    sha256_hex: String,
}

#[derive(Clone, Debug)]
pub(crate) enum CachedIssuerCrl {
    Pending {
        bytes: Vec<u8>,
        sha256_hex: Option<String>,
    },
    Ok(Arc<VerifiedIssuerCrl>),
}

impl CachedIssuerCrl {
    fn current_sha256_hex(&mut self) -> &str {
        match self {
            CachedIssuerCrl::Pending { bytes, sha256_hex } => {
                if sha256_hex.is_none() {
                    *sha256_hex = Some(crate::audit::sha256_hex(bytes));
                }
                sha256_hex
                    .as_deref()
                    .expect("pending CRL sha256 must be populated")
            }
            CachedIssuerCrl::Ok(verified) => verified.sha256_hex.as_str(),
        }
    }
}

#[derive(Clone, Debug, Default)]
pub(crate) struct IssuerResourcesIndex {
    ip_v4: Option<Vec<(Vec<u8>, Vec<u8>)>>,
    ip_v6: Option<Vec<(Vec<u8>, Vec<u8>)>>,
    asnum: Option<Vec<(u32, u32)>>,
    rdi: Option<Vec<(u32, u32)>>,
}

fn extra_rfc_refs_for_crl_selection(e: &ObjectValidateError) -> &'static [RfcRef] {
    match e {
        ObjectValidateError::MissingCrlDpUris => RFC_CRLDP,
        ObjectValidateError::CrlNotFound(_) => RFC_CRLDP_AND_LOCKED_PACK,
        _ => RFC_NONE,
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Vrp {
    pub asn: u32,
    pub prefix: IpPrefix,
    pub max_length: u16,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AspaAttestation {
    pub customer_as_id: u32,
    pub provider_as_ids: Vec<u32>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RouterKeyPayload {
    pub as_id: u32,
    pub ski: Vec<u8>,
    pub spki_der: Vec<u8>,
    pub source_object_uri: String,
    pub source_object_hash: String,
    pub source_ee_cert_hash: String,
    pub item_effective_until: PackTime,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ObjectsOutput {
    pub vrps: Vec<Vrp>,
    pub aspas: Vec<AspaAttestation>,
    pub router_keys: Vec<RouterKeyPayload>,
    pub local_outputs_cache: Vec<VcirLocalOutput>,
    pub warnings: Vec<Warning>,
    pub stats: ObjectsStats,
    pub audit: Vec<ObjectAuditEntry>,
    pub roa_cache_stats: RoaValidationCacheStats,
    pub roa_cache_object_meta: Vec<RoaCacheObjectMeta>,
}

#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ObjectsStats {
    pub roa_total: usize,
    pub roa_ok: usize,
    pub aspa_total: usize,
    pub aspa_ok: usize,
    /// Whether this publication point was dropped due to an unrecoverable objects-processing error
    /// (e.g., missing issuer CRL in the pack, or `signed_object_failure_policy=drop_publication_point`).
    pub publication_point_dropped: bool,
}

#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]
pub struct RoaValidationCacheStats {
    pub enabled_publication_points: usize,
    pub vcir_hit_publication_points: usize,
    pub vcir_miss_publication_points: usize,
    pub hit_roas: usize,
    pub miss_roas: usize,
    pub blocked_roas: usize,
    pub fresh_roas: usize,
    pub context_blocked_roas: usize,
    pub crl_recheck_hit_roas: usize,
    pub hash_blocked_roas: usize,
    pub expired_blocked_roas: usize,
    pub revoked_blocked_roas: usize,
    pub metadata_blocked_roas: usize,
    pub context_gate_nanos: u64,
    pub lookup_nanos: u64,
    pub lookup_entry_gate_nanos: u64,
    pub lookup_crl_gate_nanos: u64,
    pub lookup_materialize_nanos: u64,
    pub lookup_crl_gate_verified_crls: usize,
    pub lookup_crl_gate_reused_crls: usize,
}

#[derive(Clone, Copy, Debug)]
pub struct RoaValidationCacheInput<'a> {
    enabled: bool,
    view: Option<&'a RoaValidationCacheView>,
    ca_validation_context_digest: Option<[u8; 32]>,
    policy_fingerprint: Option<[u8; 32]>,
}

impl<'a> RoaValidationCacheInput<'a> {
    pub fn disabled() -> Self {
        Self {
            enabled: false,
            view: None,
            ca_validation_context_digest: None,
            policy_fingerprint: None,
        }
    }

    pub fn enabled(view: Option<&'a RoaValidationCacheView>) -> Self {
        Self {
            enabled: true,
            view,
            ca_validation_context_digest: None,
            policy_fingerprint: None,
        }
    }

    pub fn enabled_with_context(
        view: Option<&'a RoaValidationCacheView>,
        ca_validation_context_digest: [u8; 32],
        policy_fingerprint: [u8; 32],
    ) -> Self {
        Self {
            enabled: true,
            view,
            ca_validation_context_digest: Some(ca_validation_context_digest),
            policy_fingerprint: Some(policy_fingerprint),
        }
    }
}

impl RoaValidationCacheStats {
    pub fn add_assign(&mut self, other: &Self) {
        self.enabled_publication_points += other.enabled_publication_points;
        self.vcir_hit_publication_points += other.vcir_hit_publication_points;
        self.vcir_miss_publication_points += other.vcir_miss_publication_points;
        self.hit_roas += other.hit_roas;
        self.miss_roas += other.miss_roas;
        self.blocked_roas += other.blocked_roas;
        self.fresh_roas += other.fresh_roas;
        self.context_blocked_roas += other.context_blocked_roas;
        self.crl_recheck_hit_roas += other.crl_recheck_hit_roas;
        self.hash_blocked_roas += other.hash_blocked_roas;
        self.expired_blocked_roas += other.expired_blocked_roas;
        self.revoked_blocked_roas += other.revoked_blocked_roas;
        self.metadata_blocked_roas += other.metadata_blocked_roas;
        self.context_gate_nanos = self
            .context_gate_nanos
            .saturating_add(other.context_gate_nanos);
        self.lookup_nanos = self.lookup_nanos.saturating_add(other.lookup_nanos);
        self.lookup_entry_gate_nanos = self
            .lookup_entry_gate_nanos
            .saturating_add(other.lookup_entry_gate_nanos);
        self.lookup_crl_gate_nanos = self
            .lookup_crl_gate_nanos
            .saturating_add(other.lookup_crl_gate_nanos);
        self.lookup_materialize_nanos = self
            .lookup_materialize_nanos
            .saturating_add(other.lookup_materialize_nanos);
        self.lookup_crl_gate_verified_crls += other.lookup_crl_gate_verified_crls;
        self.lookup_crl_gate_reused_crls += other.lookup_crl_gate_reused_crls;
    }

    fn for_input(input: RoaValidationCacheInput<'_>, roa_total: usize) -> Self {
        let mut stats = Self::default();
        if !input.enabled {
            return stats;
        }

        stats.enabled_publication_points = 1;
        if input.view.is_some() {
            stats.vcir_hit_publication_points = 1;
        } else {
            stats.vcir_miss_publication_points = 1;
            stats.miss_roas = roa_total;
            stats.fresh_roas = roa_total;
        }
        stats
    }

    fn record_lookup(&mut self, total_nanos: u64, metrics: RoaCacheLookupMetrics) {
        self.lookup_nanos = self.lookup_nanos.saturating_add(total_nanos);
        self.lookup_entry_gate_nanos = self
            .lookup_entry_gate_nanos
            .saturating_add(metrics.entry_gate_nanos);
        self.lookup_crl_gate_nanos = self
            .lookup_crl_gate_nanos
            .saturating_add(metrics.crl_gate_nanos);
        self.lookup_materialize_nanos = self
            .lookup_materialize_nanos
            .saturating_add(metrics.materialize_nanos);
        self.lookup_crl_gate_verified_crls += metrics.crl_gate_verified_crls;
        self.lookup_crl_gate_reused_crls += metrics.crl_gate_reused_crls;
    }

    fn record_to_timing(&self, timing: Option<&TimingHandle>) {
        let Some(timing) = timing else {
            return;
        };
        record_non_zero(
            timing,
            "roa_validation_cache_enabled_publication_points",
            self.enabled_publication_points,
        );
        record_non_zero(
            timing,
            "roa_validation_cache_vcir_hit_publication_points",
            self.vcir_hit_publication_points,
        );
        record_non_zero(
            timing,
            "roa_validation_cache_vcir_miss_publication_points",
            self.vcir_miss_publication_points,
        );
        record_non_zero(timing, "roa_validation_cache_hit_roas", self.hit_roas);
        record_non_zero(timing, "roa_validation_cache_miss_roas", self.miss_roas);
        record_non_zero(
            timing,
            "roa_validation_cache_blocked_roas",
            self.blocked_roas,
        );
        record_non_zero(timing, "roa_validation_cache_fresh_roas", self.fresh_roas);
        record_non_zero(
            timing,
            "roa_validation_cache_context_blocked_roas",
            self.context_blocked_roas,
        );
        record_non_zero(
            timing,
            "roa_validation_cache_crl_recheck_hit_roas",
            self.crl_recheck_hit_roas,
        );
        record_non_zero(
            timing,
            "roa_validation_cache_hash_blocked_roas",
            self.hash_blocked_roas,
        );
        record_non_zero(
            timing,
            "roa_validation_cache_expired_blocked_roas",
            self.expired_blocked_roas,
        );
        record_non_zero(
            timing,
            "roa_validation_cache_revoked_blocked_roas",
            self.revoked_blocked_roas,
        );
        record_non_zero(
            timing,
            "roa_validation_cache_metadata_blocked_roas",
            self.metadata_blocked_roas,
        );
        record_non_zero(
            timing,
            "roa_validation_cache_context_gate_nanos",
            self.context_gate_nanos as usize,
        );
        record_non_zero(
            timing,
            "roa_validation_cache_lookup_nanos",
            self.lookup_nanos as usize,
        );
        record_non_zero(
            timing,
            "roa_validation_cache_lookup_entry_gate_nanos",
            self.lookup_entry_gate_nanos as usize,
        );
        record_non_zero(
            timing,
            "roa_validation_cache_lookup_crl_gate_nanos",
            self.lookup_crl_gate_nanos as usize,
        );
        record_non_zero(
            timing,
            "roa_validation_cache_lookup_materialize_nanos",
            self.lookup_materialize_nanos as usize,
        );
        record_non_zero(
            timing,
            "roa_validation_cache_lookup_crl_gate_verified_crls",
            self.lookup_crl_gate_verified_crls,
        );
        record_non_zero(
            timing,
            "roa_validation_cache_lookup_crl_gate_reused_crls",
            self.lookup_crl_gate_reused_crls,
        );
        timing.record_phase_nanos(
            "roa_validation_cache_context_gate_total",
            self.context_gate_nanos,
        );
        timing.record_phase_nanos("roa_validation_cache_lookup_total", self.lookup_nanos);
        timing.record_phase_nanos(
            "roa_validation_cache_lookup_entry_gate_total",
            self.lookup_entry_gate_nanos,
        );
        timing.record_phase_nanos(
            "roa_validation_cache_lookup_crl_gate_total",
            self.lookup_crl_gate_nanos,
        );
        timing.record_phase_nanos(
            "roa_validation_cache_lookup_materialize_total",
            self.lookup_materialize_nanos,
        );
    }
}

fn record_non_zero(timing: &TimingHandle, key: &'static str, value: usize) {
    if value > 0 {
        timing.record_count(key, value as u64);
    }
}

fn elapsed_nanos_u64(started: Instant) -> u64 {
    started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64
}

#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct RoaCacheLookupMetrics {
    entry_gate_nanos: u64,
    crl_gate_nanos: u64,
    materialize_nanos: u64,
    crl_gate_verified_crls: usize,
    crl_gate_reused_crls: usize,
}

#[derive(Clone, Debug)]
enum RoaCacheCrlGate {
    Unchanged,
    ChangedValid(Arc<VerifiedIssuerCrl>),
    Expired,
    Invalid,
    Missing,
}

#[derive(Debug, Default)]
struct RoaCacheCrlGateSet {
    gates_by_uri: HashMap<String, RoaCacheCrlGate>,
}

impl RoaCacheCrlGateSet {
    fn evaluate(
        &mut self,
        crl_uri: &str,
        expected_crl_sha256_by_uri: &HashMap<String, String>,
        crl_cache: &mut std::collections::HashMap<String, CachedIssuerCrl>,
        issuer_ca_der: &[u8],
        validation_time: time::OffsetDateTime,
        metrics: &mut RoaCacheLookupMetrics,
    ) -> RoaCacheCrlGate {
        if let Some(gate) = self.gates_by_uri.get(crl_uri) {
            metrics.crl_gate_reused_crls += 1;
            return gate.clone();
        }

        let gate = evaluate_roa_cache_crl_gate(
            crl_uri,
            expected_crl_sha256_by_uri,
            crl_cache,
            issuer_ca_der,
            validation_time,
            metrics,
        );
        self.gates_by_uri.insert(crl_uri.to_string(), gate.clone());
        gate
    }
}

fn evaluate_roa_cache_crl_gate(
    crl_uri: &str,
    expected_crl_sha256_by_uri: &HashMap<String, String>,
    crl_cache: &mut std::collections::HashMap<String, CachedIssuerCrl>,
    issuer_ca_der: &[u8],
    validation_time: time::OffsetDateTime,
    metrics: &mut RoaCacheLookupMetrics,
) -> RoaCacheCrlGate {
    let crl_unchanged = {
        let Some(current_crl_hash) = crl_cache
            .get_mut(crl_uri)
            .map(CachedIssuerCrl::current_sha256_hex)
        else {
            return RoaCacheCrlGate::Missing;
        };
        expected_crl_sha256_by_uri
            .get(crl_uri)
            .map(|expected| expected == current_crl_hash)
            .unwrap_or(false)
    };
    if crl_unchanged {
        return RoaCacheCrlGate::Unchanged;
    }

    let verified_crl = match ensure_issuer_crl_verified(crl_uri, crl_cache, issuer_ca_der) {
        Ok(verified_crl) => {
            metrics.crl_gate_verified_crls += 1;
            verified_crl
        }
        Err(_) => return RoaCacheCrlGate::Invalid,
    };
    if !crl_valid_at_time(&verified_crl.crl, validation_time) {
        return RoaCacheCrlGate::Expired;
    }
    RoaCacheCrlGate::ChangedValid(verified_crl)
}

#[derive(Clone, Debug)]
pub struct RoaValidationCacheView {
    entries_by_uri: HashMap<String, CachedRoaValidationResult>,
    issuer_ca_sha256_hex: Option<String>,
    ca_validation_context_digest: Option<[u8; 32]>,
    policy_fingerprint: Option<[u8; 32]>,
    crl_sha256_by_uri: HashMap<String, String>,
    blocked: bool,
}

#[derive(Clone, Debug)]
pub struct CachedRoaValidationResult {
    source_object_hash: [u8; 32],
    ee_serial: Option<Vec<u8>>,
    crl_uri: Option<String>,
    earliest_safe_reuse_time_unix: i64,
    outputs_effective_until_unix: i64,
    outputs: Vec<VcirLocalOutput>,
}

impl RoaValidationCacheView {
    pub fn from_projection(
        projection: &RoaCacheProjection,
        validation_time: time::OffsetDateTime,
    ) -> Self {
        let mut entries_by_uri: HashMap<String, CachedRoaValidationResult> =
            HashMap::with_capacity(projection.entries.len());
        let issuer_ca_sha256_hex = projection.issuer_ca_sha256_hex.clone();
        let ca_validation_context_digest = projection.ca_validation_context_digest;
        let policy_fingerprint = projection.policy_fingerprint;
        let crl_sha256_by_uri = projection
            .crl_sha256_by_uri
            .iter()
            .map(|crl| (crl.uri.clone(), crl.sha256.clone()))
            .collect::<HashMap<_, _>>();
        let blocked = projection
            .instance_effective_until
            .parse()
            .map(|effective_until| effective_until <= validation_time)
            .unwrap_or(true);

        if blocked {
            return Self {
                entries_by_uri,
                issuer_ca_sha256_hex,
                ca_validation_context_digest,
                policy_fingerprint,
                crl_sha256_by_uri,
                blocked,
            };
        }

        for entry in &projection.entries {
            let Some(earliest_safe_reuse_time_unix) = entry.earliest_safe_reuse_time_unix else {
                continue;
            };
            if time::OffsetDateTime::from_unix_timestamp(earliest_safe_reuse_time_unix).is_err() {
                continue;
            }
            let outputs = entry
                .outputs
                .iter()
                .map(|output| VcirLocalOutput {
                    output_type: VcirOutputType::Vrp,
                    item_effective_until: output.item_effective_until.clone(),
                    source_object_uri: entry.source_object_uri.clone(),
                    source_object_type: VcirSourceObjectType::Roa,
                    source_object_hash: entry.source_object_hash,
                    source_ee_cert_hash: output.source_ee_cert_hash,
                    payload: output.payload.clone(),
                    rule_hash: output.rule_hash,
                })
                .collect::<Vec<_>>();
            entries_by_uri.insert(
                entry.source_object_uri.clone(),
                CachedRoaValidationResult {
                    source_object_hash: entry.source_object_hash,
                    ee_serial: entry.ee_serial.clone(),
                    crl_uri: entry.crl_uri.clone(),
                    earliest_safe_reuse_time_unix,
                    outputs_effective_until_unix: entry.outputs_effective_until_unix,
                    outputs,
                },
            );
        }

        Self {
            entries_by_uri,
            issuer_ca_sha256_hex,
            ca_validation_context_digest,
            policy_fingerprint,
            crl_sha256_by_uri,
            blocked,
        }
    }

    fn matches_current_context(
        &self,
        issuer_ca_der: &[u8],
        ca_validation_context_digest: Option<[u8; 32]>,
        policy_fingerprint: Option<[u8; 32]>,
    ) -> bool {
        if self.blocked {
            return false;
        }

        let Some(expected_issuer_hash) = self.issuer_ca_sha256_hex.as_ref() else {
            return false;
        };
        if expected_issuer_hash != &sha256_hex(issuer_ca_der) {
            return false;
        }

        if let Some(expected_ca_validation_context) = self.ca_validation_context_digest {
            if Some(expected_ca_validation_context) != ca_validation_context_digest {
                return false;
            }
        } else {
            return false;
        }

        if let Some(expected_policy) = self.policy_fingerprint {
            if Some(expected_policy) != policy_fingerprint {
                return false;
            }
        } else {
            return false;
        }

        true
    }

    fn lookup(
        &self,
        file: &PackFile,
        crl_cache: &mut std::collections::HashMap<String, CachedIssuerCrl>,
        issuer_ca_der: &[u8],
        validation_time: time::OffsetDateTime,
    ) -> RoaCacheLookupResult {
        self.lookup_with_metrics(file, crl_cache, issuer_ca_der, validation_time, None)
            .0
    }

    fn lookup_with_metrics(
        &self,
        file: &PackFile,
        crl_cache: &mut std::collections::HashMap<String, CachedIssuerCrl>,
        issuer_ca_der: &[u8],
        validation_time: time::OffsetDateTime,
        crl_gate_set: Option<&mut RoaCacheCrlGateSet>,
    ) -> (RoaCacheLookupResult, RoaCacheLookupMetrics) {
        let mut metrics = RoaCacheLookupMetrics::default();
        let entry_gate_started = Instant::now();
        macro_rules! return_entry_gate {
            ($result:expr) => {{
                metrics.entry_gate_nanos = metrics
                    .entry_gate_nanos
                    .saturating_add(elapsed_nanos_u64(entry_gate_started));
                return ($result, metrics);
            }};
        }
        macro_rules! return_crl_gate {
            ($started:expr, $result:expr) => {{
                metrics.crl_gate_nanos = metrics
                    .crl_gate_nanos
                    .saturating_add(elapsed_nanos_u64($started));
                return ($result, metrics);
            }};
        }
        macro_rules! return_materialize {
            ($started:expr, $result:expr) => {{
                metrics.materialize_nanos = metrics
                    .materialize_nanos
                    .saturating_add(elapsed_nanos_u64($started));
                return ($result, metrics);
            }};
        }

        if self.blocked {
            return_entry_gate!(RoaCacheLookupResult::ExpiredBlocked);
        }

        let Some(cached) = self.entries_by_uri.get(file.rsync_uri.as_str()) else {
            return_entry_gate!(RoaCacheLookupResult::Miss);
        };
        if cached.source_object_hash != file.sha256 {
            return_entry_gate!(RoaCacheLookupResult::HashBlocked);
        }
        if cached.outputs.is_empty() {
            return_entry_gate!(RoaCacheLookupResult::Miss);
        }
        if validation_time.unix_timestamp() < cached.earliest_safe_reuse_time_unix
            || cached.outputs_effective_until_unix <= validation_time.unix_timestamp()
        {
            return_entry_gate!(RoaCacheLookupResult::ExpiredBlocked);
        }

        let Some(crl_uri) = cached.crl_uri.as_deref() else {
            return_entry_gate!(RoaCacheLookupResult::MetadataBlocked);
        };
        let Some(ee_serial) = cached.ee_serial.as_ref() else {
            return_entry_gate!(RoaCacheLookupResult::MetadataBlocked);
        };
        metrics.entry_gate_nanos = metrics
            .entry_gate_nanos
            .saturating_add(elapsed_nanos_u64(entry_gate_started));

        let crl_gate_started = Instant::now();
        let crl_gate = if let Some(crl_gate_set) = crl_gate_set {
            crl_gate_set.evaluate(
                crl_uri,
                &self.crl_sha256_by_uri,
                crl_cache,
                issuer_ca_der,
                validation_time,
                &mut metrics,
            )
        } else {
            evaluate_roa_cache_crl_gate(
                crl_uri,
                &self.crl_sha256_by_uri,
                crl_cache,
                issuer_ca_der,
                validation_time,
                &mut metrics,
            )
        };
        let crl_rechecked = match crl_gate {
            RoaCacheCrlGate::Unchanged => false,
            RoaCacheCrlGate::ChangedValid(verified_crl) => {
                if verified_crl.revoked_serials.contains(ee_serial) {
                    return_crl_gate!(crl_gate_started, RoaCacheLookupResult::RevokedBlocked);
                }
                true
            }
            RoaCacheCrlGate::Expired => {
                return_crl_gate!(crl_gate_started, RoaCacheLookupResult::ExpiredBlocked);
            }
            RoaCacheCrlGate::Invalid | RoaCacheCrlGate::Missing => {
                return_crl_gate!(crl_gate_started, RoaCacheLookupResult::MetadataBlocked);
            }
        };
        metrics.crl_gate_nanos = metrics
            .crl_gate_nanos
            .saturating_add(elapsed_nanos_u64(crl_gate_started));

        let materialize_started = Instant::now();
        let mut vrps = Vec::with_capacity(cached.outputs.len());
        for output in &cached.outputs {
            let VcirLocalOutputPayload::Vrp {
                asn,
                afi,
                prefix_len,
                addr,
                max_length,
            } = &output.payload
            else {
                return_materialize!(materialize_started, RoaCacheLookupResult::MetadataBlocked);
            };
            vrps.push(Vrp {
                asn: *asn,
                prefix: IpPrefix {
                    afi: *afi,
                    prefix_len: *prefix_len,
                    addr: *addr,
                },
                max_length: *max_length,
            });
        }

        let ok = RoaTaskOk {
            vrps,
            local_outputs: cached.outputs.clone(),
            reused_from_cache: true,
            cache_object_meta: Some(RoaCacheObjectMeta {
                source_object_uri: file.rsync_uri.clone(),
                source_object_hash: file.sha256,
                ee_serial: ee_serial.clone(),
                crl_uri: crl_uri.to_string(),
                earliest_safe_reuse_time: PackTime::from_utc_offset_datetime(
                    time::OffsetDateTime::from_unix_timestamp(cached.earliest_safe_reuse_time_unix)
                        .expect("cached ROA safe reuse time must be valid"),
                ),
            }),
        };
        metrics.materialize_nanos = metrics
            .materialize_nanos
            .saturating_add(elapsed_nanos_u64(materialize_started));
        if crl_rechecked {
            (RoaCacheLookupResult::CrlRecheckHit(ok), metrics)
        } else {
            (RoaCacheLookupResult::Hit(ok), metrics)
        }
    }
}

fn active_roa_cache_view<'a>(
    roa_cache: RoaValidationCacheInput<'a>,
    issuer_ca_der: &[u8],
    stats: &mut RoaValidationCacheStats,
    roa_total: usize,
) -> Option<&'a RoaValidationCacheView> {
    let view = roa_cache.view?;
    let gate_started = Instant::now();
    if view.matches_current_context(
        issuer_ca_der,
        roa_cache.ca_validation_context_digest,
        roa_cache.policy_fingerprint,
    ) {
        stats.context_gate_nanos = stats
            .context_gate_nanos
            .saturating_add(gate_started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64);
        Some(view)
    } else {
        stats.context_gate_nanos = stats
            .context_gate_nanos
            .saturating_add(gate_started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64);
        stats.blocked_roas += roa_total;
        stats.context_blocked_roas += roa_total;
        stats.fresh_roas += roa_total;
        None
    }
}

#[derive(Debug)]
enum RoaCacheLookupResult {
    Hit(RoaTaskOk),
    CrlRecheckHit(RoaTaskOk),
    Miss,
    HashBlocked,
    ExpiredBlocked,
    RevokedBlocked,
    MetadataBlocked,
}

fn crl_valid_at_time(
    crl: &crate::data_model::crl::RpkixCrl,
    validation_time: time::OffsetDateTime,
) -> bool {
    let this_update = crl.this_update.utc.to_offset(time::UtcOffset::UTC);
    let next_update = crl.next_update.utc.to_offset(time::UtcOffset::UTC);
    validation_time >= this_update && validation_time < next_update
}

fn roa_cache_earliest_safe_reuse_time(
    ee_not_before: time::OffsetDateTime,
    crl_this_update: time::OffsetDateTime,
    validation_time: time::OffsetDateTime,
) -> PackTime {
    PackTime::from_utc_offset_datetime(ee_not_before.max(crl_this_update).max(validation_time))
}

fn record_roa_cache_block(
    stats: &mut RoaValidationCacheStats,
    lookup_result: &RoaCacheLookupResult,
) {
    stats.blocked_roas += 1;
    stats.fresh_roas += 1;
    match lookup_result {
        RoaCacheLookupResult::HashBlocked => stats.hash_blocked_roas += 1,
        RoaCacheLookupResult::ExpiredBlocked => stats.expired_blocked_roas += 1,
        RoaCacheLookupResult::RevokedBlocked => stats.revoked_blocked_roas += 1,
        RoaCacheLookupResult::MetadataBlocked => stats.metadata_blocked_roas += 1,
        RoaCacheLookupResult::Hit(_)
        | RoaCacheLookupResult::CrlRecheckHit(_)
        | RoaCacheLookupResult::Miss => {}
    }
}

#[derive(Clone, Copy)]
pub(crate) struct RoaTask<'a> {
    pub(crate) index: usize,
    pub(crate) file: &'a PackFile,
}

#[derive(Debug)]
pub(crate) struct RoaTaskOk {
    pub(crate) vrps: Vec<Vrp>,
    pub(crate) local_outputs: Vec<VcirLocalOutput>,
    pub(crate) reused_from_cache: bool,
    pub(crate) cache_object_meta: Option<RoaCacheObjectMeta>,
}

#[derive(Debug)]
pub(crate) struct RoaTaskResult {
    pub(crate) publication_point_id: u64,
    pub(crate) index: usize,
    pub(crate) worker_index: usize,
    pub(crate) queue_wait_ms: u64,
    pub(crate) worker_ms: u64,
    pub(crate) outcome: Result<RoaTaskOk, ObjectValidateError>,
}

/// Process objects from a publication point snapshot using a known issuer CA certificate
/// and its effective resources (resolved via the resource-path, RFC 6487 §7.2).
pub fn process_publication_point_for_issuer<P: PublicationPointData>(
    publication_point: &P,
    policy: &Policy,
    issuer_ca_der: &[u8],
    issuer_ca_rsync_uri: Option<&str>,
    issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
    issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
    validation_time: time::OffsetDateTime,
    timing: Option<&TimingHandle>,
) -> ObjectsOutput {
    process_publication_point_for_issuer_with_options(
        publication_point,
        policy,
        issuer_ca_der,
        issuer_ca_rsync_uri,
        issuer_effective_ip,
        issuer_effective_as,
        validation_time,
        timing,
        true,
    )
}

pub fn process_publication_point_for_issuer_with_options<P: PublicationPointData>(
    publication_point: &P,
    policy: &Policy,
    issuer_ca_der: &[u8],
    issuer_ca_rsync_uri: Option<&str>,
    issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
    issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
    validation_time: time::OffsetDateTime,
    timing: Option<&TimingHandle>,
    collect_vcir_local_outputs: bool,
) -> ObjectsOutput {
    process_publication_point_for_issuer_with_cache_options(
        publication_point,
        policy,
        issuer_ca_der,
        issuer_ca_rsync_uri,
        issuer_effective_ip,
        issuer_effective_as,
        validation_time,
        timing,
        collect_vcir_local_outputs,
        RoaValidationCacheInput::disabled(),
    )
}

pub fn process_publication_point_for_issuer_with_cache_options<P: PublicationPointData>(
    publication_point: &P,
    policy: &Policy,
    issuer_ca_der: &[u8],
    issuer_ca_rsync_uri: Option<&str>,
    issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
    issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
    validation_time: time::OffsetDateTime,
    timing: Option<&TimingHandle>,
    collect_vcir_local_outputs: bool,
    roa_cache: RoaValidationCacheInput<'_>,
) -> ObjectsOutput {
    let manifest_rsync_uri = publication_point.manifest_rsync_uri();
    let manifest_bytes = publication_point.manifest_bytes();
    let locked_files = publication_point.files();
    let mut warnings: Vec<Warning> = Vec::new();
    let mut stats = ObjectsStats::default();
    stats.roa_total = locked_files
        .iter()
        .filter(|f| f.rsync_uri.ends_with(".roa"))
        .count();
    stats.aspa_total = locked_files
        .iter()
        .filter(|f| f.rsync_uri.ends_with(".asa"))
        .count();
    let mut roa_cache_stats = RoaValidationCacheStats::for_input(roa_cache, stats.roa_total);
    let mut audit: Vec<ObjectAuditEntry> = Vec::new();

    // Enforce that `manifest_bytes` is actually a manifest object.
    let _manifest = match ManifestObject::decode_der_with_strict_options(
        manifest_bytes,
        policy.strict.cms_der,
        policy.strict.name,
    ) {
        Ok(manifest) => manifest,
        Err(e) => {
            stats.publication_point_dropped = true;
            warnings.push(
                Warning::new(format!(
                    "dropping publication point: manifest decode failed: {e}"
                ))
                .with_rfc_refs(&[
                    RfcRef("RFC 9286 §4"),
                    RfcRef("RFC 9286 §6.2"),
                    RfcRef("RFC 9286 §6.6"),
                ])
                .with_context(manifest_rsync_uri),
            );
            for f in locked_files {
                if f.rsync_uri.ends_with(".roa") {
                    audit.push(ObjectAuditEntry {
                        rsync_uri: f.rsync_uri.clone(),
                        sha256_hex: sha256_hex_from_32(&f.sha256),
                        kind: AuditObjectKind::Roa,
                        result: AuditObjectResult::Skipped,
                        detail: Some("skipped: manifest decode failed".to_string()),
                    });
                } else if f.rsync_uri.ends_with(".asa") {
                    audit.push(ObjectAuditEntry {
                        rsync_uri: f.rsync_uri.clone(),
                        sha256_hex: sha256_hex_from_32(&f.sha256),
                        kind: AuditObjectKind::Aspa,
                        result: AuditObjectResult::Skipped,
                        detail: Some("skipped: manifest decode failed".to_string()),
                    });
                }
            }
            return ObjectsOutput {
                vrps: Vec::new(),
                aspas: Vec::new(),
                router_keys: Vec::new(),
                local_outputs_cache: Vec::new(),
                warnings,
                stats,
                audit,
                roa_cache_stats,
                roa_cache_object_meta: Vec::new(),
            };
        }
    };

    // Decode issuer CA once; if it fails we cannot validate ROA/ASPA EE certificates.
    let issuer_ca = match decode_resource_certificate_with_policy(issuer_ca_der, policy) {
        Ok(v) => v,
        Err(e) => {
            stats.publication_point_dropped = true;
            warnings.push(
                Warning::new(format!(
                    "dropping publication point: issuer CA decode failed: {e}"
                ))
                .with_rfc_refs(&[RfcRef("RFC 6487 §7.2"), RfcRef("RFC 5280 §6.1")])
                .with_context(manifest_rsync_uri),
            );
            for f in locked_files {
                if f.rsync_uri.ends_with(".roa") {
                    audit.push(ObjectAuditEntry {
                        rsync_uri: f.rsync_uri.clone(),
                        sha256_hex: sha256_hex_from_32(&f.sha256),
                        kind: AuditObjectKind::Roa,
                        result: AuditObjectResult::Skipped,
                        detail: Some("skipped: issuer CA decode failed".to_string()),
                    });
                } else if f.rsync_uri.ends_with(".asa") {
                    audit.push(ObjectAuditEntry {
                        rsync_uri: f.rsync_uri.clone(),
                        sha256_hex: sha256_hex_from_32(&f.sha256),
                        kind: AuditObjectKind::Aspa,
                        result: AuditObjectResult::Skipped,
                        detail: Some("skipped: issuer CA decode failed".to_string()),
                    });
                }
            }
            return ObjectsOutput {
                vrps: Vec::new(),
                aspas: Vec::new(),
                router_keys: Vec::new(),
                local_outputs_cache: Vec::new(),
                warnings,
                stats,
                audit,
                roa_cache_stats,
                roa_cache_object_meta: Vec::new(),
            };
        }
    };

    // Parse issuer SubjectPublicKeyInfo once and reuse for all EE certificate signature checks.
    let issuer_spki = match SubjectPublicKeyInfo::from_der(&issuer_ca.tbs.subject_public_key_info) {
        Ok((rem, spki)) if rem.is_empty() => spki,
        Ok((rem, _)) => {
            stats.publication_point_dropped = true;
            warnings.push(
                Warning::new(format!(
                    "dropping publication point: trailing bytes after issuer SPKI DER: {} bytes",
                    rem.len()
                ))
                .with_rfc_refs(&[RfcRef("RFC 5280 §4.1.2.7")])
                .with_context(manifest_rsync_uri),
            );
            return ObjectsOutput {
                vrps: Vec::new(),
                aspas: Vec::new(),
                router_keys: Vec::new(),
                local_outputs_cache: Vec::new(),
                warnings,
                stats,
                audit,
                roa_cache_stats,
                roa_cache_object_meta: Vec::new(),
            };
        }
        Err(e) => {
            stats.publication_point_dropped = true;
            warnings.push(
                Warning::new(format!(
                    "dropping publication point: issuer SPKI parse failed: {e}"
                ))
                .with_rfc_refs(&[RfcRef("RFC 5280 §4.1.2.7")])
                .with_context(manifest_rsync_uri),
            );
            return ObjectsOutput {
                vrps: Vec::new(),
                aspas: Vec::new(),
                router_keys: Vec::new(),
                local_outputs_cache: Vec::new(),
                warnings,
                stats,
                audit,
                roa_cache_stats,
                roa_cache_object_meta: Vec::new(),
            };
        }
    };

    let mut crl_cache: std::collections::HashMap<String, CachedIssuerCrl> = locked_files
        .iter()
        .filter(|f| f.rsync_uri.ends_with(".crl"))
        .map(|f| {
            let bytes = f
                .bytes_cloned()
                .expect("snapshot CRL bytes must be loadable");
            (
                f.rsync_uri.clone(),
                CachedIssuerCrl::Pending {
                    bytes,
                    sha256_hex: None,
                },
            )
        })
        .collect();

    let issuer_resources_index =
        build_issuer_resources_index(issuer_effective_ip, issuer_effective_as);

    // If the snapshot has signed objects but no CRLs at all, we cannot validate any embedded EE
    // certificate paths deterministically (EE CRLDP must reference an rsync URI in the snapshot).
    if crl_cache.is_empty() && (stats.roa_total > 0 || stats.aspa_total > 0) {
        stats.publication_point_dropped = true;
        warnings.push(
            Warning::new("dropping publication point: no CRL files in validated publication point")
                .with_rfc_refs(&[RfcRef("RFC 6487 §4.8.6"), RfcRef("RFC 9286 §7")])
                .with_context(manifest_rsync_uri),
        );
        for f in locked_files {
            if f.rsync_uri.ends_with(".roa") {
                audit.push(ObjectAuditEntry {
                    rsync_uri: f.rsync_uri.clone(),
                    sha256_hex: sha256_hex_from_32(&f.sha256),
                    kind: AuditObjectKind::Roa,
                    result: AuditObjectResult::Skipped,
                    detail: Some(
                        "skipped due to missing CRL files in validated publication point"
                            .to_string(),
                    ),
                });
            } else if f.rsync_uri.ends_with(".asa") {
                audit.push(ObjectAuditEntry {
                    rsync_uri: f.rsync_uri.clone(),
                    sha256_hex: sha256_hex_from_32(&f.sha256),
                    kind: AuditObjectKind::Aspa,
                    result: AuditObjectResult::Skipped,
                    detail: Some(
                        "skipped due to missing CRL files in validated publication point"
                            .to_string(),
                    ),
                });
            }
        }
        return ObjectsOutput {
            vrps: Vec::new(),
            aspas: Vec::new(),
            router_keys: Vec::new(),
            local_outputs_cache: Vec::new(),
            warnings,
            stats,
            audit,
            roa_cache_stats,
            roa_cache_object_meta: Vec::new(),
        };
    }

    let mut vrps: Vec<Vrp> = Vec::new();
    let mut aspas: Vec<AspaAttestation> = Vec::new();
    let mut local_outputs_cache: Vec<VcirLocalOutput> = Vec::new();
    let mut roa_cache_object_meta: Vec<RoaCacheObjectMeta> = Vec::new();
    let active_cache_view = active_roa_cache_view(
        roa_cache,
        issuer_ca_der,
        &mut roa_cache_stats,
        stats.roa_total,
    );
    let mut crl_gate_set = if active_cache_view.is_some() {
        Some(RoaCacheCrlGateSet::default())
    } else {
        None
    };

    for (idx, file) in locked_files.iter().enumerate() {
        if file.rsync_uri.ends_with(".roa") {
            let result = if let Some(cache_view) = active_cache_view {
                let lookup_started = Instant::now();
                let (lookup_result, lookup_metrics) = cache_view.lookup_with_metrics(
                    file,
                    &mut crl_cache,
                    issuer_ca_der,
                    validation_time,
                    crl_gate_set.as_mut(),
                );
                roa_cache_stats.record_lookup(elapsed_nanos_u64(lookup_started), lookup_metrics);
                match lookup_result {
                    RoaCacheLookupResult::Hit(ok) => {
                        roa_cache_stats.hit_roas += 1;
                        RoaTaskResult {
                            publication_point_id: 0,
                            index: idx,
                            worker_index: 0,
                            queue_wait_ms: 0,
                            worker_ms: 0,
                            outcome: Ok(ok),
                        }
                    }
                    RoaCacheLookupResult::CrlRecheckHit(ok) => {
                        roa_cache_stats.hit_roas += 1;
                        roa_cache_stats.crl_recheck_hit_roas += 1;
                        RoaTaskResult {
                            publication_point_id: 0,
                            index: idx,
                            worker_index: 0,
                            queue_wait_ms: 0,
                            worker_ms: 0,
                            outcome: Ok(ok),
                        }
                    }
                    RoaCacheLookupResult::Miss => {
                        roa_cache_stats.miss_roas += 1;
                        roa_cache_stats.fresh_roas += 1;
                        let task = RoaTask { index: idx, file };
                        let _t = timing.as_ref().map(|t| t.span_phase("objects_roa_total"));
                        validate_roa_task_serial(
                            task,
                            manifest_rsync_uri,
                            issuer_ca_der,
                            &issuer_ca,
                            &issuer_spki,
                            issuer_ca_rsync_uri,
                            &mut crl_cache,
                            &issuer_resources_index,
                            issuer_effective_ip,
                            issuer_effective_as,
                            validation_time,
                            timing,
                            collect_vcir_local_outputs,
                            policy.strict.cms_der,
                            policy.strict.name,
                            policy.resource_validation_mode,
                        )
                    }
                    blocked @ (RoaCacheLookupResult::HashBlocked
                    | RoaCacheLookupResult::ExpiredBlocked
                    | RoaCacheLookupResult::RevokedBlocked
                    | RoaCacheLookupResult::MetadataBlocked) => {
                        record_roa_cache_block(&mut roa_cache_stats, &blocked);
                        let task = RoaTask { index: idx, file };
                        let _t = timing.as_ref().map(|t| t.span_phase("objects_roa_total"));
                        validate_roa_task_serial(
                            task,
                            manifest_rsync_uri,
                            issuer_ca_der,
                            &issuer_ca,
                            &issuer_spki,
                            issuer_ca_rsync_uri,
                            &mut crl_cache,
                            &issuer_resources_index,
                            issuer_effective_ip,
                            issuer_effective_as,
                            validation_time,
                            timing,
                            collect_vcir_local_outputs,
                            policy.strict.cms_der,
                            policy.strict.name,
                            policy.resource_validation_mode,
                        )
                    }
                }
            } else {
                let task = RoaTask { index: idx, file };
                let _t = timing.as_ref().map(|t| t.span_phase("objects_roa_total"));
                validate_roa_task_serial(
                    task,
                    manifest_rsync_uri,
                    issuer_ca_der,
                    &issuer_ca,
                    &issuer_spki,
                    issuer_ca_rsync_uri,
                    &mut crl_cache,
                    &issuer_resources_index,
                    issuer_effective_ip,
                    issuer_effective_as,
                    validation_time,
                    timing,
                    collect_vcir_local_outputs,
                    policy.strict.cms_der,
                    policy.strict.name,
                    policy.resource_validation_mode,
                )
            };
            match result.outcome {
                Ok(mut ok) => {
                    stats.roa_ok += 1;
                    vrps.append(&mut ok.vrps);
                    if collect_vcir_local_outputs || ok.reused_from_cache {
                        local_outputs_cache.extend(ok.local_outputs);
                    }
                    if let Some(meta) = ok.cache_object_meta.take() {
                        roa_cache_object_meta.push(meta);
                    }
                    audit.push(ObjectAuditEntry {
                        rsync_uri: file.rsync_uri.clone(),
                        sha256_hex: sha256_hex_from_32(&file.sha256),
                        kind: AuditObjectKind::Roa,
                        result: AuditObjectResult::Ok,
                        detail: None,
                    });
                }
                Err(e) => match policy.signed_object_failure_policy {
                    SignedObjectFailurePolicy::DropObject => {
                        audit.push(ObjectAuditEntry {
                            rsync_uri: file.rsync_uri.clone(),
                            sha256_hex: sha256_hex_from_32(&file.sha256),
                            kind: AuditObjectKind::Roa,
                            result: AuditObjectResult::Error,
                            detail: Some(e.to_string()),
                        });
                        let mut refs = vec![RfcRef("RFC 6488 §3"), RfcRef("RFC 9582 §4-§5")];
                        refs.extend_from_slice(extra_rfc_refs_for_crl_selection(&e));
                        warnings.push(
                            Warning::new(format!("dropping invalid ROA: {}: {e}", file.rsync_uri))
                                .with_rfc_refs(&refs)
                                .with_context(&file.rsync_uri),
                        )
                    }
                    SignedObjectFailurePolicy::DropPublicationPoint => {
                        stats.publication_point_dropped = true;
                        audit.push(ObjectAuditEntry {
                            rsync_uri: file.rsync_uri.clone(),
                            sha256_hex: sha256_hex_from_32(&file.sha256),
                            kind: AuditObjectKind::Roa,
                            result: AuditObjectResult::Error,
                            detail: Some(e.to_string()),
                        });
                        for f in locked_files.iter().skip(idx + 1) {
                            if f.rsync_uri.ends_with(".roa") {
                                audit.push(ObjectAuditEntry {
                                    rsync_uri: f.rsync_uri.clone(),
                                    sha256_hex: sha256_hex_from_32(&f.sha256),
                                    kind: AuditObjectKind::Roa,
                                    result: AuditObjectResult::Skipped,
                                    detail: Some(
                                        "skipped due to policy=signed_object_failure_policy=drop_publication_point"
                                            .to_string(),
                                    ),
                                });
                            } else if f.rsync_uri.ends_with(".asa") {
                                audit.push(ObjectAuditEntry {
                                    rsync_uri: f.rsync_uri.clone(),
                                    sha256_hex: sha256_hex_from_32(&f.sha256),
                                    kind: AuditObjectKind::Aspa,
                                    result: AuditObjectResult::Skipped,
                                    detail: Some(
                                        "skipped due to policy=signed_object_failure_policy=drop_publication_point"
                                            .to_string(),
                                    ),
                                });
                            }
                        }
                        let mut refs = vec![RfcRef("RFC 6488 §3"), RfcRef("RFC 9582 §4-§5")];
                        refs.extend_from_slice(extra_rfc_refs_for_crl_selection(&e));
                        warnings.push(
                            Warning::new(format!(
                                "dropping publication point due to invalid ROA: {}: {e}",
                                file.rsync_uri
                            ))
                            .with_rfc_refs(&refs)
                            .with_context(manifest_rsync_uri),
                        );
                        return ObjectsOutput {
                            vrps: Vec::new(),
                            aspas: Vec::new(),
                            router_keys: Vec::new(),
                            local_outputs_cache: Vec::new(),
                            warnings,
                            stats,
                            audit,
                            roa_cache_stats,
                            roa_cache_object_meta: Vec::new(),
                        };
                    }
                },
            }
        } else if file.rsync_uri.ends_with(".asa") {
            let _t = timing.as_ref().map(|t| t.span_phase("objects_aspa_total"));
            match process_aspa_with_issuer(
                file,
                manifest_rsync_uri,
                issuer_ca_der,
                &issuer_ca,
                &issuer_spki,
                issuer_ca_rsync_uri,
                &mut crl_cache,
                &issuer_resources_index,
                issuer_effective_ip,
                issuer_effective_as,
                validation_time,
                timing,
                collect_vcir_local_outputs,
                policy.strict.cms_der,
                policy.strict.name,
                policy.resource_validation_mode,
            ) {
                Ok((att, local_output)) => {
                    stats.aspa_ok += 1;
                    aspas.push(att);
                    if let Some(local_output) = local_output {
                        local_outputs_cache.push(local_output);
                    }
                    audit.push(ObjectAuditEntry {
                        rsync_uri: file.rsync_uri.clone(),
                        sha256_hex: sha256_hex_from_32(&file.sha256),
                        kind: AuditObjectKind::Aspa,
                        result: AuditObjectResult::Ok,
                        detail: None,
                    });
                }
                Err(e) => match policy.signed_object_failure_policy {
                    SignedObjectFailurePolicy::DropObject => {
                        audit.push(ObjectAuditEntry {
                            rsync_uri: file.rsync_uri.clone(),
                            sha256_hex: sha256_hex_from_32(&file.sha256),
                            kind: AuditObjectKind::Aspa,
                            result: AuditObjectResult::Error,
                            detail: Some(e.to_string()),
                        });
                        let mut refs = vec![RfcRef("RFC 6488 §3")];
                        refs.extend_from_slice(extra_rfc_refs_for_crl_selection(&e));
                        warnings.push(
                            Warning::new(format!("dropping invalid ASPA: {}: {e}", file.rsync_uri))
                                .with_rfc_refs(&refs)
                                .with_context(&file.rsync_uri),
                        )
                    }
                    SignedObjectFailurePolicy::DropPublicationPoint => {
                        stats.publication_point_dropped = true;
                        audit.push(ObjectAuditEntry {
                            rsync_uri: file.rsync_uri.clone(),
                            sha256_hex: sha256_hex_from_32(&file.sha256),
                            kind: AuditObjectKind::Aspa,
                            result: AuditObjectResult::Error,
                            detail: Some(e.to_string()),
                        });
                        for f in locked_files.iter().skip(idx + 1) {
                            if f.rsync_uri.ends_with(".roa") {
                                audit.push(ObjectAuditEntry {
                                    rsync_uri: f.rsync_uri.clone(),
                                    sha256_hex: sha256_hex_from_32(&f.sha256),
                                    kind: AuditObjectKind::Roa,
                                    result: AuditObjectResult::Skipped,
                                    detail: Some(
                                        "skipped due to policy=signed_object_failure_policy=drop_publication_point"
                                            .to_string(),
                                    ),
                                });
                            } else if f.rsync_uri.ends_with(".asa") {
                                audit.push(ObjectAuditEntry {
                                    rsync_uri: f.rsync_uri.clone(),
                                    sha256_hex: sha256_hex_from_32(&f.sha256),
                                    kind: AuditObjectKind::Aspa,
                                    result: AuditObjectResult::Skipped,
                                    detail: Some(
                                        "skipped due to policy=signed_object_failure_policy=drop_publication_point"
                                            .to_string(),
                                    ),
                                });
                            }
                        }
                        let mut refs = vec![RfcRef("RFC 6488 §3")];
                        refs.extend_from_slice(extra_rfc_refs_for_crl_selection(&e));
                        warnings.push(
                            Warning::new(format!(
                                "dropping publication point due to invalid ASPA: {}: {e}",
                                file.rsync_uri
                            ))
                            .with_rfc_refs(&refs)
                            .with_context(manifest_rsync_uri),
                        );
                        return ObjectsOutput {
                            vrps: Vec::new(),
                            aspas: Vec::new(),
                            router_keys: Vec::new(),
                            local_outputs_cache: Vec::new(),
                            warnings,
                            stats,
                            audit,
                            roa_cache_stats,
                            roa_cache_object_meta: Vec::new(),
                        };
                    }
                },
            }
        }
    }

    roa_cache_stats.record_to_timing(timing);

    ObjectsOutput {
        vrps,
        aspas,
        router_keys: Vec::new(),
        local_outputs_cache,
        warnings,
        stats,
        audit,
        roa_cache_stats,
        roa_cache_object_meta,
    }
}

pub fn process_publication_point_for_issuer_parallel_roa<P: PublicationPointData>(
    publication_point: &P,
    policy: &Policy,
    issuer_ca_der: &[u8],
    issuer_ca_rsync_uri: Option<&str>,
    issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
    issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
    validation_time: time::OffsetDateTime,
    timing: Option<&TimingHandle>,
    config: &ParallelPhase2Config,
) -> ObjectsOutput {
    process_publication_point_for_issuer_parallel_roa_with_options(
        publication_point,
        policy,
        issuer_ca_der,
        issuer_ca_rsync_uri,
        issuer_effective_ip,
        issuer_effective_as,
        validation_time,
        timing,
        config,
        true,
    )
}

pub fn process_publication_point_for_issuer_parallel_roa_with_options<P: PublicationPointData>(
    publication_point: &P,
    policy: &Policy,
    issuer_ca_der: &[u8],
    issuer_ca_rsync_uri: Option<&str>,
    issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
    issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
    validation_time: time::OffsetDateTime,
    timing: Option<&TimingHandle>,
    config: &ParallelPhase2Config,
    collect_vcir_local_outputs: bool,
) -> ObjectsOutput {
    process_publication_point_for_issuer_parallel_roa_with_cache_options(
        publication_point,
        policy,
        issuer_ca_der,
        issuer_ca_rsync_uri,
        issuer_effective_ip,
        issuer_effective_as,
        validation_time,
        timing,
        config,
        collect_vcir_local_outputs,
        RoaValidationCacheInput::disabled(),
    )
}

pub fn process_publication_point_for_issuer_parallel_roa_with_cache_options<
    P: PublicationPointData,
>(
    publication_point: &P,
    policy: &Policy,
    issuer_ca_der: &[u8],
    issuer_ca_rsync_uri: Option<&str>,
    issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
    issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
    validation_time: time::OffsetDateTime,
    timing: Option<&TimingHandle>,
    config: &ParallelPhase2Config,
    collect_vcir_local_outputs: bool,
    roa_cache: RoaValidationCacheInput<'_>,
) -> ObjectsOutput {
    if config.object_workers <= 1
        || policy.signed_object_failure_policy == SignedObjectFailurePolicy::DropPublicationPoint
    {
        return process_publication_point_for_issuer_with_cache_options(
            publication_point,
            policy,
            issuer_ca_der,
            issuer_ca_rsync_uri,
            issuer_effective_ip,
            issuer_effective_as,
            validation_time,
            timing,
            collect_vcir_local_outputs,
            roa_cache,
        );
    }

    let pool = match ParallelRoaWorkerPool::new(config) {
        Ok(pool) => pool,
        Err(_) => {
            return process_publication_point_for_issuer_with_cache_options(
                publication_point,
                policy,
                issuer_ca_der,
                issuer_ca_rsync_uri,
                issuer_effective_ip,
                issuer_effective_as,
                validation_time,
                timing,
                collect_vcir_local_outputs,
                roa_cache,
            );
        }
    };

    process_publication_point_for_issuer_parallel_roa_with_pool_cache_options(
        publication_point,
        policy,
        issuer_ca_der,
        issuer_ca_rsync_uri,
        issuer_effective_ip,
        issuer_effective_as,
        validation_time,
        timing,
        &pool,
        collect_vcir_local_outputs,
        roa_cache,
    )
}

pub fn process_publication_point_for_issuer_parallel_roa_with_pool<P: PublicationPointData>(
    publication_point: &P,
    policy: &Policy,
    issuer_ca_der: &[u8],
    issuer_ca_rsync_uri: Option<&str>,
    issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
    issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
    validation_time: time::OffsetDateTime,
    timing: Option<&TimingHandle>,
    pool: &ParallelRoaWorkerPool,
) -> ObjectsOutput {
    process_publication_point_for_issuer_parallel_roa_with_pool_options(
        publication_point,
        policy,
        issuer_ca_der,
        issuer_ca_rsync_uri,
        issuer_effective_ip,
        issuer_effective_as,
        validation_time,
        timing,
        pool,
        true,
    )
}

pub fn process_publication_point_for_issuer_parallel_roa_with_pool_options<
    P: PublicationPointData,
>(
    publication_point: &P,
    policy: &Policy,
    issuer_ca_der: &[u8],
    issuer_ca_rsync_uri: Option<&str>,
    issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
    issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
    validation_time: time::OffsetDateTime,
    timing: Option<&TimingHandle>,
    pool: &ParallelRoaWorkerPool,
    collect_vcir_local_outputs: bool,
) -> ObjectsOutput {
    process_publication_point_for_issuer_parallel_roa_with_pool_cache_options(
        publication_point,
        policy,
        issuer_ca_der,
        issuer_ca_rsync_uri,
        issuer_effective_ip,
        issuer_effective_as,
        validation_time,
        timing,
        pool,
        collect_vcir_local_outputs,
        RoaValidationCacheInput::disabled(),
    )
}

pub fn process_publication_point_for_issuer_parallel_roa_with_pool_cache_options<
    P: PublicationPointData,
>(
    publication_point: &P,
    policy: &Policy,
    issuer_ca_der: &[u8],
    issuer_ca_rsync_uri: Option<&str>,
    issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
    issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
    validation_time: time::OffsetDateTime,
    timing: Option<&TimingHandle>,
    pool: &ParallelRoaWorkerPool,
    collect_vcir_local_outputs: bool,
    roa_cache: RoaValidationCacheInput<'_>,
) -> ObjectsOutput {
    if policy.signed_object_failure_policy == SignedObjectFailurePolicy::DropPublicationPoint {
        return process_publication_point_for_issuer_with_options(
            publication_point,
            policy,
            issuer_ca_der,
            issuer_ca_rsync_uri,
            issuer_effective_ip,
            issuer_effective_as,
            validation_time,
            timing,
            collect_vcir_local_outputs,
        );
    }

    process_publication_point_for_issuer_parallel_roa_inner(
        publication_point,
        policy,
        issuer_ca_der,
        issuer_ca_rsync_uri,
        issuer_effective_ip,
        issuer_effective_as,
        validation_time,
        timing,
        pool,
        collect_vcir_local_outputs,
        roa_cache,
    )
    .unwrap_or_else(|_| {
        process_publication_point_for_issuer_with_cache_options(
            publication_point,
            policy,
            issuer_ca_der,
            issuer_ca_rsync_uri,
            issuer_effective_ip,
            issuer_effective_as,
            validation_time,
            timing,
            collect_vcir_local_outputs,
            roa_cache,
        )
    })
}

#[derive(Clone)]
pub(crate) struct RoaTaskShared {
    locked_files: Arc<[PackFile]>,
    manifest_rsync_uri: Arc<str>,
    issuer_ca_der: Arc<[u8]>,
    issuer_ca: Arc<ResourceCertificate>,
    issuer_spki_der: Arc<[u8]>,
    issuer_ca_rsync_uri: Option<Arc<str>>,
    crl_cache: Arc<Mutex<std::collections::HashMap<String, CachedIssuerCrl>>>,
    issuer_resources_index: Arc<IssuerResourcesIndex>,
    issuer_effective_ip: Option<Arc<crate::data_model::rc::IpResourceSet>>,
    issuer_effective_as: Option<Arc<crate::data_model::rc::AsResourceSet>>,
    resource_validation_mode: ResourceValidationMode,
}

#[derive(Clone)]
pub(crate) struct OwnedRoaTask {
    pub(crate) publication_point_id: u64,
    index: usize,
    shared: Arc<RoaTaskShared>,
    validation_time: time::OffsetDateTime,
    collect_vcir_local_outputs: bool,
    strict_cms_der: bool,
    strict_name: bool,
    resource_validation_mode: ResourceValidationMode,
    pub(crate) submitted_at: Option<Instant>,
}

#[derive(Clone)]
struct RoaTaskExecutor;

impl ObjectTaskExecutor<OwnedRoaTask, RoaTaskResult> for RoaTaskExecutor {
    fn execute(&self, worker_index: usize, task: OwnedRoaTask) -> RoaTaskResult {
        validate_owned_roa_task(worker_index, task)
    }
}

pub struct ParallelRoaWorkerPool {
    pool: Mutex<ObjectWorkerPool<OwnedRoaTask, RoaTaskResult, RoaTaskExecutor>>,
}

impl ParallelRoaWorkerPool {
    pub fn new(config: &ParallelPhase2Config) -> Result<Self, String> {
        if config.object_workers <= 1 {
            return Err("parallel ROA worker pool requires object_workers > 1".to_string());
        }

        Ok(Self {
            pool: Mutex::new(ObjectWorkerPool::new(
                config.object_workers,
                config.worker_queue_capacity,
                RoaTaskExecutor,
            )?),
        })
    }

    pub(crate) fn try_submit_round_robin(
        &self,
        task: OwnedRoaTask,
    ) -> Result<usize, ObjectWorkerSubmitError<OwnedRoaTask>> {
        self.pool
            .lock()
            .expect("parallel ROA worker pool lock")
            .try_submit_round_robin(task)
    }

    pub(crate) fn recv_result_timeout(
        &self,
        timeout: Duration,
    ) -> Result<Option<RoaTaskResult>, String> {
        self.pool
            .lock()
            .expect("parallel ROA worker pool lock")
            .recv_result_timeout(timeout)
    }
}

fn validate_owned_roa_task(worker_index: usize, task: OwnedRoaTask) -> RoaTaskResult {
    let worker_started = Instant::now();
    let queue_wait_ms = task
        .submitted_at
        .map(|submitted_at| worker_started.saturating_duration_since(submitted_at))
        .map(|duration| duration.as_millis() as u64)
        .unwrap_or(0);
    let shared = task.shared.as_ref();
    let file = task
        .shared
        .locked_files
        .get(task.index)
        .expect("ROA task index must reference locked file");
    let issuer_spki = match SubjectPublicKeyInfo::from_der(shared.issuer_spki_der.as_ref()) {
        Ok((rem, spki)) if rem.is_empty() => spki,
        Ok((rem, _)) => {
            return RoaTaskResult {
                publication_point_id: task.publication_point_id,
                index: task.index,
                worker_index,
                queue_wait_ms,
                worker_ms: worker_started.elapsed().as_millis() as u64,
                outcome: Err(ObjectValidateError::CertPath(
                    CertPathError::IssuerSpkiTrailingBytes(rem.len()),
                )),
            };
        }
        Err(e) => {
            return RoaTaskResult {
                publication_point_id: task.publication_point_id,
                index: task.index,
                worker_index,
                queue_wait_ms,
                worker_ms: worker_started.elapsed().as_millis() as u64,
                outcome: Err(ObjectValidateError::CertPath(
                    CertPathError::IssuerSpkiParse(e.to_string()),
                )),
            };
        }
    };
    let outcome = process_roa_with_issuer_parallel_cached(
        file,
        shared.manifest_rsync_uri.as_ref(),
        shared.issuer_ca_der.as_ref(),
        shared.issuer_ca.as_ref(),
        &issuer_spki,
        shared.issuer_ca_rsync_uri.as_deref(),
        shared.crl_cache.as_ref(),
        shared.issuer_resources_index.as_ref(),
        shared.issuer_effective_ip.as_deref(),
        shared.issuer_effective_as.as_deref(),
        task.validation_time,
        None,
        task.collect_vcir_local_outputs,
        task.strict_cms_der,
        task.strict_name,
        task.resource_validation_mode,
    )
    .map(|(vrps, local_outputs, cache_object_meta)| RoaTaskOk {
        vrps,
        local_outputs,
        reused_from_cache: false,
        cache_object_meta,
    });

    RoaTaskResult {
        publication_point_id: task.publication_point_id,
        index: task.index,
        worker_index,
        queue_wait_ms,
        worker_ms: worker_started.elapsed().as_millis() as u64,
        outcome,
    }
}

pub(crate) enum ParallelObjectsPrepare {
    Complete(ObjectsOutput),
    Staged(ParallelObjectsStage),
}

pub(crate) struct ParallelObjectsStage {
    pub(crate) publication_point_id: u64,
    shared: Arc<RoaTaskShared>,
    validation_time: time::OffsetDateTime,
    collect_vcir_local_outputs: bool,
    strict_cms_der: bool,
    strict_name: bool,
    resource_validation_mode: ResourceValidationMode,
    roa_task_indices: Vec<usize>,
    cached_roa_results: Vec<RoaTaskResult>,
    roa_cache_stats: RoaValidationCacheStats,
    warnings: Vec<Warning>,
    stats: ObjectsStats,
    audit: Vec<ObjectAuditEntry>,
}

impl ParallelObjectsStage {
    #[cfg(test)]
    pub(crate) fn build_roa_tasks(&self) -> Vec<OwnedRoaTask> {
        let mut tasks = Vec::with_capacity(self.roa_task_count());
        self.extend_roa_tasks(|task| tasks.push(task));
        tasks
    }

    pub(crate) fn append_roa_tasks_to(
        &self,
        pending: &mut std::collections::VecDeque<OwnedRoaTask>,
    ) {
        self.extend_roa_tasks(|task| pending.push_back(task));
    }

    fn extend_roa_tasks<F>(&self, mut push: F)
    where
        F: FnMut(OwnedRoaTask),
    {
        let shared = self.shared.clone();
        self.roa_task_indices.iter().for_each(|index| {
            push(OwnedRoaTask {
                publication_point_id: self.publication_point_id,
                index: *index,
                shared: shared.clone(),
                validation_time: self.validation_time,
                collect_vcir_local_outputs: self.collect_vcir_local_outputs,
                strict_cms_der: self.strict_cms_der,
                strict_name: self.strict_name,
                resource_validation_mode: self.resource_validation_mode,
                submitted_at: None,
            });
        });
    }

    pub(crate) fn roa_task_count(&self) -> usize {
        self.roa_task_indices.len()
    }

    pub(crate) fn aspa_task_count(&self) -> usize {
        self.stats.aspa_total
    }

    pub(crate) fn locked_file_count(&self) -> usize {
        self.shared.locked_files.len()
    }
}

pub(crate) fn prepare_publication_point_for_parallel_roa_with_cache<P: PublicationPointData>(
    publication_point_id: u64,
    publication_point: &P,
    policy: &Policy,
    issuer_ca_der: &[u8],
    issuer_ca_rsync_uri: Option<&str>,
    issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
    issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
    validation_time: time::OffsetDateTime,
    collect_vcir_local_outputs: bool,
    roa_cache: RoaValidationCacheInput<'_>,
) -> ParallelObjectsPrepare {
    let manifest_rsync_uri = publication_point.manifest_rsync_uri();
    let manifest_bytes = publication_point.manifest_bytes();
    let locked_files = publication_point.files();
    let mut warnings: Vec<Warning> = Vec::new();
    let mut stats = ObjectsStats::default();
    stats.roa_total = locked_files
        .iter()
        .filter(|f| f.rsync_uri.ends_with(".roa"))
        .count();
    stats.aspa_total = locked_files
        .iter()
        .filter(|f| f.rsync_uri.ends_with(".asa"))
        .count();
    let mut roa_cache_stats = RoaValidationCacheStats::for_input(roa_cache, stats.roa_total);
    let mut audit: Vec<ObjectAuditEntry> = Vec::new();

    let _manifest = match ManifestObject::decode_der_with_strict_options(
        manifest_bytes,
        policy.strict.cms_der,
        policy.strict.name,
    ) {
        Ok(manifest) => manifest,
        Err(e) => {
            stats.publication_point_dropped = true;
            warnings.push(
                Warning::new(format!(
                    "dropping publication point: manifest decode failed: {e}"
                ))
                .with_rfc_refs(&[
                    RfcRef("RFC 9286 §4"),
                    RfcRef("RFC 9286 §6.2"),
                    RfcRef("RFC 9286 §6.6"),
                ])
                .with_context(manifest_rsync_uri),
            );
            for f in locked_files {
                if f.rsync_uri.ends_with(".roa") {
                    audit.push(ObjectAuditEntry {
                        rsync_uri: f.rsync_uri.clone(),
                        sha256_hex: sha256_hex_from_32(&f.sha256),
                        kind: AuditObjectKind::Roa,
                        result: AuditObjectResult::Skipped,
                        detail: Some("skipped: manifest decode failed".to_string()),
                    });
                } else if f.rsync_uri.ends_with(".asa") {
                    audit.push(ObjectAuditEntry {
                        rsync_uri: f.rsync_uri.clone(),
                        sha256_hex: sha256_hex_from_32(&f.sha256),
                        kind: AuditObjectKind::Aspa,
                        result: AuditObjectResult::Skipped,
                        detail: Some("skipped: manifest decode failed".to_string()),
                    });
                }
            }
            return ParallelObjectsPrepare::Complete(ObjectsOutput {
                vrps: Vec::new(),
                aspas: Vec::new(),
                router_keys: Vec::new(),
                local_outputs_cache: Vec::new(),
                warnings,
                stats,
                audit,
                roa_cache_stats,
                roa_cache_object_meta: Vec::new(),
            });
        }
    };

    let issuer_ca = match decode_resource_certificate_with_policy(issuer_ca_der, policy) {
        Ok(v) => v,
        Err(e) => {
            stats.publication_point_dropped = true;
            warnings.push(
                Warning::new(format!(
                    "dropping publication point: issuer CA decode failed: {e}"
                ))
                .with_rfc_refs(&[RfcRef("RFC 6487 §7.2"), RfcRef("RFC 5280 §6.1")])
                .with_context(manifest_rsync_uri),
            );
            for f in locked_files {
                if f.rsync_uri.ends_with(".roa") {
                    audit.push(ObjectAuditEntry {
                        rsync_uri: f.rsync_uri.clone(),
                        sha256_hex: sha256_hex_from_32(&f.sha256),
                        kind: AuditObjectKind::Roa,
                        result: AuditObjectResult::Skipped,
                        detail: Some("skipped: issuer CA decode failed".to_string()),
                    });
                } else if f.rsync_uri.ends_with(".asa") {
                    audit.push(ObjectAuditEntry {
                        rsync_uri: f.rsync_uri.clone(),
                        sha256_hex: sha256_hex_from_32(&f.sha256),
                        kind: AuditObjectKind::Aspa,
                        result: AuditObjectResult::Skipped,
                        detail: Some("skipped: issuer CA decode failed".to_string()),
                    });
                }
            }
            return ParallelObjectsPrepare::Complete(ObjectsOutput {
                vrps: Vec::new(),
                aspas: Vec::new(),
                router_keys: Vec::new(),
                local_outputs_cache: Vec::new(),
                warnings,
                stats,
                audit,
                roa_cache_stats,
                roa_cache_object_meta: Vec::new(),
            });
        }
    };

    match SubjectPublicKeyInfo::from_der(&issuer_ca.tbs.subject_public_key_info) {
        Ok((rem, _)) if rem.is_empty() => {}
        Ok((rem, _)) => {
            stats.publication_point_dropped = true;
            warnings.push(
                Warning::new(format!(
                    "dropping publication point: trailing bytes after issuer SPKI DER: {} bytes",
                    rem.len()
                ))
                .with_rfc_refs(&[RfcRef("RFC 5280 §4.1.2.7")])
                .with_context(manifest_rsync_uri),
            );
            return ParallelObjectsPrepare::Complete(ObjectsOutput {
                vrps: Vec::new(),
                aspas: Vec::new(),
                router_keys: Vec::new(),
                local_outputs_cache: Vec::new(),
                warnings,
                stats,
                audit,
                roa_cache_stats,
                roa_cache_object_meta: Vec::new(),
            });
        }
        Err(e) => {
            stats.publication_point_dropped = true;
            warnings.push(
                Warning::new(format!(
                    "dropping publication point: issuer SPKI parse failed: {e}"
                ))
                .with_rfc_refs(&[RfcRef("RFC 5280 §4.1.2.7")])
                .with_context(manifest_rsync_uri),
            );
            return ParallelObjectsPrepare::Complete(ObjectsOutput {
                vrps: Vec::new(),
                aspas: Vec::new(),
                router_keys: Vec::new(),
                local_outputs_cache: Vec::new(),
                warnings,
                stats,
                audit,
                roa_cache_stats,
                roa_cache_object_meta: Vec::new(),
            });
        }
    }

    let mut crl_cache: std::collections::HashMap<String, CachedIssuerCrl> = locked_files
        .iter()
        .filter(|f| f.rsync_uri.ends_with(".crl"))
        .map(|f| {
            let bytes = f
                .bytes_cloned()
                .expect("snapshot CRL bytes must be loadable");
            (
                f.rsync_uri.clone(),
                CachedIssuerCrl::Pending {
                    bytes,
                    sha256_hex: None,
                },
            )
        })
        .collect();

    if crl_cache.is_empty() && (stats.roa_total > 0 || stats.aspa_total > 0) {
        stats.publication_point_dropped = true;
        warnings.push(
            Warning::new("dropping publication point: no CRL files in validated publication point")
                .with_rfc_refs(&[RfcRef("RFC 6487 §4.8.6"), RfcRef("RFC 9286 §7")])
                .with_context(manifest_rsync_uri),
        );
        for f in locked_files {
            if f.rsync_uri.ends_with(".roa") {
                audit.push(ObjectAuditEntry {
                    rsync_uri: f.rsync_uri.clone(),
                    sha256_hex: sha256_hex_from_32(&f.sha256),
                    kind: AuditObjectKind::Roa,
                    result: AuditObjectResult::Skipped,
                    detail: Some(
                        "skipped due to missing CRL files in validated publication point"
                            .to_string(),
                    ),
                });
            } else if f.rsync_uri.ends_with(".asa") {
                audit.push(ObjectAuditEntry {
                    rsync_uri: f.rsync_uri.clone(),
                    sha256_hex: sha256_hex_from_32(&f.sha256),
                    kind: AuditObjectKind::Aspa,
                    result: AuditObjectResult::Skipped,
                    detail: Some(
                        "skipped due to missing CRL files in validated publication point"
                            .to_string(),
                    ),
                });
            }
        }
        return ParallelObjectsPrepare::Complete(ObjectsOutput {
            vrps: Vec::new(),
            aspas: Vec::new(),
            router_keys: Vec::new(),
            local_outputs_cache: Vec::new(),
            warnings,
            stats,
            audit,
            roa_cache_stats,
            roa_cache_object_meta: Vec::new(),
        });
    }

    let active_cache_view = active_roa_cache_view(
        roa_cache,
        issuer_ca_der,
        &mut roa_cache_stats,
        stats.roa_total,
    );
    let mut crl_gate_set = if active_cache_view.is_some() {
        Some(RoaCacheCrlGateSet::default())
    } else {
        None
    };
    let mut roa_task_indices = Vec::new();
    let mut cached_roa_results = Vec::new();
    for (index, file) in locked_files.iter().enumerate() {
        if !file.rsync_uri.ends_with(".roa") {
            continue;
        }
        if let Some(cache_view) = active_cache_view {
            let lookup_started = Instant::now();
            let (lookup_result, lookup_metrics) = cache_view.lookup_with_metrics(
                file,
                &mut crl_cache,
                issuer_ca_der,
                validation_time,
                crl_gate_set.as_mut(),
            );
            roa_cache_stats.record_lookup(elapsed_nanos_u64(lookup_started), lookup_metrics);
            match lookup_result {
                RoaCacheLookupResult::Hit(ok) => {
                    roa_cache_stats.hit_roas += 1;
                    cached_roa_results.push(RoaTaskResult {
                        publication_point_id,
                        index,
                        worker_index: usize::MAX,
                        queue_wait_ms: 0,
                        worker_ms: 0,
                        outcome: Ok(ok),
                    });
                }
                RoaCacheLookupResult::CrlRecheckHit(ok) => {
                    roa_cache_stats.hit_roas += 1;
                    roa_cache_stats.crl_recheck_hit_roas += 1;
                    cached_roa_results.push(RoaTaskResult {
                        publication_point_id,
                        index,
                        worker_index: usize::MAX,
                        queue_wait_ms: 0,
                        worker_ms: 0,
                        outcome: Ok(ok),
                    });
                }
                RoaCacheLookupResult::Miss => {
                    roa_cache_stats.miss_roas += 1;
                    roa_cache_stats.fresh_roas += 1;
                    roa_task_indices.push(index);
                }
                blocked @ (RoaCacheLookupResult::HashBlocked
                | RoaCacheLookupResult::ExpiredBlocked
                | RoaCacheLookupResult::RevokedBlocked
                | RoaCacheLookupResult::MetadataBlocked) => {
                    record_roa_cache_block(&mut roa_cache_stats, &blocked);
                    roa_task_indices.push(index);
                }
            }
        } else {
            roa_task_indices.push(index);
        }
    }

    ParallelObjectsPrepare::Staged(ParallelObjectsStage {
        publication_point_id,
        shared: Arc::new(RoaTaskShared {
            locked_files: Arc::<[PackFile]>::from(locked_files.to_vec()),
            manifest_rsync_uri: Arc::<str>::from(manifest_rsync_uri),
            issuer_ca_der: Arc::<[u8]>::from(issuer_ca_der.to_vec()),
            issuer_spki_der: Arc::<[u8]>::from(issuer_ca.tbs.subject_public_key_info.clone()),
            issuer_ca: Arc::new(issuer_ca),
            issuer_ca_rsync_uri: issuer_ca_rsync_uri.map(Arc::<str>::from),
            crl_cache: Arc::new(Mutex::new(crl_cache)),
            issuer_resources_index: Arc::new(build_issuer_resources_index(
                issuer_effective_ip,
                issuer_effective_as,
            )),
            issuer_effective_ip: issuer_effective_ip.cloned().map(Arc::new),
            issuer_effective_as: issuer_effective_as.cloned().map(Arc::new),
            resource_validation_mode: policy.resource_validation_mode,
        }),
        validation_time,
        collect_vcir_local_outputs,
        strict_cms_der: policy.strict.cms_der,
        strict_name: policy.strict.name,
        resource_validation_mode: policy.resource_validation_mode,
        roa_task_indices,
        cached_roa_results,
        roa_cache_stats,
        warnings,
        stats,
        audit,
    })
}

pub(crate) fn reduce_parallel_roa_stage(
    stage: ParallelObjectsStage,
    mut roa_results: Vec<RoaTaskResult>,
    timing: Option<&TimingHandle>,
) -> Result<ObjectsOutput, String> {
    roa_results.extend(stage.cached_roa_results);
    roa_results.sort_by_key(|result| result.index);
    let mut roa_results = roa_results.into_iter().peekable();
    let shared = stage.shared.clone();
    let mut aspa_crl_cache = shared
        .crl_cache
        .lock()
        .expect("parallel ROA CRL cache lock")
        .clone();
    let issuer_spki = SubjectPublicKeyInfo::from_der(shared.issuer_spki_der.as_ref())
        .map_err(|e| e.to_string())?
        .1;
    let collect_vcir_local_outputs = stage.collect_vcir_local_outputs;
    let validation_time = stage.validation_time;
    let strict_cms_der = stage.strict_cms_der;
    let strict_name = stage.strict_name;
    let roa_cache_stats = stage.roa_cache_stats;
    let mut stats = stage.stats;
    let mut warnings = stage.warnings;
    let mut audit = stage.audit;
    let mut vrps: Vec<Vrp> = Vec::new();
    let mut aspas: Vec<AspaAttestation> = Vec::new();
    let mut local_outputs_cache: Vec<VcirLocalOutput> = Vec::new();
    let mut roa_cache_object_meta: Vec<RoaCacheObjectMeta> = Vec::new();

    for (idx, file) in shared.locked_files.iter().enumerate() {
        if file.rsync_uri.ends_with(".roa") {
            let result = match roa_results.peek() {
                Some(result) if result.index == idx => roa_results
                    .next()
                    .expect("peeked ROA task result must be present"),
                Some(result) => {
                    return Err(format!(
                        "unexpected ROA task result index {} while reducing {} at index {}",
                        result.index, file.rsync_uri, idx
                    ));
                }
                None => {
                    return Err(format!("missing ROA task result for {}", file.rsync_uri));
                }
            };
            match result.outcome {
                Ok(mut ok) => {
                    stats.roa_ok += 1;
                    vrps.append(&mut ok.vrps);
                    if collect_vcir_local_outputs || ok.reused_from_cache {
                        local_outputs_cache.extend(ok.local_outputs);
                    }
                    if let Some(meta) = ok.cache_object_meta.take() {
                        roa_cache_object_meta.push(meta);
                    }
                    audit.push(ObjectAuditEntry {
                        rsync_uri: file.rsync_uri.clone(),
                        sha256_hex: sha256_hex_from_32(&file.sha256),
                        kind: AuditObjectKind::Roa,
                        result: AuditObjectResult::Ok,
                        detail: None,
                    });
                }
                Err(e) => {
                    audit.push(ObjectAuditEntry {
                        rsync_uri: file.rsync_uri.clone(),
                        sha256_hex: sha256_hex_from_32(&file.sha256),
                        kind: AuditObjectKind::Roa,
                        result: AuditObjectResult::Error,
                        detail: Some(e.to_string()),
                    });
                    let mut refs = vec![RfcRef("RFC 6488 §3"), RfcRef("RFC 9582 §4-§5")];
                    refs.extend_from_slice(extra_rfc_refs_for_crl_selection(&e));
                    warnings.push(
                        Warning::new(format!("dropping invalid ROA: {}: {e}", file.rsync_uri))
                            .with_rfc_refs(&refs)
                            .with_context(&file.rsync_uri),
                    )
                }
            }
        } else if file.rsync_uri.ends_with(".asa") {
            let _t = timing.as_ref().map(|t| t.span_phase("objects_aspa_total"));
            match process_aspa_with_issuer(
                file,
                shared.manifest_rsync_uri.as_ref(),
                shared.issuer_ca_der.as_ref(),
                shared.issuer_ca.as_ref(),
                &issuer_spki,
                shared.issuer_ca_rsync_uri.as_deref(),
                &mut aspa_crl_cache,
                shared.issuer_resources_index.as_ref(),
                shared.issuer_effective_ip.as_deref(),
                shared.issuer_effective_as.as_deref(),
                validation_time,
                timing,
                collect_vcir_local_outputs,
                strict_cms_der,
                strict_name,
                shared.resource_validation_mode,
            ) {
                Ok((att, local_output)) => {
                    stats.aspa_ok += 1;
                    aspas.push(att);
                    if let Some(local_output) = local_output {
                        local_outputs_cache.push(local_output);
                    }
                    audit.push(ObjectAuditEntry {
                        rsync_uri: file.rsync_uri.clone(),
                        sha256_hex: sha256_hex_from_32(&file.sha256),
                        kind: AuditObjectKind::Aspa,
                        result: AuditObjectResult::Ok,
                        detail: None,
                    });
                }
                Err(e) => {
                    audit.push(ObjectAuditEntry {
                        rsync_uri: file.rsync_uri.clone(),
                        sha256_hex: sha256_hex_from_32(&file.sha256),
                        kind: AuditObjectKind::Aspa,
                        result: AuditObjectResult::Error,
                        detail: Some(e.to_string()),
                    });
                    let mut refs = vec![RfcRef("RFC 6488 §3")];
                    refs.extend_from_slice(extra_rfc_refs_for_crl_selection(&e));
                    warnings.push(
                        Warning::new(format!("dropping invalid ASPA: {}: {e}", file.rsync_uri))
                            .with_rfc_refs(&refs)
                            .with_context(&file.rsync_uri),
                    )
                }
            }
        }
    }
    if let Some(result) = roa_results.next() {
        return Err(format!(
            "unexpected trailing ROA task result at index {}",
            result.index
        ));
    }

    roa_cache_stats.record_to_timing(timing);

    Ok(ObjectsOutput {
        vrps,
        aspas,
        router_keys: Vec::new(),
        local_outputs_cache,
        warnings,
        stats,
        audit,
        roa_cache_stats,
        roa_cache_object_meta,
    })
}

fn process_publication_point_for_issuer_parallel_roa_inner<P: PublicationPointData>(
    publication_point: &P,
    _policy: &Policy,
    issuer_ca_der: &[u8],
    issuer_ca_rsync_uri: Option<&str>,
    issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
    issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
    validation_time: time::OffsetDateTime,
    timing: Option<&TimingHandle>,
    pool: &ParallelRoaWorkerPool,
    collect_vcir_local_outputs: bool,
    roa_cache: RoaValidationCacheInput<'_>,
) -> Result<ObjectsOutput, String> {
    let stage = match prepare_publication_point_for_parallel_roa_with_cache(
        0,
        publication_point,
        _policy,
        issuer_ca_der,
        issuer_ca_rsync_uri,
        issuer_effective_ip,
        issuer_effective_as,
        validation_time,
        collect_vcir_local_outputs,
        roa_cache,
    ) {
        ParallelObjectsPrepare::Complete(out) => return Ok(out),
        ParallelObjectsPrepare::Staged(stage) => stage,
    };

    let roa_task_count = stage.roa_task_count();
    let mut pending = std::collections::VecDeque::with_capacity(roa_task_count);
    stage.append_roa_tasks_to(&mut pending);
    let mut worker_pool = pool
        .pool
        .lock()
        .map_err(|_| "parallel ROA worker pool lock poisoned".to_string())?;

    while let Some(task) = pending.pop_front() {
        match worker_pool.try_submit_round_robin(task) {
            Ok(_) => {}
            Err(ObjectWorkerSubmitError::QueueFull { task, .. }) => {
                pending.push_front(task);
                std::thread::yield_now();
            }
            Err(ObjectWorkerSubmitError::Disconnected { .. }) => {
                return Err("parallel ROA worker queue disconnected".to_string());
            }
        }
    }

    let mut roa_results = Vec::with_capacity(roa_task_count);
    while roa_results.len() < roa_task_count {
        let Some(result) = worker_pool.recv_result_timeout(Duration::from_secs(30))? else {
            return Err("parallel ROA worker timed out".to_string());
        };
        roa_results.push(result);
    }
    drop(worker_pool);

    reduce_parallel_roa_stage(stage, roa_results, timing)
}
/// Compatibility wrapper that processes a publication point snapshot.
pub fn process_publication_point_snapshot_for_issuer(
    pack: &PublicationPointSnapshot,
    policy: &Policy,
    issuer_ca_der: &[u8],
    issuer_ca_rsync_uri: Option<&str>,
    issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
    issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
    validation_time: time::OffsetDateTime,
    timing: Option<&TimingHandle>,
) -> ObjectsOutput {
    process_publication_point_for_issuer(
        pack,
        policy,
        issuer_ca_der,
        issuer_ca_rsync_uri,
        issuer_effective_ip,
        issuer_effective_as,
        validation_time,
        timing,
    )
}

pub fn process_publication_point_snapshot_for_issuer_parallel_roa(
    pack: &PublicationPointSnapshot,
    policy: &Policy,
    issuer_ca_der: &[u8],
    issuer_ca_rsync_uri: Option<&str>,
    issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
    issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
    validation_time: time::OffsetDateTime,
    timing: Option<&TimingHandle>,
    config: &ParallelPhase2Config,
) -> ObjectsOutput {
    process_publication_point_for_issuer_parallel_roa(
        pack,
        policy,
        issuer_ca_der,
        issuer_ca_rsync_uri,
        issuer_effective_ip,
        issuer_effective_as,
        validation_time,
        timing,
        config,
    )
}

#[derive(Debug, thiserror::Error)]
pub(crate) enum ObjectValidateError {
    #[error("object bytes load failed: {0}")]
    BytesLoad(String),

    #[error("ROA decode failed: {0}")]
    RoaDecode(#[from] RoaDecodeError),

    #[error("ROA embedded EE resource validation failed: {0}")]
    RoaEeResources(#[from] RoaValidateError),

    #[error("ASPA decode failed: {0}")]
    AspaDecode(#[from] AspaDecodeError),

    #[error("ASPA embedded EE resource validation failed: {0}")]
    AspaEeResources(#[from] AspaValidateError),

    #[error("CMS signature verification failed: {0}")]
    Signature(#[from] SignedObjectVerifyError),

    #[error("EE certificate path validation failed: {0}")]
    CertPath(#[from] CertPathError),

    #[error(
        "certificate CRLDistributionPoints URIs missing (cannot select issuer CRL) (RFC 6487 §4.8.6)"
    )]
    MissingCrlDpUris,

    #[error(
        "no CRL available in publication point snapshot (cannot validate certificates) (RFC 9286 §7; RFC 6487 §4.8.6)"
    )]
    MissingCrlInPack,

    #[error(
        "CRL referenced by CRLDistributionPoints not found in publication point snapshot: {0} (RFC 6487 §4.8.6; RFC 9286 §4.2.1)"
    )]
    CrlNotFound(String),

    #[error(
        "issuer effective IP resources missing (cannot validate EE IP resources subset) (RFC 6487 §7.2; RFC 3779 §2.3)"
    )]
    MissingIssuerEffectiveIp,

    #[error(
        "issuer effective AS resources missing (cannot validate EE AS resources subset) (RFC 6487 §7.2; RFC 3779 §3.3)"
    )]
    MissingIssuerEffectiveAs,

    #[error(
        "EE certificate resources are not a subset of issuer effective resources (RFC 6487 §7.2; RFC 3779)"
    )]
    EeResourcesNotSubset,
}

pub(crate) fn validate_roa_task_serial(
    task: RoaTask<'_>,
    manifest_rsync_uri: &str,
    issuer_ca_der: &[u8],
    issuer_ca: &ResourceCertificate,
    issuer_spki: &SubjectPublicKeyInfo<'_>,
    issuer_ca_rsync_uri: Option<&str>,
    crl_cache: &mut std::collections::HashMap<String, CachedIssuerCrl>,
    issuer_resources_index: &IssuerResourcesIndex,
    issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
    issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
    validation_time: time::OffsetDateTime,
    timing: Option<&TimingHandle>,
    collect_vcir_local_outputs: bool,
    strict_cms_der: bool,
    strict_name: bool,
    resource_validation_mode: ResourceValidationMode,
) -> RoaTaskResult {
    let outcome = process_roa_with_issuer(
        task.file,
        manifest_rsync_uri,
        issuer_ca_der,
        issuer_ca,
        issuer_spki,
        issuer_ca_rsync_uri,
        crl_cache,
        issuer_resources_index,
        issuer_effective_ip,
        issuer_effective_as,
        validation_time,
        timing,
        collect_vcir_local_outputs,
        strict_cms_der,
        strict_name,
        resource_validation_mode,
    )
    .map(|(vrps, local_outputs, cache_object_meta)| RoaTaskOk {
        vrps,
        local_outputs,
        reused_from_cache: false,
        cache_object_meta,
    });

    RoaTaskResult {
        publication_point_id: 0,
        index: task.index,
        worker_index: 0,
        queue_wait_ms: 0,
        worker_ms: 0,
        outcome,
    }
}

fn process_roa_with_issuer(
    file: &PackFile,
    _manifest_rsync_uri: &str,
    issuer_ca_der: &[u8],
    issuer_ca: &ResourceCertificate,
    issuer_spki: &SubjectPublicKeyInfo<'_>,
    issuer_ca_rsync_uri: Option<&str>,
    crl_cache: &mut std::collections::HashMap<String, CachedIssuerCrl>,
    issuer_resources_index: &IssuerResourcesIndex,
    issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
    issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
    validation_time: time::OffsetDateTime,
    timing: Option<&TimingHandle>,
    collect_vcir_local_outputs: bool,
    strict_cms_der: bool,
    strict_name: bool,
    resource_validation_mode: ResourceValidationMode,
) -> Result<(Vec<Vrp>, Vec<VcirLocalOutput>, Option<RoaCacheObjectMeta>), ObjectValidateError> {
    let _decode = timing
        .as_ref()
        .map(|t| t.span_phase("objects_roa_decode_and_validate_total"));
    let roa = RoaObject::decode_der_with_strict_options(
        file.bytes().map_err(ObjectValidateError::BytesLoad)?,
        strict_cms_der,
        strict_name,
    )?;
    drop(_decode);

    let _ee_profile = timing
        .as_ref()
        .map(|t| t.span_phase("objects_roa_validate_embedded_ee_total"));
    roa.validate_embedded_ee_cert()?;
    drop(_ee_profile);

    let _verify = timing
        .as_ref()
        .map(|t| t.span_phase("objects_roa_verify_signature_total"));
    roa.signed_object.verify()?;
    drop(_verify);

    let ee = &roa.signed_object.signed_data.certificates[0];
    let ee_crldp_uris = ee
        .resource_cert
        .tbs
        .extensions
        .crl_distribution_points_uris
        .as_ref();
    let issuer_crl_rsync_uri = choose_crl_uri_for_certificate(ee_crldp_uris, crl_cache)?;
    let verified_crl = ensure_issuer_crl_verified(issuer_crl_rsync_uri, crl_cache, issuer_ca_der)?;

    let _cert_path = timing
        .as_ref()
        .map(|t| t.span_phase("objects_roa_validate_ee_cert_path_total"));
    validate_signed_object_ee_cert_path_fast(
        ee,
        issuer_ca,
        issuer_spki,
        &verified_crl.crl,
        &verified_crl.revoked_serials,
        issuer_ca_rsync_uri,
        Some(issuer_crl_rsync_uri),
        validation_time,
    )?;
    drop(_cert_path);

    let _subset = timing
        .as_ref()
        .map(|t| t.span_phase("objects_roa_validate_ee_resources_subset_total"));
    let ee_vrs = validate_ee_resources_for_mode(
        &ee.resource_cert,
        issuer_effective_ip,
        issuer_effective_as,
        issuer_resources_index,
        resource_validation_mode,
    )?;
    drop(_subset);

    let vrps = roa_to_vrps_with_vrs(&roa, ee_vrs.ip.as_ref())?;
    let cache_object_meta = RoaCacheObjectMeta {
        source_object_uri: file.rsync_uri.clone(),
        source_object_hash: file.sha256,
        ee_serial: BigUnsigned::from_biguint(&ee.resource_cert.tbs.serial_number).bytes_be,
        crl_uri: issuer_crl_rsync_uri.to_string(),
        earliest_safe_reuse_time: roa_cache_earliest_safe_reuse_time(
            ee.resource_cert.tbs.validity_not_before,
            verified_crl.crl.this_update.utc,
            validation_time,
        ),
    };
    if !collect_vcir_local_outputs {
        return Ok((vrps, Vec::new(), Some(cache_object_meta)));
    }
    let source_object_hash = sha256_hex_from_32(&file.sha256);
    let source_ee_cert_hash = crate::audit::sha256_hex(ee.raw_der.as_slice());
    let item_effective_until =
        PackTime::from_utc_offset_datetime(ee.resource_cert.tbs.validity_not_after);
    let local_outputs = vrps
        .iter()
        .map(|vrp| {
            let prefix = vrp_prefix_to_string(vrp);
            let rule_hash = crate::audit::sha256_hex(
                format!(
                    "roa-rule:{}:{}:{}:{}",
                    source_object_hash, vrp.asn, prefix, vrp.max_length
                )
                .as_bytes(),
            );
            VcirLocalOutput {
                output_type: VcirOutputType::Vrp,
                item_effective_until: item_effective_until.clone(),
                source_object_uri: file.rsync_uri.clone(),
                source_object_type: VcirSourceObjectType::Roa,
                source_object_hash: file.sha256,
                source_ee_cert_hash: sha256_hex_to_32(&source_ee_cert_hash),
                payload: VcirLocalOutputPayload::Vrp {
                    asn: vrp.asn,
                    afi: vrp.prefix.afi,
                    prefix_len: vrp.prefix.prefix_len,
                    addr: vrp.prefix.addr,
                    max_length: vrp.max_length,
                },
                rule_hash: sha256_hex_to_32(&rule_hash),
            }
        })
        .collect();

    Ok((vrps, local_outputs, Some(cache_object_meta)))
}

fn process_roa_with_issuer_parallel_cached(
    file: &PackFile,
    _manifest_rsync_uri: &str,
    issuer_ca_der: &[u8],
    issuer_ca: &ResourceCertificate,
    issuer_spki: &SubjectPublicKeyInfo<'_>,
    issuer_ca_rsync_uri: Option<&str>,
    crl_cache: &Mutex<std::collections::HashMap<String, CachedIssuerCrl>>,
    issuer_resources_index: &IssuerResourcesIndex,
    issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
    issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
    validation_time: time::OffsetDateTime,
    timing: Option<&TimingHandle>,
    collect_vcir_local_outputs: bool,
    strict_cms_der: bool,
    strict_name: bool,
    resource_validation_mode: ResourceValidationMode,
) -> Result<(Vec<Vrp>, Vec<VcirLocalOutput>, Option<RoaCacheObjectMeta>), ObjectValidateError> {
    let _decode = timing
        .as_ref()
        .map(|t| t.span_phase("objects_roa_decode_and_validate_total"));
    let roa = RoaObject::decode_der_with_strict_options(
        file.bytes().map_err(ObjectValidateError::BytesLoad)?,
        strict_cms_der,
        strict_name,
    )?;
    drop(_decode);

    let _ee_profile = timing
        .as_ref()
        .map(|t| t.span_phase("objects_roa_validate_embedded_ee_total"));
    roa.validate_embedded_ee_cert()?;
    drop(_ee_profile);

    let _verify = timing
        .as_ref()
        .map(|t| t.span_phase("objects_roa_verify_signature_total"));
    roa.signed_object.verify()?;
    drop(_verify);

    let ee = &roa.signed_object.signed_data.certificates[0];
    let ee_crldp_uris = ee
        .resource_cert
        .tbs
        .extensions
        .crl_distribution_points_uris
        .as_ref();
    let (issuer_crl_rsync_uri, verified_crl) = {
        let mut crl_cache = crl_cache.lock().expect("parallel ROA CRL cache lock");
        let issuer_crl_rsync_uri =
            choose_crl_uri_for_certificate(ee_crldp_uris, &crl_cache)?.to_string();
        let verified_crl =
            ensure_issuer_crl_verified(&issuer_crl_rsync_uri, &mut crl_cache, issuer_ca_der)?;
        (issuer_crl_rsync_uri, verified_crl)
    };

    let _cert_path = timing
        .as_ref()
        .map(|t| t.span_phase("objects_roa_validate_ee_cert_path_total"));
    validate_signed_object_ee_cert_path_fast(
        ee,
        issuer_ca,
        issuer_spki,
        &verified_crl.crl,
        &verified_crl.revoked_serials,
        issuer_ca_rsync_uri,
        Some(issuer_crl_rsync_uri.as_str()),
        validation_time,
    )?;
    drop(_cert_path);

    let _subset = timing
        .as_ref()
        .map(|t| t.span_phase("objects_roa_validate_ee_resources_subset_total"));
    let ee_vrs = validate_ee_resources_for_mode(
        &ee.resource_cert,
        issuer_effective_ip,
        issuer_effective_as,
        issuer_resources_index,
        resource_validation_mode,
    )?;
    drop(_subset);

    let vrps = roa_to_vrps_with_vrs(&roa, ee_vrs.ip.as_ref())?;
    let cache_object_meta = RoaCacheObjectMeta {
        source_object_uri: file.rsync_uri.clone(),
        source_object_hash: file.sha256,
        ee_serial: BigUnsigned::from_biguint(&ee.resource_cert.tbs.serial_number).bytes_be,
        crl_uri: issuer_crl_rsync_uri.clone(),
        earliest_safe_reuse_time: roa_cache_earliest_safe_reuse_time(
            ee.resource_cert.tbs.validity_not_before,
            verified_crl.crl.this_update.utc,
            validation_time,
        ),
    };
    if !collect_vcir_local_outputs {
        return Ok((vrps, Vec::new(), Some(cache_object_meta)));
    }
    let source_object_hash = sha256_hex_from_32(&file.sha256);
    let source_ee_cert_hash = crate::audit::sha256_hex(ee.raw_der.as_slice());
    let item_effective_until =
        PackTime::from_utc_offset_datetime(ee.resource_cert.tbs.validity_not_after);
    let local_outputs = vrps
        .iter()
        .map(|vrp| {
            let prefix = vrp_prefix_to_string(vrp);
            let rule_hash = crate::audit::sha256_hex(
                format!(
                    "roa-rule:{}:{}:{}:{}",
                    source_object_hash, vrp.asn, prefix, vrp.max_length
                )
                .as_bytes(),
            );
            VcirLocalOutput {
                output_type: VcirOutputType::Vrp,
                item_effective_until: item_effective_until.clone(),
                source_object_uri: file.rsync_uri.clone(),
                source_object_type: VcirSourceObjectType::Roa,
                source_object_hash: file.sha256,
                source_ee_cert_hash: sha256_hex_to_32(&source_ee_cert_hash),
                payload: VcirLocalOutputPayload::Vrp {
                    asn: vrp.asn,
                    afi: vrp.prefix.afi,
                    prefix_len: vrp.prefix.prefix_len,
                    addr: vrp.prefix.addr,
                    max_length: vrp.max_length,
                },
                rule_hash: sha256_hex_to_32(&rule_hash),
            }
        })
        .collect();

    Ok((vrps, local_outputs, Some(cache_object_meta)))
}

fn process_aspa_with_issuer(
    file: &PackFile,
    _manifest_rsync_uri: &str,
    issuer_ca_der: &[u8],
    issuer_ca: &ResourceCertificate,
    issuer_spki: &SubjectPublicKeyInfo<'_>,
    issuer_ca_rsync_uri: Option<&str>,
    crl_cache: &mut std::collections::HashMap<String, CachedIssuerCrl>,
    issuer_resources_index: &IssuerResourcesIndex,
    issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
    issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
    validation_time: time::OffsetDateTime,
    timing: Option<&TimingHandle>,
    collect_vcir_local_outputs: bool,
    strict_cms_der: bool,
    strict_name: bool,
    resource_validation_mode: ResourceValidationMode,
) -> Result<(AspaAttestation, Option<VcirLocalOutput>), ObjectValidateError> {
    let _decode = timing
        .as_ref()
        .map(|t| t.span_phase("objects_aspa_decode_and_validate_total"));
    let aspa = AspaObject::decode_der_with_strict_options(
        file.bytes().map_err(ObjectValidateError::BytesLoad)?,
        strict_cms_der,
        strict_name,
    )?;
    drop(_decode);

    let _ee_profile = timing
        .as_ref()
        .map(|t| t.span_phase("objects_aspa_validate_embedded_ee_total"));
    aspa.validate_embedded_ee_cert()?;
    drop(_ee_profile);

    let _verify = timing
        .as_ref()
        .map(|t| t.span_phase("objects_aspa_verify_signature_total"));
    aspa.signed_object.verify()?;
    drop(_verify);

    let ee = &aspa.signed_object.signed_data.certificates[0];
    let ee_crldp_uris = ee
        .resource_cert
        .tbs
        .extensions
        .crl_distribution_points_uris
        .as_ref();
    let issuer_crl_rsync_uri = choose_crl_uri_for_certificate(ee_crldp_uris, crl_cache)?;
    let verified_crl = ensure_issuer_crl_verified(issuer_crl_rsync_uri, crl_cache, issuer_ca_der)?;

    let _cert_path = timing
        .as_ref()
        .map(|t| t.span_phase("objects_aspa_validate_ee_cert_path_total"));
    validate_signed_object_ee_cert_path_fast(
        ee,
        issuer_ca,
        issuer_spki,
        &verified_crl.crl,
        &verified_crl.revoked_serials,
        issuer_ca_rsync_uri,
        Some(issuer_crl_rsync_uri),
        validation_time,
    )?;
    drop(_cert_path);

    let _subset = timing
        .as_ref()
        .map(|t| t.span_phase("objects_aspa_validate_ee_resources_subset_total"));
    let ee_vrs = validate_ee_resources_for_mode(
        &ee.resource_cert,
        issuer_effective_ip,
        issuer_effective_as,
        issuer_resources_index,
        resource_validation_mode,
    )?;
    drop(_subset);

    validate_aspa_customer_in_vrs(&aspa, ee_vrs.asn.as_ref())?;

    let attestation = AspaAttestation {
        customer_as_id: aspa.aspa.customer_as_id,
        provider_as_ids: aspa.aspa.provider_as_ids.clone(),
    };
    if !collect_vcir_local_outputs {
        return Ok((attestation, None));
    }
    let source_object_hash = sha256_hex_from_32(&file.sha256);
    let source_ee_cert_hash = crate::audit::sha256_hex(ee.raw_der.as_slice());
    let item_effective_until =
        PackTime::from_utc_offset_datetime(ee.resource_cert.tbs.validity_not_after);
    let providers = attestation
        .provider_as_ids
        .iter()
        .map(u32::to_string)
        .collect::<Vec<_>>()
        .join(",");
    let rule_hash = crate::audit::sha256_hex(
        format!(
            "aspa-rule:{}:{}:{}",
            source_object_hash, attestation.customer_as_id, providers
        )
        .as_bytes(),
    );
    let local_output = VcirLocalOutput {
        output_type: VcirOutputType::Aspa,
        item_effective_until,
        source_object_uri: file.rsync_uri.clone(),
        source_object_type: VcirSourceObjectType::Aspa,
        source_object_hash: file.sha256,
        source_ee_cert_hash: sha256_hex_to_32(&source_ee_cert_hash),
        payload: VcirLocalOutputPayload::Aspa {
            customer_as_id: attestation.customer_as_id,
            provider_as_ids: attestation.provider_as_ids.clone(),
        },
        rule_hash: sha256_hex_to_32(&rule_hash),
    };

    Ok((attestation, Some(local_output)))
}

fn vrp_prefix_to_string(vrp: &Vrp) -> String {
    let prefix = &vrp.prefix;
    match prefix.afi {
        RoaAfi::Ipv4 => {
            let addr = std::net::Ipv4Addr::new(
                prefix.addr[0],
                prefix.addr[1],
                prefix.addr[2],
                prefix.addr[3],
            );
            format!("{addr}/{}", prefix.prefix_len)
        }
        RoaAfi::Ipv6 => {
            let mut octets = [0u8; 16];
            octets.copy_from_slice(&prefix.addr[..16]);
            let addr = std::net::Ipv6Addr::from(octets);
            format!("{addr}/{}", prefix.prefix_len)
        }
    }
}

fn choose_crl_uri_for_certificate<'a>(
    crldp_uris: Option<&'a Vec<String>>,
    crl_cache: &std::collections::HashMap<String, CachedIssuerCrl>,
) -> Result<&'a str, ObjectValidateError> {
    if crl_cache.is_empty() {
        return Err(ObjectValidateError::MissingCrlInPack);
    }

    let Some(crldp_uris) = crldp_uris else {
        return Err(ObjectValidateError::MissingCrlDpUris);
    };

    for u in crldp_uris {
        let s = u.as_str();
        if crl_cache.contains_key(s) {
            return Ok(s);
        }
    }
    Err(ObjectValidateError::CrlNotFound(
        crldp_uris
            .iter()
            .map(|u| u.as_str())
            .collect::<Vec<_>>()
            .join(", "),
    ))
}

fn ensure_issuer_crl_verified<'a>(
    crl_rsync_uri: &str,
    crl_cache: &'a mut std::collections::HashMap<String, CachedIssuerCrl>,
    issuer_ca_der: &[u8],
) -> Result<Arc<VerifiedIssuerCrl>, CertPathError> {
    let entry = crl_cache
        .get_mut(crl_rsync_uri)
        .expect("CRL must exist in cache");
    match entry {
        CachedIssuerCrl::Ok(v) => Ok(Arc::clone(v)),
        CachedIssuerCrl::Pending { bytes, sha256_hex } => {
            let der = std::mem::take(bytes);
            let current_sha256_hex = sha256_hex
                .take()
                .unwrap_or_else(|| crate::audit::sha256_hex(&der));
            let crl = crate::data_model::crl::RpkixCrl::decode_der(&der)
                .map_err(CertPathError::CrlDecode)?;
            crl.verify_signature_with_issuer_certificate_der(issuer_ca_der)
                .map_err(CertPathError::CrlVerify)?;

            let mut revoked_serials: std::collections::HashSet<Vec<u8>> =
                std::collections::HashSet::with_capacity(crl.revoked_certs.len());
            for rc in &crl.revoked_certs {
                revoked_serials.insert(rc.serial_number.bytes_be.clone());
            }

            *entry = CachedIssuerCrl::Ok(Arc::new(VerifiedIssuerCrl {
                crl,
                revoked_serials,
                sha256_hex: current_sha256_hex,
            }));
            match entry {
                CachedIssuerCrl::Ok(v) => Ok(Arc::clone(v)),
                _ => unreachable!(),
            }
        }
    }
}

fn validate_ee_resources_subset(
    ee: &ResourceCertificate,
    issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
    issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
    issuer_resources_index: &IssuerResourcesIndex,
) -> Result<(), ObjectValidateError> {
    if let Some(child_ip) = ee.tbs.extensions.ip_resources.as_ref() {
        let Some(parent_ip) = issuer_effective_ip else {
            return Err(ObjectValidateError::MissingIssuerEffectiveIp);
        };
        if !ip_resources_is_subset_indexed(child_ip, parent_ip, issuer_resources_index) {
            return Err(ObjectValidateError::EeResourcesNotSubset);
        }
    }

    if let Some(child_as) = ee.tbs.extensions.as_resources.as_ref() {
        let Some(parent_as) = issuer_effective_as else {
            return Err(ObjectValidateError::MissingIssuerEffectiveAs);
        };
        if !as_resources_is_subset_indexed(child_as, parent_as, issuer_resources_index) {
            return Err(ObjectValidateError::EeResourcesNotSubset);
        }
    }

    Ok(())
}

#[derive(Debug)]
struct EeVerifiedResources {
    ip: Option<crate::data_model::rc::IpResourceSet>,
    asn: Option<crate::data_model::rc::AsResourceSet>,
}

fn validate_ee_resources_for_mode(
    ee: &ResourceCertificate,
    issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
    issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
    issuer_resources_index: &IssuerResourcesIndex,
    mode: ResourceValidationMode,
) -> Result<EeVerifiedResources, ObjectValidateError> {
    match mode {
        ResourceValidationMode::Rfc6487 => {
            validate_ee_resources_subset(
                ee,
                issuer_effective_ip,
                issuer_effective_as,
                issuer_resources_index,
            )?;
            Ok(EeVerifiedResources {
                ip: ee.tbs.extensions.ip_resources.clone(),
                asn: ee.tbs.extensions.as_resources.clone(),
            })
        }
        ResourceValidationMode::ValidationUpdate03 => {
            let ip = match ee.tbs.extensions.ip_resources.as_ref() {
                Some(child_ip) => Some(intersect_ee_ip_resources_vrs(
                    child_ip,
                    issuer_effective_ip,
                    issuer_resources_index,
                )?),
                None => None,
            };
            let asn = match ee.tbs.extensions.as_resources.as_ref() {
                Some(child_as) => Some(intersect_ee_as_resources_vrs(
                    child_as,
                    issuer_effective_as,
                    issuer_resources_index,
                )?),
                None => None,
            };
            Ok(EeVerifiedResources { ip, asn })
        }
    }
}

fn intersect_ee_ip_resources_vrs(
    child_ip: &crate::data_model::rc::IpResourceSet,
    issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
    issuer_resources_index: &IssuerResourcesIndex,
) -> Result<crate::data_model::rc::IpResourceSet, ObjectValidateError> {
    if child_ip.has_any_inherit() {
        return Err(ObjectValidateError::EeResourcesNotSubset);
    }
    let _ = issuer_effective_ip;
    let mut families = Vec::new();
    for fam in &child_ip.families {
        let parent_intervals = match fam.afi {
            crate::data_model::rc::Afi::Ipv4 => issuer_resources_index.ip_v4.as_deref(),
            crate::data_model::rc::Afi::Ipv6 => issuer_resources_index.ip_v6.as_deref(),
        }
        .unwrap_or(&[]);
        let items = match &fam.choice {
            IpAddressChoice::Inherit => return Err(ObjectValidateError::EeResourcesNotSubset),
            IpAddressChoice::AddressesOrRanges(items) => items,
        };
        let intersections = intersect_ip_items_with_parent_intervals(items, parent_intervals);
        if !intersections.is_empty() {
            families.push(crate::data_model::rc::IpAddressFamily {
                afi: fam.afi,
                choice: IpAddressChoice::AddressesOrRanges(ip_intervals_to_ranges(
                    fam.afi,
                    &intersections,
                )),
            });
        }
    }
    Ok(crate::data_model::rc::IpResourceSet { families })
}

fn intersect_ee_as_resources_vrs(
    child_as: &crate::data_model::rc::AsResourceSet,
    issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
    issuer_resources_index: &IssuerResourcesIndex,
) -> Result<crate::data_model::rc::AsResourceSet, ObjectValidateError> {
    let _ = issuer_effective_as;
    if matches!(child_as.asnum, Some(AsIdentifierChoice::Inherit))
        || matches!(child_as.rdi, Some(AsIdentifierChoice::Inherit))
    {
        return Err(ObjectValidateError::EeResourcesNotSubset);
    }
    let asnum = child_as.asnum.as_ref().map(|choice| {
        let child_intervals = as_choice_to_merged_intervals(choice);
        AsIdentifierChoice::AsIdsOrRanges(as_intervals_to_items(&intersect_as_intervals(
            &child_intervals,
            issuer_resources_index.asnum.as_deref().unwrap_or(&[]),
        )))
    });
    let rdi = child_as.rdi.as_ref().map(|choice| {
        let child_intervals = as_choice_to_merged_intervals(choice);
        AsIdentifierChoice::AsIdsOrRanges(as_intervals_to_items(&intersect_as_intervals(
            &child_intervals,
            issuer_resources_index.rdi.as_deref().unwrap_or(&[]),
        )))
    });
    Ok(crate::data_model::rc::AsResourceSet { asnum, rdi })
}

fn roa_to_vrps_with_vrs(
    roa: &RoaObject,
    ee_vrs_ip: Option<&crate::data_model::rc::IpResourceSet>,
) -> Result<Vec<Vrp>, ObjectValidateError> {
    let vrps = roa_to_vrps(roa);
    let Some(ee_vrs_ip) = ee_vrs_ip else {
        return Err(ObjectValidateError::EeResourcesNotSubset);
    };
    for vrp in &vrps {
        let rc_prefix = roa_prefix_to_rc_prefix(&vrp.prefix);
        if !ee_vrs_ip.contains_prefix(&rc_prefix) {
            return Err(ObjectValidateError::EeResourcesNotSubset);
        }
    }
    Ok(vrps)
}

fn validate_aspa_customer_in_vrs(
    aspa: &AspaObject,
    ee_vrs_as: Option<&crate::data_model::rc::AsResourceSet>,
) -> Result<(), ObjectValidateError> {
    let Some(ee_vrs_as) = ee_vrs_as else {
        return Err(ObjectValidateError::EeResourcesNotSubset);
    };
    if !as_resource_set_contains_asn(ee_vrs_as, aspa.aspa.customer_as_id) {
        return Err(ObjectValidateError::EeResourcesNotSubset);
    }
    Ok(())
}

fn as_resource_set_contains_asn(
    resources: &crate::data_model::rc::AsResourceSet,
    asn: u32,
) -> bool {
    let Some(choice) = resources.asnum.as_ref() else {
        return false;
    };
    match choice {
        AsIdentifierChoice::Inherit => false,
        AsIdentifierChoice::AsIdsOrRanges(items) => items.iter().any(|item| match item {
            crate::data_model::rc::AsIdOrRange::Id(id) => *id == asn,
            crate::data_model::rc::AsIdOrRange::Range { min, max } => *min <= asn && asn <= *max,
        }),
    }
}

fn roa_prefix_to_rc_prefix(prefix: &IpPrefix) -> RcIpPrefix {
    let afi = match prefix.afi {
        RoaAfi::Ipv4 => crate::data_model::rc::Afi::Ipv4,
        RoaAfi::Ipv6 => crate::data_model::rc::Afi::Ipv6,
    };
    let mut addr = prefix.addr.to_vec();
    addr.truncate(afi.octets_len());
    RcIpPrefix {
        afi,
        prefix_len: prefix.prefix_len,
        addr,
    }
}

fn as_resources_is_subset(child: &AsResourceSet, parent: &AsResourceSet) -> bool {
    as_choice_subset(child.asnum.as_ref(), parent.asnum.as_ref())
        && as_choice_subset(child.rdi.as_ref(), parent.rdi.as_ref())
}

fn as_resources_is_subset_indexed(
    child: &AsResourceSet,
    parent: &AsResourceSet,
    idx: &IssuerResourcesIndex,
) -> bool {
    let _ = parent;
    as_choice_subset_indexed(child.asnum.as_ref(), idx.asnum.as_deref())
        && as_choice_subset_indexed(child.rdi.as_ref(), idx.rdi.as_deref())
}

fn as_choice_subset_indexed(
    child: Option<&AsIdentifierChoice>,
    parent_intervals: Option<&[(u32, u32)]>,
) -> bool {
    let Some(child) = child else {
        return true;
    };
    let Some(parent_intervals) = parent_intervals else {
        return false;
    };

    if matches!(child, AsIdentifierChoice::Inherit) {
        return false;
    }

    let child_intervals = as_choice_to_merged_intervals(child);
    for (cmin, cmax) in &child_intervals {
        if !as_interval_is_covered(parent_intervals, *cmin, *cmax) {
            return false;
        }
    }
    true
}

fn as_choice_subset(
    child: Option<&AsIdentifierChoice>,
    parent: Option<&AsIdentifierChoice>,
) -> bool {
    let Some(child) = child else {
        return true;
    };
    let Some(parent) = parent else {
        return false;
    };

    match (child, parent) {
        (AsIdentifierChoice::Inherit, _) => return false,
        (_, AsIdentifierChoice::Inherit) => return false,
        _ => {}
    }

    let child_intervals = as_choice_to_merged_intervals(child);
    let parent_intervals = as_choice_to_merged_intervals(parent);
    for (cmin, cmax) in &child_intervals {
        if !as_interval_is_covered(&parent_intervals, *cmin, *cmax) {
            return false;
        }
    }
    true
}

fn as_choice_to_merged_intervals(choice: &AsIdentifierChoice) -> Vec<(u32, u32)> {
    let mut v = Vec::new();
    match choice {
        AsIdentifierChoice::Inherit => {}
        AsIdentifierChoice::AsIdsOrRanges(items) => {
            for item in items {
                match item {
                    crate::data_model::rc::AsIdOrRange::Id(id) => v.push((*id, *id)),
                    crate::data_model::rc::AsIdOrRange::Range { min, max } => v.push((*min, *max)),
                }
            }
        }
    }
    v.sort_by_key(|(a, _)| *a);
    merge_as_intervals(&v)
}

fn merge_as_intervals(v: &[(u32, u32)]) -> Vec<(u32, u32)> {
    let mut out: Vec<(u32, u32)> = Vec::new();
    for (min, max) in v {
        let Some(last) = out.last_mut() else {
            out.push((*min, *max));
            continue;
        };
        if *min <= last.1.saturating_add(1) {
            last.1 = last.1.max(*max);
            continue;
        }
        out.push((*min, *max));
    }
    out
}

fn as_interval_is_covered(parent: &[(u32, u32)], min: u32, max: u32) -> bool {
    for (pmin, pmax) in parent {
        if *pmin <= min && max <= *pmax {
            return true;
        }
        if *pmin > min {
            break;
        }
    }
    false
}

fn ip_resources_is_subset(
    child: &crate::data_model::rc::IpResourceSet,
    parent: &crate::data_model::rc::IpResourceSet,
) -> bool {
    let parent_by_afi = ip_resources_to_merged_intervals(parent);
    let child_by_afi = match ip_resources_to_merged_intervals_strict(child) {
        Ok(v) => v,
        Err(()) => return false,
    };

    for (afi, child_intervals) in child_by_afi {
        let Some(parent_intervals) = parent_by_afi.get(&afi) else {
            return false;
        };
        for (cmin, cmax) in &child_intervals {
            if !interval_is_covered(parent_intervals, cmin, cmax) {
                return false;
            }
        }
    }
    true
}

fn ip_resources_is_subset_indexed(
    child: &crate::data_model::rc::IpResourceSet,
    parent: &crate::data_model::rc::IpResourceSet,
    idx: &IssuerResourcesIndex,
) -> bool {
    let _ = parent;

    for fam in &child.families {
        let parent_intervals = match fam.afi {
            crate::data_model::rc::Afi::Ipv4 => idx.ip_v4.as_deref(),
            crate::data_model::rc::Afi::Ipv6 => idx.ip_v6.as_deref(),
        };
        let Some(parent_intervals) = parent_intervals else {
            return false;
        };
        let items = match &fam.choice {
            IpAddressChoice::Inherit => return false,
            IpAddressChoice::AddressesOrRanges(items) => items,
        };

        let mut child_intervals: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
        for item in items {
            match item {
                IpAddressOrRange::Prefix(p) => child_intervals.push(prefix_to_range(p)),
                IpAddressOrRange::Range(r) => child_intervals.push((r.min.clone(), r.max.clone())),
            }
        }
        if child_intervals.is_empty() {
            continue;
        }
        child_intervals.sort_by(|(a, _), (b, _)| a.cmp(b));
        merge_ip_intervals_in_place(&mut child_intervals);
        if !intervals_are_covered(parent_intervals, &child_intervals) {
            return false;
        }
    }
    true
}

fn ip_items_to_merged_intervals(
    items: &[crate::data_model::rc::IpAddressOrRange],
) -> Vec<(Vec<u8>, Vec<u8>)> {
    let mut intervals = Vec::new();
    for item in items {
        match item {
            IpAddressOrRange::Prefix(p) => intervals.push(prefix_to_range(p)),
            IpAddressOrRange::Range(r) => intervals.push((r.min.clone(), r.max.clone())),
        }
    }
    intervals.sort_by(|(a, _), (b, _)| a.cmp(b));
    merge_ip_intervals_in_place(&mut intervals);
    intervals
}

fn intersect_ip_items_with_parent_intervals(
    items: &[crate::data_model::rc::IpAddressOrRange],
    parent_intervals: &[(Vec<u8>, Vec<u8>)],
) -> Vec<(Vec<u8>, Vec<u8>)> {
    let child_intervals = ip_items_to_merged_intervals(items);
    let mut out = Vec::new();
    let mut parent_index = 0usize;
    for (child_min, child_max) in &child_intervals {
        while parent_index < parent_intervals.len()
            && parent_intervals[parent_index].1.as_slice() < child_min.as_slice()
        {
            parent_index += 1;
        }
        let mut scan = parent_index;
        while scan < parent_intervals.len()
            && parent_intervals[scan].0.as_slice() <= child_max.as_slice()
        {
            let (parent_min, parent_max) = &parent_intervals[scan];
            let min = if bytes_leq(child_min, parent_min) {
                parent_min.clone()
            } else {
                child_min.clone()
            };
            let max = if bytes_leq(child_max, parent_max) {
                child_max.clone()
            } else {
                parent_max.clone()
            };
            if bytes_leq(&min, &max) {
                out.push((min, max));
            }
            scan += 1;
        }
    }
    merge_ip_intervals_in_place(&mut out);
    out
}

fn ip_intervals_to_ranges(
    afi: crate::data_model::rc::Afi,
    intervals: &[(Vec<u8>, Vec<u8>)],
) -> Vec<crate::data_model::rc::IpAddressOrRange> {
    intervals
        .iter()
        .map(|(min, max)| {
            IpAddressOrRange::Range(crate::data_model::rc::IpAddressRange {
                min: normalize_ip_bytes(afi, min),
                max: normalize_ip_bytes(afi, max),
            })
        })
        .collect()
}

fn normalize_ip_bytes(afi: crate::data_model::rc::Afi, bytes: &[u8]) -> Vec<u8> {
    let target_len = afi.octets_len();
    if bytes.len() == target_len {
        return bytes.to_vec();
    }
    let mut out = vec![0u8; target_len];
    let copy_len = bytes.len().min(target_len);
    out[..copy_len].copy_from_slice(&bytes[..copy_len]);
    out
}

fn intersect_as_intervals(child: &[(u32, u32)], parent: &[(u32, u32)]) -> Vec<(u32, u32)> {
    let mut out = Vec::new();
    let mut parent_index = 0usize;
    for (child_min, child_max) in child {
        while parent_index < parent.len() && parent[parent_index].1 < *child_min {
            parent_index += 1;
        }
        let mut scan = parent_index;
        while scan < parent.len() && parent[scan].0 <= *child_max {
            let min = (*child_min).max(parent[scan].0);
            let max = (*child_max).min(parent[scan].1);
            if min <= max {
                out.push((min, max));
            }
            scan += 1;
        }
    }
    merge_as_intervals(&out)
}

fn as_intervals_to_items(intervals: &[(u32, u32)]) -> Vec<crate::data_model::rc::AsIdOrRange> {
    intervals
        .iter()
        .map(|(min, max)| {
            if min == max {
                crate::data_model::rc::AsIdOrRange::Id(*min)
            } else {
                crate::data_model::rc::AsIdOrRange::Range {
                    min: *min,
                    max: *max,
                }
            }
        })
        .collect()
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum AfiKey {
    V4,
    V6,
}

fn ip_resources_to_merged_intervals(
    set: &crate::data_model::rc::IpResourceSet,
) -> std::collections::HashMap<AfiKey, Vec<(Vec<u8>, Vec<u8>)>> {
    let mut m: std::collections::HashMap<AfiKey, Vec<(Vec<u8>, Vec<u8>)>> =
        std::collections::HashMap::new();

    for fam in &set.families {
        let afi = match fam.afi {
            crate::data_model::rc::Afi::Ipv4 => AfiKey::V4,
            crate::data_model::rc::Afi::Ipv6 => AfiKey::V6,
        };
        match &fam.choice {
            IpAddressChoice::Inherit => {
                // Effective resource sets should not contain inherit, but if they do we treat it
                // as "unknown" by leaving it empty here (subset checks will fail).
            }
            IpAddressChoice::AddressesOrRanges(items) => {
                let ent = m.entry(afi).or_default();
                for item in items {
                    match item {
                        IpAddressOrRange::Prefix(p) => ent.push(prefix_to_range(p)),
                        IpAddressOrRange::Range(r) => ent.push((r.min.clone(), r.max.clone())),
                    }
                }
            }
        }
    }

    for (_afi, v) in m.iter_mut() {
        v.sort_by(|(a, _), (b, _)| a.cmp(b));
        merge_ip_intervals_in_place(v);
    }

    m
}

fn ip_resources_to_merged_intervals_strict(
    set: &crate::data_model::rc::IpResourceSet,
) -> Result<std::collections::HashMap<AfiKey, Vec<(Vec<u8>, Vec<u8>)>>, ()> {
    let mut m: std::collections::HashMap<AfiKey, Vec<(Vec<u8>, Vec<u8>)>> =
        std::collections::HashMap::new();

    for fam in &set.families {
        let afi = match fam.afi {
            crate::data_model::rc::Afi::Ipv4 => AfiKey::V4,
            crate::data_model::rc::Afi::Ipv6 => AfiKey::V6,
        };
        match &fam.choice {
            IpAddressChoice::Inherit => return Err(()),
            IpAddressChoice::AddressesOrRanges(items) => {
                let ent = m.entry(afi).or_default();
                for item in items {
                    match item {
                        IpAddressOrRange::Prefix(p) => ent.push(prefix_to_range(p)),
                        IpAddressOrRange::Range(r) => ent.push((r.min.clone(), r.max.clone())),
                    }
                }
            }
        }
    }

    for (_afi, v) in m.iter_mut() {
        v.sort_by(|(a, _), (b, _)| a.cmp(b));
        merge_ip_intervals_in_place(v);
    }

    Ok(m)
}

fn merge_ip_intervals_in_place(v: &mut Vec<(Vec<u8>, Vec<u8>)>) {
    if v.is_empty() {
        return;
    }
    let mut out: Vec<(Vec<u8>, Vec<u8>)> = Vec::with_capacity(v.len());
    for (min, max) in v.drain(..) {
        let Some(last) = out.last_mut() else {
            out.push((min, max));
            continue;
        };
        if bytes_leq(&min, &last.1) || bytes_is_next(&min, &last.1) {
            if bytes_leq(&last.1, &max) {
                last.1 = max;
            }
            continue;
        }
        out.push((min, max));
    }
    *v = out;
}

fn interval_is_covered(parent: &[(Vec<u8>, Vec<u8>)], min: &[u8], max: &[u8]) -> bool {
    for (pmin, pmax) in parent {
        if bytes_leq(pmin, min) && bytes_leq(max, pmax) {
            return true;
        }
        if pmin.as_slice() > min {
            break;
        }
    }
    false
}

fn intervals_are_covered(parent: &[(Vec<u8>, Vec<u8>)], child: &[(Vec<u8>, Vec<u8>)]) -> bool {
    let mut i = 0usize;
    for (cmin, cmax) in child {
        while i < parent.len() && parent[i].1.as_slice() < cmin.as_slice() {
            i += 1;
        }
        if i >= parent.len() {
            return false;
        }
        let (pmin, pmax) = &parent[i];
        if !bytes_leq(pmin, cmin) || !bytes_leq(cmax, pmax) {
            return false;
        }
    }
    true
}

fn prefix_to_range(prefix: &RcIpPrefix) -> (Vec<u8>, Vec<u8>) {
    let mut min = prefix.addr.clone();
    let mut max = prefix.addr.clone();
    let bitlen = prefix.afi.ub();
    let plen = prefix.prefix_len.min(bitlen);
    for bit in plen..bitlen {
        let byte = (bit / 8) as usize;
        let offset = 7 - (bit % 8);
        let mask = 1u8 << offset;
        min[byte] &= !mask;
        max[byte] |= mask;
    }
    (min, max)
}

fn bytes_leq(a: &[u8], b: &[u8]) -> bool {
    a <= b
}

fn increment_bytes(v: &[u8]) -> Vec<u8> {
    let mut out = v.to_vec();
    for i in (0..out.len()).rev() {
        if out[i] != 0xFF {
            out[i] += 1;
            for j in i + 1..out.len() {
                out[j] = 0;
            }
            return out;
        }
    }
    vec![0u8; out.len()]
}

fn bytes_is_next(a: &[u8], b: &[u8]) -> bool {
    if a.len() != b.len() {
        return false;
    }
    let mut carry: u16 = 1;
    for i in (0..b.len()).rev() {
        let sum = (b[i] as u16) + carry;
        let expected = (sum & 0xFF) as u8;
        carry = sum >> 8;
        if a[i] != expected {
            return false;
        }
    }
    true
}

fn build_issuer_resources_index(
    issuer_effective_ip: Option<&crate::data_model::rc::IpResourceSet>,
    issuer_effective_as: Option<&crate::data_model::rc::AsResourceSet>,
) -> IssuerResourcesIndex {
    let mut idx = IssuerResourcesIndex::default();

    if let Some(ip) = issuer_effective_ip {
        let mut v4: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
        let mut v6: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
        for fam in &ip.families {
            let ent = match fam.afi {
                crate::data_model::rc::Afi::Ipv4 => &mut v4,
                crate::data_model::rc::Afi::Ipv6 => &mut v6,
            };
            match &fam.choice {
                IpAddressChoice::Inherit => {
                    // Effective resources should not contain inherit; leave empty so subset fails.
                }
                IpAddressChoice::AddressesOrRanges(items) => {
                    for item in items {
                        match item {
                            IpAddressOrRange::Prefix(p) => ent.push(prefix_to_range(p)),
                            IpAddressOrRange::Range(r) => ent.push((r.min.clone(), r.max.clone())),
                        }
                    }
                }
            }
        }
        if !v4.is_empty() {
            v4.sort_by(|(a, _), (b, _)| a.cmp(b));
            merge_ip_intervals_in_place(&mut v4);
            idx.ip_v4 = Some(v4);
        }
        if !v6.is_empty() {
            v6.sort_by(|(a, _), (b, _)| a.cmp(b));
            merge_ip_intervals_in_place(&mut v6);
            idx.ip_v6 = Some(v6);
        }
    }

    if let Some(asr) = issuer_effective_as {
        if let Some(choice) = asr.asnum.as_ref() {
            if !matches!(choice, AsIdentifierChoice::Inherit) {
                idx.asnum = Some(as_choice_to_merged_intervals(choice));
            }
        }
        if let Some(choice) = asr.rdi.as_ref() {
            if !matches!(choice, AsIdentifierChoice::Inherit) {
                idx.rdi = Some(as_choice_to_merged_intervals(choice));
            }
        }
    }

    idx
}

fn roa_to_vrps(roa: &RoaObject) -> Vec<Vrp> {
    let asn = roa.roa.as_id;
    let mut out = Vec::new();
    for fam in &roa.roa.ip_addr_blocks {
        for entry in &fam.addresses {
            let max_length = entry.max_length.unwrap_or(entry.prefix.prefix_len);
            out.push(Vrp {
                asn,
                prefix: entry.prefix.clone(),
                max_length,
            });
        }
    }
    out
}

#[allow(dead_code)]
fn roa_afi_to_string(afi: RoaAfi) -> &'static str {
    match afi {
        RoaAfi::Ipv4 => "ipv4",
        RoaAfi::Ipv6 => "ipv6",
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::analysis::timing::{TimingHandle, TimingMeta};
    use crate::data_model::rc::{
        Afi, AsIdOrRange, AsIdentifierChoice, IpAddressFamily, IpAddressOrRange, IpAddressRange,
        IpPrefix, IpResourceSet,
    };
    use crate::policy::Policy;
    use crate::storage::{
        PackTime, RoaCacheObjectMeta, RoaCacheProjection, RoaCacheProjectionContext,
        ValidatedCaInstanceResult, ValidatedManifestMeta, VcirArtifactKind, VcirArtifactRole,
        VcirArtifactValidationStatus, VcirAuditSummary, VcirCcrManifestProjection,
        VcirInstanceGate, VcirRelatedArtifact, VcirSummary,
    };
    use crate::validation::publication_point::PublicationPointSnapshot;
    use std::collections::HashMap;
    use time::OffsetDateTime;
    use time::format_description::well_known::Rfc3339;

    fn fixture_bytes(path: &str) -> Vec<u8> {
        std::fs::read(std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(path))
            .unwrap_or_else(|e| panic!("read fixture {path}: {e}"))
    }

    fn fixed_time(value: &str) -> OffsetDateTime {
        OffsetDateTime::parse(value, &Rfc3339).expect("parse fixed test time")
    }

    const TEST_CA_VALIDATION_CONTEXT: [u8; 32] = [0x70; 32];
    const TEST_POLICY_FINGERPRINT: [u8; 32] = [0x71; 32];
    const TEST_CRL_URI: &str = "rsync://example.test/repo/current.crl";
    const TEST_ROA_URI: &str = "rsync://example.test/repo/a.roa";

    fn sha256_32(bytes: &[u8]) -> [u8; 32] {
        let digest = sha2::Sha256::digest(bytes);
        let mut out = [0u8; 32];
        out.copy_from_slice(&digest);
        out
    }

    fn sample_roa_cache_projection(
        vcir: &ValidatedCaInstanceResult,
        roa_hash: [u8; 32],
    ) -> RoaCacheProjection {
        RoaCacheProjection::from_vcir_with_context(
            vcir,
            Some(&RoaCacheProjectionContext {
                ca_validation_context_digest: TEST_CA_VALIDATION_CONTEXT,
                policy_fingerprint: TEST_POLICY_FINGERPRINT,
                object_meta: vec![RoaCacheObjectMeta {
                    source_object_uri: TEST_ROA_URI.to_string(),
                    source_object_hash: roa_hash,
                    ee_serial: vec![0x01],
                    crl_uri: TEST_CRL_URI.to_string(),
                    earliest_safe_reuse_time: vcir.last_successful_validation_time.clone(),
                }],
            }),
        )
        .expect("build projection")
        .expect("projection exists")
    }

    fn sample_crl_cache(crl_bytes: Vec<u8>) -> HashMap<String, CachedIssuerCrl> {
        HashMap::from([(
            TEST_CRL_URI.to_string(),
            CachedIssuerCrl::Pending {
                bytes: crl_bytes,
                sha256_hex: None,
            },
        )])
    }

    fn sample_roa_cache_vcir(
        issuer_der: &[u8],
        crl_hash: [u8; 32],
        roa_hash: [u8; 32],
        item_effective_until: OffsetDateTime,
        instance_effective_until: OffsetDateTime,
    ) -> ValidatedCaInstanceResult {
        let manifest_time = PackTime::from_utc_offset_datetime(fixed_time("2026-06-04T00:00:00Z"));
        let effective_until = PackTime::from_utc_offset_datetime(instance_effective_until);
        ValidatedCaInstanceResult {
            manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(),
            parent_manifest_rsync_uri: Some("rsync://example.test/repo/parent.mft".to_string()),
            tal_id: "test-tal".to_string(),
            ca_subject_name: "CN=example".to_string(),
            ca_ski: "001122".to_string(),
            issuer_ski: "334455".to_string(),
            last_successful_validation_time: manifest_time.clone(),
            current_manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(),
            current_crl_rsync_uri: "rsync://example.test/repo/current.crl".to_string(),
            validated_manifest_meta: ValidatedManifestMeta {
                validated_manifest_number: vec![1],
                validated_manifest_this_update: manifest_time.clone(),
                validated_manifest_next_update: effective_until.clone(),
            },
            ccr_manifest_projection: VcirCcrManifestProjection {
                manifest_rsync_uri: "rsync://example.test/repo/current.mft".to_string(),
                manifest_sha256: vec![0xaa; 32],
                manifest_size: 2048,
                manifest_ee_aki: vec![0xbb; 20],
                manifest_number_be: vec![1],
                manifest_this_update: manifest_time.clone(),
                manifest_sia_locations_der: vec![vec![0x30, 0x00]],
                subordinate_skis: Vec::new(),
            },
            instance_gate: VcirInstanceGate {
                manifest_next_update: effective_until.clone(),
                current_crl_next_update: effective_until.clone(),
                self_ca_not_after: effective_until.clone(),
                instance_effective_until: effective_until,
            },
            child_entries: Vec::new(),
            local_outputs: vec![VcirLocalOutput {
                output_type: VcirOutputType::Vrp,
                item_effective_until: PackTime::from_utc_offset_datetime(item_effective_until),
                source_object_uri: "rsync://example.test/repo/a.roa".to_string(),
                source_object_type: VcirSourceObjectType::Roa,
                source_object_hash: roa_hash,
                source_ee_cert_hash: [0xcc; 32],
                payload: VcirLocalOutputPayload::Vrp {
                    asn: 64500,
                    afi: RoaAfi::Ipv4,
                    prefix_len: 24,
                    addr: [192, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                    max_length: 24,
                },
                rule_hash: [0xdd; 32],
            }],
            related_artifacts: vec![
                VcirRelatedArtifact {
                    artifact_role: VcirArtifactRole::IssuerCert,
                    artifact_kind: VcirArtifactKind::Cer,
                    uri: Some("rsync://example.test/repo/ca.cer".to_string()),
                    sha256: sha256_hex(issuer_der),
                    object_type: Some("cer".to_string()),
                    validation_status: VcirArtifactValidationStatus::Accepted,
                    reject_reason: None,
                },
                VcirRelatedArtifact {
                    artifact_role: VcirArtifactRole::CurrentCrl,
                    artifact_kind: VcirArtifactKind::Crl,
                    uri: Some("rsync://example.test/repo/current.crl".to_string()),
                    sha256: sha256_hex_from_32(&crl_hash),
                    object_type: Some("crl".to_string()),
                    validation_status: VcirArtifactValidationStatus::Accepted,
                    reject_reason: None,
                },
            ],
            summary: VcirSummary {
                local_vrp_count: 1,
                local_aspa_count: 0,
                local_router_key_count: 0,
                child_count: 0,
                accepted_object_count: 2,
                rejected_object_count: 0,
            },
            audit_summary: VcirAuditSummary {
                failed_fetch_eligible: true,
                last_failed_fetch_reason: None,
                warning_count: 0,
                audit_flags: Vec::new(),
            },
        }
    }

    #[test]
    fn roa_validation_cache_view_hits_when_context_and_hash_match() {
        let validation_time = fixed_time("2026-06-05T00:00:00Z");
        let issuer_der = b"issuer-ca";
        let crl_bytes = b"current-crl".to_vec();
        let crl_hash = sha256_32(&crl_bytes);
        let roa_hash = [0x11; 32];
        let vcir = sample_roa_cache_vcir(
            issuer_der,
            crl_hash,
            roa_hash,
            fixed_time("2026-06-07T00:00:00Z"),
            fixed_time("2026-06-08T00:00:00Z"),
        );
        let projection = sample_roa_cache_projection(&vcir, roa_hash);
        let view = RoaValidationCacheView::from_projection(&projection, validation_time);
        let file = PackFile::from_bytes_with_sha256(TEST_ROA_URI, vec![0x01], roa_hash);
        let mut crl_cache = sample_crl_cache(crl_bytes);

        assert!(view.matches_current_context(
            issuer_der,
            Some(TEST_CA_VALIDATION_CONTEXT),
            Some(TEST_POLICY_FINGERPRINT)
        ));
        let hit = view.lookup(&file, &mut crl_cache, issuer_der, validation_time);
        let RoaCacheLookupResult::Hit(ok) = hit else {
            panic!("expected cache hit, got {hit:?}");
        };
        assert!(ok.reused_from_cache);
        assert_eq!(ok.vrps.len(), 1);
        assert_eq!(ok.vrps[0].asn, 64500);
        assert_eq!(ok.local_outputs.len(), 1);
        assert!(ok.cache_object_meta.is_some());
    }

    #[test]
    fn roa_validation_cache_lookup_memoizes_current_crl_hash() {
        let validation_time = fixed_time("2026-06-05T00:00:00Z");
        let issuer_der = b"issuer-ca";
        let crl_bytes = b"current-crl".to_vec();
        let crl_hash = sha256_32(&crl_bytes);
        let crl_hash_hex = sha256_hex(&crl_bytes);
        let roa_hash = [0x11; 32];
        let vcir = sample_roa_cache_vcir(
            issuer_der,
            crl_hash,
            roa_hash,
            fixed_time("2026-06-07T00:00:00Z"),
            fixed_time("2026-06-08T00:00:00Z"),
        );
        let projection = sample_roa_cache_projection(&vcir, roa_hash);
        let view = RoaValidationCacheView::from_projection(&projection, validation_time);
        let file = PackFile::from_bytes_with_sha256(TEST_ROA_URI, vec![0x01], roa_hash);
        let mut crl_cache = sample_crl_cache(crl_bytes);

        let first = view.lookup(&file, &mut crl_cache, issuer_der, validation_time);
        assert!(matches!(first, RoaCacheLookupResult::Hit(_)));
        match crl_cache.get(TEST_CRL_URI).expect("test CRL cache entry") {
            CachedIssuerCrl::Pending {
                sha256_hex: Some(cached),
                ..
            } => assert_eq!(cached, &crl_hash_hex),
            other => panic!("expected pending CRL with memoized hash, got {other:?}"),
        }

        let second = view.lookup(&file, &mut crl_cache, issuer_der, validation_time);
        assert!(matches!(second, RoaCacheLookupResult::Hit(_)));
    }

    #[test]
    fn roa_validation_cache_lookup_reuses_publication_point_crl_gate() {
        let validation_time = fixed_time("2026-06-05T00:00:00Z");
        let issuer_der = b"issuer-ca";
        let crl_bytes = b"current-crl".to_vec();
        let crl_hash = sha256_32(&crl_bytes);
        let roa_hash = [0x11; 32];
        let vcir = sample_roa_cache_vcir(
            issuer_der,
            crl_hash,
            roa_hash,
            fixed_time("2026-06-07T00:00:00Z"),
            fixed_time("2026-06-08T00:00:00Z"),
        );
        let projection = sample_roa_cache_projection(&vcir, roa_hash);
        let view = RoaValidationCacheView::from_projection(&projection, validation_time);
        let file = PackFile::from_bytes_with_sha256(TEST_ROA_URI, vec![0x01], roa_hash);
        let mut crl_cache = sample_crl_cache(crl_bytes);
        let mut gate_set = RoaCacheCrlGateSet::default();

        let (first, first_metrics) = view.lookup_with_metrics(
            &file,
            &mut crl_cache,
            issuer_der,
            validation_time,
            Some(&mut gate_set),
        );
        assert!(matches!(first, RoaCacheLookupResult::Hit(_)));
        assert_eq!(first_metrics.crl_gate_reused_crls, 0);
        assert_eq!(gate_set.gates_by_uri.len(), 1);

        let (second, second_metrics) = view.lookup_with_metrics(
            &file,
            &mut crl_cache,
            issuer_der,
            validation_time,
            Some(&mut gate_set),
        );
        assert!(matches!(second, RoaCacheLookupResult::Hit(_)));
        assert_eq!(second_metrics.crl_gate_reused_crls, 1);
        assert_eq!(second_metrics.crl_gate_verified_crls, 0);
        assert_eq!(gate_set.gates_by_uri.len(), 1);
    }

    #[test]
    fn cached_verified_crl_reports_hash_and_validity_window() {
        let crl_bytes = fixture_bytes(
            "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.crl",
        );
        let crl = crate::data_model::crl::RpkixCrl::decode_der(&crl_bytes).expect("decode CRL");
        let verified = Arc::new(VerifiedIssuerCrl {
            crl,
            revoked_serials: std::collections::HashSet::new(),
            sha256_hex: sha256_hex(&crl_bytes),
        });
        let mut cached = CachedIssuerCrl::Ok(Arc::clone(&verified));

        assert_eq!(cached.current_sha256_hex(), verified.sha256_hex);
        assert!(crl_valid_at_time(
            &verified.crl,
            fixed_time("2026-01-21T00:00:00Z")
        ));
        assert!(!crl_valid_at_time(
            &verified.crl,
            fixed_time("2026-01-22T00:00:00Z")
        ));
    }

    #[test]
    fn roa_validation_cache_view_from_projection_preserves_metadata() {
        let validation_time = fixed_time("2026-06-05T00:00:00Z");
        let issuer_der = b"issuer-ca";
        let crl_bytes = b"current-crl".to_vec();
        let crl_hash = sha256_32(&crl_bytes);
        let roa_hash = [0x11; 32];
        let vcir = sample_roa_cache_vcir(
            issuer_der,
            crl_hash,
            roa_hash,
            fixed_time("2026-06-07T00:00:00Z"),
            fixed_time("2026-06-08T00:00:00Z"),
        );
        let projection = sample_roa_cache_projection(&vcir, roa_hash);
        assert_eq!(
            projection.ca_validation_context_digest,
            Some(TEST_CA_VALIDATION_CONTEXT)
        );
        assert_eq!(projection.policy_fingerprint, Some(TEST_POLICY_FINGERPRINT));
        assert_eq!(
            projection.entries[0].outputs_effective_until_unix,
            fixed_time("2026-06-07T00:00:00Z").unix_timestamp()
        );
        assert_eq!(
            projection.entries[0].ee_serial.as_deref(),
            Some(&[0x01][..])
        );
        assert_eq!(projection.entries[0].crl_uri.as_deref(), Some(TEST_CRL_URI));

        let view = RoaValidationCacheView::from_projection(&projection, validation_time);
        let file = PackFile::from_bytes_with_sha256(TEST_ROA_URI, vec![0x01], roa_hash);
        let mut crl_cache = sample_crl_cache(crl_bytes);
        let hit = view.lookup(&file, &mut crl_cache, issuer_der, validation_time);
        let RoaCacheLookupResult::Hit(ok) = hit else {
            panic!("expected projection cache hit, got {hit:?}");
        };
        assert_eq!(ok.local_outputs[0].source_object_uri, TEST_ROA_URI);
    }

    #[test]
    fn roa_validation_cache_view_blocks_on_context_hash_and_expiry_gates() {
        let validation_time = fixed_time("2026-06-05T00:00:00Z");
        let issuer_der = b"issuer-ca";
        let crl_bytes = b"current-crl".to_vec();
        let crl_hash = sha256_32(&crl_bytes);
        let roa_hash = [0x11; 32];
        let vcir = sample_roa_cache_vcir(
            issuer_der,
            crl_hash,
            roa_hash,
            fixed_time("2026-06-07T00:00:00Z"),
            fixed_time("2026-06-08T00:00:00Z"),
        );
        let file = PackFile::from_bytes_with_sha256(TEST_ROA_URI, vec![0x01], roa_hash);

        let mut projection = sample_roa_cache_projection(&vcir, roa_hash);
        projection.issuer_ca_sha256_hex = Some("00".repeat(32));
        let issuer_changed = RoaValidationCacheView::from_projection(&projection, validation_time);
        assert!(!issuer_changed.matches_current_context(
            issuer_der,
            Some(TEST_CA_VALIDATION_CONTEXT),
            Some(TEST_POLICY_FINGERPRINT)
        ));

        let projection = sample_roa_cache_projection(&vcir, roa_hash);
        let parent_changed = RoaValidationCacheView::from_projection(&projection, validation_time);
        assert!(!parent_changed.matches_current_context(
            issuer_der,
            Some([0x99; 32]),
            Some(TEST_POLICY_FINGERPRINT)
        ));
        assert!(!parent_changed.matches_current_context(
            issuer_der,
            Some(TEST_CA_VALIDATION_CONTEXT),
            Some([0x98; 32])
        ));

        let mut projection = sample_roa_cache_projection(&vcir, roa_hash);
        projection.entries[0].source_object_hash = [0xff; 32];
        let roa_changed = RoaValidationCacheView::from_projection(&projection, validation_time);
        let mut crl_cache = sample_crl_cache(crl_bytes);
        assert!(matches!(
            roa_changed.lookup(&file, &mut crl_cache, issuer_der, validation_time),
            RoaCacheLookupResult::HashBlocked
        ));

        let expired_vcir = sample_roa_cache_vcir(
            issuer_der,
            crl_hash,
            roa_hash,
            fixed_time("2026-06-07T00:00:00Z"),
            fixed_time("2026-06-04T00:00:00Z"),
        );
        let expired_projection = sample_roa_cache_projection(&expired_vcir, roa_hash);
        let expired_view =
            RoaValidationCacheView::from_projection(&expired_projection, validation_time);
        assert!(!expired_view.matches_current_context(
            issuer_der,
            Some(TEST_CA_VALIDATION_CONTEXT),
            Some(TEST_POLICY_FINGERPRINT)
        ));
        let mut crl_cache = sample_crl_cache(b"current-crl".to_vec());
        assert!(matches!(
            expired_view.lookup(&file, &mut crl_cache, issuer_der, validation_time),
            RoaCacheLookupResult::ExpiredBlocked
        ));
    }

    #[test]
    fn active_roa_cache_view_records_context_block() {
        let validation_time = fixed_time("2026-06-05T00:00:00Z");
        let issuer_der = b"issuer-ca";
        let crl_hash = [0x22; 32];
        let roa_hash = [0x11; 32];
        let vcir = sample_roa_cache_vcir(
            issuer_der,
            crl_hash,
            roa_hash,
            fixed_time("2026-06-07T00:00:00Z"),
            fixed_time("2026-06-08T00:00:00Z"),
        );
        let projection = sample_roa_cache_projection(&vcir, roa_hash);
        let view = RoaValidationCacheView::from_projection(&projection, validation_time);
        let input = RoaValidationCacheInput::enabled_with_context(
            Some(&view),
            [0x99; 32],
            TEST_POLICY_FINGERPRINT,
        );
        let mut stats = RoaValidationCacheStats::for_input(input, 1);

        assert!(active_roa_cache_view(input, issuer_der, &mut stats, 1).is_none());
        assert_eq!(stats.blocked_roas, 1);
        assert_eq!(stats.context_blocked_roas, 1);
        assert_eq!(stats.fresh_roas, 1);
    }

    #[test]
    fn roa_validation_cache_view_blocks_expired_output() {
        let validation_time = fixed_time("2026-06-05T00:00:00Z");
        let issuer_der = b"issuer-ca";
        let crl_bytes = b"current-crl".to_vec();
        let crl_hash = sha256_32(&crl_bytes);
        let roa_hash = [0x11; 32];
        let vcir = sample_roa_cache_vcir(
            issuer_der,
            crl_hash,
            roa_hash,
            fixed_time("2026-06-04T01:00:00Z"),
            fixed_time("2026-06-08T00:00:00Z"),
        );
        let projection = sample_roa_cache_projection(&vcir, roa_hash);
        let view = RoaValidationCacheView::from_projection(&projection, validation_time);
        let file = PackFile::from_bytes_with_sha256(TEST_ROA_URI, vec![0x01], roa_hash);
        let mut crl_cache = sample_crl_cache(crl_bytes);

        assert!(matches!(
            view.lookup(&file, &mut crl_cache, issuer_der, validation_time),
            RoaCacheLookupResult::ExpiredBlocked
        ));
    }

    #[test]
    fn roa_validation_cache_view_blocks_before_safe_reuse_time() {
        let validation_time = fixed_time("2026-06-05T00:00:00Z");
        let issuer_der = b"issuer-ca";
        let crl_bytes = b"current-crl".to_vec();
        let crl_hash = sha256_32(&crl_bytes);
        let roa_hash = [0x11; 32];
        let vcir = sample_roa_cache_vcir(
            issuer_der,
            crl_hash,
            roa_hash,
            fixed_time("2026-06-07T00:00:00Z"),
            fixed_time("2026-06-08T00:00:00Z"),
        );
        let mut projection = sample_roa_cache_projection(&vcir, roa_hash);
        projection.entries[0].earliest_safe_reuse_time_unix =
            Some(fixed_time("2026-06-06T00:00:00Z").unix_timestamp());
        let view = RoaValidationCacheView::from_projection(&projection, validation_time);
        let file = PackFile::from_bytes_with_sha256(TEST_ROA_URI, vec![0x01], roa_hash);
        let mut crl_cache = sample_crl_cache(crl_bytes);

        assert!(matches!(
            view.lookup(&file, &mut crl_cache, issuer_der, validation_time),
            RoaCacheLookupResult::ExpiredBlocked
        ));
    }

    #[test]
    fn roa_validation_cache_view_treats_legacy_entry_without_lower_bound_as_miss() {
        let validation_time = fixed_time("2026-06-05T00:00:00Z");
        let issuer_der = b"issuer-ca";
        let crl_hash = sha256_32(b"current-crl");
        let roa_hash = [0x11; 32];
        let vcir = sample_roa_cache_vcir(
            issuer_der,
            crl_hash,
            roa_hash,
            fixed_time("2026-06-07T00:00:00Z"),
            fixed_time("2026-06-08T00:00:00Z"),
        );
        let mut projection = sample_roa_cache_projection(&vcir, roa_hash);
        projection.entries[0].earliest_safe_reuse_time_unix = None;
        let view = RoaValidationCacheView::from_projection(&projection, validation_time);
        let file = PackFile::from_bytes_with_sha256(TEST_ROA_URI, vec![0x01], roa_hash);
        let mut crl_cache = sample_crl_cache(b"current-crl".to_vec());

        assert!(matches!(
            view.lookup(&file, &mut crl_cache, issuer_der, validation_time),
            RoaCacheLookupResult::Miss
        ));
    }

    #[test]
    fn roa_validation_cache_stats_records_vcir_miss_to_timing() {
        let stats = RoaValidationCacheStats::for_input(RoaValidationCacheInput::enabled(None), 3);
        assert_eq!(stats.enabled_publication_points, 1);
        assert_eq!(stats.vcir_miss_publication_points, 1);
        assert_eq!(stats.miss_roas, 3);
        assert_eq!(stats.fresh_roas, 3);

        let timing = TimingHandle::new(TimingMeta {
            recorded_at_utc_rfc3339: "2026-06-05T00:00:00Z".to_string(),
            validation_time_utc_rfc3339: "2026-06-05T00:00:00Z".to_string(),
            tal_url: None,
            db_path: None,
        });
        stats.record_to_timing(Some(&timing));
        let dir = tempfile::tempdir().expect("timing dir");
        let path = dir.path().join("timing.json");
        timing.write_json(&path, 10).expect("write timing");
        let report: serde_json::Value =
            serde_json::from_slice(&std::fs::read(path).expect("read timing"))
                .expect("parse timing");

        assert_eq!(
            report["counts"]["roa_validation_cache_enabled_publication_points"],
            1
        );
        assert_eq!(
            report["counts"]["roa_validation_cache_vcir_miss_publication_points"],
            1
        );
        assert_eq!(report["counts"]["roa_validation_cache_miss_roas"], 3);
        assert_eq!(report["counts"]["roa_validation_cache_fresh_roas"], 3);
    }

    #[test]
    fn roa_validation_cache_stats_records_block_breakdown_to_timing() {
        let mut stats = RoaValidationCacheStats::default();
        record_roa_cache_block(&mut stats, &RoaCacheLookupResult::HashBlocked);
        record_roa_cache_block(&mut stats, &RoaCacheLookupResult::ExpiredBlocked);
        record_roa_cache_block(&mut stats, &RoaCacheLookupResult::RevokedBlocked);
        record_roa_cache_block(&mut stats, &RoaCacheLookupResult::MetadataBlocked);
        stats.crl_recheck_hit_roas = 2;
        stats.context_blocked_roas = 3;
        stats.context_gate_nanos = 4;
        stats.lookup_nanos = 5;
        stats.lookup_entry_gate_nanos = 6;
        stats.lookup_crl_gate_nanos = 7;
        stats.lookup_materialize_nanos = 8;
        stats.lookup_crl_gate_verified_crls = 9;
        stats.lookup_crl_gate_reused_crls = 10;

        let timing = TimingHandle::new(TimingMeta {
            recorded_at_utc_rfc3339: "2026-06-05T00:00:00Z".to_string(),
            validation_time_utc_rfc3339: "2026-06-05T00:00:00Z".to_string(),
            tal_url: None,
            db_path: None,
        });
        stats.record_to_timing(Some(&timing));
        let dir = tempfile::tempdir().expect("timing dir");
        let path = dir.path().join("timing.json");
        timing.write_json(&path, 10).expect("write timing");
        let report: serde_json::Value =
            serde_json::from_slice(&std::fs::read(path).expect("read timing"))
                .expect("parse timing");

        assert_eq!(stats.blocked_roas, 4);
        assert_eq!(stats.fresh_roas, 4);
        assert_eq!(report["counts"]["roa_validation_cache_blocked_roas"], 4);
        assert_eq!(
            report["counts"]["roa_validation_cache_hash_blocked_roas"],
            1
        );
        assert_eq!(
            report["counts"]["roa_validation_cache_expired_blocked_roas"],
            1
        );
        assert_eq!(
            report["counts"]["roa_validation_cache_revoked_blocked_roas"],
            1
        );
        assert_eq!(
            report["counts"]["roa_validation_cache_metadata_blocked_roas"],
            1
        );
        assert_eq!(
            report["counts"]["roa_validation_cache_crl_recheck_hit_roas"],
            2
        );
        assert_eq!(
            report["counts"]["roa_validation_cache_context_blocked_roas"],
            3
        );
        assert_eq!(
            report["counts"]["roa_validation_cache_context_gate_nanos"],
            4
        );
        assert_eq!(report["counts"]["roa_validation_cache_lookup_nanos"], 5);
        assert_eq!(
            report["counts"]["roa_validation_cache_lookup_entry_gate_nanos"],
            6
        );
        assert_eq!(
            report["counts"]["roa_validation_cache_lookup_crl_gate_nanos"],
            7
        );
        assert_eq!(
            report["counts"]["roa_validation_cache_lookup_materialize_nanos"],
            8
        );
        assert_eq!(
            report["counts"]["roa_validation_cache_lookup_crl_gate_verified_crls"],
            9
        );
        assert_eq!(
            report["counts"]["roa_validation_cache_lookup_crl_gate_reused_crls"],
            10
        );
    }

    #[test]
    fn roa_validation_cache_view_ignores_rejected_artifacts_and_non_roa_outputs() {
        let validation_time = fixed_time("2026-06-05T00:00:00Z");
        let issuer_der = b"issuer-ca";
        let crl_bytes = b"current-crl".to_vec();
        let crl_hash = sha256_32(&crl_bytes);
        let roa_hash = [0x11; 32];
        let mut vcir = sample_roa_cache_vcir(
            issuer_der,
            crl_hash,
            roa_hash,
            fixed_time("2026-06-07T00:00:00Z"),
            fixed_time("2026-06-08T00:00:00Z"),
        );
        vcir.related_artifacts.insert(
            0,
            VcirRelatedArtifact {
                artifact_role: VcirArtifactRole::IssuerCert,
                artifact_kind: VcirArtifactKind::Cer,
                uri: Some("rsync://example.test/repo/rejected.cer".to_string()),
                sha256: "00".repeat(32),
                object_type: Some("cer".to_string()),
                validation_status: VcirArtifactValidationStatus::Rejected,
                reject_reason: None,
            },
        );
        vcir.local_outputs.push(VcirLocalOutput {
            output_type: VcirOutputType::Aspa,
            item_effective_until: PackTime::from_utc_offset_datetime(fixed_time(
                "2026-06-07T00:00:00Z",
            )),
            source_object_uri: "rsync://example.test/repo/a.asa".to_string(),
            source_object_type: VcirSourceObjectType::Aspa,
            source_object_hash: [0x44; 32],
            source_ee_cert_hash: [0x55; 32],
            payload: VcirLocalOutputPayload::Aspa {
                customer_as_id: 64500,
                provider_as_ids: vec![64501],
            },
            rule_hash: [0x66; 32],
        });
        let projection = sample_roa_cache_projection(&vcir, roa_hash);
        let view = RoaValidationCacheView::from_projection(&projection, validation_time);
        let mut crl_cache = sample_crl_cache(crl_bytes);

        assert!(view.matches_current_context(
            issuer_der,
            Some(TEST_CA_VALIDATION_CONTEXT),
            Some(TEST_POLICY_FINGERPRINT)
        ));
        assert!(matches!(
            view.lookup(
                &PackFile::from_bytes_with_sha256(TEST_ROA_URI, vec![0x01], roa_hash),
                &mut crl_cache,
                issuer_der,
                validation_time
            ),
            RoaCacheLookupResult::Hit(_)
        ));
        assert!(matches!(
            view.lookup(
                &PackFile::from_bytes_with_sha256(
                    "rsync://example.test/repo/a.asa",
                    vec![0x03],
                    [0x44; 32],
                ),
                &mut crl_cache,
                issuer_der,
                validation_time
            ),
            RoaCacheLookupResult::Miss
        ));
    }

    #[test]
    fn roa_validation_cache_context_requires_issuer_parent_and_policy() {
        let mut view = RoaValidationCacheView {
            entries_by_uri: HashMap::new(),
            issuer_ca_sha256_hex: None,
            ca_validation_context_digest: Some(TEST_CA_VALIDATION_CONTEXT),
            policy_fingerprint: Some(TEST_POLICY_FINGERPRINT),
            crl_sha256_by_uri: HashMap::new(),
            blocked: false,
        };
        assert!(!view.matches_current_context(
            b"issuer-ca",
            Some(TEST_CA_VALIDATION_CONTEXT),
            Some(TEST_POLICY_FINGERPRINT)
        ));

        view.issuer_ca_sha256_hex = Some("00".repeat(32));
        assert!(!view.matches_current_context(
            b"issuer-ca",
            Some(TEST_CA_VALIDATION_CONTEXT),
            Some(TEST_POLICY_FINGERPRINT)
        ));

        view.issuer_ca_sha256_hex = Some(sha256_hex(b"issuer-ca"));
        assert!(view.matches_current_context(
            b"issuer-ca",
            Some(TEST_CA_VALIDATION_CONTEXT),
            Some(TEST_POLICY_FINGERPRINT)
        ));
        assert!(!view.matches_current_context(b"issuer-ca", None, Some(TEST_POLICY_FINGERPRINT)));

        view.ca_validation_context_digest = None;
        assert!(!view.matches_current_context(
            b"issuer-ca",
            Some(TEST_CA_VALIDATION_CONTEXT),
            Some(TEST_POLICY_FINGERPRINT)
        ));
        view.ca_validation_context_digest = Some(TEST_CA_VALIDATION_CONTEXT);
        view.policy_fingerprint = None;
        assert!(!view.matches_current_context(
            b"issuer-ca",
            Some(TEST_CA_VALIDATION_CONTEXT),
            Some(TEST_POLICY_FINGERPRINT)
        ));
    }

    #[test]
    fn roa_validation_cache_lookup_classifies_hash_empty_payload_and_missing_uri() {
        let validation_time = fixed_time("2026-06-05T00:00:00Z");
        let issuer_der = b"issuer-ca";
        let crl_bytes = b"current-crl".to_vec();
        let crl_hash_hex = sha256_hex(&crl_bytes);
        let roa_hash = [0x11; 32];
        let file = PackFile::from_bytes_with_sha256(TEST_ROA_URI, vec![0x01], roa_hash);
        let mut output = sample_roa_cache_vcir(
            issuer_der,
            sha256_32(&crl_bytes),
            roa_hash,
            fixed_time("2026-06-07T00:00:00Z"),
            fixed_time("2026-06-08T00:00:00Z"),
        )
        .local_outputs
        .remove(0);
        let outputs_effective_until_unix = output
            .item_effective_until
            .parse()
            .expect("parse output effective until")
            .unix_timestamp();
        let mut view = RoaValidationCacheView {
            entries_by_uri: HashMap::new(),
            issuer_ca_sha256_hex: Some(sha256_hex(issuer_der)),
            ca_validation_context_digest: Some(TEST_CA_VALIDATION_CONTEXT),
            policy_fingerprint: Some(TEST_POLICY_FINGERPRINT),
            crl_sha256_by_uri: HashMap::from([(TEST_CRL_URI.to_string(), crl_hash_hex.clone())]),
            blocked: false,
        };
        let mut crl_cache = sample_crl_cache(crl_bytes.clone());

        assert!(matches!(
            view.lookup(&file, &mut crl_cache, issuer_der, validation_time),
            RoaCacheLookupResult::Miss
        ));

        view.entries_by_uri.insert(
            file.rsync_uri.clone(),
            CachedRoaValidationResult {
                source_object_hash: roa_hash,
                ee_serial: Some(vec![0x01]),
                crl_uri: None,
                earliest_safe_reuse_time_unix: validation_time.unix_timestamp(),
                outputs_effective_until_unix,
                outputs: vec![output.clone()],
            },
        );
        let mut crl_cache = sample_crl_cache(crl_bytes.clone());
        assert!(matches!(
            view.lookup(&file, &mut crl_cache, issuer_der, validation_time),
            RoaCacheLookupResult::MetadataBlocked
        ));

        view.entries_by_uri.insert(
            file.rsync_uri.clone(),
            CachedRoaValidationResult {
                source_object_hash: roa_hash,
                ee_serial: None,
                crl_uri: Some(TEST_CRL_URI.to_string()),
                earliest_safe_reuse_time_unix: validation_time.unix_timestamp(),
                outputs_effective_until_unix,
                outputs: vec![output.clone()],
            },
        );
        let mut crl_cache = sample_crl_cache(crl_bytes.clone());
        assert!(matches!(
            view.lookup(&file, &mut crl_cache, issuer_der, validation_time),
            RoaCacheLookupResult::MetadataBlocked
        ));

        view.entries_by_uri.insert(
            file.rsync_uri.clone(),
            CachedRoaValidationResult {
                source_object_hash: roa_hash,
                ee_serial: Some(vec![0x01]),
                crl_uri: Some(TEST_CRL_URI.to_string()),
                earliest_safe_reuse_time_unix: validation_time.unix_timestamp(),
                outputs_effective_until_unix,
                outputs: vec![output.clone()],
            },
        );
        let mut crl_cache = HashMap::new();
        assert!(matches!(
            view.lookup(&file, &mut crl_cache, issuer_der, validation_time),
            RoaCacheLookupResult::MetadataBlocked
        ));

        view.crl_sha256_by_uri
            .insert(TEST_CRL_URI.to_string(), "00".repeat(32));
        let mut crl_cache = sample_crl_cache(crl_bytes.clone());
        assert!(matches!(
            view.lookup(&file, &mut crl_cache, issuer_der, validation_time),
            RoaCacheLookupResult::MetadataBlocked
        ));
        view.crl_sha256_by_uri
            .insert(TEST_CRL_URI.to_string(), crl_hash_hex);

        view.entries_by_uri.insert(
            file.rsync_uri.clone(),
            CachedRoaValidationResult {
                source_object_hash: [0xff; 32],
                ee_serial: Some(vec![0x01]),
                crl_uri: Some(TEST_CRL_URI.to_string()),
                earliest_safe_reuse_time_unix: validation_time.unix_timestamp(),
                outputs_effective_until_unix,
                outputs: vec![output.clone()],
            },
        );
        let mut crl_cache = sample_crl_cache(crl_bytes.clone());
        assert!(matches!(
            view.lookup(&file, &mut crl_cache, issuer_der, validation_time),
            RoaCacheLookupResult::HashBlocked
        ));

        view.entries_by_uri.insert(
            file.rsync_uri.clone(),
            CachedRoaValidationResult {
                source_object_hash: roa_hash,
                ee_serial: Some(vec![0x01]),
                crl_uri: Some(TEST_CRL_URI.to_string()),
                earliest_safe_reuse_time_unix: validation_time.unix_timestamp(),
                outputs_effective_until_unix,
                outputs: Vec::new(),
            },
        );
        let mut crl_cache = sample_crl_cache(crl_bytes.clone());
        assert!(matches!(
            view.lookup(&file, &mut crl_cache, issuer_der, validation_time),
            RoaCacheLookupResult::Miss
        ));

        output.payload = VcirLocalOutputPayload::Aspa {
            customer_as_id: 64500,
            provider_as_ids: vec![64501],
        };
        view.entries_by_uri.insert(
            file.rsync_uri.clone(),
            CachedRoaValidationResult {
                source_object_hash: roa_hash,
                ee_serial: Some(vec![0x01]),
                crl_uri: Some(TEST_CRL_URI.to_string()),
                earliest_safe_reuse_time_unix: validation_time.unix_timestamp(),
                outputs_effective_until_unix,
                outputs: vec![output],
            },
        );
        let mut crl_cache = sample_crl_cache(crl_bytes);
        assert!(matches!(
            view.lookup(&file, &mut crl_cache, issuer_der, validation_time),
            RoaCacheLookupResult::MetadataBlocked
        ));
    }

    #[test]
    fn parallel_roa_cache_blocked_falls_back_to_single_fresh_task() {
        let manifest_bytes = fixture_bytes(
            "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft",
        );
        let issuer_ca_der = fixture_bytes(
            "tests/fixtures/repository/rpki.apnic.net/repository/B527EF581D6611E2BB468F7C72FD1FF2/BfycW4hQb3wNP4YsiJW-1n6fjro.cer",
        );
        let crl_bytes = fixture_bytes(
            "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.crl",
        );
        let crl_hash = sha256_32(&crl_bytes);
        let actual_roa_hash = [0x11; 32];
        let cached_roa_hash = [0x33; 32];
        let validation_time = fixed_time("2026-06-05T00:00:00Z");
        let publication_point = PublicationPointSnapshot {
            format_version: PublicationPointSnapshot::FORMAT_VERSION_V1,
            manifest_rsync_uri:
                "rsync://rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft"
                    .to_string(),
            publication_point_rsync_uri: "rsync://rpki.cernet.net/repo/cernet/0/".to_string(),
            manifest_number_be: vec![1],
            this_update: PackTime::from_utc_offset_datetime(validation_time),
            next_update: PackTime::from_utc_offset_datetime(
                validation_time + time::Duration::days(1),
            ),
            verified_at: PackTime::from_utc_offset_datetime(validation_time),
            manifest_bytes,
            files: vec![
                PackFile::from_bytes_with_sha256(TEST_CRL_URI, crl_bytes, crl_hash),
                PackFile::from_bytes_with_sha256(
                    "rsync://example.test/repo/a.roa",
                    vec![0x01],
                    actual_roa_hash,
                ),
            ],
        };
        let vcir = sample_roa_cache_vcir(
            &issuer_ca_der,
            crl_hash,
            cached_roa_hash,
            fixed_time("2026-06-07T00:00:00Z"),
            fixed_time("2026-06-08T00:00:00Z"),
        );
        let projection = sample_roa_cache_projection(&vcir, cached_roa_hash);
        let view = RoaValidationCacheView::from_projection(&projection, validation_time);

        let stage = match prepare_publication_point_for_parallel_roa_with_cache(
            7,
            &publication_point,
            &Policy::default(),
            &issuer_ca_der,
            None,
            None,
            None,
            validation_time,
            false,
            RoaValidationCacheInput::enabled_with_context(
                Some(&view),
                TEST_CA_VALIDATION_CONTEXT,
                TEST_POLICY_FINGERPRINT,
            ),
        ) {
            ParallelObjectsPrepare::Staged(stage) => stage,
            ParallelObjectsPrepare::Complete(_) => panic!("expected staged ROA fallback"),
        };

        assert_eq!(stage.roa_cache_stats.blocked_roas, 1);
        assert_eq!(stage.roa_cache_stats.fresh_roas, 1);
        assert_eq!(stage.roa_task_indices, vec![1]);
        assert_eq!(stage.roa_task_count(), 1);
    }

    #[test]
    fn merge_as_intervals_merges_overlapping_and_adjacent() {
        let v = vec![(1, 2), (3, 5), (10, 10), (11, 12)];
        let merged = merge_as_intervals(&v);
        assert_eq!(merged, vec![(1, 5), (10, 12)]);
    }

    #[test]
    fn as_choice_subset_rejects_inherit() {
        let child = Some(&AsIdentifierChoice::Inherit);
        let parent = Some(&AsIdentifierChoice::AsIdsOrRanges(vec![
            AsIdOrRange::Range { min: 1, max: 10 },
        ]));
        assert!(!as_choice_subset(child, parent));
    }

    #[test]
    fn as_choice_subset_checks_ranges() {
        let child = Some(&AsIdentifierChoice::AsIdsOrRanges(vec![
            AsIdOrRange::Id(5),
            AsIdOrRange::Range { min: 7, max: 9 },
        ]));
        let parent = Some(&AsIdentifierChoice::AsIdsOrRanges(vec![
            AsIdOrRange::Range { min: 1, max: 10 },
        ]));
        assert!(as_choice_subset(child, parent));
    }

    #[test]
    fn ip_resources_is_subset_accepts_prefixes_and_ranges() {
        let parent = IpResourceSet {
            families: vec![IpAddressFamily {
                afi: Afi::Ipv4,
                choice: IpAddressChoice::AddressesOrRanges(vec![
                    IpAddressOrRange::Prefix(IpPrefix {
                        afi: Afi::Ipv4,
                        prefix_len: 8,
                        addr: vec![10, 0, 0, 0],
                    }),
                    IpAddressOrRange::Range(IpAddressRange {
                        min: vec![192, 0, 2, 0],
                        max: vec![192, 0, 2, 255],
                    }),
                ]),
            }],
        };
        let child = IpResourceSet {
            families: vec![IpAddressFamily {
                afi: Afi::Ipv4,
                choice: IpAddressChoice::AddressesOrRanges(vec![
                    IpAddressOrRange::Prefix(IpPrefix {
                        afi: Afi::Ipv4,
                        prefix_len: 16,
                        addr: vec![10, 1, 0, 0],
                    }),
                    IpAddressOrRange::Range(IpAddressRange {
                        min: vec![192, 0, 2, 10],
                        max: vec![192, 0, 2, 20],
                    }),
                ]),
            }],
        };

        assert!(ip_resources_is_subset(&child, &parent));
    }

    #[test]
    fn ip_resources_is_subset_rejects_inherit_in_child() {
        let parent = IpResourceSet {
            families: vec![IpAddressFamily {
                afi: Afi::Ipv6,
                choice: IpAddressChoice::AddressesOrRanges(vec![IpAddressOrRange::Prefix(
                    IpPrefix {
                        afi: Afi::Ipv6,
                        prefix_len: 32,
                        addr: vec![0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                    },
                )]),
            }],
        };
        let child = IpResourceSet {
            families: vec![IpAddressFamily {
                afi: Afi::Ipv6,
                choice: IpAddressChoice::Inherit,
            }],
        };
        assert!(!ip_resources_is_subset(&child, &parent));
    }

    #[test]
    fn increment_bytes_wraps_all_ff_to_zero() {
        assert_eq!(increment_bytes(&[0xFF, 0xFF]), vec![0x00, 0x00]);
    }

    #[test]
    fn merge_ip_intervals_merges_contiguous() {
        let mut v = vec![
            (vec![0, 0, 0, 0], vec![0, 0, 0, 10]),
            (vec![0, 0, 0, 11], vec![0, 0, 0, 20]),
        ];
        merge_ip_intervals_in_place(&mut v);
        assert_eq!(v, vec![(vec![0, 0, 0, 0], vec![0, 0, 0, 20])]);
    }

    #[test]
    fn choose_crl_for_certificate_reports_missing_crl_in_snapshot() {
        let roa_der =
            fixture_bytes("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa");
        let roa = RoaObject::decode_der(&roa_der).expect("decode roa");
        let ee_crldp_uris = roa.signed_object.signed_data.certificates[0]
            .resource_cert
            .tbs
            .extensions
            .crl_distribution_points_uris
            .as_ref();
        let crl_cache: HashMap<String, CachedIssuerCrl> = HashMap::new();
        let err = choose_crl_uri_for_certificate(ee_crldp_uris, &crl_cache).unwrap_err();
        assert!(matches!(err, ObjectValidateError::MissingCrlInPack));
    }

    #[test]
    fn choose_crl_for_certificate_reports_missing_crldp_uris() {
        let mut crl_cache: HashMap<String, CachedIssuerCrl> = HashMap::new();
        crl_cache.insert(
            "rsync://example.test/a.crl".to_string(),
            CachedIssuerCrl::Pending {
                bytes: vec![0x01],
                sha256_hex: None,
            },
        );
        let err = choose_crl_uri_for_certificate(None, &crl_cache).unwrap_err();
        assert!(matches!(err, ObjectValidateError::MissingCrlDpUris));
    }

    #[test]
    fn choose_crl_for_certificate_prefers_matching_crldp_uri_in_order() {
        let roa_der =
            fixture_bytes("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa");
        let roa = RoaObject::decode_der(&roa_der).expect("decode roa");
        let ee_crldp_uris = roa.signed_object.signed_data.certificates[0]
            .resource_cert
            .tbs
            .extensions
            .crl_distribution_points_uris
            .as_ref()
            .expect("fixture ee has crldp");

        let matching_uri = ee_crldp_uris[0].as_str().to_string();
        let mut crl_cache: HashMap<String, CachedIssuerCrl> = HashMap::new();
        crl_cache.insert(
            "rsync://example.test/other.crl".to_string(),
            CachedIssuerCrl::Pending {
                bytes: vec![0x00],
                sha256_hex: None,
            },
        );
        crl_cache.insert(
            matching_uri.clone(),
            CachedIssuerCrl::Pending {
                bytes: vec![0x01],
                sha256_hex: None,
            },
        );

        let uri = choose_crl_uri_for_certificate(Some(ee_crldp_uris), &crl_cache).unwrap();
        assert_eq!(uri, matching_uri);
    }

    #[test]
    fn choose_crl_for_certificate_reports_not_found_when_crldp_does_not_match_snapshot() {
        let roa_der =
            fixture_bytes("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa");
        let roa = RoaObject::decode_der(&roa_der).expect("decode roa");
        let ee_crldp_uris = roa.signed_object.signed_data.certificates[0]
            .resource_cert
            .tbs
            .extensions
            .crl_distribution_points_uris
            .as_ref();

        let mut crl_cache: HashMap<String, CachedIssuerCrl> = HashMap::new();
        crl_cache.insert(
            "rsync://example.test/other.crl".to_string(),
            CachedIssuerCrl::Pending {
                bytes: vec![0x01],
                sha256_hex: None,
            },
        );
        let err = choose_crl_uri_for_certificate(ee_crldp_uris, &crl_cache).unwrap_err();
        assert!(matches!(err, ObjectValidateError::CrlNotFound(_)));
    }

    #[test]
    fn validate_ee_resources_subset_reports_missing_issuer_effective_ip() {
        let roa_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa");
        let roa_der = std::fs::read(roa_path).expect("read roa");
        let roa = RoaObject::decode_der(&roa_der).expect("decode roa");
        let ee = &roa.signed_object.signed_data.certificates[0].resource_cert;

        let idx = IssuerResourcesIndex::default();
        let err = validate_ee_resources_subset(ee, None, None, &idx).unwrap_err();
        assert!(matches!(err, ObjectValidateError::MissingIssuerEffectiveIp));
    }

    #[test]
    fn validate_ee_resources_subset_reports_missing_issuer_effective_as() {
        let aspa_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(
            "tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/5m80fwYws_3FiFD7JiQjAqZ1RYQ.asa",
        );
        let aspa_der = std::fs::read(aspa_path).expect("read aspa");
        let aspa = AspaObject::decode_der(&aspa_der).expect("decode aspa");
        let ee = &aspa.signed_object.signed_data.certificates[0].resource_cert;

        let issuer_ip = IpResourceSet {
            families: vec![IpAddressFamily {
                afi: Afi::Ipv6,
                choice: IpAddressChoice::AddressesOrRanges(vec![IpAddressOrRange::Prefix(
                    IpPrefix {
                        afi: Afi::Ipv6,
                        prefix_len: 32,
                        addr: vec![0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                    },
                )]),
            }],
        };

        let idx = build_issuer_resources_index(Some(&issuer_ip), None);
        let err = validate_ee_resources_subset(ee, Some(&issuer_ip), None, &idx).unwrap_err();
        assert!(matches!(err, ObjectValidateError::MissingIssuerEffectiveAs));
    }

    #[test]
    fn validate_ee_resources_subset_reports_not_subset() {
        let roa_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa");
        let roa_der = std::fs::read(roa_path).expect("read roa");
        let roa = RoaObject::decode_der(&roa_der).expect("decode roa");
        let ee = &roa.signed_object.signed_data.certificates[0].resource_cert;

        // Unrelated parent resources.
        let issuer_ip = IpResourceSet {
            families: vec![IpAddressFamily {
                afi: Afi::Ipv6,
                choice: IpAddressChoice::AddressesOrRanges(vec![IpAddressOrRange::Prefix(
                    IpPrefix {
                        afi: Afi::Ipv6,
                        prefix_len: 32,
                        addr: vec![0x26, 0x20, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
                    },
                )]),
            }],
        };

        let idx = build_issuer_resources_index(Some(&issuer_ip), None);
        let err = validate_ee_resources_subset(ee, Some(&issuer_ip), None, &idx).unwrap_err();
        assert!(matches!(err, ObjectValidateError::EeResourcesNotSubset));
    }

    #[test]
    fn validation_update_03_ee_resources_reduce_to_vrs_for_roa_checks() {
        let roa_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa");
        let roa_der = std::fs::read(roa_path).expect("read roa");
        let roa = RoaObject::decode_der(&roa_der).expect("decode roa");
        let ee = &roa.signed_object.signed_data.certificates[0].resource_cert;
        let ee_ip = ee
            .tbs
            .extensions
            .ip_resources
            .as_ref()
            .expect("fixture EE has IP resources");
        let first_family = ee_ip.families.first().expect("family");
        let first_item = match &first_family.choice {
            IpAddressChoice::AddressesOrRanges(items) => items.first().expect("item").clone(),
            IpAddressChoice::Inherit => panic!("fixture should not inherit"),
        };
        let issuer_ip = IpResourceSet {
            families: vec![IpAddressFamily {
                afi: first_family.afi,
                choice: IpAddressChoice::AddressesOrRanges(vec![first_item]),
            }],
        };
        let idx = build_issuer_resources_index(Some(&issuer_ip), None);

        let strict_err = validate_ee_resources_for_mode(
            ee,
            Some(&issuer_ip),
            None,
            &idx,
            ResourceValidationMode::Rfc6487,
        )
        .unwrap_err();
        assert!(matches!(
            strict_err,
            ObjectValidateError::EeResourcesNotSubset
        ));

        let vrs = validate_ee_resources_for_mode(
            ee,
            Some(&issuer_ip),
            None,
            &idx,
            ResourceValidationMode::ValidationUpdate03,
        )
        .expect("vrs resource resolution");
        assert_eq!(vrs.ip.expect("vrs ip").families.len(), 1);
    }

    #[test]
    fn validation_update_03_ee_as_vrs_filters_aspa_customer_resources() {
        let issuer_as = AsResourceSet {
            asnum: Some(AsIdentifierChoice::AsIdsOrRanges(vec![
                AsIdOrRange::Range {
                    min: 64500,
                    max: 64510,
                },
            ])),
            rdi: None,
        };
        let child_as = AsResourceSet {
            asnum: Some(AsIdentifierChoice::AsIdsOrRanges(vec![
                AsIdOrRange::Range {
                    min: 64505,
                    max: 64520,
                },
            ])),
            rdi: None,
        };
        let idx = build_issuer_resources_index(None, Some(&issuer_as));
        let vrs = intersect_ee_as_resources_vrs(&child_as, Some(&issuer_as), &idx)
            .expect("intersect AS resources");
        assert!(as_resource_set_contains_asn(&vrs, 64505));
        assert!(as_resource_set_contains_asn(&vrs, 64510));
        assert!(!as_resource_set_contains_asn(&vrs, 64511));
    }

    #[test]
    fn validation_update_03_empty_ee_vrs_rejects_roa_and_aspa_outputs() {
        let roa_der =
            fixture_bytes("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa");
        let roa = RoaObject::decode_der(&roa_der).expect("decode roa");
        let empty_ip_vrs = IpResourceSet {
            families: Vec::new(),
        };
        let roa_err = roa_to_vrps_with_vrs(&roa, Some(&empty_ip_vrs)).unwrap_err();
        assert!(matches!(roa_err, ObjectValidateError::EeResourcesNotSubset));
        let missing_ip_err = roa_to_vrps_with_vrs(&roa, None).unwrap_err();
        assert!(matches!(
            missing_ip_err,
            ObjectValidateError::EeResourcesNotSubset
        ));

        let aspa_der = fixture_bytes(
            "tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/5m80fwYws_3FiFD7JiQjAqZ1RYQ.asa",
        );
        let aspa = AspaObject::decode_der(&aspa_der).expect("decode aspa");
        let empty_as_vrs = AsResourceSet {
            asnum: Some(AsIdentifierChoice::AsIdsOrRanges(Vec::new())),
            rdi: None,
        };
        let aspa_err = validate_aspa_customer_in_vrs(&aspa, Some(&empty_as_vrs)).unwrap_err();
        assert!(matches!(
            aspa_err,
            ObjectValidateError::EeResourcesNotSubset
        ));
        let missing_as_err = validate_aspa_customer_in_vrs(&aspa, None).unwrap_err();
        assert!(matches!(
            missing_as_err,
            ObjectValidateError::EeResourcesNotSubset
        ));
    }

    #[test]
    fn extra_rfc_refs_for_crl_selection_distinguishes_crl_errors() {
        assert_eq!(
            extra_rfc_refs_for_crl_selection(&ObjectValidateError::MissingCrlDpUris),
            RFC_CRLDP
        );
        assert_eq!(
            extra_rfc_refs_for_crl_selection(&ObjectValidateError::CrlNotFound(
                "rsync://example.test/x.crl".to_string(),
            )),
            RFC_CRLDP_AND_LOCKED_PACK
        );
        assert!(
            extra_rfc_refs_for_crl_selection(&ObjectValidateError::MissingCrlInPack).is_empty()
        );
    }

    #[test]
    fn as_subset_helpers_cover_success_and_failure_paths() {
        let child = AsIdentifierChoice::AsIdsOrRanges(vec![
            AsIdOrRange::Id(5),
            AsIdOrRange::Range { min: 7, max: 9 },
        ]);
        let parent =
            AsIdentifierChoice::AsIdsOrRanges(vec![AsIdOrRange::Range { min: 1, max: 10 }]);
        let parent_intervals = [(1, 10)];

        assert!(as_choice_subset(None, Some(&parent)));
        assert!(!as_choice_subset(Some(&child), None));
        assert!(!as_choice_subset(
            Some(&AsIdentifierChoice::Inherit),
            Some(&parent)
        ));
        assert!(!as_choice_subset(
            Some(&child),
            Some(&AsIdentifierChoice::Inherit)
        ));
        assert!(as_choice_subset(Some(&child), Some(&parent)));

        assert!(as_choice_subset_indexed(None, Some(&parent_intervals)));
        assert!(!as_choice_subset_indexed(Some(&child), None));
        assert!(!as_choice_subset_indexed(
            Some(&AsIdentifierChoice::Inherit),
            Some(&parent_intervals),
        ));
        assert!(as_choice_subset_indexed(
            Some(&child),
            Some(&parent_intervals)
        ));
        assert!(!as_choice_subset_indexed(
            Some(&AsIdentifierChoice::AsIdsOrRanges(vec![
                AsIdOrRange::Range { min: 11, max: 12 }
            ])),
            Some(&parent_intervals),
        ));

        let child_set = AsResourceSet {
            asnum: Some(child.clone()),
            rdi: Some(AsIdentifierChoice::AsIdsOrRanges(vec![AsIdOrRange::Id(42)])),
        };
        let parent_set = AsResourceSet {
            asnum: Some(parent.clone()),
            rdi: Some(AsIdentifierChoice::AsIdsOrRanges(vec![
                AsIdOrRange::Range { min: 40, max: 50 },
            ])),
        };
        assert!(as_resources_is_subset(&child_set, &parent_set));
        assert!(as_resources_is_subset_indexed(
            &child_set,
            &parent_set,
            &IssuerResourcesIndex {
                asnum: Some(vec![(1, 10)]),
                rdi: Some(vec![(40, 50)]),
                ..IssuerResourcesIndex::default()
            },
        ));
    }

    #[test]
    fn ip_subset_helpers_cover_strict_and_indexed_paths() {
        let parent = IpResourceSet {
            families: vec![
                IpAddressFamily {
                    afi: Afi::Ipv4,
                    choice: IpAddressChoice::AddressesOrRanges(vec![
                        IpAddressOrRange::Prefix(IpPrefix {
                            afi: Afi::Ipv4,
                            prefix_len: 8,
                            addr: vec![10, 0, 0, 0],
                        }),
                        IpAddressOrRange::Range(IpAddressRange {
                            min: vec![192, 0, 2, 0],
                            max: vec![192, 0, 2, 255],
                        }),
                    ]),
                },
                IpAddressFamily {
                    afi: Afi::Ipv6,
                    choice: IpAddressChoice::Inherit,
                },
            ],
        };
        let child = IpResourceSet {
            families: vec![IpAddressFamily {
                afi: Afi::Ipv4,
                choice: IpAddressChoice::AddressesOrRanges(vec![
                    IpAddressOrRange::Prefix(IpPrefix {
                        afi: Afi::Ipv4,
                        prefix_len: 16,
                        addr: vec![10, 1, 0, 0],
                    }),
                    IpAddressOrRange::Range(IpAddressRange {
                        min: vec![192, 0, 2, 10],
                        max: vec![192, 0, 2, 20],
                    }),
                ]),
            }],
        };
        let strict_bad = IpResourceSet {
            families: vec![IpAddressFamily {
                afi: Afi::Ipv4,
                choice: IpAddressChoice::Inherit,
            }],
        };

        assert!(ip_resources_is_subset(&child, &parent));
        assert!(ip_resources_to_merged_intervals(&parent).contains_key(&AfiKey::V4));
        assert!(ip_resources_to_merged_intervals_strict(&child).is_ok());
        assert!(ip_resources_to_merged_intervals_strict(&strict_bad).is_err());

        let idx = build_issuer_resources_index(
            Some(&parent),
            Some(&AsResourceSet {
                asnum: Some(AsIdentifierChoice::AsIdsOrRanges(vec![
                    AsIdOrRange::Range {
                        min: 64500,
                        max: 64510,
                    },
                ])),
                rdi: Some(AsIdentifierChoice::Inherit),
            }),
        );
        assert!(idx.ip_v4.is_some());
        assert!(idx.ip_v6.is_none());
        assert!(idx.asnum.is_some());
        assert!(idx.rdi.is_none());
        assert!(ip_resources_is_subset_indexed(&child, &parent, &idx));
        assert!(!ip_resources_is_subset_indexed(&strict_bad, &parent, &idx));
    }

    #[test]
    fn interval_and_byte_helpers_cover_edge_cases() {
        let parent = vec![(vec![0, 0, 0, 0], vec![0, 0, 0, 10])];
        assert!(interval_is_covered(&parent, &[0, 0, 0, 1], &[0, 0, 0, 2]));
        assert!(!interval_is_covered(
            &parent,
            &[0, 0, 0, 11],
            &[0, 0, 0, 12]
        ));
        assert!(intervals_are_covered(
            &parent,
            &[(vec![0, 0, 0, 1], vec![0, 0, 0, 2])]
        ));
        assert!(!intervals_are_covered(
            &parent,
            &[(vec![0, 0, 0, 9], vec![0, 0, 0, 11])],
        ));

        let prefix = RcIpPrefix {
            afi: Afi::Ipv4,
            prefix_len: 24,
            addr: vec![203, 0, 113, 7],
        };
        assert_eq!(
            prefix_to_range(&prefix),
            (vec![203, 0, 113, 0], vec![203, 0, 113, 255])
        );
        assert_eq!(increment_bytes(&[0, 0, 0, 255]), vec![0, 0, 1, 0]);
        assert!(bytes_is_next(&[0, 0, 1, 0], &[0, 0, 0, 255]));
        assert!(!bytes_is_next(&[1, 2], &[1]));
    }

    #[test]
    fn merged_interval_helpers_cover_empty_and_break_paths() {
        let mut empty: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
        merge_ip_intervals_in_place(&mut empty);
        assert!(empty.is_empty());

        let mut v = vec![
            (vec![0, 0, 0, 20], vec![0, 0, 0, 30]),
            (vec![0, 0, 0, 0], vec![0, 0, 0, 10]),
            (vec![0, 0, 0, 11], vec![0, 0, 0, 19]),
        ];
        v.sort_by(|(a, _), (b, _)| a.cmp(b));
        merge_ip_intervals_in_place(&mut v);
        assert_eq!(v, vec![(vec![0, 0, 0, 0], vec![0, 0, 0, 30])]);

        assert_eq!(
            as_choice_to_merged_intervals(&AsIdentifierChoice::AsIdsOrRanges(vec![
                AsIdOrRange::Id(1),
                AsIdOrRange::Range { min: 2, max: 3 },
                AsIdOrRange::Range { min: 7, max: 9 },
            ])),
            vec![(1, 3), (7, 9)]
        );
        assert!(as_interval_is_covered(&[(1, 3), (7, 9)], 2, 3));
        assert!(!as_interval_is_covered(&[(7, 9)], 2, 3));
    }

    #[test]
    fn roa_output_helpers_cover_vrps_and_afi_strings() {
        let roa_der =
            fixture_bytes("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa");
        let roa = RoaObject::decode_der(&roa_der).expect("decode roa");
        let vrps = roa_to_vrps(&roa);
        assert!(!vrps.is_empty());
        assert!(vrps.iter().all(|vrp| vrp.asn == roa.roa.as_id));
        assert_eq!(roa_afi_to_string(RoaAfi::Ipv4), "ipv4");
        assert_eq!(roa_afi_to_string(RoaAfi::Ipv6), "ipv6");
    }

    #[test]
    fn parallel_stage_roa_tasks_share_stage_owned_payloads() {
        let stage = ParallelObjectsStage {
            publication_point_id: 7,
            shared: Arc::new(RoaTaskShared {
                locked_files: Arc::<[PackFile]>::from(vec![
                    PackFile::from_bytes_with_sha256(
                        "rsync://example.test/repo/a.roa",
                        vec![1, 2, 3],
                        [1u8; 32],
                    ),
                    PackFile::from_bytes_with_sha256(
                        "rsync://example.test/repo/b.roa",
                        vec![4, 5, 6],
                        [2u8; 32],
                    ),
                ]),
                manifest_rsync_uri: Arc::<str>::from("rsync://example.test/repo/manifest.mft"),
                issuer_ca_der: Arc::from([0x01u8].as_slice()),
                issuer_ca: Arc::new(
                    ResourceCertificate::decode_der(&fixture_bytes(
                        "tests/fixtures/ta/apnic-ta.cer",
                    ))
                    .expect("decode fixture CA certificate"),
                ),
                issuer_spki_der: Arc::from([0x02u8].as_slice()),
                issuer_ca_rsync_uri: Some(Arc::<str>::from("rsync://example.test/repo/ca.cer")),
                crl_cache: Arc::new(Mutex::new(HashMap::new())),
                issuer_resources_index: Arc::new(IssuerResourcesIndex::default()),
                issuer_effective_ip: None,
                issuer_effective_as: None,
                resource_validation_mode: ResourceValidationMode::default(),
            }),
            validation_time: OffsetDateTime::now_utc(),
            collect_vcir_local_outputs: false,
            strict_cms_der: false,
            strict_name: false,
            resource_validation_mode: ResourceValidationMode::default(),
            roa_task_indices: vec![0, 1],
            cached_roa_results: Vec::new(),
            roa_cache_stats: RoaValidationCacheStats::default(),
            warnings: Vec::new(),
            stats: ObjectsStats {
                roa_total: 2,
                ..ObjectsStats::default()
            },
            audit: Vec::new(),
        };
        let tasks = stage.build_roa_tasks();
        assert_eq!(tasks.len(), 2);
        assert!(Arc::ptr_eq(&tasks[0].shared, &tasks[1].shared));
    }

    #[test]
    fn strict_name_manifest_decode_failure_drops_publication_point() {
        let publication_point = PublicationPointSnapshot {
            format_version: PublicationPointSnapshot::FORMAT_VERSION_V1,
            manifest_rsync_uri: "rsync://example.test/repo/manifest.mft".to_string(),
            publication_point_rsync_uri: "rsync://example.test/repo/".to_string(),
            manifest_number_be: vec![0x01],
            this_update: PackTime::from_utc_offset_datetime(OffsetDateTime::now_utc()),
            next_update: PackTime::from_utc_offset_datetime(OffsetDateTime::now_utc()),
            verified_at: PackTime::from_utc_offset_datetime(OffsetDateTime::now_utc()),
            manifest_bytes: vec![0x01, 0x02, 0x03],
            files: vec![],
        };
        let policy = Policy::default();
        let output = process_publication_point_for_issuer_with_options(
            &publication_point,
            &policy,
            &[],
            None,
            None,
            None,
            OffsetDateTime::now_utc(),
            None,
            false,
        );
        assert!(output.stats.publication_point_dropped);
        assert!(output.vrps.is_empty());
        assert!(
            output
                .warnings
                .iter()
                .any(|warning| warning.message.contains("manifest decode failed")),
            "{:?}",
            output.warnings
        );
    }
}
